mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-07 13:20:38 -08:00
Compare commits
402 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3f4be7410 | ||
|
|
3915ef0fb6 | ||
|
|
20d26166dd | ||
|
|
ddca724bd8 | ||
|
|
b86c1a0479 | ||
|
|
1fa7830ddf | ||
|
|
59abafbe16 | ||
|
|
b6eebb9736 | ||
|
|
7797053102 | ||
|
|
d763445f72 | ||
|
|
7bc6b14b5f | ||
|
|
f70d2ac8af | ||
|
|
defdfc5a47 | ||
|
|
e67eeda492 | ||
|
|
a17588d02c | ||
|
|
67b59305c4 | ||
|
|
4f0768a060 | ||
|
|
21704cbbea | ||
|
|
886bc4d011 | ||
|
|
e3437e066a | ||
|
|
8f2795843a | ||
|
|
c6290592e8 | ||
|
|
050ba740b8 | ||
|
|
0b1a27b223 | ||
|
|
bafd04b788 | ||
|
|
fb5f51eea5 | ||
|
|
799e1f0681 | ||
|
|
53a2d953f8 | ||
|
|
9ce5bc3c76 | ||
|
|
dc58fc8536 | ||
|
|
1d5c3016fc | ||
|
|
8737aea746 | ||
|
|
bd03866f5e | ||
|
|
81690a8015 | ||
|
|
933112a52b | ||
|
|
eb513dfe0e | ||
|
|
3928b77506 | ||
|
|
95cb2bd78c | ||
|
|
4fa1c45eb2 | ||
|
|
b9051bc792 | ||
|
|
a590024f1c | ||
|
|
2f51936679 | ||
|
|
327c50d290 | ||
|
|
031dfbb9b5 | ||
|
|
050365302a | ||
|
|
0f248b1119 | ||
|
|
871d5cf758 | ||
|
|
320376d2e8 | ||
|
|
02e7fdff6f | ||
|
|
2c5c28f295 | ||
|
|
2d3509ccc1 | ||
|
|
30babf2d69 | ||
|
|
cfbbabf898 | ||
|
|
5ac6c45fdf | ||
|
|
a14645b563 | ||
|
|
90dbc26c46 | ||
|
|
54cc830c35 | ||
|
|
4928ff5b74 | ||
|
|
bb481fe21a | ||
|
|
0d27b8f652 | ||
|
|
bdd3aae399 | ||
|
|
af94cd7eb5 | ||
|
|
54044f9527 | ||
|
|
1e5c039ece | ||
|
|
15555759dc | ||
|
|
0ed51e05cc | ||
|
|
634ef6febf | ||
|
|
bda4b2dbe1 | ||
|
|
f015305e7c | ||
|
|
d32b7e917f | ||
|
|
3b35e80199 | ||
|
|
c65a1a2815 | ||
|
|
0b3615c9f5 | ||
|
|
3ac4e1ac71 | ||
|
|
d62f580d7a | ||
|
|
02e35b66cb | ||
|
|
7b11e0a301 | ||
|
|
aa8b91aed3 | ||
|
|
fe0fa97576 | ||
|
|
92059cd5ed | ||
|
|
ed3064e3b1 | ||
|
|
441d1e5e6c | ||
|
|
653b2cf4eb | ||
|
|
8d4b71e0c8 | ||
|
|
29cc6cad09 | ||
|
|
8119eef263 | ||
|
|
912c8674cf | ||
|
|
6b3ca236dd | ||
|
|
f1c352d4ff | ||
|
|
714533d845 | ||
|
|
56dd25df8d | ||
|
|
8248dc53df | ||
|
|
1a8a187de6 | ||
|
|
bc86be8c93 | ||
|
|
75026d4fc5 | ||
|
|
f8a5ccb8d2 | ||
|
|
719d1bd187 | ||
|
|
0dd83463c6 | ||
|
|
966301bce8 | ||
|
|
d776880306 | ||
|
|
1ee50e8a55 | ||
|
|
ae95c5ea3d | ||
|
|
d64ad5e11d | ||
|
|
d1a47c6d44 | ||
|
|
51a834a62f | ||
|
|
3a030bf6f7 | ||
|
|
eb6a6fc82c | ||
|
|
437ccd94e4 | ||
|
|
d65868cc30 | ||
|
|
8678aa6544 | ||
|
|
00e5141152 | ||
|
|
90e757dfe1 | ||
|
|
8b471b08e8 | ||
|
|
158bc5710f | ||
|
|
a0b946a13d | ||
|
|
b547b75f03 | ||
|
|
58c7427a47 | ||
|
|
6220b9c55d | ||
|
|
6b9b5c131c | ||
|
|
212f2af39c | ||
|
|
f7b2b4e0c9 | ||
|
|
a747529279 | ||
|
|
1dfdcc27ce | ||
|
|
3c03289453 | ||
|
|
06fd446a72 | ||
|
|
172d912d8b | ||
|
|
2396018607 | ||
|
|
a9be9779c5 | ||
|
|
2f76b26a99 | ||
|
|
2fe5edf810 | ||
|
|
d67ee6a779 | ||
|
|
e06ec5dbd4 | ||
|
|
c1b24ba2aa | ||
|
|
59e9cf9fd0 | ||
|
|
58761f5b96 | ||
|
|
ac959da229 | ||
|
|
bacc8c48ec | ||
|
|
905a159428 | ||
|
|
20f734cab2 | ||
|
|
7c2c644aef | ||
|
|
0efc92081a | ||
|
|
fafeee2367 | ||
|
|
e03063cd76 | ||
|
|
93b38b055f | ||
|
|
045635fb55 | ||
|
|
de7f773e9e | ||
|
|
ef6a465bd2 | ||
|
|
0c623af8a4 | ||
|
|
0589f83998 | ||
|
|
e17608afd5 | ||
|
|
b915654685 | ||
|
|
2ce9bf6c47 | ||
|
|
3c22232432 | ||
|
|
3474e9520c | ||
|
|
e9bacf4f9c | ||
|
|
ef422ed6fd | ||
|
|
d0f5366908 | ||
|
|
3557205feb | ||
|
|
ba4c41d888 | ||
|
|
1427a3193c | ||
|
|
b5cee20e56 | ||
|
|
be7f464073 | ||
|
|
c7f8f168f5 | ||
|
|
ba59fbdcb0 | ||
|
|
9f54fa4998 | ||
|
|
3c9688b32c | ||
|
|
1f046447bb | ||
|
|
87e3a275bb | ||
|
|
037b5c36a4 | ||
|
|
7d8b60fb14 | ||
|
|
0ad16fee53 | ||
|
|
249243aeb4 | ||
|
|
c208dc3579 | ||
|
|
ea93f2ba23 | ||
|
|
d910a0bb6a | ||
|
|
550fcfeddc | ||
|
|
c6910e5a1c | ||
|
|
8555edb521 | ||
|
|
139193ce29 | ||
|
|
1a87375ccd | ||
|
|
83cbef40f6 | ||
|
|
85b4fc75a1 | ||
|
|
f2e2da378f | ||
|
|
7c34bc9120 | ||
|
|
6f153f2acb | ||
|
|
8171083978 | ||
|
|
db5b9a59b4 | ||
|
|
6fa656ba11 | ||
|
|
de0682c1bb | ||
|
|
a6a32d8de4 | ||
|
|
bb14b269de | ||
|
|
14331d8bc2 | ||
|
|
1729464844 | ||
|
|
5fb9747285 | ||
|
|
394228d391 | ||
|
|
5d3c0cc6ec | ||
|
|
3ef7c5248c | ||
|
|
8bebc401fd | ||
|
|
215b28457b | ||
|
|
dfd2bfc857 | ||
|
|
f991292e94 | ||
|
|
d837457f80 | ||
|
|
343bdba31b | ||
|
|
1c1c2457e8 | ||
|
|
b083bfb074 | ||
|
|
ea1abcb2ae | ||
|
|
001030ba2b | ||
|
|
eda8984781 | ||
|
|
d8dc6f0a34 | ||
|
|
2d711a7a7f | ||
|
|
30ca25626a | ||
|
|
b1f5a558c8 | ||
|
|
8062c8dc83 | ||
|
|
cb7eed46bc | ||
|
|
4626eca89e | ||
|
|
0d549c5915 | ||
|
|
33c518ed4c | ||
|
|
8e155dcc74 | ||
|
|
7743b0423e | ||
|
|
6346ea7343 | ||
|
|
32de01047f | ||
|
|
35c7f81afb | ||
|
|
2dbbb1c4df | ||
|
|
6a6efa9d56 | ||
|
|
e510dc3a11 | ||
|
|
9639fd8c05 | ||
|
|
add35ce682 | ||
|
|
6bcc77ea44 | ||
|
|
1a72f88be3 | ||
|
|
1a9f1120b8 | ||
|
|
c2fc807688 | ||
|
|
2b0ade093c | ||
|
|
a26193706e | ||
|
|
ff3c57ef9b | ||
|
|
3b987bd07a | ||
|
|
e8474c0428 | ||
|
|
c78a759aa1 | ||
|
|
d1aad70c48 | ||
|
|
62b36f3e58 | ||
|
|
c5b905fb0d | ||
|
|
7d3dc671ed | ||
|
|
0ec3c7a5bb | ||
|
|
8e0619863a | ||
|
|
e8a05ec4b8 | ||
|
|
34e8b2abd1 | ||
|
|
161b6eb961 | ||
|
|
dd2090f85d | ||
|
|
8b1595a5da | ||
|
|
77ffa27ed8 | ||
|
|
15f79b65c9 | ||
|
|
33c3af0241 | ||
|
|
9badde62fb | ||
|
|
4e401dca40 | ||
|
|
25422b1b7d | ||
|
|
e8463f13b4 | ||
|
|
556f42e41f | ||
|
|
b99a4f7efc | ||
|
|
f6f45cf322 | ||
|
|
ae6db1847a | ||
|
|
20d04ea07b | ||
|
|
8f3834453c | ||
|
|
7ad8b8a0e3 | ||
|
|
80b41f06da | ||
|
|
e79321ed50 | ||
|
|
f7b5898dfa | ||
|
|
144bf53081 | ||
|
|
16dded9724 | ||
|
|
c47b158bff | ||
|
|
9a36e15d9d | ||
|
|
d6b2bd7761 | ||
|
|
2346552dc4 | ||
|
|
ba275055db | ||
|
|
de4ddf2f3a | ||
|
|
9c94d824d1 | ||
|
|
495f3cfbf6 | ||
|
|
b56c9ae3dd | ||
|
|
5e9ef87526 | ||
|
|
b68d6d6fe9 | ||
|
|
5870cc6640 | ||
|
|
7a43d58d82 | ||
|
|
fc7efebc8d | ||
|
|
528be74194 | ||
|
|
ab782acf2f | ||
|
|
45836d1ebc | ||
|
|
dff059d8eb | ||
|
|
4010cfc9c8 | ||
|
|
6329730820 | ||
|
|
006592ae7d | ||
|
|
831dcf4e88 | ||
|
|
0d2cf7ed66 | ||
|
|
aa6dc2b98e | ||
|
|
2e5cde3365 | ||
|
|
d75a03e594 | ||
|
|
9268c02683 | ||
|
|
89913036c9 | ||
|
|
2244026c67 | ||
|
|
c70564474b | ||
|
|
74514c9fbc | ||
|
|
077e9ab8c4 | ||
|
|
b05f7f1640 | ||
|
|
3382b720e3 | ||
|
|
f72c2d4b17 | ||
|
|
ff027991e0 | ||
|
|
21cdc6b015 | ||
|
|
29a2e3e6d1 | ||
|
|
5b3b9f740b | ||
|
|
5bc0e52179 | ||
|
|
40f1c4fba5 | ||
|
|
454341eaf5 | ||
|
|
abab2540a3 | ||
|
|
b2bc8cbace | ||
|
|
90bbf3c033 | ||
|
|
ac91b1770a | ||
|
|
19d42b7924 | ||
|
|
9ec3136734 | ||
|
|
943fca43cf | ||
|
|
b2e00feb94 | ||
|
|
f726c8d55c | ||
|
|
57db2e0626 | ||
|
|
40f66b5fde | ||
|
|
c87417e5e7 | ||
|
|
a841dd6f66 | ||
|
|
d6e85bad5c | ||
|
|
b590ac1e91 | ||
|
|
9cfa3aeea5 | ||
|
|
18c60691ca | ||
|
|
2e9fadf3b2 | ||
|
|
510b47b187 | ||
|
|
49c4d0eec0 | ||
|
|
8367f7bbed | ||
|
|
0182f674e0 | ||
|
|
2b50fb4c97 | ||
|
|
2602a20aa7 | ||
|
|
13200e2d1f | ||
|
|
22f6e89400 | ||
|
|
8409fa7d43 | ||
|
|
c81da78190 | ||
|
|
e17ea4bb89 | ||
|
|
0087728aa8 | ||
|
|
9e48e02f7a | ||
|
|
1291d55ab0 | ||
|
|
b5c6a1e39e | ||
|
|
d6adb30802 | ||
|
|
1d08a69a85 | ||
|
|
1087ab3408 | ||
|
|
51afd504df | ||
|
|
75efc9d73a | ||
|
|
6b68086cff | ||
|
|
3686cdfdb3 | ||
|
|
83c98936d1 | ||
|
|
0891cb279a | ||
|
|
95ba96f537 | ||
|
|
586790173b | ||
|
|
1d19449ab7 | ||
|
|
e1f73334ef | ||
|
|
4faac017b5 | ||
|
|
bfbd2a57a0 | ||
|
|
9519472f83 | ||
|
|
5c0c119cbc | ||
|
|
87eb257a10 | ||
|
|
4a08076c3b | ||
|
|
0d239e6793 | ||
|
|
0a0d47ae88 | ||
|
|
2ba07d47b3 | ||
|
|
f1b520fe3c | ||
|
|
8cfcc26468 | ||
|
|
cd51edf0b8 | ||
|
|
6eb28cfa3d | ||
|
|
542d39fa6a | ||
|
|
e5e328148f | ||
|
|
cea1a67d64 | ||
|
|
97c6dc7968 | ||
|
|
d97072e298 | ||
|
|
7cd246478e | ||
|
|
8afe1df3a9 | ||
|
|
452c2a3569 | ||
|
|
f738069794 | ||
|
|
d178eb976e | ||
|
|
d58dae6d6b | ||
|
|
136cf841e1 | ||
|
|
748d321f36 | ||
|
|
3e71239981 | ||
|
|
571ab488f8 | ||
|
|
62878311c6 | ||
|
|
432e9374cb | ||
|
|
a4974fbba7 | ||
|
|
b935e80928 | ||
|
|
8999d88d23 | ||
|
|
9608bace07 | ||
|
|
09b88df49a | ||
|
|
ccaacc9948 | ||
|
|
96d88b0f47 | ||
|
|
6939471e48 | ||
|
|
b1a2307c4d | ||
|
|
1ead2fb176 | ||
|
|
51e3ca004e | ||
|
|
3814db460f | ||
|
|
4102685cbc | ||
|
|
c80a8235e1 | ||
|
|
622427b748 | ||
|
|
e36413cdef | ||
|
|
7e9a510706 |
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -8,31 +8,24 @@ jobs:
|
||||
debug_build:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
|
||||
- name: "Set up Python"
|
||||
uses: actions/setup-python@v5
|
||||
- name: Install poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
- name: Install the project dependencies
|
||||
run: poetry install
|
||||
- name: build app
|
||||
run: poetry build
|
||||
enable-cache: true
|
||||
|
||||
- name: Build fastanime
|
||||
run: uv build
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fastanime_debug_build
|
||||
path: |
|
||||
dist
|
||||
!dist/*.whl
|
||||
# - name: Run the automated tests (for example)
|
||||
# run: poetry run pytest -v
|
||||
|
||||
12
.github/workflows/publish.yml
vendored
12
.github/workflows/publish.yml
vendored
@@ -27,11 +27,13 @@ jobs:
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Build release distributions
|
||||
run: |
|
||||
# NOTE: put your own distribution build steps here.
|
||||
python -m pip install build
|
||||
python -m build
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Build fastanime
|
||||
run: uv build
|
||||
|
||||
- name: Upload distributions
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
40
.github/workflows/test.yml
vendored
40
.github/workflows/test.yml
vendored
@@ -6,37 +6,35 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11"] # List the Python versions you want to test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install poetry
|
||||
uses: abatilo/actions-poetry@v2
|
||||
- name: Setup a local virtual environment (if no poetry.toml file)
|
||||
run: |
|
||||
poetry config virtualenvs.create true --local
|
||||
poetry config virtualenvs.in-project true --local
|
||||
- uses: actions/cache@v3
|
||||
name: Define a cache for the virtual environment based on the dependencies lock file
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
path: ./.venv
|
||||
key: venv-${{ hashFiles('poetry.lock') }}
|
||||
- name: Install the project dependencies
|
||||
run: poetry install
|
||||
- name: run linter, formatters and sort imports
|
||||
run: |
|
||||
poetry run black .
|
||||
poetry run ruff check --output-format=github . --fix
|
||||
poetry run isort . --profile black
|
||||
- name: run type checking
|
||||
run: poetry run pyright
|
||||
- name: run tests
|
||||
run: poetry run pytest
|
||||
enable-cache: true
|
||||
|
||||
- name: Install the project
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Run linter and formater
|
||||
run: uv run ruff check --output-format=github
|
||||
|
||||
- name: Run type checking
|
||||
run: uv run pyright
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest tests
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -176,3 +176,4 @@ app/View/SearchScreen/.search_screen.py.un~
|
||||
app/View/SearchScreen/search_screen.py~
|
||||
app/user_data.json
|
||||
.buildozer
|
||||
result
|
||||
|
||||
40
DISCLAIMER.md
Normal file
40
DISCLAIMER.md
Normal file
@@ -0,0 +1,40 @@
|
||||
<h1 align="center">Disclaimer</h1>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<h2>This project: fastanime</h2>
|
||||
|
||||
<br>
|
||||
|
||||
The core aim of this project is to co-relate automation and efficiency to extract what is provided to a user on the internet. All content available through the project is hosted by external non-affiliated sources.
|
||||
|
||||
<br>
|
||||
|
||||
<b>All content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site.</b>
|
||||
|
||||
Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to only make requests associated with getting the content served by the sites.
|
||||
|
||||
<b>
|
||||
|
||||
This project is to be used at the user's own risk, based on their government and laws.
|
||||
|
||||
This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk.
|
||||
|
||||
</b>
|
||||
|
||||
|
||||
<br>
|
||||
|
||||
<h2>DMCA and Copyright Infrigements</h3>
|
||||
|
||||
<br>
|
||||
|
||||
<b>
|
||||
|
||||
A browser is a tool, and the maliciousness of the tool is directly based on the user.
|
||||
</b>
|
||||
|
||||
|
||||
This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. This is one of the main reasons the sites are listed in this project.
|
||||
|
||||
<b>Do not harass the developer. Any personal information about the developer is intentionally not made public. Exploiting such information without consent in regards to this topic will lead to legal actions by the developer themselves.</b>
|
||||
11
Dockerfile
11
Dockerfile
@@ -1,10 +1,7 @@
|
||||
FROM ubuntu
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install python3
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install pipx
|
||||
RUN pipx ensurepath
|
||||
FROM python:3.12-slim-bookworm
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
COPY . /fastanime
|
||||
ENV PATH=/root/.local/bin:$PATH
|
||||
WORKDIR /fastanime
|
||||
RUN pipx install .
|
||||
RUN uv tool install .
|
||||
CMD ["bash"]
|
||||
|
||||
5
fa
5
fa
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env sh
|
||||
# exec "${PYTHON:-python3}" -Werror -Xdev -m "$(dirname "$(realpath "$0")")/fastanime" "$@"
|
||||
cd "$(dirname "$(realpath "$0")")" || exit 1
|
||||
exec python -m fastanime "$@"
|
||||
CLI_DIR="$(dirname "$(realpath "$0")")"
|
||||
exec uv run --directory "$CLI_DIR/../" fastanime "$@"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .libs.anime_provider import anime_sources
|
||||
@@ -12,13 +10,12 @@ from .libs.anime_provider import anime_sources
|
||||
if TYPE_CHECKING:
|
||||
from typing import Iterator
|
||||
|
||||
from .libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
from .libs.anime_provider.types import Anime, SearchResults, Server
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: improve performance of this class and add cool features like auto retry
|
||||
# TODO: add cool features like auto retry
|
||||
class AnimeProvider:
|
||||
"""Class that manages all anime sources adding some extra functionality to them.
|
||||
Attributes:
|
||||
@@ -33,25 +30,41 @@ class AnimeProvider:
|
||||
PROVIDERS = list(anime_sources.keys())
|
||||
provider = PROVIDERS[0]
|
||||
|
||||
def __init__(self, provider, dynamic=False, retries=0) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
provider,
|
||||
cache_requests=os.environ.get("FASTANIME_CACHE_REQUESTS", "false"),
|
||||
use_persistent_provider_store=os.environ.get(
|
||||
"FASTANIME_USE_PERSISTENT_PROVIDER_STORE", "false"
|
||||
),
|
||||
dynamic=False,
|
||||
retries=0,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.dynamic = dynamic
|
||||
self.retries = retries
|
||||
self.lazyload_provider()
|
||||
self.cache_requests = cache_requests
|
||||
self.use_persistent_provider_store = use_persistent_provider_store
|
||||
self.lazyload_provider(self.provider)
|
||||
|
||||
def lazyload_provider(self):
|
||||
def lazyload_provider(self, provider):
|
||||
"""updates the current provider being used"""
|
||||
_, anime_provider_cls_name = anime_sources[self.provider].split(".", 1)
|
||||
package = f"fastanime.libs.anime_provider.{self.provider}"
|
||||
try:
|
||||
self.anime_provider.session.kill_connection_to_db()
|
||||
except Exception:
|
||||
pass
|
||||
_, anime_provider_cls_name = anime_sources[provider].split(".", 1)
|
||||
package = f"fastanime.libs.anime_provider.{provider}"
|
||||
provider_api = importlib.import_module(".api", package)
|
||||
anime_provider = getattr(provider_api, anime_provider_cls_name)
|
||||
self.anime_provider = anime_provider()
|
||||
self.anime_provider = anime_provider(
|
||||
self.cache_requests, self.use_persistent_provider_store
|
||||
)
|
||||
|
||||
def search_for_anime(
|
||||
self,
|
||||
user_query,
|
||||
translation_type,
|
||||
anilist_obj: "AnilistBaseMediaDataSchema | None" = None,
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
) -> "SearchResults | None":
|
||||
@@ -68,19 +81,15 @@ class AnimeProvider:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.search_for_anime(
|
||||
user_query, translation_type, nsfw, unknown
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
results = None
|
||||
results = anime_provider.search_for_anime(
|
||||
user_query, translation_type, nsfw, unknown
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def get_anime(
|
||||
self,
|
||||
anime_id: str,
|
||||
anilist_obj: "AnilistBaseMediaDataSchema | None" = None,
|
||||
) -> "Anime | None":
|
||||
"""core abstraction over getting info of an anime from all providers
|
||||
|
||||
@@ -92,19 +101,15 @@ class AnimeProvider:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.get_anime(anime_id)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
results = None
|
||||
results = anime_provider.get_anime(anime_id)
|
||||
|
||||
return results
|
||||
|
||||
def get_episode_streams(
|
||||
self,
|
||||
anime,
|
||||
anime_id,
|
||||
episode: str,
|
||||
translation_type: str,
|
||||
anilist_obj: "AnilistBaseMediaDataSchema|None" = None,
|
||||
) -> "Iterator[Server] | None":
|
||||
"""core abstractions for getting juicy streams from all providers
|
||||
|
||||
@@ -118,11 +123,7 @@ class AnimeProvider:
|
||||
[TODO:return]
|
||||
"""
|
||||
anime_provider = self.anime_provider
|
||||
try:
|
||||
results = anime_provider.get_episode_streams(
|
||||
anime, episode, translation_type
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(e)
|
||||
results = None
|
||||
return results # pyright:ignore
|
||||
results = anime_provider.get_episode_streams(
|
||||
anime_id, episode, translation_type
|
||||
)
|
||||
return results
|
||||
|
||||
105
fastanime/MangaProvider.py
Normal file
105
fastanime/MangaProvider.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .libs.manga_provider import manga_sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MangaProvider:
|
||||
"""Class that manages all anime sources adding some extra functionality to them.
|
||||
Attributes:
|
||||
PROVIDERS: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
dynamic: [TODO:attribute]
|
||||
retries: [TODO:attribute]
|
||||
manga_provider: [TODO:attribute]
|
||||
"""
|
||||
|
||||
PROVIDERS = list(manga_sources.keys())
|
||||
provider = PROVIDERS[0]
|
||||
|
||||
def __init__(self, provider="mangadex", dynamic=False, retries=0) -> None:
|
||||
self.provider = provider
|
||||
self.dynamic = dynamic
|
||||
self.retries = retries
|
||||
self.lazyload_provider(self.provider)
|
||||
|
||||
def lazyload_provider(self, provider):
|
||||
"""updates the current provider being used"""
|
||||
_, anime_provider_cls_name = manga_sources[provider].split(".", 1)
|
||||
package = f"fastanime.libs.manga_provider.{provider}"
|
||||
provider_api = importlib.import_module(".api", package)
|
||||
manga_provider = getattr(provider_api, anime_provider_cls_name)
|
||||
self.manga_provider = manga_provider()
|
||||
|
||||
def search_for_manga(
|
||||
self,
|
||||
user_query,
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
):
|
||||
"""core abstraction over all providers search functionality
|
||||
|
||||
Args:
|
||||
user_query ([TODO:parameter]): [TODO:description]
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
nsfw ([TODO:parameter]): [TODO:description]
|
||||
manga_provider ([TODO:parameter]): [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.search_for_manga(user_query, nsfw, unknown)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results
|
||||
|
||||
def get_manga(
|
||||
self,
|
||||
anime_id: str,
|
||||
):
|
||||
"""core abstraction over getting info of an anime from all providers
|
||||
|
||||
Args:
|
||||
anime_id: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.get_manga(anime_id)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results
|
||||
|
||||
def get_chapter_thumbnails(
|
||||
self,
|
||||
manga_id: str,
|
||||
chapter: str,
|
||||
):
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.get_chapter_thumbnails(manga_id, chapter)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results # pyright:ignore
|
||||
@@ -0,0 +1,4 @@
|
||||
"""This package exist as away to expose functions and classes that my be useful to a developer using the fastanime library
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistDateObject, AnilistMediaNextAiringEpisode
|
||||
|
||||
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
|
||||
|
||||
|
||||
# TODO: Add formating options for the final date
|
||||
def format_anilist_date_object(anilist_date_object: AnilistDateObject):
|
||||
if anilist_date_object:
|
||||
def format_anilist_date_object(anilist_date_object: "AnilistDateObject"):
|
||||
if anilist_date_object and anilist_date_object["day"]:
|
||||
return f"{anilist_date_object['day']}/{anilist_date_object['month']}/{anilist_date_object['year']}"
|
||||
else:
|
||||
return "Unknown"
|
||||
@@ -25,7 +30,13 @@ def format_list_data_with_comma(data: list | None):
|
||||
return "None"
|
||||
|
||||
|
||||
def extract_next_airing_episode(airing_episode: AnilistMediaNextAiringEpisode):
|
||||
def format_number_with_commas(number: int | None):
|
||||
if not number:
|
||||
return "0"
|
||||
return COMMA_REGEX.sub(lambda match: f"{match.group(1)},", str(number)[::-1])[::-1]
|
||||
|
||||
|
||||
def extract_next_airing_episode(airing_episode: "AnilistMediaNextAiringEpisode"):
|
||||
if airing_episode:
|
||||
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
|
||||
else:
|
||||
|
||||
@@ -3,12 +3,27 @@ Just contains some useful data used across the codebase
|
||||
"""
|
||||
|
||||
# useful incases where the anilist title is too different from the provider title
|
||||
anime_normalizer = {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
anime_normalizer_raw = {
|
||||
"allanime": {
|
||||
"1P": "one piece",
|
||||
"Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica",
|
||||
"Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka",
|
||||
'Hazurewaku no "Joutai Ijou Skill" de Saikyou ni Natta Ore ga Subete wo Juurin suru made': "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made",
|
||||
"Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season",
|
||||
},
|
||||
"hianime": {"My Star": "Oshi no Ko"},
|
||||
"animepahe": {"Azumanga Daiou The Animation": "Azumanga Daioh"},
|
||||
"nyaa": {},
|
||||
"yugen": {},
|
||||
}
|
||||
|
||||
|
||||
anilist_sort_normalizer = {"search match": "SEARCH_MATCH"}
|
||||
def get_anime_normalizer():
|
||||
"""Used because there are different providers"""
|
||||
import os
|
||||
|
||||
current_provider = os.environ.get("FASTANIME_PROVIDER", "allanime")
|
||||
return anime_normalizer_raw[current_provider]
|
||||
|
||||
|
||||
anime_normalizer = get_anime_normalizer()
|
||||
|
||||
6
fastanime/Utility/downloader/_yt_dlp.py
Normal file
6
fastanime/Utility/downloader/_yt_dlp.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from yt_dlp import YoutubeDL
|
||||
|
||||
|
||||
# TODO: create a class that makes yt-dlp's YoutubeDL fit in more with fastanime
|
||||
class YtDlp(YoutubeDL):
|
||||
pass
|
||||
@@ -1,8 +1,14 @@
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
|
||||
import yt_dlp
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,6 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class YtDLPDownloader:
|
||||
downloads_queue = Queue()
|
||||
_thread = None
|
||||
|
||||
def _worker(self):
|
||||
while True:
|
||||
@@ -20,29 +27,178 @@ class YtDLPDownloader:
|
||||
logger.error(f"Something went wrong {e}")
|
||||
self.downloads_queue.task_done()
|
||||
|
||||
def __init__(self):
|
||||
self._thread = Thread(target=self._worker)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
def _download_file(
|
||||
self,
|
||||
url: str,
|
||||
anime_title: str,
|
||||
episode_title: str,
|
||||
download_dir: str,
|
||||
silent: bool,
|
||||
progress_hooks=[],
|
||||
vid_format: str = "best",
|
||||
force_unknown_ext=False,
|
||||
verbose=False,
|
||||
headers={},
|
||||
sub="",
|
||||
merge=False,
|
||||
clean=False,
|
||||
prompt=True,
|
||||
):
|
||||
"""Helper function that downloads anime given url and path details
|
||||
|
||||
# Function to download the file
|
||||
# TODO: untpack the title to its actual values episode_title and anime_title
|
||||
def _download_file(self, url: str, download_dir, title, silent, vid_format="best"):
|
||||
anime_title = sanitize_filename(title[0])
|
||||
episode_title = sanitize_filename(title[1])
|
||||
Args:
|
||||
url: [TODO:description]
|
||||
anime_title: [TODO:description]
|
||||
episode_title: [TODO:description]
|
||||
download_dir: [TODO:description]
|
||||
silent: [TODO:description]
|
||||
vid_format: [TODO:description]
|
||||
"""
|
||||
anime_title = sanitize_filename(anime_title)
|
||||
episode_title = sanitize_filename(episode_title)
|
||||
if url.endswith(".torrent"):
|
||||
WEBTORRENT_CLI = shutil.which("webtorrent")
|
||||
if not WEBTORRENT_CLI:
|
||||
import time
|
||||
|
||||
print(
|
||||
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
|
||||
)
|
||||
time.sleep(120)
|
||||
return
|
||||
cmd = [
|
||||
WEBTORRENT_CLI,
|
||||
"download",
|
||||
url,
|
||||
"--out",
|
||||
os.path.join(download_dir, anime_title, episode_title),
|
||||
]
|
||||
subprocess.run(cmd)
|
||||
return
|
||||
ydl_opts = {
|
||||
# Specify the output path and template
|
||||
"http_headers": headers,
|
||||
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s",
|
||||
"silent": silent,
|
||||
"verbose": False,
|
||||
"verbose": verbose,
|
||||
"format": vid_format,
|
||||
"compat_opts": ("allow-unsafe-ext",) if force_unknown_ext else tuple(),
|
||||
"progress_hooks": progress_hooks,
|
||||
}
|
||||
urls = [url]
|
||||
if sub:
|
||||
urls.append(sub)
|
||||
vid_path = ""
|
||||
sub_path = ""
|
||||
for i, url in enumerate(urls):
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
if not info:
|
||||
continue
|
||||
if i == 0:
|
||||
vid_path: str = info["requested_downloads"][0]["filepath"]
|
||||
if vid_path.endswith(".unknown_video"):
|
||||
print("Normalizing path...")
|
||||
_vid_path = vid_path.replace(".unknown_video", ".mp4")
|
||||
shutil.move(vid_path, _vid_path)
|
||||
vid_path = _vid_path
|
||||
print("successfully normalized path")
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
ydl.download([url])
|
||||
else:
|
||||
sub_path = info["requested_downloads"][0]["filepath"]
|
||||
if sub_path and vid_path and merge:
|
||||
self.merge_subtitles(vid_path, sub_path, clean, prompt)
|
||||
|
||||
def download_file(self, url: str, title, silent=True):
|
||||
self.downloads_queue.put((self._download_file, (url, title, silent)))
|
||||
def merge_subtitles(self, video_path, sub_path, clean, prompt):
|
||||
# Extract the directory and filename
|
||||
video_dir = os.path.dirname(video_path)
|
||||
video_name = os.path.basename(video_path)
|
||||
video_name, _ = os.path.splitext(video_name)
|
||||
video_name += ".mkv"
|
||||
|
||||
FFMPEG_EXECUTABLE = shutil.which("ffmpeg")
|
||||
if not FFMPEG_EXECUTABLE:
|
||||
print("[yellow bold]WARNING: [/]FFmpeg not found")
|
||||
return
|
||||
# Create a temporary directory
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Temporary output path in the temporary directory
|
||||
temp_output_path = os.path.join(temp_dir, video_name)
|
||||
# FFmpeg command to merge subtitles
|
||||
command = [
|
||||
FFMPEG_EXECUTABLE,
|
||||
"-hide_banner",
|
||||
"-i",
|
||||
video_path,
|
||||
"-i",
|
||||
sub_path,
|
||||
"-c",
|
||||
"copy",
|
||||
"-map",
|
||||
"0",
|
||||
"-map",
|
||||
"1",
|
||||
temp_output_path,
|
||||
]
|
||||
|
||||
# Run the command
|
||||
try:
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
# Move the file back to the original directory with the original name
|
||||
final_output_path = os.path.join(video_dir, video_name)
|
||||
|
||||
if os.path.exists(final_output_path):
|
||||
if not prompt or Confirm.ask(
|
||||
f"File exists({final_output_path}) would you like to overwrite it",
|
||||
default=True,
|
||||
):
|
||||
# move file to dest
|
||||
os.remove(final_output_path)
|
||||
shutil.move(temp_output_path, final_output_path)
|
||||
else:
|
||||
shutil.move(temp_output_path, final_output_path)
|
||||
# clean up
|
||||
if clean:
|
||||
print("[cyan]Cleaning original files...[/]")
|
||||
os.remove(video_path)
|
||||
os.remove(sub_path)
|
||||
|
||||
print(
|
||||
f"[green bold]Subtitles merged successfully.[/] Output file: {final_output_path}"
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[red bold]Error[/] during merging subtitles: {e}")
|
||||
except Exception as e:
|
||||
print(f"[red bold]An error[/] occurred: {e}")
|
||||
|
||||
def download_file(
|
||||
self,
|
||||
url: str,
|
||||
anime_title: str,
|
||||
episode_title: str,
|
||||
download_dir: str,
|
||||
silent: bool = True,
|
||||
**kwargs,
|
||||
):
|
||||
"""A helper that just does things in the background
|
||||
|
||||
Args:
|
||||
title ([TODO:parameter]): [TODO:description]
|
||||
silent ([TODO:parameter]): [TODO:description]
|
||||
url: [TODO:description]
|
||||
"""
|
||||
if not self._thread:
|
||||
self._thread = Thread(target=self._worker)
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
self.downloads_queue.put(
|
||||
(
|
||||
self._download_file,
|
||||
(url, anime_title, episode_title, download_dir, silent),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
downloader = YtDLPDownloader()
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..constants import USER_DATA_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# TODO: merger this functionality with the config object
|
||||
class UserData:
|
||||
user_data = {"watch_history": {}, "animelist": [], "user": {}}
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
if os.path.isfile(USER_DATA_PATH):
|
||||
with open(USER_DATA_PATH, "r") as f:
|
||||
user_data = json.load(f)
|
||||
self.user_data.update(user_data)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def update_watch_history(self, watch_history: dict):
|
||||
self.user_data["watch_history"] = watch_history
|
||||
self._update_user_data()
|
||||
|
||||
def update_user_info(self, user: dict):
|
||||
self.user_data["user"] = user
|
||||
self._update_user_data()
|
||||
|
||||
def update_animelist(self, anime_list: list):
|
||||
self.user_data["animelist"] = list(set(anime_list))
|
||||
self._update_user_data()
|
||||
|
||||
def _update_user_data(self):
|
||||
with open(USER_DATA_PATH, "w") as f:
|
||||
json.dump(self.user_data, f)
|
||||
|
||||
|
||||
user_data_helper = UserData()
|
||||
@@ -1,79 +1,25 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
from fastanime.libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
|
||||
from .data import anime_normalizer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def remove_html_tags(text: str):
|
||||
clean = re.compile("<.*?>")
|
||||
return re.sub(clean, "", text)
|
||||
def sort_by_episode_number(filename: str):
|
||||
import re
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def sanitize_filename(filename: str):
|
||||
"""
|
||||
Sanitize a string to be safe for use as a file name.
|
||||
|
||||
:param filename: The original filename string.
|
||||
:return: A sanitized filename string.
|
||||
"""
|
||||
# List of characters not allowed in filenames on various operating systems
|
||||
invalid_chars = r'[<>:"/\\|?*\0]'
|
||||
reserved_names = {
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
}
|
||||
|
||||
# Replace invalid characters with an underscore
|
||||
sanitized = re.sub(invalid_chars, " ", filename)
|
||||
|
||||
# Remove leading and trailing whitespace
|
||||
sanitized = sanitized.strip()
|
||||
|
||||
# Check for reserved filenames
|
||||
name, ext = os.path.splitext(sanitized)
|
||||
if name.upper() in reserved_names:
|
||||
name += "_file"
|
||||
sanitized = name + ext
|
||||
|
||||
# Ensure the filename is not empty
|
||||
if not sanitized:
|
||||
sanitized = "default_filename"
|
||||
|
||||
return sanitized
|
||||
match = re.search(r"\d+", filename)
|
||||
return int(match.group()) if match else 0
|
||||
|
||||
|
||||
def anime_title_percentage_match(
|
||||
possible_user_requested_anime_title: str, anime: AnilistBaseMediaDataSchema
|
||||
possible_user_requested_anime_title: str, anime: "AnilistBaseMediaDataSchema"
|
||||
) -> float:
|
||||
"""Returns the percentage match between the possible title and user title
|
||||
|
||||
@@ -84,23 +30,19 @@ def anime_title_percentage_match(
|
||||
Returns:
|
||||
int: the percentage match
|
||||
"""
|
||||
if normalized_anime_title := anime_normalizer.get(
|
||||
possible_user_requested_anime_title
|
||||
):
|
||||
possible_user_requested_anime_title = normalized_anime_title
|
||||
possible_user_requested_anime_title = anime_normalizer.get(
|
||||
possible_user_requested_anime_title, possible_user_requested_anime_title
|
||||
)
|
||||
# compares both the romaji and english names and gets highest Score
|
||||
title_a = str(anime["title"]["romaji"])
|
||||
title_b = str(anime["title"]["english"])
|
||||
percentage_ratio = max(
|
||||
*[
|
||||
fuzz.ratio(title.lower(), possible_user_requested_anime_title.lower())
|
||||
for title in anime["synonyms"]
|
||||
],
|
||||
fuzz.ratio(title_a.lower(), possible_user_requested_anime_title.lower()),
|
||||
fuzz.ratio(title_b.lower(), possible_user_requested_anime_title.lower()),
|
||||
)
|
||||
logger.info(f"{locals()}")
|
||||
return percentage_ratio
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
unsafe_filename = "CON:example?file*name.txt"
|
||||
safe_filename = sanitize_filename(unsafe_filename)
|
||||
print(safe_filename) # Output: 'CON_example_file_name.txt'
|
||||
|
||||
@@ -2,11 +2,11 @@ import sys
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
raise ImportError(
|
||||
"You are using an unsupported version of Python. Only Python versions 3.8 and above are supported by yt-dlp"
|
||||
"You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by FastAnime"
|
||||
) # noqa: F541
|
||||
|
||||
|
||||
__version__ = "v0.62.5"
|
||||
__version__ = "v2.8.3"
|
||||
|
||||
APP_NAME = "FastAnime"
|
||||
AUTHOR = "Benex254"
|
||||
|
||||
93
fastanime/api/__init__.py
Normal file
93
fastanime/api/__init__.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import FastAPI
|
||||
from requests import post
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ..AnimeProvider import AnimeProvider
|
||||
from ..Utility.data import anime_normalizer
|
||||
|
||||
app = FastAPI()
|
||||
anime_provider = AnimeProvider("allanime", "true", "true")
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
|
||||
|
||||
@app.get("/search")
|
||||
def search_for_anime(title: str, translation_type: Literal["dub", "sub"] = "sub"):
|
||||
return anime_provider.search_for_anime(title, translation_type)
|
||||
|
||||
|
||||
@app.get("/anime/{anime_id}")
|
||||
def get_anime(anime_id: str):
|
||||
return anime_provider.get_anime(anime_id)
|
||||
|
||||
|
||||
@app.get("/anime/{anime_id}/watch")
|
||||
def get_episode_streams(
|
||||
anime_id: str, episode: str, translation_type: Literal["sub", "dub"]
|
||||
):
|
||||
return anime_provider.get_episode_streams(anime_id, episode, translation_type)
|
||||
|
||||
|
||||
def get_anime_by_anilist_id(anilist_id: int):
|
||||
query = f"""
|
||||
query {{
|
||||
Media(id: {anilist_id}) {{
|
||||
id
|
||||
title {{
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}}
|
||||
synonyms
|
||||
episodes
|
||||
duration
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
response = post(ANILIST_ENDPOINT, json={"query": query}).json()
|
||||
return response["data"]["Media"]
|
||||
|
||||
|
||||
@app.get("/watch/{anilist_id}")
|
||||
def get_episode_streams_by_anilist_id(
|
||||
anilist_id: int, episode: str, translation_type: Literal["sub", "dub"]
|
||||
):
|
||||
anime = get_anime_by_anilist_id(anilist_id)
|
||||
if not anime:
|
||||
return
|
||||
if search_results := anime_provider.search_for_anime(
|
||||
str(anime["title"]["romaji"] or anime["title"]["english"]), translation_type
|
||||
):
|
||||
if not search_results["results"]:
|
||||
return
|
||||
|
||||
def match_title(possible_user_requested_anime_title):
|
||||
possible_user_requested_anime_title = anime_normalizer.get(
|
||||
possible_user_requested_anime_title, possible_user_requested_anime_title
|
||||
)
|
||||
title_a = str(anime["title"]["romaji"])
|
||||
title_b = str(anime["title"]["english"])
|
||||
percentage_ratio = max(
|
||||
*[
|
||||
fuzz.ratio(
|
||||
title.lower(), possible_user_requested_anime_title.lower()
|
||||
)
|
||||
for title in anime["synonyms"]
|
||||
],
|
||||
fuzz.ratio(
|
||||
title_a.lower(), possible_user_requested_anime_title.lower()
|
||||
),
|
||||
fuzz.ratio(
|
||||
title_b.lower(), possible_user_requested_anime_title.lower()
|
||||
),
|
||||
)
|
||||
return percentage_ratio
|
||||
|
||||
provider_anime = max(
|
||||
search_results["results"], key=lambda x: match_title(x["title"])
|
||||
)
|
||||
anime_provider.get_anime(provider_anime["id"])
|
||||
return anime_provider.get_episode_streams(
|
||||
provider_anime["id"], episode, translation_type
|
||||
)
|
||||
84
fastanime/assets/rofi_theme.rasi
Normal file
84
fastanime/assets/rofi_theme.rasi
Normal file
@@ -0,0 +1,84 @@
|
||||
// https://github.com/Wraient/curd/blob/main/rofi/selectanime.rasi
|
||||
// Go give there project a star!
|
||||
// Was too lazy to make my own preview, so I just used theirs
|
||||
|
||||
|
||||
configuration {
|
||||
font: "Sans 12";
|
||||
line-margin: 10;
|
||||
display-drun: "";
|
||||
}
|
||||
|
||||
* {
|
||||
background: #000000; /* Black background for everything */
|
||||
background-alt: #000000; /* Ensures no alternation */
|
||||
foreground: #CCCCCC;
|
||||
selected: #3584E4;
|
||||
active: #2E7D32;
|
||||
urgent: #C62828;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: false;
|
||||
background-color: rgba(0, 0, 0, 1); /* Solid black background */
|
||||
}
|
||||
|
||||
mainbox {
|
||||
padding: 50px 100px;
|
||||
background-color: rgba(0, 0, 0, 1); /* Ensures black background fills entire main area */
|
||||
children: [inputbar, listview];
|
||||
spacing: 20px;
|
||||
}
|
||||
|
||||
inputbar {
|
||||
background-color: #333333; /* Dark gray background for input bar */
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
children: [prompt, entry];
|
||||
}
|
||||
|
||||
prompt {
|
||||
enabled: true;
|
||||
padding: 8px;
|
||||
background-color: @selected;
|
||||
text-color: #000000;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 8px;
|
||||
background-color: #444444; /* Slightly lighter gray for visibility */
|
||||
text-color: #FFFFFF; /* White text to make typing visible */
|
||||
placeholder: "Search...";
|
||||
placeholder-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
listview {
|
||||
layout: vertical;
|
||||
spacing: 8px;
|
||||
lines: 10;
|
||||
background-color: @background; /* Consistent black background for list items */
|
||||
}
|
||||
|
||||
element {
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
background-color: @background; /* Uniform color for each list item */
|
||||
text-color: @foreground;
|
||||
}
|
||||
|
||||
element normal.normal {
|
||||
background-color: @background; /* Ensures no alternating color */
|
||||
}
|
||||
|
||||
element selected.normal {
|
||||
background-color: @selected;
|
||||
text-color: #FFFFFF;
|
||||
}
|
||||
|
||||
element-text {
|
||||
background-color: transparent;
|
||||
text-color: inherit;
|
||||
vertical-align: 0.5;
|
||||
}
|
||||
55
fastanime/assets/rofi_theme_confirm.rasi
Normal file
55
fastanime/assets/rofi_theme_confirm.rasi
Normal file
@@ -0,0 +1,55 @@
|
||||
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
|
||||
// Go give there project a star!
|
||||
// Was too lazy to make my own preview, so I just used theirs
|
||||
|
||||
configuration {
|
||||
font: "Sans 12";
|
||||
}
|
||||
|
||||
* {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
text-color: #FFFFFF;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: true;
|
||||
transparency: "real";
|
||||
background-color: @background-color;
|
||||
}
|
||||
|
||||
mainbox {
|
||||
children: [ message, listview, inputbar ];
|
||||
padding: 40% 30%;
|
||||
}
|
||||
|
||||
message {
|
||||
border: 0;
|
||||
padding: 10px;
|
||||
margin: 0 0 20px 0;
|
||||
font: "Sans Bold 24"; /* Increased font size and made it bold */
|
||||
}
|
||||
|
||||
inputbar {
|
||||
children: [ prompt, entry ];
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
prompt {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
listview {
|
||||
lines: 0;
|
||||
}
|
||||
|
||||
/* Style for the message text specifically */
|
||||
textbox {
|
||||
horizontal-align: 0.5; /* Center the text */
|
||||
font: "Sans Bold 24"; /* Match message font */
|
||||
}
|
||||
55
fastanime/assets/rofi_theme_input.rasi
Normal file
55
fastanime/assets/rofi_theme_input.rasi
Normal file
@@ -0,0 +1,55 @@
|
||||
// https://github.com/Wraient/curd/blob/main/rofi/userinput.rasi
|
||||
// Go give there project a star!
|
||||
// Was too lazy to make my own preview, so I just used theirs
|
||||
|
||||
configuration {
|
||||
font: "Sans 12";
|
||||
}
|
||||
|
||||
* {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
text-color: #FFFFFF;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: true;
|
||||
transparency: "real";
|
||||
background-color: @background-color;
|
||||
}
|
||||
|
||||
mainbox {
|
||||
children: [ message, listview, inputbar ];
|
||||
padding: 40% 30%;
|
||||
}
|
||||
|
||||
message {
|
||||
border: 0;
|
||||
padding: 10px;
|
||||
margin: 0 0 20px 0;
|
||||
font: "Sans Bold 24"; /* Increased font size and made it bold */
|
||||
}
|
||||
|
||||
inputbar {
|
||||
children: [ prompt, entry ];
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
prompt {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
entry {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
listview {
|
||||
lines: 0;
|
||||
}
|
||||
|
||||
/* Style for the message text specifically */
|
||||
textbox {
|
||||
horizontal-align: 0.5; /* Center the text */
|
||||
font: "Sans Bold 24"; /* Match message font */
|
||||
}
|
||||
122
fastanime/assets/rofi_theme_preview.rasi
Normal file
122
fastanime/assets/rofi_theme_preview.rasi
Normal file
@@ -0,0 +1,122 @@
|
||||
// Based on https://github.com/Wraient/curd/blob/main/rofi/selectanimepreview.rasi
|
||||
// Go give there project a star!
|
||||
// Was too lazy to make my own preview, so I just used theirs
|
||||
|
||||
// Colours
|
||||
* {
|
||||
background-color: transparent;
|
||||
background: #1D2330;
|
||||
background-transparent: #1D2330A0;
|
||||
text-color: #BBBBBB;
|
||||
text-color-selected: #FFFFFF;
|
||||
primary: #BB77BB;
|
||||
important: #BF616A;
|
||||
}
|
||||
|
||||
configuration {
|
||||
font: "Roboto 17";
|
||||
show-icons: true;
|
||||
}
|
||||
|
||||
window {
|
||||
fullscreen: true;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transparency: "real";
|
||||
background-color: @background-transparent;
|
||||
border: 0px;
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
mainbox {
|
||||
children: [prompt, inputbar-box, listview];
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
prompt {
|
||||
width: 100%;
|
||||
margin: 10px 0px 0px 30px;
|
||||
text-color: @important;
|
||||
font: "Roboto Bold 27";
|
||||
}
|
||||
|
||||
listview {
|
||||
layout: vertical;
|
||||
padding: 60px;
|
||||
dynamic: true;
|
||||
columns: 7;
|
||||
spacing: 20px;
|
||||
horizontal-align: center; /* Center the list items */
|
||||
}
|
||||
|
||||
inputbar-box {
|
||||
children: [dummy, inputbar, dummy];
|
||||
orientation: horizontal;
|
||||
expand: false;
|
||||
}
|
||||
|
||||
inputbar {
|
||||
children: [textbox-prompt, entry];
|
||||
margin: 0px;
|
||||
background-color: @primary;
|
||||
border: 4px;
|
||||
border-color: @primary;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
textbox-prompt {
|
||||
text-color: @background;
|
||||
horizontal-align: 0.5;
|
||||
vertical-align: 0.5;
|
||||
expand: false;
|
||||
}
|
||||
|
||||
entry {
|
||||
expand: false;
|
||||
padding: 8px;
|
||||
margin: -6px;
|
||||
horizontal-align: 0;
|
||||
width: 300;
|
||||
background-color: @background;
|
||||
border: 6px;
|
||||
border-color: @primary;
|
||||
border-radius: 8px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
element {
|
||||
children: [dummy, element-box, dummy];
|
||||
padding: 5px;
|
||||
orientation: vertical;
|
||||
border: 0px;
|
||||
border-radius: 16px;
|
||||
background-color: transparent; /* Default background */
|
||||
}
|
||||
|
||||
element selected {
|
||||
background-color: @primary; /* Solid color for selected item */
|
||||
}
|
||||
|
||||
element-box {
|
||||
children: [element-icon, element-text];
|
||||
orientation: vertical;
|
||||
expand: false;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
element-icon {
|
||||
padding: 10px;
|
||||
cursor: inherit;
|
||||
size: 33%;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
element-text {
|
||||
horizontal-align: 0.5;
|
||||
cursor: inherit;
|
||||
text-color: @text-color;
|
||||
}
|
||||
|
||||
element-text selected {
|
||||
text-color: @text-color-selected;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import click
|
||||
|
||||
from .. import __version__
|
||||
from ..libs.anime_provider import SERVERS_AVAILABLE, anime_sources
|
||||
from ..Utility.data import anilist_sort_normalizer
|
||||
from .commands import LazyGroup
|
||||
|
||||
commands = {
|
||||
@@ -15,6 +14,9 @@ commands = {
|
||||
"downloads": "downloads.downloads",
|
||||
"cache": "cache.cache",
|
||||
"completions": "completions.completions",
|
||||
"update": "update.update",
|
||||
"grab": "grab.grab",
|
||||
"serve": "serve.serve",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,12 +39,35 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
cls=LazyGroup,
|
||||
help="A command line application for streaming anime that provides a complete and featureful interface",
|
||||
short_help="Stream Anime",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# example of syncplay intergration
|
||||
fastanime --sync-play --server sharepoint search -t <anime-title>
|
||||
\b
|
||||
# --- or ---
|
||||
\b
|
||||
# to watch with anilist intergration
|
||||
fastanime --sync-play --server sharepoint anilist
|
||||
\b
|
||||
# downloading dubbed anime
|
||||
fastanime --dub download -t <anime>
|
||||
\b
|
||||
# use icons and fzf for a more elegant ui with preview
|
||||
fastanime --icons --preview --fzf anilist
|
||||
\b
|
||||
# use icons with default ui
|
||||
fastanime --icons --default anilist
|
||||
\b
|
||||
# viewing manga
|
||||
fastanime --manga search -t <manga-title>
|
||||
""",
|
||||
)
|
||||
@click.version_option(__version__, "--version")
|
||||
@click.option("--manga", "-m", help="Enable manga mode", is_flag=True)
|
||||
@click.option("--log", help="Allow logging to stdout", is_flag=True)
|
||||
@click.option("--log-file", help="Allow logging to a file", is_flag=True)
|
||||
@click.option("--rich-traceback", help="Use rich to output tracebacks", is_flag=True)
|
||||
@click.option("--update", help="Update fastanime to the latest version", is_flag=True)
|
||||
@click.option(
|
||||
"-p",
|
||||
"--provider",
|
||||
@@ -68,6 +93,11 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
type=bool,
|
||||
help="Continue from last episode?",
|
||||
)
|
||||
@click.option(
|
||||
"--local-history/--remote-history",
|
||||
type=bool,
|
||||
help="Whether to continue from local history or remote history",
|
||||
)
|
||||
@click.option(
|
||||
"--skip/--no-skip",
|
||||
type=bool,
|
||||
@@ -76,7 +106,14 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
@click.option(
|
||||
"-q",
|
||||
"--quality",
|
||||
type=click.Choice(["360", "720", "1080", "unknown"]),
|
||||
type=click.Choice(
|
||||
[
|
||||
"360",
|
||||
"480",
|
||||
"720",
|
||||
"1080",
|
||||
]
|
||||
),
|
||||
help="set the quality of the stream",
|
||||
)
|
||||
@click.option(
|
||||
@@ -85,6 +122,11 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
type=click.Choice(["dub", "sub"]),
|
||||
help="Anime language[dub/sub]",
|
||||
)
|
||||
@click.option(
|
||||
"-sl",
|
||||
"--sub-lang",
|
||||
help="Set the preferred language for subs",
|
||||
)
|
||||
@click.option(
|
||||
"-A/-no-A",
|
||||
"--auto-next/--no-auto-next",
|
||||
@@ -98,9 +140,9 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
help="Auto select anime title?",
|
||||
)
|
||||
@click.option(
|
||||
"-S",
|
||||
"--sort-by",
|
||||
type=click.Choice(anilist_sort_normalizer.keys()), # pyright: ignore
|
||||
"--normalize-titles/--no-normalize-titles",
|
||||
type=bool,
|
||||
help="whether to normalize anime and episode titls given by providers",
|
||||
)
|
||||
@click.option("-d", "--downloads-dir", type=click.Path(), help="Downloads location")
|
||||
@click.option("--fzf", is_flag=True, help="Use fzf for the ui")
|
||||
@@ -116,6 +158,9 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
@click.option("--sub", help="Set the translation type to sub", is_flag=True)
|
||||
@click.option("--rofi", help="Use rofi for the ui", is_flag=True)
|
||||
@click.option("--rofi-theme", help="Rofi theme to use", type=click.Path())
|
||||
@click.option(
|
||||
"--rofi-theme-preview", help="Rofi theme to use for previews", type=click.Path()
|
||||
)
|
||||
@click.option(
|
||||
"--rofi-theme-confirm",
|
||||
help="Rofi theme to use for the confirm prompt",
|
||||
@@ -127,25 +172,37 @@ signal.signal(signal.SIGINT, handle_exit)
|
||||
type=click.Path(),
|
||||
)
|
||||
@click.option(
|
||||
"--use-mpv-mod/--use-default-player", help="Whether to use python-mpv", type=bool
|
||||
"--use-python-mpv/--use-default-player", help="Whether to use python-mpv", type=bool
|
||||
)
|
||||
@click.option("--sync-play", "-sp", help="Use sync play", is_flag=True)
|
||||
@click.option(
|
||||
"--player",
|
||||
"-P",
|
||||
help="the player to use when streaming",
|
||||
type=click.Choice(["mpv", "vlc"]),
|
||||
)
|
||||
@click.option(
|
||||
"--fresh-requests", is_flag=True, help="Force the requests cache to be updated"
|
||||
)
|
||||
@click.pass_context
|
||||
def run_cli(
|
||||
ctx: click.Context,
|
||||
manga,
|
||||
log,
|
||||
log_file,
|
||||
rich_traceback,
|
||||
update,
|
||||
provider,
|
||||
server,
|
||||
format,
|
||||
continue_,
|
||||
local_history,
|
||||
skip,
|
||||
translation_type,
|
||||
sub_lang,
|
||||
quality,
|
||||
auto_next,
|
||||
auto_select,
|
||||
sort_by,
|
||||
normalize_titles,
|
||||
downloads_dir,
|
||||
fzf,
|
||||
default,
|
||||
@@ -156,13 +213,54 @@ def run_cli(
|
||||
sub,
|
||||
rofi,
|
||||
rofi_theme,
|
||||
rofi_theme_preview,
|
||||
rofi_theme_confirm,
|
||||
rofi_theme_input,
|
||||
use_mpv_mod,
|
||||
use_python_mpv,
|
||||
sync_play,
|
||||
player,
|
||||
fresh_requests,
|
||||
):
|
||||
import os
|
||||
|
||||
from .config import Config
|
||||
|
||||
ctx.obj = Config()
|
||||
if ctx.obj.check_for_updates and ctx.invoked_subcommand != "completions":
|
||||
from .app_updater import check_for_updates
|
||||
|
||||
print("Checking for updates...")
|
||||
print("So you can enjoy the latest features and bug fixes")
|
||||
print(
|
||||
"You can disable this by setting check_for_updates to False in the config"
|
||||
)
|
||||
is_latest, github_release_data = check_for_updates()
|
||||
if not is_latest:
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
from .app_updater import update_app
|
||||
from rich.prompt import Confirm
|
||||
|
||||
def _print_release(release_data):
|
||||
console = Console()
|
||||
body = Markdown(release_data["body"])
|
||||
tag = github_release_data["tag_name"]
|
||||
tag_title = release_data["name"]
|
||||
github_page_url = release_data["html_url"]
|
||||
console.print(f"Release Page: {github_page_url}")
|
||||
console.print(f"Tag: {tag}")
|
||||
console.print(f"Title: {tag_title}")
|
||||
console.print(body)
|
||||
|
||||
if Confirm.ask(
|
||||
"A new version of fastanime is available, would you like to update?"
|
||||
):
|
||||
_, release_json = update_app()
|
||||
print("Successfully updated")
|
||||
_print_release(release_json)
|
||||
exit(0)
|
||||
|
||||
ctx.obj.manga = manga
|
||||
if log:
|
||||
import logging
|
||||
|
||||
@@ -171,43 +269,55 @@ def run_cli(
|
||||
FORMAT = "%(message)s"
|
||||
|
||||
logging.basicConfig(
|
||||
level="NOTSET", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
|
||||
level=logging.DEBUG, format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("logging has been initialized")
|
||||
elif log_file:
|
||||
import logging
|
||||
|
||||
from ..constants import NOTIFIER_LOG_FILE_PATH
|
||||
from ..constants import LOG_FILE_PATH
|
||||
|
||||
format = "%(asctime)s%(levelname)s: %(message)s"
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
filename=NOTIFIER_LOG_FILE_PATH,
|
||||
filename=LOG_FILE_PATH,
|
||||
format=format,
|
||||
datefmt="[%d/%m/%Y@%H:%M:%S]",
|
||||
filemode="w",
|
||||
)
|
||||
else:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.CRITICAL)
|
||||
if rich_traceback:
|
||||
from rich.traceback import install
|
||||
|
||||
install()
|
||||
if update and None:
|
||||
from .app_updater import update_app
|
||||
|
||||
update_app()
|
||||
return
|
||||
|
||||
if fresh_requests:
|
||||
os.environ["FASTANIME_FRESH_REQUESTS"] = "1"
|
||||
if sync_play:
|
||||
ctx.obj.sync_play = sync_play
|
||||
if provider:
|
||||
ctx.obj.provider = provider
|
||||
if server:
|
||||
ctx.obj.server = server
|
||||
if format:
|
||||
ctx.obj.format = format
|
||||
if sub_lang:
|
||||
ctx.obj.sub_lang = sub_lang
|
||||
if ctx.get_parameter_source("continue_") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.continue_from_history = continue_
|
||||
if ctx.get_parameter_source("player") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.player = player
|
||||
if ctx.get_parameter_source("skip") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.skip = skip
|
||||
if (
|
||||
ctx.get_parameter_source("normalize_titles")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.normalize_titles = normalize_titles
|
||||
|
||||
if quality:
|
||||
ctx.obj.quality = quality
|
||||
@@ -215,26 +325,30 @@ def run_cli(
|
||||
ctx.obj.auto_next = auto_next
|
||||
if ctx.get_parameter_source("icons") == click.core.ParameterSource.COMMANDLINE:
|
||||
ctx.obj.icons = icons
|
||||
if (
|
||||
ctx.get_parameter_source("local_history")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.preferred_history = "local" if local_history else "remote"
|
||||
if (
|
||||
ctx.get_parameter_source("auto_select")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.auto_select = auto_select
|
||||
if (
|
||||
ctx.get_parameter_source("use_mpv_mod")
|
||||
ctx.get_parameter_source("use_python_mpv")
|
||||
== click.core.ParameterSource.COMMANDLINE
|
||||
):
|
||||
ctx.obj.use_mpv_mod = use_mpv_mod
|
||||
if sort_by:
|
||||
ctx.obj.sort_by = sort_by
|
||||
ctx.obj.use_python_mpv = use_python_mpv
|
||||
if downloads_dir:
|
||||
ctx.obj.downloads_dir = downloads_dir
|
||||
if translation_type:
|
||||
ctx.obj.translation_type = translation_type
|
||||
if fzf:
|
||||
ctx.obj.use_fzf = True
|
||||
if default:
|
||||
ctx.obj.use_fzf = False
|
||||
ctx.obj.use_rofi = False
|
||||
if fzf:
|
||||
ctx.obj.use_fzf = True
|
||||
if preview:
|
||||
ctx.obj.preview = True
|
||||
if no_preview:
|
||||
@@ -249,6 +363,10 @@ def run_cli(
|
||||
if rofi:
|
||||
from ..libs.rofi import Rofi
|
||||
|
||||
if rofi_theme_preview:
|
||||
ctx.obj.rofi_theme_preview = rofi_theme_preview
|
||||
Rofi.rofi_theme_preview = rofi_theme_preview
|
||||
|
||||
if rofi_theme:
|
||||
ctx.obj.rofi_theme = rofi_theme
|
||||
Rofi.rofi_theme = rofi_theme
|
||||
@@ -260,3 +378,4 @@ def run_cli(
|
||||
if rofi_theme_confirm:
|
||||
ctx.obj.rofi_theme_confirm = rofi_theme_confirm
|
||||
Rofi.rofi_theme_confirm = rofi_theme_confirm
|
||||
ctx.obj.set_fastanime_config_environs()
|
||||
|
||||
@@ -2,8 +2,9 @@ import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from subprocess import PIPE, Popen
|
||||
import os
|
||||
|
||||
import requests
|
||||
from rich import print
|
||||
@@ -15,90 +16,123 @@ API_URL = f"https://api.{GIT_REPO}/repos/{AUTHOR}/{APP_NAME}/releases/latest"
|
||||
|
||||
def check_for_updates():
|
||||
USER_AGENT = f"{APP_NAME} user"
|
||||
request = requests.get(
|
||||
API_URL,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
request = requests.get(
|
||||
API_URL,
|
||||
headers={
|
||||
"User-Agent": USER_AGENT,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
print("You are not connected to the internet")
|
||||
return True, {}
|
||||
|
||||
if request.status_code == 200:
|
||||
release_json = request.json()
|
||||
return (release_json["tag_name"] == __version__, release_json)
|
||||
remote_tag = list(
|
||||
map(int, release_json["tag_name"].replace("v", "").split("."))
|
||||
)
|
||||
local_tag = list(map(int, __version__.replace("v", "").split(".")))
|
||||
if (
|
||||
(remote_tag[0] > local_tag[0])
|
||||
or (remote_tag[1] > local_tag[1] and remote_tag[0] == local_tag[0])
|
||||
or (
|
||||
remote_tag[2] > local_tag[2]
|
||||
and remote_tag[0] == local_tag[0]
|
||||
and remote_tag[1] == local_tag[1]
|
||||
)
|
||||
):
|
||||
is_latest = False
|
||||
else:
|
||||
is_latest = True
|
||||
|
||||
return (is_latest, release_json)
|
||||
else:
|
||||
print("Failed to check for updates")
|
||||
print(request.text)
|
||||
return (False, {})
|
||||
return (True, {})
|
||||
|
||||
|
||||
def is_git_repo(author, repository):
|
||||
# Check if the current directory contains a .git folder
|
||||
if not pathlib.Path("./.git").exists():
|
||||
git_dir = pathlib.Path(".git")
|
||||
if not git_dir.exists() or not git_dir.is_dir():
|
||||
return False
|
||||
|
||||
repository_qualname = f"{author}/{repository}"
|
||||
|
||||
# Read the .git/config file to find the remote repository URL
|
||||
config_path = pathlib.Path("./.git/config")
|
||||
# Check if the config file exists
|
||||
config_path = git_dir / "config"
|
||||
if not config_path.exists():
|
||||
return False
|
||||
print("here")
|
||||
|
||||
with open(config_path, "r") as git_config:
|
||||
git_config_content = git_config.read()
|
||||
|
||||
# Use regex to find the repository URL in the config file
|
||||
repo_name_pattern = r"\[remote \"origin\"\]\s+url = .*\/([^/]+\/[^/]+)\.git"
|
||||
match = re.search(repo_name_pattern, git_config_content)
|
||||
print(match)
|
||||
|
||||
if match is None:
|
||||
try:
|
||||
# Read the .git/config file to find the remote repository URL
|
||||
with config_path.open("r") as git_config:
|
||||
git_config_content = git_config.read()
|
||||
except (FileNotFoundError, PermissionError):
|
||||
return False
|
||||
|
||||
# Extract the repository name and compare with the expected repository_qualname
|
||||
config_repo_name = match.group(1)
|
||||
return config_repo_name == repository_qualname
|
||||
# Use regex to find the repository URL in the config file
|
||||
repo_name_pattern = r"url\s*=\s*.+/([^/]+/[^/]+)\.git"
|
||||
match = re.search(repo_name_pattern, git_config_content)
|
||||
|
||||
# Return True if match found and repository name matches
|
||||
return bool(match) and match.group(1) == f"{author}/{repository}"
|
||||
|
||||
|
||||
def update_app():
|
||||
def update_app(force=False):
|
||||
is_latest, release_json = check_for_updates()
|
||||
if is_latest:
|
||||
if is_latest and not force:
|
||||
print("[green]App is up to date[/]")
|
||||
return
|
||||
return False, release_json
|
||||
tag_name = release_json["tag_name"]
|
||||
|
||||
print("[cyan]Updating app to version %s[/]" % tag_name)
|
||||
if is_git_repo(AUTHOR, APP_NAME):
|
||||
executable = shutil.which("git")
|
||||
if os.path.exists("/nix/store") and os.path.exists("/run/current-system"):
|
||||
NIX = shutil.which("nix")
|
||||
if not NIX:
|
||||
print("[red]Cannot find nix, it looks like your system is broken.[/]")
|
||||
return False, release_json
|
||||
|
||||
process = subprocess.run([NIX, "profile", "upgrade", APP_NAME.lower()])
|
||||
elif is_git_repo(AUTHOR, APP_NAME):
|
||||
GIT_EXECUTABLE = shutil.which("git")
|
||||
args = [
|
||||
executable,
|
||||
GIT_EXECUTABLE,
|
||||
"pull",
|
||||
]
|
||||
|
||||
print(f"Pulling latest changes from the repository via git: {shlex.join(args)}")
|
||||
|
||||
if not executable:
|
||||
return print("[red]Cannot find git.[/]")
|
||||
if not GIT_EXECUTABLE:
|
||||
print("[red]Cannot find git please install it.[/]")
|
||||
return False, release_json
|
||||
|
||||
process = Popen(
|
||||
process = subprocess.run(
|
||||
args,
|
||||
stdout=PIPE,
|
||||
stderr=PIPE,
|
||||
)
|
||||
|
||||
process.communicate()
|
||||
else:
|
||||
executable = sys.executable
|
||||
if UV := shutil.which("uv"):
|
||||
process = subprocess.run([UV, "tool", "upgrade", APP_NAME])
|
||||
elif PIPX := shutil.which("pipx"):
|
||||
process = subprocess.run([PIPX, "upgrade", APP_NAME])
|
||||
else:
|
||||
PYTHON_EXECUTABLE = sys.executable
|
||||
|
||||
args = [
|
||||
executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
APP_NAME,
|
||||
"--user",
|
||||
"--no-warn-script-location",
|
||||
]
|
||||
process = Popen(args)
|
||||
process.communicate()
|
||||
args = [
|
||||
PYTHON_EXECUTABLE,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
APP_NAME,
|
||||
"-U",
|
||||
"--user",
|
||||
"--no-warn-script-location",
|
||||
]
|
||||
process = subprocess.run(args)
|
||||
if process.returncode == 0:
|
||||
return True, release_json
|
||||
else:
|
||||
return False, release_json
|
||||
|
||||
@@ -20,6 +20,9 @@ commands = {
|
||||
"completed": "completed.completed",
|
||||
"planning": "planning.planning",
|
||||
"notifier": "notifier.notifier",
|
||||
"stats": "stats.stats",
|
||||
"download": "download.download",
|
||||
"downloads": "downloads.downloads",
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +32,53 @@ commands = {
|
||||
invoke_without_command=True,
|
||||
help="A beautiful interface that gives you access to a commplete streaming experience",
|
||||
short_help="Access all streaming options",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# ---- search ----
|
||||
\b
|
||||
# get anime with the tag of isekai
|
||||
fastanime anilist search -T isekai
|
||||
\b
|
||||
# get anime of 2024 and sort by popularity
|
||||
# that has already finished airing or is releasing
|
||||
# and is not in your anime lists
|
||||
fastanime anilist search -y 2024 -s POPULARITY_DESC --status RELEASING --status FINISHED --not-on-list
|
||||
\b
|
||||
# get anime of 2024 season WINTER
|
||||
fastanime anilist search -y 2024 --season WINTER
|
||||
\b
|
||||
# get anime genre action and tag isekai,magic
|
||||
fastanime anilist search -g Action -T Isekai -T Magic
|
||||
\b
|
||||
# get anime of 2024 thats finished airing
|
||||
fastanime anilist search -y 2024 -S FINISHED
|
||||
\b
|
||||
# get the most favourite anime movies
|
||||
fastanime anilist search -f MOVIE -s FAVOURITES_DESC
|
||||
\b
|
||||
# ---- login ----
|
||||
\b
|
||||
# To sign in just run
|
||||
fastanime anilist login
|
||||
\b
|
||||
# To view your login status
|
||||
fastanime anilist login --status
|
||||
\b
|
||||
# To erase login data
|
||||
fastanime anilist login --erase
|
||||
\b
|
||||
# ---- notifier ----
|
||||
\b
|
||||
# basic form
|
||||
fastanime anilist notifier
|
||||
\b
|
||||
# with logging to stdout
|
||||
fastanime --log anilist notifier
|
||||
\b
|
||||
# with logging to a file. stored in the same place as your config
|
||||
fastanime --log-file anilist notifier
|
||||
""",
|
||||
)
|
||||
@click.pass_context
|
||||
def anilist(ctx: click.Context):
|
||||
|
||||
@@ -7,16 +7,23 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you completed")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def completed(config: "Config"):
|
||||
def completed(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("COMPLETED")
|
||||
if not anime_list or not anime_list[1]:
|
||||
return
|
||||
@@ -27,6 +34,13 @@ def completed(config: "Config"):
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
476
fastanime/cli/commands/anilist/data.py
Normal file
476
fastanime/cli/commands/anilist/data.py
Normal file
@@ -0,0 +1,476 @@
|
||||
sorts_available = [
|
||||
"ID",
|
||||
"ID_DESC",
|
||||
"TITLE_ROMAJI",
|
||||
"TITLE_ROMAJI_DESC",
|
||||
"TITLE_ENGLISH",
|
||||
"TITLE_ENGLISH_DESC",
|
||||
"TITLE_NATIVE",
|
||||
"TITLE_NATIVE_DESC",
|
||||
"TYPE",
|
||||
"TYPE_DESC",
|
||||
"FORMAT",
|
||||
"FORMAT_DESC",
|
||||
"START_DATE",
|
||||
"START_DATE_DESC",
|
||||
"END_DATE",
|
||||
"END_DATE_DESC",
|
||||
"SCORE",
|
||||
"SCORE_DESC",
|
||||
"POPULARITY",
|
||||
"POPULARITY_DESC",
|
||||
"TRENDING",
|
||||
"TRENDING_DESC",
|
||||
"EPISODES",
|
||||
"EPISODES_DESC",
|
||||
"DURATION",
|
||||
"DURATION_DESC",
|
||||
"STATUS",
|
||||
"STATUS_DESC",
|
||||
"CHAPTERS",
|
||||
"CHAPTERS_DESC",
|
||||
"VOLUMES",
|
||||
"VOLUMES_DESC",
|
||||
"UPDATED_AT",
|
||||
"UPDATED_AT_DESC",
|
||||
"SEARCH_MATCH",
|
||||
"FAVOURITES",
|
||||
"FAVOURITES_DESC",
|
||||
]
|
||||
|
||||
media_statuses_available = [
|
||||
"FINISHED",
|
||||
"RELEASING",
|
||||
"NOT_YET_RELEASED",
|
||||
"CANCELLED",
|
||||
"HIATUS",
|
||||
]
|
||||
seasons_available = ["WINTER", "SPRING", "SUMMER", "FALL"]
|
||||
genres_available = [
|
||||
"Action",
|
||||
"Adventure",
|
||||
"Comedy",
|
||||
"Drama",
|
||||
"Ecchi",
|
||||
"Fantasy",
|
||||
"Horror",
|
||||
"Mahou Shoujo",
|
||||
"Mecha",
|
||||
"Music",
|
||||
"Mystery",
|
||||
"Psychological",
|
||||
"Romance",
|
||||
"Sci-Fi",
|
||||
"Slice of Life",
|
||||
"Sports",
|
||||
"Supernatural",
|
||||
"Thriller",
|
||||
"Hentai",
|
||||
]
|
||||
media_formats_available = [
|
||||
"TV",
|
||||
"TV_SHORT",
|
||||
"MOVIE",
|
||||
"SPECIAL",
|
||||
"OVA",
|
||||
"MUSIC",
|
||||
"NOVEL",
|
||||
"ONE_SHOT",
|
||||
]
|
||||
years_available = [
|
||||
"1900",
|
||||
"1910",
|
||||
"1920",
|
||||
"1930",
|
||||
"1940",
|
||||
"1950",
|
||||
"1960",
|
||||
"1970",
|
||||
"1980",
|
||||
"1990",
|
||||
"2000",
|
||||
"2004",
|
||||
"2005",
|
||||
"2006",
|
||||
"2007",
|
||||
"2008",
|
||||
"2009",
|
||||
"2010",
|
||||
"2011",
|
||||
"2012",
|
||||
"2013",
|
||||
"2014",
|
||||
"2015",
|
||||
"2016",
|
||||
"2017",
|
||||
"2018",
|
||||
"2019",
|
||||
"2020",
|
||||
"2021",
|
||||
"2022",
|
||||
"2023",
|
||||
"2024",
|
||||
]
|
||||
|
||||
tags_available = {
|
||||
"Cast": ["Polyamorous"],
|
||||
"Cast Main Cast": [
|
||||
"Anti-Hero",
|
||||
"Elderly Protagonist",
|
||||
"Ensemble Cast",
|
||||
"Estranged Family",
|
||||
"Female Protagonist",
|
||||
"Male Protagonist",
|
||||
"Primarily Adult Cast",
|
||||
"Primarily Animal Cast",
|
||||
"Primarily Child Cast",
|
||||
"Primarily Female Cast",
|
||||
"Primarily Male Cast",
|
||||
"Primarily Teen Cast",
|
||||
],
|
||||
"Cast Traits": [
|
||||
"Age Regression",
|
||||
"Agender",
|
||||
"Aliens",
|
||||
"Amnesia",
|
||||
"Angels",
|
||||
"Anthropomorphism",
|
||||
"Aromantic",
|
||||
"Arranged Marriage",
|
||||
"Artificial Intelligence",
|
||||
"Asexual",
|
||||
"Butler",
|
||||
"Centaur",
|
||||
"Chimera",
|
||||
"Chuunibyou",
|
||||
"Clone",
|
||||
"Cosplay",
|
||||
"Cowboys",
|
||||
"Crossdressing",
|
||||
"Cyborg",
|
||||
"Delinquents",
|
||||
"Demons",
|
||||
"Detective",
|
||||
"Dinosaurs",
|
||||
"Disability",
|
||||
"Dissociative Identities",
|
||||
"Dragons",
|
||||
"Dullahan",
|
||||
"Elf",
|
||||
"Fairy",
|
||||
"Femboy",
|
||||
"Ghost",
|
||||
"Goblin",
|
||||
"Gods",
|
||||
"Gyaru",
|
||||
"Hikikomori",
|
||||
"Homeless",
|
||||
"Idol",
|
||||
"Kemonomimi",
|
||||
"Kuudere",
|
||||
"Maids",
|
||||
"Mermaid",
|
||||
"Monster Boy",
|
||||
"Monster Girl",
|
||||
"Nekomimi",
|
||||
"Ninja",
|
||||
"Nudity",
|
||||
"Nun",
|
||||
"Office Lady",
|
||||
"Oiran",
|
||||
"Ojou-sama",
|
||||
"Orphan",
|
||||
"Pirates",
|
||||
"Robots",
|
||||
"Samurai",
|
||||
"Shrine Maiden",
|
||||
"Skeleton",
|
||||
"Succubus",
|
||||
"Tanned Skin",
|
||||
"Teacher",
|
||||
"Tomboy",
|
||||
"Transgender",
|
||||
"Tsundere",
|
||||
"Twins",
|
||||
"Vampire",
|
||||
"Veterinarian",
|
||||
"Vikings",
|
||||
"Villainess",
|
||||
"VTuber",
|
||||
"Werewolf",
|
||||
"Witch",
|
||||
"Yandere",
|
||||
"Zombie",
|
||||
],
|
||||
"Demographic": ["Josei", "Kids", "Seinen", "Shoujo", "Shounen"],
|
||||
"Setting": ["Matriarchy"],
|
||||
"Setting Scene": [
|
||||
"Bar",
|
||||
"Boarding School",
|
||||
"Circus",
|
||||
"Coastal",
|
||||
"College",
|
||||
"Desert",
|
||||
"Dungeon",
|
||||
"Foreign",
|
||||
"Inn",
|
||||
"Konbini",
|
||||
"Natural Disaster",
|
||||
"Office",
|
||||
"Outdoor",
|
||||
"Prison",
|
||||
"Restaurant",
|
||||
"Rural",
|
||||
"School",
|
||||
"School Club",
|
||||
"Snowscape",
|
||||
"Urban",
|
||||
"Work",
|
||||
],
|
||||
"Setting Time": [
|
||||
"Achronological Order",
|
||||
"Anachronism",
|
||||
"Ancient China",
|
||||
"Dystopian",
|
||||
"Historical",
|
||||
"Time Skip",
|
||||
],
|
||||
"Setting Universe": [
|
||||
"Afterlife",
|
||||
"Alternate Universe",
|
||||
"Augmented Reality",
|
||||
"Omegaverse",
|
||||
"Post-Apocalyptic",
|
||||
"Space",
|
||||
"Urban Fantasy",
|
||||
"Virtual World",
|
||||
],
|
||||
"Technical": [
|
||||
"4-koma",
|
||||
"Achromatic",
|
||||
"Advertisement",
|
||||
"Anthology",
|
||||
"CGI",
|
||||
"Episodic",
|
||||
"Flash",
|
||||
"Full CGI",
|
||||
"Full Color",
|
||||
"No Dialogue",
|
||||
"Non-fiction",
|
||||
"POV",
|
||||
"Puppetry",
|
||||
"Rotoscoping",
|
||||
"Stop Motion",
|
||||
],
|
||||
"Theme Action": [
|
||||
"Archery",
|
||||
"Battle Royale",
|
||||
"Espionage",
|
||||
"Fugitive",
|
||||
"Guns",
|
||||
"Martial Arts",
|
||||
"Spearplay",
|
||||
"Swordplay",
|
||||
],
|
||||
"Theme Arts": [
|
||||
"Acting",
|
||||
"Calligraphy",
|
||||
"Classic Literature",
|
||||
"Drawing",
|
||||
"Fashion",
|
||||
"Food",
|
||||
"Makeup",
|
||||
"Photography",
|
||||
"Rakugo",
|
||||
"Writing",
|
||||
],
|
||||
"Theme Arts-Music": [
|
||||
"Band",
|
||||
"Classical Music",
|
||||
"Dancing",
|
||||
"Hip-hop Music",
|
||||
"Jazz Music",
|
||||
"Metal Music",
|
||||
"Musical Theater",
|
||||
"Rock Music",
|
||||
],
|
||||
"Theme Comedy": ["Parody", "Satire", "Slapstick", "Surreal Comedy"],
|
||||
"Theme Drama": [
|
||||
"Bullying",
|
||||
"Class Struggle",
|
||||
"Coming of Age",
|
||||
"Conspiracy",
|
||||
"Eco-Horror",
|
||||
"Fake Relationship",
|
||||
"Kingdom Management",
|
||||
"Rehabilitation",
|
||||
"Revenge",
|
||||
"Suicide",
|
||||
"Tragedy",
|
||||
],
|
||||
"Theme Fantasy": [
|
||||
"Alchemy",
|
||||
"Body Swapping",
|
||||
"Cultivation",
|
||||
"Fairy Tale",
|
||||
"Henshin",
|
||||
"Isekai",
|
||||
"Kaiju",
|
||||
"Magic",
|
||||
"Mythology",
|
||||
"Necromancy",
|
||||
"Shapeshifting",
|
||||
"Steampunk",
|
||||
"Super Power",
|
||||
"Superhero",
|
||||
"Wuxia",
|
||||
"Youkai",
|
||||
],
|
||||
"Theme Game": ["Board Game", "E-Sports", "Video Games"],
|
||||
"Theme Game-Card & Board Game": [
|
||||
"Card Battle",
|
||||
"Go",
|
||||
"Karuta",
|
||||
"Mahjong",
|
||||
"Poker",
|
||||
"Shogi",
|
||||
],
|
||||
"Theme Game-Sport": [
|
||||
"Acrobatics",
|
||||
"Airsoft",
|
||||
"American Football",
|
||||
"Athletics",
|
||||
"Badminton",
|
||||
"Baseball",
|
||||
"Basketball",
|
||||
"Bowling",
|
||||
"Boxing",
|
||||
"Cheerleading",
|
||||
"Cycling",
|
||||
"Fencing",
|
||||
"Fishing",
|
||||
"Fitness",
|
||||
"Football",
|
||||
"Golf",
|
||||
"Handball",
|
||||
"Ice Skating",
|
||||
"Judo",
|
||||
"Lacrosse",
|
||||
"Parkour",
|
||||
"Rugby",
|
||||
"Scuba Diving",
|
||||
"Skateboarding",
|
||||
"Sumo",
|
||||
"Surfing",
|
||||
"Swimming",
|
||||
"Table Tennis",
|
||||
"Tennis",
|
||||
"Volleyball",
|
||||
"Wrestling",
|
||||
],
|
||||
"Theme Other": [
|
||||
"Adoption",
|
||||
"Animals",
|
||||
"Astronomy",
|
||||
"Autobiographical",
|
||||
"Biographical",
|
||||
"Body Horror",
|
||||
"Cannibalism",
|
||||
"Chibi",
|
||||
"Cosmic Horror",
|
||||
"Crime",
|
||||
"Crossover",
|
||||
"Death Game",
|
||||
"Denpa",
|
||||
"Drugs",
|
||||
"Economics",
|
||||
"Educational",
|
||||
"Environmental",
|
||||
"Ero Guro",
|
||||
"Filmmaking",
|
||||
"Found Family",
|
||||
"Gambling",
|
||||
"Gender Bending",
|
||||
"Gore",
|
||||
"Language Barrier",
|
||||
"LGBTQ+ Themes",
|
||||
"Lost Civilization",
|
||||
"Marriage",
|
||||
"Medicine",
|
||||
"Memory Manipulation",
|
||||
"Meta",
|
||||
"Mountaineering",
|
||||
"Noir",
|
||||
"Otaku Culture",
|
||||
"Pandemic",
|
||||
"Philosophy",
|
||||
"Politics",
|
||||
"Proxy Battle",
|
||||
"Psychosexual",
|
||||
"Reincarnation",
|
||||
"Religion",
|
||||
"Royal Affairs",
|
||||
"Slavery",
|
||||
"Software Development",
|
||||
"Survival",
|
||||
"Terrorism",
|
||||
"Torture",
|
||||
"Travel",
|
||||
"War",
|
||||
],
|
||||
"Theme Other-Organisations": [
|
||||
"Assassins",
|
||||
"Criminal Organization",
|
||||
"Cult",
|
||||
"Firefighters",
|
||||
"Gangs",
|
||||
"Mafia",
|
||||
"Military",
|
||||
"Police",
|
||||
"Triads",
|
||||
"Yakuza",
|
||||
],
|
||||
"Theme Other-Vehicle": [
|
||||
"Aviation",
|
||||
"Cars",
|
||||
"Mopeds",
|
||||
"Motorcycles",
|
||||
"Ships",
|
||||
"Tanks",
|
||||
"Trains",
|
||||
],
|
||||
"Theme Romance": [
|
||||
"Age Gap",
|
||||
"Bisexual",
|
||||
"Boys' Love",
|
||||
"Female Harem",
|
||||
"Heterosexual",
|
||||
"Love Triangle",
|
||||
"Male Harem",
|
||||
"Matchmaking",
|
||||
"Mixed Gender Harem",
|
||||
"Teens' Love",
|
||||
"Unrequited Love",
|
||||
"Yuri",
|
||||
],
|
||||
"Theme Sci Fi": [
|
||||
"Cyberpunk",
|
||||
"Space Opera",
|
||||
"Time Loop",
|
||||
"Time Manipulation",
|
||||
"Tokusatsu",
|
||||
],
|
||||
"Theme Sci Fi-Mecha": ["Real Robot", "Super Robot"],
|
||||
"Theme Slice of Life": [
|
||||
"Agriculture",
|
||||
"Cute Boys Doing Cute Things",
|
||||
"Cute Girls Doing Cute Things",
|
||||
"Family Life",
|
||||
"Horticulture",
|
||||
"Iyashikei",
|
||||
"Parenthood",
|
||||
],
|
||||
}
|
||||
tags_available_list = []
|
||||
for tag_category, tags_in_category in tags_available.items():
|
||||
tags_available_list.extend(tags_in_category)
|
||||
382
fastanime/cli/commands/anilist/download.py
Normal file
382
fastanime/cli/commands/anilist/download.py
Normal file
@@ -0,0 +1,382 @@
|
||||
import click
|
||||
|
||||
|
||||
from ...completion_functions import anime_titles_shell_complete
|
||||
from .data import (
|
||||
tags_available_list,
|
||||
sorts_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
years_available,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="download anime using anilists api to get the titles",
|
||||
short_help="download anime with anilist intergration",
|
||||
)
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-F",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.option(
|
||||
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="After merging delete the original files",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-time",
|
||||
"-w",
|
||||
type=int,
|
||||
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
|
||||
default=60,
|
||||
)
|
||||
@click.option(
|
||||
"--prompt/--no-prompt",
|
||||
help="Whether to prompt for anything instead just do the best thing",
|
||||
default=True,
|
||||
)
|
||||
@click.option(
|
||||
"--max-results", "-M", type=int, help="The maximum number of results to show"
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(
|
||||
config,
|
||||
title,
|
||||
season,
|
||||
status,
|
||||
sort,
|
||||
genres,
|
||||
tags,
|
||||
media_format,
|
||||
year,
|
||||
on_list,
|
||||
episode_range,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
max_results,
|
||||
):
|
||||
from ....anilist import AniList
|
||||
from rich import print
|
||||
|
||||
success, anilist_search_results = AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
max_results=max_results,
|
||||
)
|
||||
if success:
|
||||
import time
|
||||
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ....AnimeProvider import AnimeProvider
|
||||
from ....libs.anime_provider.types import Anime
|
||||
from ....libs.fzf import fzf
|
||||
from ....Utility.data import anime_normalizer
|
||||
from ....Utility.downloader.downloader import downloader
|
||||
from ...utils.tools import exit_app
|
||||
from ...utils.utils import (
|
||||
filter_by_quality,
|
||||
fuzzy_inquirer,
|
||||
move_preferred_subtitle_lang_to_top,
|
||||
)
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
translation_type = config.translation_type
|
||||
download_dir = config.downloads_dir
|
||||
anime_titles = [
|
||||
(
|
||||
anime["title"][config.preferred_language]
|
||||
or anime["title"]["english"]
|
||||
or anime["title"]["romaji"]
|
||||
)
|
||||
for anime in anilist_search_results["data"]["Page"]["media"]
|
||||
]
|
||||
print(f"[green bold]Queued:[/] {anime_titles}")
|
||||
for i, anime_title in enumerate(anime_titles):
|
||||
print(f"[green bold]Now Downloading: [/] {anime_title}")
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print(
|
||||
"No search results found from provider for {}".format(anime_title)
|
||||
)
|
||||
continue
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Nothing muches your search term")
|
||||
continue
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
selected_anime_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto selecting:[/] ", selected_anime_title)
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
selected_anime_title = fzf.run(
|
||||
choices, "Please Select title", "FastAnime"
|
||||
)
|
||||
else:
|
||||
selected_anime_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select title",
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[selected_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
print("Failed to fetch anime {}".format(selected_anime_title))
|
||||
continue
|
||||
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
print(f"[green bold]Downloading: [/] {episodes_range}")
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if config.normalize_titles:
|
||||
anilist_anime_info = anilist_search_results["data"]["Page"]["media"][i]
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
episode = str(episode)
|
||||
if episode not in episodes:
|
||||
print(
|
||||
f"[cyan]Warning[/]: Episode {episode} not found, skipping"
|
||||
)
|
||||
continue
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("No streams skipping")
|
||||
continue
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server_name = next(streams, None)
|
||||
if not server_name:
|
||||
print("Sth went wrong when fetching the server")
|
||||
continue
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, server_name["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = server_name["headers"]
|
||||
episode_title = server_name["episode_title"]
|
||||
subtitles = server_name["subtitles"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server_name = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server_name = fzf.run(servers_names, "Select an link")
|
||||
else:
|
||||
server_name = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server_name]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = servers[server_name]["headers"]
|
||||
|
||||
subtitles = servers[server_name]["subtitles"]
|
||||
episode_title = servers[server_name]["episode_title"]
|
||||
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["streamingEpisodes"]:
|
||||
if re.match(
|
||||
f".*Episode {episode} .*", episode_detail["title"]
|
||||
):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(f"[purple]Now Downloading:[/] {episode_title}")
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
downloader._download_file(
|
||||
link,
|
||||
selected_anime_title,
|
||||
episode_title,
|
||||
download_dir,
|
||||
silent,
|
||||
vid_format=config.format,
|
||||
force_unknown_ext=force_unknown_ext,
|
||||
verbose=verbose,
|
||||
headers=provider_headers,
|
||||
sub=subtitles[0]["url"] if subtitles else "",
|
||||
merge=merge,
|
||||
clean=clean,
|
||||
prompt=prompt,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
print("Done Downloading")
|
||||
time.sleep(wait_time)
|
||||
exit_app()
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
print("Failed to search for anime", anilist_search_results)
|
||||
exit(1)
|
||||
358
fastanime/cli/commands/anilist/downloads.py
Normal file
358
fastanime/cli/commands/anilist/downloads.py
Normal file
@@ -0,0 +1,358 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv",
|
||||
short_help="Watch downloads",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
fastanime downloads
|
||||
\b
|
||||
# view individual episodes
|
||||
fastanime downloads --view-episodes
|
||||
# --- or ---
|
||||
fastanime downloads -v
|
||||
\b
|
||||
# to set seek time when using ffmpegthumbnailer for local previews
|
||||
# -1 means random and is the default
|
||||
fastanime downloads --time-to-seek <intRange(-1,100)>
|
||||
# --- or ---
|
||||
fastanime downloads -t <intRange(-1,100)>
|
||||
\b
|
||||
# to watch a specific title
|
||||
# be sure to get the completions for the best experience
|
||||
fastanime downloads --title <title>
|
||||
\b
|
||||
# to get the path to the downloads folder set
|
||||
fastanime downloads --path
|
||||
# useful when you want to use the value for other programs
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ....cli.utils.mpv import run_mpv
|
||||
from ....libs.fzf import fzf
|
||||
from ....libs.rofi import Rofi
|
||||
from ....Utility.utils import sort_by_episode_number
|
||||
from ...utils.tools import exit_app
|
||||
from ...utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
return
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import fzf_preview
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for anime_title in anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ....constants import APP_CACHE_DIR
|
||||
from ...utils.scripts import fzf_preview
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(
|
||||
episode_path,
|
||||
player=config.player,
|
||||
)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
if config.sync_play:
|
||||
from ...utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(
|
||||
playlist,
|
||||
player=config.player,
|
||||
)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
@@ -7,26 +7,40 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you dropped")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def dropped(config: "Config"):
|
||||
def dropped(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("DROPPED")
|
||||
if not anime_list:
|
||||
return
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
return
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
@@ -5,14 +5,30 @@ import click
|
||||
help="Fetch the top 15 most favourited anime from anilist",
|
||||
short_help="View most favourited anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def favourites(config):
|
||||
def favourites(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anime_data = AniList.get_most_favourite()
|
||||
if anime_data[0]:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -8,41 +8,54 @@ if TYPE_CHECKING:
|
||||
|
||||
@click.command(help="Login to your anilist account")
|
||||
@click.option("--status", "-s", help="Whether you are logged in or not", is_flag=True)
|
||||
@click.option("--erase", "-e", help="Erase your login details", is_flag=True)
|
||||
@click.pass_obj
|
||||
def login(config: "Config", status):
|
||||
from click import launch
|
||||
def login(config: "Config", status, erase):
|
||||
from sys import exit
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm, Prompt
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...utils.tools import exit_app
|
||||
|
||||
if status:
|
||||
is_logged_in = True if config.user else False
|
||||
message = (
|
||||
"You are logged in :happy:" if is_logged_in else "You arent logged in :cry"
|
||||
"You are logged in :smile:" if is_logged_in else "You arent logged in :cry:"
|
||||
)
|
||||
print(message)
|
||||
print(config.user)
|
||||
exit_app()
|
||||
if config.user:
|
||||
print("Already logged in :confused:")
|
||||
if not Confirm.ask("or would you like to reloggin", default=True):
|
||||
exit_app()
|
||||
# ---- new loggin -----
|
||||
print(
|
||||
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
|
||||
)
|
||||
launch(config.fastanime_anilist_app_login_url, wait=True)
|
||||
print("Please paste the token provided here")
|
||||
token = Prompt.ask("Enter token")
|
||||
user = AniList.login_user(token)
|
||||
if not user:
|
||||
print("Sth went wrong", user)
|
||||
exit_app()
|
||||
return
|
||||
user["token"] = token
|
||||
config.update_user(user)
|
||||
print("Successfully saved credentials")
|
||||
print(user)
|
||||
exit_app()
|
||||
exit(0)
|
||||
elif erase:
|
||||
if Confirm.ask(
|
||||
"Are you sure you want to erase your login status", default=False
|
||||
):
|
||||
config.update_user({})
|
||||
print("Success")
|
||||
exit(0)
|
||||
else:
|
||||
exit(1)
|
||||
else:
|
||||
from click import launch
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
if config.user:
|
||||
print("Already logged in :confused:")
|
||||
if not Confirm.ask("or would you like to reloggin", default=True):
|
||||
exit(0)
|
||||
# ---- new loggin -----
|
||||
print(
|
||||
f"A browser session will be opened ( [link]{config.fastanime_anilist_app_login_url}[/link] )",
|
||||
)
|
||||
launch(config.fastanime_anilist_app_login_url, wait=True)
|
||||
print("Please paste the token provided here")
|
||||
token = Prompt.ask("Enter token")
|
||||
user = AniList.login_user(token)
|
||||
if not user:
|
||||
print("Sth went wrong", user)
|
||||
exit(1)
|
||||
return
|
||||
user["token"] = token
|
||||
config.update_user(user)
|
||||
print("Successfully saved credentials")
|
||||
print(user)
|
||||
exit(0)
|
||||
|
||||
@@ -13,9 +13,15 @@ def notifier(config: "Config"):
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from sys import exit
|
||||
|
||||
import requests
|
||||
from plyer import notification
|
||||
|
||||
try:
|
||||
from plyer import notification
|
||||
except ImportError:
|
||||
print("Please install plyer to use this command")
|
||||
exit(1)
|
||||
|
||||
from ....anilist import AniList
|
||||
from ....constants import APP_CACHE_DIR, APP_DATA_DIR, APP_NAME, ICON_PATH, PLATFORM
|
||||
@@ -30,7 +36,7 @@ def notifier(config: "Config"):
|
||||
if not config.user:
|
||||
print("Not Authenticated")
|
||||
print("Run the following to get started: fastanime anilist loggin")
|
||||
return
|
||||
exit(1)
|
||||
run = True
|
||||
# WARNING: Mess around with this value at your own risk
|
||||
timeout = 2 # time is in minutes
|
||||
|
||||
@@ -7,26 +7,40 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you paused on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def paused(config: "Config"):
|
||||
def paused(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("PAUSED")
|
||||
if not anime_list:
|
||||
return
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
return
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
anilist_config = FastAnimeRuntimeState()
|
||||
anilist_config.data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, anilist_config)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anilist_config = FastAnimeRuntimeState()
|
||||
anilist_config.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, anilist_config)
|
||||
|
||||
@@ -7,26 +7,40 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you are planning on watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def planning(config: "Config"):
|
||||
def planning(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("PLANNING")
|
||||
if not anime_list:
|
||||
return
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
return
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
@@ -4,14 +4,30 @@ import click
|
||||
@click.command(
|
||||
help="Fetch the top 15 most popular anime", short_help="View most popular anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def popular(config):
|
||||
def popular(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anime_data = AniList.get_most_popular()
|
||||
if anime_data[0]:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -5,23 +5,35 @@ import click
|
||||
help="Get random anime from anilist based on a range of anilist anime ids that are seected at random",
|
||||
short_help="View random anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def random_anime(config):
|
||||
def random_anime(config, dump_json):
|
||||
import random
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
random_anime = range(1, 15000)
|
||||
random_anime = range(1, 100000)
|
||||
|
||||
random_anime = random.sample(random_anime, k=50)
|
||||
|
||||
anime_data = AniList.search(id_in=list(random_anime))
|
||||
|
||||
if anime_data[0]:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
print(anime_data[1])
|
||||
exit(1)
|
||||
|
||||
@@ -5,14 +5,30 @@ import click
|
||||
help="Fetch the 15 most recently updated anime from anilist that are currently releasing",
|
||||
short_help="View recently updated anime",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def recent(config):
|
||||
def recent(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anime_data = AniList.get_most_recently_updated()
|
||||
if anime_data[0]:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -7,26 +7,40 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you are rewatching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def rewatching(config: "Config"):
|
||||
def rewatching(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("REPEATING")
|
||||
if not anime_list:
|
||||
return
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
return
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
@@ -4,14 +4,30 @@ import click
|
||||
@click.command(
|
||||
help="Fetch the 15 most scored anime", short_help="View most scored anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def scores(config):
|
||||
def scores(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
anime_data = AniList.get_most_scored()
|
||||
if anime_data[0]:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_data[1]))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_data[1]
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -1,21 +1,119 @@
|
||||
import click
|
||||
|
||||
from ...completion_functions import anime_titles_shell_complete
|
||||
from .data import (
|
||||
tags_available_list,
|
||||
sorts_available,
|
||||
media_statuses_available,
|
||||
seasons_available,
|
||||
genres_available,
|
||||
media_formats_available,
|
||||
years_available,
|
||||
)
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Search for anime using anilists api and get top ~50 results",
|
||||
short_help="Search for anime",
|
||||
)
|
||||
@click.argument(
|
||||
"title",
|
||||
@click.option("--title", "-t", shell_complete=anime_titles_shell_complete)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.option(
|
||||
"--season",
|
||||
help="The season the media was released",
|
||||
type=click.Choice(seasons_available),
|
||||
)
|
||||
@click.option(
|
||||
"--status",
|
||||
"-S",
|
||||
help="The media status of the anime",
|
||||
multiple=True,
|
||||
type=click.Choice(media_statuses_available),
|
||||
)
|
||||
@click.option(
|
||||
"--sort",
|
||||
"-s",
|
||||
help="What to sort the search results on",
|
||||
type=click.Choice(sorts_available),
|
||||
)
|
||||
@click.option(
|
||||
"--genres",
|
||||
"-g",
|
||||
multiple=True,
|
||||
help="the genres to filter by",
|
||||
type=click.Choice(genres_available),
|
||||
)
|
||||
@click.option(
|
||||
"--tags",
|
||||
"-T",
|
||||
multiple=True,
|
||||
help="the tags to filter by",
|
||||
type=click.Choice(tags_available_list),
|
||||
)
|
||||
@click.option(
|
||||
"--media-format",
|
||||
"-f",
|
||||
multiple=True,
|
||||
help="Media format",
|
||||
type=click.Choice(media_formats_available),
|
||||
)
|
||||
@click.option(
|
||||
"--year",
|
||||
"-y",
|
||||
type=click.Choice(years_available),
|
||||
help="the year the media was released",
|
||||
)
|
||||
@click.option(
|
||||
"--on-list/--not-on-list",
|
||||
"-L/-no-L",
|
||||
help="Whether the anime should be in your list or not",
|
||||
type=bool,
|
||||
)
|
||||
@click.pass_obj
|
||||
def search(config, title):
|
||||
def search(
|
||||
config,
|
||||
title,
|
||||
dump_json,
|
||||
season,
|
||||
status,
|
||||
sort,
|
||||
genres,
|
||||
tags,
|
||||
media_format,
|
||||
year,
|
||||
on_list,
|
||||
):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
success, search_results = AniList.search(title)
|
||||
success, search_results = AniList.search(
|
||||
query=title,
|
||||
sort=sort,
|
||||
status_in=list(status),
|
||||
genre_in=list(genres),
|
||||
season=season,
|
||||
tag_in=list(tags),
|
||||
seasonYear=year,
|
||||
format_in=list(media_format),
|
||||
on_list=on_list,
|
||||
)
|
||||
if success:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = search_results
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(search_results))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = search_results
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
63
fastanime/cli/commands/anilist/stats.py
Normal file
63
fastanime/cli/commands/anilist/stats.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...config import Config
|
||||
|
||||
|
||||
@click.command(help="Print out your anilist stats")
|
||||
@click.pass_obj
|
||||
def stats(
|
||||
config: "Config",
|
||||
):
|
||||
import shutil
|
||||
import subprocess
|
||||
from sys import exit
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
from rich.markdown import Markdown
|
||||
from rich.panel import Panel
|
||||
|
||||
from ....anilist import AniList
|
||||
|
||||
user_data = AniList.get_user_info()
|
||||
if not user_data[0] or not user_data[1]:
|
||||
print("Failed to get user info")
|
||||
print(user_data[1])
|
||||
exit(1)
|
||||
|
||||
KITTEN_EXECUTABLE = shutil.which("kitten")
|
||||
if not KITTEN_EXECUTABLE:
|
||||
print("Kitten not found")
|
||||
exit(1)
|
||||
|
||||
image_url = user_data[1]["data"]["User"]["avatar"]["medium"]
|
||||
user_name = user_data[1]["data"]["User"]["name"]
|
||||
about = user_data[1]["data"]["User"]["about"] or ""
|
||||
console.clear()
|
||||
image_x = int(console.size.width * 0.1)
|
||||
image_y = int(console.size.height * 0.1)
|
||||
img_w = console.size.width // 3
|
||||
img_h = console.size.height // 3
|
||||
image_process = subprocess.run(
|
||||
[
|
||||
KITTEN_EXECUTABLE,
|
||||
"icat",
|
||||
"--clear",
|
||||
"--place",
|
||||
f"{img_w}x{img_h}@{image_x}x{image_y}",
|
||||
image_url,
|
||||
],
|
||||
)
|
||||
if not image_process.returncode == 0:
|
||||
print("failed to get image from icat")
|
||||
exit(1)
|
||||
console.print(
|
||||
Panel(
|
||||
Markdown(about),
|
||||
title=user_name,
|
||||
)
|
||||
)
|
||||
@@ -5,14 +5,30 @@ import click
|
||||
help="Fetch the top 15 anime that are currently trending",
|
||||
short_help="Trending anime 🔥🔥🔥",
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def trending(config):
|
||||
def trending(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
success, data = AniList.get_trending()
|
||||
if success:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -4,14 +4,30 @@ import click
|
||||
@click.command(
|
||||
help="Fetch the 15 most anticipited anime", short_help="View upcoming anime"
|
||||
)
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def upcoming(config):
|
||||
def upcoming(config, dump_json):
|
||||
from ....anilist import AniList
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
success, data = AniList.get_upcoming_anime()
|
||||
if success:
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(data))
|
||||
else:
|
||||
from ...interfaces.anilist_interfaces import anilist_results_menu
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = data
|
||||
anilist_results_menu(config, fastanime_runtime_state)
|
||||
else:
|
||||
from sys import exit
|
||||
|
||||
exit(1)
|
||||
|
||||
@@ -7,26 +7,40 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
@click.command(help="View anime you are watching")
|
||||
@click.option(
|
||||
"--dump-json",
|
||||
"-d",
|
||||
is_flag=True,
|
||||
help="Only print out the results dont open anilist menu",
|
||||
)
|
||||
@click.pass_obj
|
||||
def watching(config: "Config"):
|
||||
def watching(config: "Config", dump_json):
|
||||
from sys import exit
|
||||
|
||||
from ....anilist import AniList
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState, exit_app
|
||||
|
||||
if not config.user:
|
||||
print("Not authenticated")
|
||||
print("Please run: fastanime anilist loggin")
|
||||
exit_app()
|
||||
exit(1)
|
||||
anime_list = AniList.get_anime_list("CURRENT")
|
||||
if not anime_list:
|
||||
return
|
||||
exit(1)
|
||||
if not anime_list[0] or not anime_list[1]:
|
||||
return
|
||||
exit(1)
|
||||
media = [
|
||||
mediaListItem["media"]
|
||||
for mediaListItem in anime_list[1]["data"]["Page"]["mediaList"]
|
||||
] # pyright:ignore
|
||||
anime_list[1]["data"]["Page"]["media"] = media # pyright:ignore
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
if dump_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(anime_list[1]))
|
||||
else:
|
||||
from ...interfaces import anilist_interfaces
|
||||
from ...utils.tools import FastAnimeRuntimeState
|
||||
|
||||
fastanime_runtime_state = FastAnimeRuntimeState()
|
||||
fastanime_runtime_state.anilist_results_data = anime_list[1]
|
||||
anilist_interfaces.anilist_results_menu(config, fastanime_runtime_state)
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(help="Helper command to manage cache")
|
||||
@click.command(
|
||||
help="Helper command to manage cache",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# delete everything in the cache dir
|
||||
fastanime cache --clean
|
||||
\b
|
||||
# print the path to the cache dir and exit
|
||||
fastanime cache --path
|
||||
\b
|
||||
# print the current size of the cache dir and exit
|
||||
fastanime cache --size
|
||||
\b
|
||||
# open the cache dir and exit
|
||||
fastanime cache
|
||||
""",
|
||||
)
|
||||
@click.option("--clean", help="Clean the cache dir", is_flag=True)
|
||||
@click.option("--path", help="The path to the cache dir", is_flag=True)
|
||||
@click.option("--size", help="The size of the cache dir", is_flag=True)
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(help="Helper command to get shell completions")
|
||||
@click.option("--fish", is_flag=True)
|
||||
@click.option("--zsh", is_flag=True)
|
||||
@click.option("--bash", is_flag=True)
|
||||
@click.command(
|
||||
help="Helper command to get shell completions",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# try to detect your shell and print completions
|
||||
fastanime completions
|
||||
\b
|
||||
# print fish completions
|
||||
fastanime completions --fish
|
||||
\b
|
||||
# print bash completions
|
||||
fastanime completions --bash
|
||||
\b
|
||||
# print zsh completions
|
||||
fastanime completions --zsh
|
||||
""",
|
||||
)
|
||||
@click.option("--fish", is_flag=True, help="print fish completions")
|
||||
@click.option("--zsh", is_flag=True, help="print zsh completions")
|
||||
@click.option("--bash", is_flag=True, help="print bash completions")
|
||||
def completions(fish, zsh, bash):
|
||||
if not fish or not zsh or not bash:
|
||||
import os
|
||||
|
||||
@@ -1,48 +1,120 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Opens up your fastanime config in your preferred editor",
|
||||
help="Manage your config with ease",
|
||||
short_help="Edit your config",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Edit your config in your default editor
|
||||
# NB: If it opens vim or vi exit with `:q`
|
||||
fastanime config
|
||||
\b
|
||||
# get the path of the config file
|
||||
fastanime config --path
|
||||
\b
|
||||
# print desktop entry info
|
||||
fastanime config --desktop-entry
|
||||
\b
|
||||
# update your config without opening an editor
|
||||
fastanime --icons --fzf --preview config --update
|
||||
\b
|
||||
# view the current contents of your config
|
||||
fastanime config --view
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="Print the config location and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--view", "-v", help="View the current contents of your config", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--desktop-entry",
|
||||
"-d",
|
||||
help="Configure the desktop entry of fastanime",
|
||||
is_flag=True,
|
||||
)
|
||||
# @click.pass_obj
|
||||
def config(path, desktop_entry):
|
||||
pass
|
||||
@click.option(
|
||||
"--update",
|
||||
"-u",
|
||||
help="Persist all the config options passed to fastanime to your config file",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def config(user_config: "Config", path, view, desktop_entry, update):
|
||||
import sys
|
||||
|
||||
from pyshortcuts import make_shortcut
|
||||
from rich import print
|
||||
|
||||
from ...constants import APP_NAME, ICON_PATH, USER_CONFIG_PATH
|
||||
from ... import __version__
|
||||
from ...constants import APP_NAME, ICON_PATH, S_PLATFORM, USER_CONFIG_PATH
|
||||
|
||||
if path:
|
||||
print(USER_CONFIG_PATH)
|
||||
elif view:
|
||||
print(user_config)
|
||||
elif desktop_entry:
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
from rich import print
|
||||
from rich.prompt import Confirm
|
||||
|
||||
from ..utils.tools import exit_app
|
||||
|
||||
FASTANIME_EXECUTABLE = shutil.which("fastanime")
|
||||
if FASTANIME_EXECUTABLE:
|
||||
cmds = f"{FASTANIME_EXECUTABLE} --rofi anilist"
|
||||
else:
|
||||
cmds = "_ -m fastanime --rofi anilist"
|
||||
shortcut = make_shortcut(
|
||||
name=APP_NAME,
|
||||
description="Watch Anime from the terminal",
|
||||
icon=ICON_PATH,
|
||||
script=cmds,
|
||||
terminal=False,
|
||||
)
|
||||
if shortcut:
|
||||
print("Success", shortcut)
|
||||
else:
|
||||
print("Failed")
|
||||
else:
|
||||
import click
|
||||
cmds = f"{sys.executable} -m fastanime --rofi anilist"
|
||||
|
||||
# TODO: Get funs of the other platforms to complete this lol
|
||||
if S_PLATFORM == "win32":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of windows to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
elif S_PLATFORM == "darwin":
|
||||
print(
|
||||
"Not implemented; the author thinks its not straight forward so welcomes lovers of mac to try and implement it themselves or to switch to a proper os like arch linux or pray the author gets bored 😜"
|
||||
)
|
||||
else:
|
||||
desktop_entry = dedent(
|
||||
f"""
|
||||
[Desktop Entry]
|
||||
Name={APP_NAME}
|
||||
Type=Application
|
||||
version={__version__}
|
||||
Path={Path().home()}
|
||||
Comment=Watch anime from your terminal
|
||||
Terminal=false
|
||||
Icon={ICON_PATH}
|
||||
Exec={cmds}
|
||||
Categories=Entertainment
|
||||
"""
|
||||
)
|
||||
base = os.path.expanduser("~/.local/share/applications")
|
||||
desktop_entry_path = os.path.join(base, f"{APP_NAME}.desktop")
|
||||
if os.path.exists(desktop_entry_path):
|
||||
if not Confirm.ask(
|
||||
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",
|
||||
default=False,
|
||||
):
|
||||
exit_app(1)
|
||||
with open(desktop_entry_path, "w") as f:
|
||||
f.write(desktop_entry)
|
||||
with open(desktop_entry_path) as f:
|
||||
print(f"Successfully wrote \n{f.read()}")
|
||||
exit_app(0)
|
||||
elif update:
|
||||
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as file:
|
||||
file.write(user_config.__str__())
|
||||
print("update successfull")
|
||||
else:
|
||||
click.edit(filename=USER_CONFIG_PATH)
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
@@ -9,25 +11,125 @@ if TYPE_CHECKING:
|
||||
@click.command(
|
||||
help="Download anime using the anime provider for a specified range",
|
||||
short_help="Download anime",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# Download all available episodes
|
||||
# multiple titles can be specified with -t option
|
||||
fastanime download -t <anime-title> -t <anime-title>
|
||||
# -- or --
|
||||
fastanime download -t <anime-title> -t <anime-title> -r ':'
|
||||
\b
|
||||
# download latest episode for the two anime titles
|
||||
# the number can be any no of latest episodes but a minus sign
|
||||
# must be present
|
||||
fastanime download -t <anime-title> -t <anime-title> -r '-1'
|
||||
\b
|
||||
# latest 5
|
||||
fastanime download -t <anime-title> -t <anime-title> -r '-5'
|
||||
\b
|
||||
# Download specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>:<step>'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:<episodes-end>'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r '<episodes-start>:'
|
||||
\b
|
||||
fastanime download -t <anime-title> -r ':<episodes-end>'
|
||||
\b
|
||||
# download specific episode
|
||||
# remember python indexing starts at 0
|
||||
fastanime download -t <anime-title> -r '<episode-1>:<episode>'
|
||||
\b
|
||||
# merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files
|
||||
# and dont prompt for anything
|
||||
# eg existing file in destination instead remove
|
||||
# and clean
|
||||
# ie remove original files (sub file and vid file)
|
||||
# only keep merged files
|
||||
fastanime download -t <anime-title> --merge --clean --no-prompt
|
||||
\b
|
||||
# EOF is used since -t always expects a title
|
||||
# you can supply anime titles from file or -t at the same time
|
||||
# from stdin
|
||||
echo -e "<anime-title>\\n<anime-title>\\n<anime-title>" | fastanime download -t "EOF" -r <range> -f -
|
||||
\b
|
||||
# from file
|
||||
fastanime download -t "EOF" -r <range> -f <file-path>
|
||||
""",
|
||||
)
|
||||
@click.argument(
|
||||
"anime-title",
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--highest_priority",
|
||||
"-h",
|
||||
help="Choose stream indicated as highest priority",
|
||||
"--file",
|
||||
"-f",
|
||||
type=click.File(),
|
||||
help="A file to read from all anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--force-unknown-ext",
|
||||
"-F",
|
||||
help="This option forces yt-dlp to download extensions its not aware of",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--silent/--no-silent",
|
||||
"-q/-V",
|
||||
type=bool,
|
||||
help="Download silently (during download)",
|
||||
default=True,
|
||||
)
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Download verbosely (everywhere)")
|
||||
@click.option(
|
||||
"--merge", "-m", is_flag=True, help="Merge the subfile with video using ffmpeg"
|
||||
)
|
||||
@click.option(
|
||||
"--clean",
|
||||
"-c",
|
||||
is_flag=True,
|
||||
help="After merging delete the original files",
|
||||
)
|
||||
@click.option(
|
||||
"--wait-time",
|
||||
"-w",
|
||||
type=int,
|
||||
help="The amount of time to wait after downloading is complete before the screen is completely cleared",
|
||||
default=60,
|
||||
)
|
||||
@click.option(
|
||||
"--prompt/--no-prompt",
|
||||
help="Whether to prompt for anything instead just do the best thing",
|
||||
default=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def download(config: "Config", anime_title, episode_range, highest_priority):
|
||||
from click import clear
|
||||
def download(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
):
|
||||
import time
|
||||
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
@@ -35,124 +137,243 @@ def download(config: "Config", anime_title, episode_range, highest_priority):
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...libs.fzf import fzf
|
||||
from ...Utility.data import anime_normalizer
|
||||
from ...Utility.downloader.downloader import downloader
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import filter_by_quality, fuzzy_inquirer
|
||||
from ..utils.utils import (
|
||||
filter_by_quality,
|
||||
fuzzy_inquirer,
|
||||
move_preferred_subtitle_lang_to_top,
|
||||
)
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
translation_type = config.translation_type
|
||||
download_dir = config.downloads_dir
|
||||
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=translation_type
|
||||
if file:
|
||||
contents = file.read()
|
||||
anime_titles_from_file = tuple(
|
||||
[title for title in contents.split("\n") if title]
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results failed")
|
||||
input("Enter to retry")
|
||||
download(config, anime_title, episode_range, highest_priority)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
file.close()
|
||||
|
||||
if config.auto_select:
|
||||
search_result = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
print("[cyan]Auto selecting:[/] ", search_result)
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
|
||||
anime_titles = (*anime_titles_from_file, *anime_titles)
|
||||
print(f"[green bold]Queued:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
if anime_title == "EOF":
|
||||
break
|
||||
print(f"[green bold]Now Downloading: [/] {anime_title}")
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results failed")
|
||||
input("Enter to retry")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Nothing muches your search term")
|
||||
continue
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
selected_anime_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto selecting:[/] ", selected_anime_title)
|
||||
else:
|
||||
search_result = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select title",
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[search_result]["id"]
|
||||
)
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
download(config, anime_title, episode_range, highest_priority)
|
||||
return
|
||||
|
||||
episodes = anime["availableEpisodesDetail"][config.translation_type]
|
||||
if episode_range:
|
||||
episodes_start, episodes_end = episode_range.split("-")
|
||||
|
||||
else:
|
||||
episodes_start, episodes_end = 0, len(episodes)
|
||||
for episode in range(round(float(episodes_start)), round(float(episodes_end))):
|
||||
try:
|
||||
episode = str(episode)
|
||||
if episode not in episodes:
|
||||
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
|
||||
continue
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
selected_anime_title = fzf.run(
|
||||
choices, "Please Select title", "FastAnime"
|
||||
)
|
||||
if not streams:
|
||||
print("No streams skipping")
|
||||
continue
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams)
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.use_fzf:
|
||||
server = fzf.run(servers_names, "Select an link: ")
|
||||
else:
|
||||
server = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server]["links"]
|
||||
selected_anime_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select title",
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
|
||||
episode_title = servers[server]["episode_title"]
|
||||
print(f"[purple]Now Downloading:[/] {search_result} Episode {episode}")
|
||||
|
||||
downloader._download_file(
|
||||
link,
|
||||
download_dir,
|
||||
(anime["title"], episode_title),
|
||||
True,
|
||||
config.format,
|
||||
# ---- fetch anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[selected_anime_title]["id"]
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print("Continuing")
|
||||
clear()
|
||||
print("Done")
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
download(
|
||||
config,
|
||||
anime_title,
|
||||
episode_range,
|
||||
file,
|
||||
force_unknown_ext,
|
||||
silent,
|
||||
verbose,
|
||||
merge,
|
||||
clean,
|
||||
wait_time,
|
||||
prompt,
|
||||
)
|
||||
return
|
||||
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[int(episodes_start) : int(episodes_end)]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
print(f"[green bold]Downloading: [/] {episodes_range}")
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if config.normalize_titles:
|
||||
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
|
||||
|
||||
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
episode = str(episode)
|
||||
if episode not in episodes:
|
||||
print(f"[cyan]Warning[/]: Episode {episode} not found, skipping")
|
||||
continue
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("No streams skipping")
|
||||
continue
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server_name = next(streams, None)
|
||||
if not server_name:
|
||||
print("Sth went wrong when fetching the server")
|
||||
continue
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, server_name["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = server_name["headers"]
|
||||
episode_title = server_name["episode_title"]
|
||||
subtitles = server_name["subtitles"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server_name = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server_name = fzf.run(servers_names, "Select an link")
|
||||
else:
|
||||
server_name = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server_name]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("[yellow bold]WARNING:[/] No streams found")
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
continue
|
||||
link = stream_link["link"]
|
||||
provider_headers = servers[server_name]["headers"]
|
||||
|
||||
subtitles = servers[server_name]["subtitles"]
|
||||
episode_title = servers[server_name]["episode_title"]
|
||||
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["episodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(f"[purple]Now Downloading:[/] {episode_title}")
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
downloader._download_file(
|
||||
link,
|
||||
selected_anime_title,
|
||||
episode_title,
|
||||
download_dir,
|
||||
silent,
|
||||
vid_format=config.format,
|
||||
force_unknown_ext=force_unknown_ext,
|
||||
verbose=verbose,
|
||||
headers=provider_headers,
|
||||
sub=subtitles[0]["url"] if subtitles else "",
|
||||
merge=merge,
|
||||
clean=clean,
|
||||
prompt=prompt,
|
||||
)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
time.sleep(1)
|
||||
print("Continuing...")
|
||||
print("Done Downloading")
|
||||
time.sleep(wait_time)
|
||||
exit_app()
|
||||
|
||||
@@ -1,25 +1,73 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import downloaded_anime_titles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="View and watch your downloads using mpv", short_help="Watch downloads"
|
||||
help="View and watch your downloads using mpv",
|
||||
short_help="Watch downloads",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
fastanime downloads
|
||||
\b
|
||||
# view individual episodes
|
||||
fastanime downloads --view-episodes
|
||||
# --- or ---
|
||||
fastanime downloads -v
|
||||
\b
|
||||
# to set seek time when using ffmpegthumbnailer for local previews
|
||||
# -1 means random and is the default
|
||||
fastanime downloads --time-to-seek <intRange(-1,100)>
|
||||
# --- or ---
|
||||
fastanime downloads -t <intRange(-1,100)>
|
||||
\b
|
||||
# to watch a specific title
|
||||
# be sure to get the completions for the best experience
|
||||
fastanime downloads --title <title>
|
||||
\b
|
||||
# to get the path to the downloads folder set
|
||||
fastanime downloads --path
|
||||
# useful when you want to use the value for other programs
|
||||
""",
|
||||
)
|
||||
@click.option("--path", "-p", help="print the downloads folder and exit", is_flag=True)
|
||||
@click.option(
|
||||
"--title",
|
||||
"-T",
|
||||
shell_complete=downloaded_anime_titles,
|
||||
help="watch a specific title",
|
||||
)
|
||||
@click.option("--view-episodes", "-v", help="View individual episodes", is_flag=True)
|
||||
@click.option(
|
||||
"--ffmpegthumbnailer-seek-time",
|
||||
"--time-to-seek",
|
||||
"-t",
|
||||
type=click.IntRange(-1, 100),
|
||||
help="ffmpegthumbnailer seek time",
|
||||
)
|
||||
@click.pass_obj
|
||||
def downloads(config: "Config", path: bool):
|
||||
def downloads(
|
||||
config: "Config", path: bool, title, view_episodes, ffmpegthumbnailer_seek_time
|
||||
):
|
||||
import os
|
||||
|
||||
from ...cli.utils.mpv import run_mpv
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ...Utility.utils import sort_by_episode_number
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
if not ffmpegthumbnailer_seek_time:
|
||||
ffmpegthumbnailer_seek_time = config.ffmpegthumbnailer_seek_time
|
||||
USER_VIDEOS_DIR = config.downloads_dir
|
||||
if path:
|
||||
print(USER_VIDEOS_DIR)
|
||||
@@ -27,24 +75,284 @@ def downloads(config: "Config", path: bool):
|
||||
if not os.path.exists(USER_VIDEOS_DIR):
|
||||
print("Downloads directory specified does not exist")
|
||||
return
|
||||
playlists = os.listdir(USER_VIDEOS_DIR)
|
||||
playlists.append("Exit")
|
||||
anime_downloads = sorted(
|
||||
os.listdir(USER_VIDEOS_DIR),
|
||||
)
|
||||
anime_downloads.append("Exit")
|
||||
|
||||
def stream():
|
||||
if config.use_fzf:
|
||||
playlist_name = fzf.run(playlists, "Enter Playlist Name", "Downloads")
|
||||
def create_thumbnails(video_path, anime_title, downloads_thumbnail_cache_dir):
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
FFMPEG_THUMBNAILER = shutil.which("ffmpegthumbnailer")
|
||||
if not FFMPEG_THUMBNAILER:
|
||||
return
|
||||
|
||||
out = os.path.join(downloads_thumbnail_cache_dir, anime_title)
|
||||
if ffmpegthumbnailer_seek_time == -1:
|
||||
import random
|
||||
|
||||
seektime = str(random.randrange(0, 100))
|
||||
else:
|
||||
seektime = str(ffmpegthumbnailer_seek_time)
|
||||
_ = subprocess.run(
|
||||
[
|
||||
FFMPEG_THUMBNAILER,
|
||||
"-i",
|
||||
video_path,
|
||||
"-o",
|
||||
out,
|
||||
"-s",
|
||||
"0",
|
||||
"-t",
|
||||
seektime,
|
||||
],
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def get_previews_anime(workers=None, bg=True):
|
||||
import concurrent.futures
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import fzf_preview
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for anime_title in anime_downloads:
|
||||
anime_path = os.path.join(USER_VIDEOS_DIR, anime_title)
|
||||
if not os.path.isdir(anime_path):
|
||||
continue
|
||||
playlist = [
|
||||
anime
|
||||
for anime in sorted(
|
||||
os.listdir(anime_path),
|
||||
)
|
||||
if "mp4" in anime
|
||||
]
|
||||
if playlist:
|
||||
# actual link to download image from
|
||||
video_path = os.path.join(anime_path, random.choice(playlist))
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
video_path,
|
||||
anime_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = anime_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def get_previews_episodes(anime_playlist_path, workers=None, bg=True):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ..utils.scripts import fzf_preview
|
||||
|
||||
if not shutil.which("ffmpegthumbnailer"):
|
||||
print("ffmpegthumbnailer not found")
|
||||
logger.error("ffmpegthumbnailer not found")
|
||||
return
|
||||
|
||||
downloads_thumbnail_cache_dir = os.path.join(APP_CACHE_DIR, "video_thumbnails")
|
||||
Path(downloads_thumbnail_cache_dir).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _worker():
|
||||
import concurrent.futures
|
||||
|
||||
# use concurrency to download the images as fast as possible
|
||||
# anime_playlist_path = os.path.join(USER_VIDEOS_DIR, anime_playlist_path)
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
return
|
||||
anime_episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for episode_title in anime_episodes:
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
|
||||
# actual link to download image from
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
create_thumbnails,
|
||||
episode_path,
|
||||
episode_title,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
] = episode_title
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
if bg:
|
||||
from threading import Thread
|
||||
|
||||
worker = Thread(target=_worker)
|
||||
worker.daemon = True
|
||||
worker.start()
|
||||
else:
|
||||
_worker()
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then
|
||||
if ! fzf-preview %s/{} 2>/dev/null; then
|
||||
echo Loading...
|
||||
fi
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
downloads_thumbnail_cache_dir,
|
||||
downloads_thumbnail_cache_dir,
|
||||
)
|
||||
return preview
|
||||
|
||||
def stream_episode(
|
||||
anime_playlist_path,
|
||||
):
|
||||
if view_episodes:
|
||||
if not os.path.isdir(anime_playlist_path):
|
||||
print(anime_playlist_path, "is not dir")
|
||||
exit_app(1)
|
||||
return
|
||||
episodes = sorted(
|
||||
os.listdir(anime_playlist_path), key=sort_by_episode_number
|
||||
)
|
||||
downloaded_episodes = [*episodes, "Back"]
|
||||
|
||||
if config.use_fzf:
|
||||
if not config.preview:
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_episodes(anime_playlist_path)
|
||||
episode_title = fzf.run(
|
||||
downloaded_episodes,
|
||||
"Enter Episode ",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode_title = Rofi.run(downloaded_episodes, "Enter Episode")
|
||||
else:
|
||||
episode_title = fuzzy_inquirer(
|
||||
downloaded_episodes,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if episode_title == "Back":
|
||||
stream_anime()
|
||||
return
|
||||
episode_path = os.path.join(anime_playlist_path, episode_title)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(episode_path)
|
||||
else:
|
||||
run_mpv(
|
||||
episode_path,
|
||||
player=config.player,
|
||||
)
|
||||
stream_episode(anime_playlist_path)
|
||||
|
||||
def stream_anime(title=None):
|
||||
if title:
|
||||
from thefuzz import fuzz
|
||||
|
||||
playlist_name = max(anime_downloads, key=lambda t: fuzz.ratio(title, t))
|
||||
elif config.use_fzf:
|
||||
if not config.preview:
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
else:
|
||||
preview = get_previews_anime()
|
||||
playlist_name = fzf.run(
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
preview=preview,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
playlist_name = Rofi.run(playlists, "Enter Playlist Name")
|
||||
playlist_name = Rofi.run(anime_downloads, "Enter Playlist Name")
|
||||
else:
|
||||
playlist_name = fuzzy_inquirer(
|
||||
playlists,
|
||||
"Enter Playlist Name: ",
|
||||
anime_downloads,
|
||||
"Enter Playlist Name",
|
||||
)
|
||||
if playlist_name == "Exit":
|
||||
exit_app()
|
||||
return
|
||||
playlist = os.path.join(USER_VIDEOS_DIR, playlist_name)
|
||||
run_mpv(playlist)
|
||||
stream()
|
||||
if view_episodes:
|
||||
stream_episode(
|
||||
playlist,
|
||||
)
|
||||
else:
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
stream()
|
||||
SyncPlayer(playlist)
|
||||
else:
|
||||
run_mpv(
|
||||
playlist,
|
||||
player=config.player,
|
||||
)
|
||||
stream_anime()
|
||||
|
||||
stream_anime(title)
|
||||
|
||||
243
fastanime/cli/commands/grab.py
Normal file
243
fastanime/cli/commands/grab.py
Normal file
@@ -0,0 +1,243 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to get streams for anime to use externally in a non-python application",
|
||||
short_help="Print anime streams to standard out",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# --- print anime info + episode streams ---
|
||||
\b
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime grab -t <anime-title> -t <anime-title>
|
||||
# -- or --
|
||||
# print all available episodes
|
||||
fastanime grab -t <anime-title> -r ':'
|
||||
\b
|
||||
# print the latest episode
|
||||
fastanime grab -t <anime-title> -r '-1'
|
||||
\b
|
||||
# print a specific episode range
|
||||
# be sure to observe the range Syntax
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
fastanime grab -t <anime-title> -r ':<end>'
|
||||
\b
|
||||
# --- grab options ---
|
||||
\b
|
||||
# print search results only
|
||||
fastanime grab -t <anime-title> -r <range> --search-results-only
|
||||
\b
|
||||
# print anime info only
|
||||
fastanime grab -t <anime-title> -r <range> --anime-info-only
|
||||
\b
|
||||
# print episode streams only
|
||||
fastanime grab -t <anime-title> -r <range> --episode-streams-only
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to download (start-end)",
|
||||
)
|
||||
@click.option(
|
||||
"--search-results-only",
|
||||
"-s",
|
||||
help="print only the search results to stdout",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.option(
|
||||
"--anime-info-only", "-i", help="print only selected anime title info", is_flag=True
|
||||
)
|
||||
@click.option(
|
||||
"--episode-streams-only",
|
||||
"-e",
|
||||
help="print only selected anime episodes streams of given range",
|
||||
is_flag=True,
|
||||
)
|
||||
@click.pass_obj
|
||||
def grab(
|
||||
config: "Config",
|
||||
anime_titles: tuple,
|
||||
episode_range,
|
||||
search_results_only,
|
||||
anime_info_only,
|
||||
episode_streams_only,
|
||||
):
|
||||
import json
|
||||
from logging import getLogger
|
||||
from sys import exit
|
||||
|
||||
from thefuzz import fuzz
|
||||
|
||||
logger = getLogger(__name__)
|
||||
if config.manga:
|
||||
manga_title = anime_titles[0]
|
||||
from ...MangaProvider import MangaProvider
|
||||
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
print(json.dumps(search_data))
|
||||
exit(0)
|
||||
search_results = search_data["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_titles[0])
|
||||
)
|
||||
manga_info = manga_provider.get_manga(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not manga_info:
|
||||
return
|
||||
if anime_info_only:
|
||||
print(json.dumps(manga_info))
|
||||
exit(0)
|
||||
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(episode_range)
|
||||
)
|
||||
if not chapter_info:
|
||||
exit(1)
|
||||
print(json.dumps(chapter_info))
|
||||
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
|
||||
grabbed_animes = []
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, translation_type=config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
exit(1)
|
||||
if search_results_only:
|
||||
# grab only search results skipping all lines after this
|
||||
grabbed_animes.append(search_results)
|
||||
continue
|
||||
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
logger.error("no results for your search")
|
||||
exit(1)
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
search_result_anime_title = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
|
||||
# ---- fetch anime ----
|
||||
anime = anime_provider.get_anime(
|
||||
search_results_[search_result_anime_title]["id"]
|
||||
)
|
||||
if not anime:
|
||||
exit(1)
|
||||
if anime_info_only:
|
||||
# grab only the anime data skipping all lines after this
|
||||
grabbed_animes.append(anime)
|
||||
continue
|
||||
episodes = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
|
||||
# where the magic happens
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
elif len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
else:
|
||||
episodes_range = sorted(episodes, key=float)
|
||||
|
||||
if not episode_streams_only:
|
||||
grabbed_anime = dict(anime)
|
||||
grabbed_anime["requested_episodes"] = episodes_range
|
||||
grabbed_anime["translation_type"] = config.translation_type
|
||||
grabbed_anime["episodes_streams"] = {}
|
||||
else:
|
||||
grabbed_anime = {}
|
||||
|
||||
# lets download em
|
||||
for episode in episodes_range:
|
||||
try:
|
||||
if episode not in episodes:
|
||||
continue
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
continue
|
||||
episode_streams = {server["server"]: server for server in streams}
|
||||
|
||||
if episode_streams_only:
|
||||
grabbed_anime[episode] = episode_streams
|
||||
else:
|
||||
grabbed_anime["episodes_streams"][ # pyright:ignore
|
||||
episode
|
||||
] = episode_streams
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
# grab the full data for single title and appen to final result or episode streams
|
||||
grabbed_animes.append(grabbed_anime)
|
||||
|
||||
# print out the final result either {} or [] depending if more than one title os requested
|
||||
if len(grabbed_animes) == 1:
|
||||
print(json.dumps(grabbed_animes[0]))
|
||||
else:
|
||||
print(json.dumps(grabbed_animes))
|
||||
@@ -1,175 +1,385 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
from ...cli.config import Config
|
||||
from ..completion_functions import anime_titles_shell_complete
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...cli.config import Config
|
||||
|
||||
|
||||
@click.command(
|
||||
help="This subcommand directly interacts with the provider to enable basic streaming. Useful for binging anime.",
|
||||
short_help="Binge anime",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# basic form where you will still be prompted for the episode number
|
||||
# multiple titles can be specified with the -t option
|
||||
fastanime search -t <anime-title> -t <anime-title>
|
||||
\b
|
||||
# binge all episodes with this command
|
||||
fastanime search -t <anime-title> -r ':'
|
||||
\b
|
||||
# watch latest episode
|
||||
fastanime search -t <anime-title> -r '-1'
|
||||
\b
|
||||
# binge a specific episode range with this command
|
||||
# be sure to observe the range Syntax
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:<stop>:<step>'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r '<start>:'
|
||||
\b
|
||||
fastanime search -t <anime-title> -r ':<end>'
|
||||
""",
|
||||
)
|
||||
@click.option(
|
||||
"--anime-titles",
|
||||
"--anime_title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
)
|
||||
@click.option(
|
||||
"--episode-range",
|
||||
"-r",
|
||||
help="A range of episodes to binge",
|
||||
help="A range of episodes to binge (start-end)",
|
||||
)
|
||||
@click.argument("anime_title", required=True, type=str)
|
||||
@click.pass_obj
|
||||
def search(config: Config, anime_title: str, episode_range: str):
|
||||
def search(config: "Config", anime_titles: str, episode_range: str):
|
||||
from click import clear
|
||||
from rich import print
|
||||
from rich.progress import Progress
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...libs.fzf import fzf
|
||||
from ...libs.rofi import Rofi
|
||||
from ..utils.mpv import run_mpv
|
||||
from ..utils.tools import exit_app
|
||||
from ..utils.utils import filter_by_quality, fuzzy_inquirer
|
||||
from ..utils.utils import fuzzy_inquirer
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
if config.manga:
|
||||
from InquirerPy.prompts.number import NumberPrompt
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results not found")
|
||||
input("Enter to retry")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Anime not found :cry:")
|
||||
exit_app()
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result for search_result in search_results
|
||||
}
|
||||
from ...MangaProvider import MangaProvider
|
||||
from ..utils.feh import feh_manga_viewer
|
||||
|
||||
if config.auto_select:
|
||||
search_result = max(
|
||||
search_results_.keys(), key=lambda title: fuzz.ratio(title, anime_title)
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result)
|
||||
manga_title = anime_titles[0]
|
||||
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
search_result = fzf.run(choices, "Please Select title: ", "FastAnime")
|
||||
elif config.use_rofi:
|
||||
search_result = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
manga_provider = MangaProvider()
|
||||
search_data = manga_provider.search_for_manga(manga_title)
|
||||
if not search_data:
|
||||
print("No search results")
|
||||
exit(1)
|
||||
|
||||
search_results = search_data["results"]
|
||||
|
||||
search_results_ = {
|
||||
sanitize_filename(search_result["title"]): search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(title, manga_title),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[search_result]["id"]
|
||||
)
|
||||
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
episode_range_ = None
|
||||
episodes = anime["availableEpisodesDetail"][config.translation_type]
|
||||
if episode_range:
|
||||
episodes_start, episodes_end = episode_range.split("-")
|
||||
if episodes_start and episodes_end:
|
||||
episode_range_ = iter(
|
||||
range(round(float(episodes_start)), round(float(episodes_end)) + 1)
|
||||
)
|
||||
else:
|
||||
episode_range_ = iter(sorted(episodes, key=float))
|
||||
choices = list(search_results_.keys())
|
||||
preview = None
|
||||
if config.preview:
|
||||
from ..interfaces.utils import get_fzf_manga_preview
|
||||
|
||||
def stream_anime():
|
||||
clear()
|
||||
episode = None
|
||||
|
||||
if episode_range_:
|
||||
try:
|
||||
episode = str(next(episode_range_))
|
||||
print(
|
||||
f"[cyan]Auto selecting:[/] {search_result} [cyan]Episode:[/] {episode}"
|
||||
)
|
||||
except StopIteration:
|
||||
print("[green]Completed binge sequence[/]:smile:")
|
||||
input("Enter to continue...")
|
||||
|
||||
if not episode or episode not in episodes:
|
||||
preview = get_fzf_manga_preview(search_results)
|
||||
if config.use_fzf:
|
||||
episode = fzf.run(episodes, "Select an episode: ", header=search_result)
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title", preview=preview
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode = Rofi.run(episodes, "Select an episode")
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
episode = fuzzy_inquirer(
|
||||
episodes,
|
||||
"Select episode",
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
# ---- fetch streams ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime, episode, config.translation_type
|
||||
anilist_id = search_results_[search_result_manga_title]["id"]
|
||||
manga_info = manga_provider.get_manga(anilist_id)
|
||||
if not manga_info:
|
||||
print("No manga info")
|
||||
exit(1)
|
||||
|
||||
anilist_helper = None
|
||||
if config.user:
|
||||
from ...anilist import AniList
|
||||
|
||||
AniList.login_user(config.user["token"])
|
||||
anilist_helper = AniList
|
||||
|
||||
def _manga_viewer():
|
||||
chapter_number = NumberPrompt("Select a chapter number").execute()
|
||||
chapter_info = manga_provider.get_chapter_thumbnails(
|
||||
manga_info["id"], str(chapter_number)
|
||||
)
|
||||
if not streams:
|
||||
print("Failed to get streams")
|
||||
|
||||
if not chapter_info:
|
||||
print("No chapter info")
|
||||
input("Enter to retry...")
|
||||
_manga_viewer()
|
||||
return
|
||||
|
||||
try:
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams)
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
return
|
||||
link = stream_link["link"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.use_fzf:
|
||||
server = fzf.run(servers_names, "Select an link: ")
|
||||
elif config.use_rofi:
|
||||
server = Rofi.run(servers_names, "Select an link")
|
||||
else:
|
||||
server = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server]["links"]
|
||||
print(
|
||||
f"[purple bold]Now Reading: [/] {search_result_manga_title} [cyan bold]Chapter:[/] {chapter_info['title']}"
|
||||
)
|
||||
feh_manga_viewer(chapter_info["thumbnails"], str(chapter_info["title"]))
|
||||
if anilist_helper:
|
||||
anilist_helper.update_anime_list(
|
||||
{"mediaId": anilist_id, "progress": chapter_number}
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
_manga_viewer()
|
||||
|
||||
_manga_viewer()
|
||||
else:
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ...libs.anime_provider.types import Anime
|
||||
from ...Utility.data import anime_normalizer
|
||||
from ..utils.mpv import run_mpv
|
||||
from ..utils.utils import filter_by_quality, move_preferred_subtitle_lang_to_top
|
||||
|
||||
anime_provider = AnimeProvider(config.provider)
|
||||
anilist_anime_info = None
|
||||
|
||||
print(f"[green bold]Streaming:[/] {anime_titles}")
|
||||
for anime_title in anime_titles:
|
||||
# ---- search for anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Search Results...", total=None)
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime_title, config.translation_type
|
||||
)
|
||||
if not search_results:
|
||||
print("Search results not found")
|
||||
input("Enter to retry")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
search_results = search_results["results"]
|
||||
if not search_results:
|
||||
print("Anime not found :cry:")
|
||||
exit_app()
|
||||
search_results_ = {
|
||||
search_result["title"]: search_result
|
||||
for search_result in search_results
|
||||
}
|
||||
|
||||
if config.auto_select:
|
||||
search_result_manga_title = max(
|
||||
search_results_.keys(),
|
||||
key=lambda title: fuzz.ratio(
|
||||
anime_normalizer.get(title, title), anime_title
|
||||
),
|
||||
)
|
||||
print("[cyan]Auto Selecting:[/] ", search_result_manga_title)
|
||||
|
||||
else:
|
||||
choices = list(search_results_.keys())
|
||||
if config.use_fzf:
|
||||
search_result_manga_title = fzf.run(
|
||||
choices, "Please Select title", "FastAnime"
|
||||
)
|
||||
elif config.use_rofi:
|
||||
search_result_manga_title = Rofi.run(choices, "Please Select Title")
|
||||
else:
|
||||
search_result_manga_title = fuzzy_inquirer(
|
||||
choices,
|
||||
"Please Select Title",
|
||||
)
|
||||
|
||||
# ---- fetch selected anime ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Anime...", total=None)
|
||||
anime: Anime | None = anime_provider.get_anime(
|
||||
search_results_[search_result_manga_title]["id"]
|
||||
)
|
||||
|
||||
if not anime:
|
||||
print("Sth went wring anime no found")
|
||||
input("Enter to continue...")
|
||||
search(config, anime_title, episode_range)
|
||||
return
|
||||
episodes_range = []
|
||||
episodes: list[str] = sorted(
|
||||
anime["availableEpisodesDetail"][config.translation_type], key=float
|
||||
)
|
||||
if episode_range:
|
||||
if ":" in episode_range:
|
||||
ep_range_tuple = episode_range.split(":")
|
||||
if len(ep_range_tuple) == 3 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end, step = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end) : int(step)
|
||||
]
|
||||
|
||||
elif len(ep_range_tuple) == 2 and all(ep_range_tuple):
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
episodes_range = episodes[
|
||||
int(episodes_start) : int(episodes_end)
|
||||
]
|
||||
else:
|
||||
episodes_start, episodes_end = ep_range_tuple
|
||||
if episodes_start.strip():
|
||||
episodes_range = episodes[int(episodes_start) :]
|
||||
elif episodes_end.strip():
|
||||
episodes_range = episodes[: int(episodes_end)]
|
||||
else:
|
||||
episodes_range = episodes
|
||||
else:
|
||||
episodes_range = episodes[int(episode_range) :]
|
||||
|
||||
episodes_range = iter(episodes_range)
|
||||
|
||||
if config.normalize_titles:
|
||||
from ...libs.common.mini_anilist import get_basic_anime_info_by_title
|
||||
|
||||
anilist_anime_info = get_basic_anime_info_by_title(anime["title"])
|
||||
|
||||
def stream_anime(anime: "Anime"):
|
||||
clear()
|
||||
episode = None
|
||||
|
||||
if episodes_range:
|
||||
try:
|
||||
episode = next(episodes_range) # pyright:ignore
|
||||
print(
|
||||
f"[cyan]Auto selecting:[/] {search_result_manga_title} [cyan]Episode:[/] {episode}"
|
||||
)
|
||||
except StopIteration:
|
||||
print("[green]Completed binge sequence[/]:smile:")
|
||||
return
|
||||
|
||||
if not episode or episode not in episodes:
|
||||
choices = [*episodes, "end"]
|
||||
if config.use_fzf:
|
||||
episode = fzf.run(
|
||||
choices,
|
||||
"Select an episode",
|
||||
header=search_result_manga_title,
|
||||
)
|
||||
elif config.use_rofi:
|
||||
episode = Rofi.run(choices, "Select an episode")
|
||||
else:
|
||||
episode = fuzzy_inquirer(
|
||||
choices,
|
||||
"Select episode",
|
||||
)
|
||||
if episode == "end":
|
||||
return
|
||||
link = stream_link["link"]
|
||||
episode_title = servers[server]["episode_title"]
|
||||
print(f"[purple]Now Playing:[/] {search_result} Episode {episode}")
|
||||
|
||||
run_mpv(link, episode_title)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
input("Enter to continue")
|
||||
stream_anime()
|
||||
# ---- fetch streams ----
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching Episode Streams...", total=None)
|
||||
streams = anime_provider.get_episode_streams(
|
||||
anime["id"], episode, config.translation_type
|
||||
)
|
||||
if not streams:
|
||||
print("Failed to get streams")
|
||||
return
|
||||
|
||||
stream_anime()
|
||||
try:
|
||||
# ---- fetch servers ----
|
||||
if config.server == "top":
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching top server...", total=None)
|
||||
server = next(streams, None)
|
||||
if not server:
|
||||
print("Sth went wrong when fetching the episode")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
stream_link = filter_by_quality(config.quality, server["links"])
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
link = stream_link["link"]
|
||||
subtitles = server["subtitles"]
|
||||
stream_headers = server["headers"]
|
||||
episode_title = server["episode_title"]
|
||||
else:
|
||||
with Progress() as progress:
|
||||
progress.add_task("Fetching servers", total=None)
|
||||
# prompt for server selection
|
||||
servers = {server["server"]: server for server in streams}
|
||||
servers_names = list(servers.keys())
|
||||
if config.server in servers_names:
|
||||
server = config.server
|
||||
else:
|
||||
if config.use_fzf:
|
||||
server = fzf.run(servers_names, "Select an link")
|
||||
elif config.use_rofi:
|
||||
server = Rofi.run(servers_names, "Select an link")
|
||||
else:
|
||||
server = fuzzy_inquirer(
|
||||
servers_names,
|
||||
"Select link",
|
||||
)
|
||||
stream_link = filter_by_quality(
|
||||
config.quality, servers[server]["links"]
|
||||
)
|
||||
if not stream_link:
|
||||
print("Quality not found")
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
return
|
||||
link = stream_link["link"]
|
||||
stream_headers = servers[server]["headers"]
|
||||
subtitles = servers[server]["subtitles"]
|
||||
episode_title = servers[server]["episode_title"]
|
||||
|
||||
selected_anime_title = search_result_manga_title
|
||||
if anilist_anime_info:
|
||||
selected_anime_title = (
|
||||
anilist_anime_info["title"][config.preferred_language]
|
||||
or anilist_anime_info["title"]["romaji"]
|
||||
or anilist_anime_info["title"]["english"]
|
||||
)
|
||||
import re
|
||||
|
||||
for episode_detail in anilist_anime_info["episodes"]:
|
||||
if re.match(f"Episode {episode} ", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
break
|
||||
print(
|
||||
f"[purple]Now Playing:[/] {selected_anime_title} Episode {episode}"
|
||||
)
|
||||
subtitles = move_preferred_subtitle_lang_to_top(
|
||||
subtitles, config.sub_lang
|
||||
)
|
||||
if config.sync_play:
|
||||
from ..utils.syncplay import SyncPlayer
|
||||
|
||||
SyncPlayer(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
)
|
||||
else:
|
||||
run_mpv(
|
||||
link,
|
||||
episode_title,
|
||||
headers=stream_headers,
|
||||
subtitles=subtitles,
|
||||
player=config.player,
|
||||
)
|
||||
except IndexError as e:
|
||||
print(e)
|
||||
input("Enter to continue")
|
||||
stream_anime(anime)
|
||||
|
||||
stream_anime(anime)
|
||||
|
||||
31
fastanime/cli/commands/serve.py
Normal file
31
fastanime/cli/commands/serve.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Command that automates the starting of the builtin fastanime server",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# default
|
||||
fastanime serve
|
||||
|
||||
# specify host and port
|
||||
fastanime serve --host 127.0.0.1 --port 8080
|
||||
""",
|
||||
)
|
||||
@click.option("--host", "-H", help="Specify the host to run the server on")
|
||||
@click.option("--port", "-p", help="Specify the port to run the server on")
|
||||
def serve(host, port):
|
||||
import os
|
||||
import sys
|
||||
|
||||
from ...constants import APP_DIR
|
||||
|
||||
args = [sys.executable, "-m", "fastapi", "run"]
|
||||
if host:
|
||||
args.extend(["--host", host])
|
||||
|
||||
if port:
|
||||
args.extend(["--port", port])
|
||||
args.append(os.path.join(APP_DIR, "api"))
|
||||
os.execv(sys.executable, args)
|
||||
55
fastanime/cli/commands/update.py
Normal file
55
fastanime/cli/commands/update.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import click
|
||||
|
||||
|
||||
@click.command(
|
||||
help="Helper command to update fastanime to latest",
|
||||
epilog="""
|
||||
\b
|
||||
\b\bExamples:
|
||||
# update fastanime to latest
|
||||
fastanime update
|
||||
\b
|
||||
# check for latest release
|
||||
fastanime update --check
|
||||
|
||||
# Force an update regardless of the current version
|
||||
fastanime update --force
|
||||
""",
|
||||
)
|
||||
@click.option("--check", "-c", help="Check for the latest release", is_flag=True)
|
||||
@click.option("--force", "-c", help="Force update", is_flag=True)
|
||||
def update(check, force):
|
||||
from rich.console import Console
|
||||
from rich.markdown import Markdown
|
||||
|
||||
from ... import __version__
|
||||
from ..app_updater import check_for_updates, update_app
|
||||
|
||||
def _print_release(release_data):
|
||||
console = Console()
|
||||
body = Markdown(release_data["body"])
|
||||
tag = github_release_data["tag_name"]
|
||||
tag_title = release_data["name"]
|
||||
github_page_url = release_data["html_url"]
|
||||
console.print(f"Release Page: {github_page_url}")
|
||||
console.print(f"Tag: {tag}")
|
||||
console.print(f"Title: {tag_title}")
|
||||
console.print(body)
|
||||
|
||||
if check:
|
||||
is_latest, github_release_data = check_for_updates()
|
||||
if not is_latest:
|
||||
print(
|
||||
f"You are running an older version ({__version__}) of fastanime please update to get the latest features"
|
||||
)
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
print(f"You are running the latest version ({__version__}) of fastanime")
|
||||
_print_release(github_release_data)
|
||||
else:
|
||||
success, github_release_data = update_app(force)
|
||||
_print_release(github_release_data)
|
||||
if success:
|
||||
print("Successfully updated")
|
||||
else:
|
||||
print("failed to update")
|
||||
95
fastanime/cli/completion_functions.py
Normal file
95
fastanime/cli/completion_functions.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
|
||||
|
||||
anime_title_query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def get_anime_titles(query: str, variables: dict = {}):
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
from requests import post
|
||||
|
||||
try:
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data = response.json()
|
||||
|
||||
if response.status_code == 200:
|
||||
eng_titles = [
|
||||
anime["title"]["english"]
|
||||
for anime in anilist_data["data"]["Page"]["media"]
|
||||
if anime["title"]["english"]
|
||||
]
|
||||
romaji_titles = [
|
||||
anime["title"]["romaji"]
|
||||
for anime in anilist_data["data"]["Page"]["media"]
|
||||
if anime["title"]["romaji"]
|
||||
]
|
||||
return [*eng_titles, *romaji_titles]
|
||||
else:
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
return []
|
||||
|
||||
|
||||
def downloaded_anime_titles(ctx, param, incomplete):
|
||||
import os
|
||||
|
||||
from ..constants import USER_VIDEOS_DIR
|
||||
|
||||
try:
|
||||
titles = [
|
||||
title
|
||||
for title in os.listdir(USER_VIDEOS_DIR)
|
||||
if title.lower().startswith(incomplete.lower()) or not incomplete
|
||||
]
|
||||
return titles
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def anime_titles_shell_complete(ctx, param, incomplete):
|
||||
incomplete = incomplete.strip()
|
||||
if not incomplete:
|
||||
incomplete = None
|
||||
variables = {}
|
||||
else:
|
||||
variables = {"query": incomplete}
|
||||
return get_anime_titles(anime_title_query, variables)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = input("Enter title")
|
||||
results = get_anime_titles(anime_title_query, {"query": t})
|
||||
print(results)
|
||||
@@ -1,196 +1,243 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from configparser import ConfigParser
|
||||
from typing import TYPE_CHECKING
|
||||
from ..libs.fzf import FZF_DEFAULT_OPTS, HEADER
|
||||
|
||||
from rich import print
|
||||
|
||||
from ..constants import USER_CONFIG_PATH, USER_VIDEOS_DIR
|
||||
from ..constants import (
|
||||
USER_CONFIG_PATH,
|
||||
USER_DATA_PATH,
|
||||
USER_VIDEOS_DIR,
|
||||
ASSETS_DIR,
|
||||
USER_WATCH_HISTORY_PATH,
|
||||
S_PLATFORM,
|
||||
)
|
||||
from ..libs.rofi import Rofi
|
||||
from ..Utility.user_data_helper import user_data_helper
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
from ..AnimeProvider import AnimeProvider
|
||||
|
||||
|
||||
class Config(object):
|
||||
manga = False
|
||||
sync_play = False
|
||||
anime_list: list
|
||||
watch_history: dict
|
||||
watch_history: dict = {}
|
||||
fastanime_anilist_app_login_url = (
|
||||
"https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token"
|
||||
)
|
||||
anime_provider: "AnimeProvider"
|
||||
user_data = {"recent_anime": [], "animelist": [], "user": {}}
|
||||
default_config = {
|
||||
"auto_next": "False",
|
||||
"auto_select": "True",
|
||||
"cache_requests": "true",
|
||||
"check_for_updates": "True",
|
||||
"continue_from_history": "True",
|
||||
"default_media_list_tracking": "None",
|
||||
"downloads_dir": USER_VIDEOS_DIR,
|
||||
"disable_mpv_popen": "True",
|
||||
"episode_complete_at": "80",
|
||||
"ffmpegthumbnailer_seek_time": "-1",
|
||||
"force_forward_tracking": "true",
|
||||
"force_window": "immediate",
|
||||
"fzf_opts": FZF_DEFAULT_OPTS,
|
||||
"header_color": "95,135,175",
|
||||
"header_ascii_art": HEADER,
|
||||
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
|
||||
"icons": "false",
|
||||
"image_previews": "True" if S_PLATFORM != "win32" else "False",
|
||||
"normalize_titles": "True",
|
||||
"notification_duration": "2",
|
||||
"player": "mpv",
|
||||
"preferred_history": "local",
|
||||
"preferred_language": "english",
|
||||
"preview": "False",
|
||||
"preview_header_color": "215,0,95",
|
||||
"preview_separator_color": "208,208,208",
|
||||
"provider": "allanime",
|
||||
"quality": "1080",
|
||||
"recent": "50",
|
||||
"rofi_theme": os.path.join(ASSETS_DIR, "rofi_theme.rasi"),
|
||||
"rofi_theme_preview": os.path.join(ASSETS_DIR, "rofi_theme_preview.rasi"),
|
||||
"rofi_theme_confirm": os.path.join(ASSETS_DIR, "rofi_theme_confirm.rasi"),
|
||||
"rofi_theme_input": os.path.join(ASSETS_DIR, "rofi_theme_input.rasi"),
|
||||
"server": "top",
|
||||
"skip": "false",
|
||||
"sort_by": "search match",
|
||||
"sub_lang": "eng",
|
||||
"translation_type": "sub",
|
||||
"use_fzf": "False",
|
||||
"use_persistent_provider_store": "false",
|
||||
"use_python_mpv": "false",
|
||||
"use_rofi": "false",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.initialize_user_data_and_watch_history_recent_anime()
|
||||
self.load_config()
|
||||
|
||||
def load_config(self):
|
||||
self.configparser = ConfigParser(
|
||||
{
|
||||
"server": "top",
|
||||
"continue_from_history": "True",
|
||||
"quality": "1080",
|
||||
"auto_next": "False",
|
||||
"auto_select": "True",
|
||||
"sort_by": "search match",
|
||||
"downloads_dir": USER_VIDEOS_DIR,
|
||||
"translation_type": "sub",
|
||||
"preferred_language": "english",
|
||||
"use_fzf": "False",
|
||||
"preview": "False",
|
||||
"format": "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best",
|
||||
"provider": "allanime",
|
||||
"error": "3",
|
||||
"icons": "false",
|
||||
"notification_duration": "2",
|
||||
"skip": "false",
|
||||
"use_rofi": "false",
|
||||
"rofi_theme": "",
|
||||
"rofi_theme_input": "",
|
||||
"rofi_theme_confirm": "",
|
||||
"use_mpv_mod": "false",
|
||||
"force_window": "immediate",
|
||||
}
|
||||
)
|
||||
self.configparser = ConfigParser(self.default_config)
|
||||
self.configparser.add_section("stream")
|
||||
self.configparser.add_section("general")
|
||||
self.configparser.add_section("anilist")
|
||||
if not os.path.exists(USER_CONFIG_PATH):
|
||||
with open(USER_CONFIG_PATH, "w") as config:
|
||||
self.configparser.write(config)
|
||||
|
||||
self.configparser.read(USER_CONFIG_PATH)
|
||||
# --- set config values from file or using defaults ---
|
||||
if os.path.exists(USER_CONFIG_PATH):
|
||||
self.configparser.read(USER_CONFIG_PATH, encoding="utf-8")
|
||||
|
||||
# get the configuration
|
||||
self.auto_next = self.configparser.getboolean("stream", "auto_next")
|
||||
self.auto_select = self.configparser.getboolean("stream", "auto_select")
|
||||
self.cache_requests = self.configparser.getboolean("general", "cache_requests")
|
||||
self.check_for_updates = self.configparser.getboolean(
|
||||
"general", "check_for_updates"
|
||||
)
|
||||
self.continue_from_history = self.configparser.getboolean(
|
||||
"stream", "continue_from_history"
|
||||
)
|
||||
self.default_media_list_tracking = self.configparser.get(
|
||||
"general", "default_media_list_tracking"
|
||||
)
|
||||
self.disable_mpv_popen = self.configparser.getboolean(
|
||||
"stream", "disable_mpv_popen"
|
||||
)
|
||||
self.downloads_dir = self.configparser.get("general", "downloads_dir")
|
||||
self.episode_complete_at = self.configparser.getint(
|
||||
"stream", "episode_complete_at"
|
||||
)
|
||||
self.ffmpegthumbnailer_seek_time = self.configparser.getint(
|
||||
"general", "ffmpegthumbnailer_seek_time"
|
||||
)
|
||||
self.force_forward_tracking = self.configparser.getboolean(
|
||||
"general", "force_forward_tracking"
|
||||
)
|
||||
self.force_window = self.configparser.get("stream", "force_window")
|
||||
self.format = self.configparser.get("stream", "format")
|
||||
self.fzf_opts = self.configparser.get("general", "fzf_opts")
|
||||
self.header_color = self.configparser.get("general", "header_color")
|
||||
self.header_ascii_art = self.configparser.get("general", "header_ascii_art")
|
||||
self.icons = self.configparser.getboolean("general", "icons")
|
||||
self.image_previews = self.configparser.getboolean("general", "image_previews")
|
||||
self.normalize_titles = self.configparser.getboolean(
|
||||
"general", "normalize_titles"
|
||||
)
|
||||
self.notification_duration = self.configparser.getint(
|
||||
"general", "notification_duration"
|
||||
)
|
||||
self.player = self.configparser.get("stream", "player")
|
||||
self.preferred_history = self.configparser.get("stream", "preferred_history")
|
||||
self.preferred_language = self.configparser.get("general", "preferred_language")
|
||||
self.preview = self.configparser.getboolean("general", "preview")
|
||||
self.preview_separator_color = self.configparser.get(
|
||||
"general", "preview_separator_color"
|
||||
)
|
||||
self.preview_header_color = self.configparser.get(
|
||||
"general", "preview_header_color"
|
||||
)
|
||||
self.provider = self.configparser.get("general", "provider")
|
||||
self.quality = self.configparser.get("stream", "quality")
|
||||
self.recent = self.configparser.getint("general", "recent")
|
||||
self.rofi_theme_confirm = self.configparser.get("general", "rofi_theme_confirm")
|
||||
self.rofi_theme_input = self.configparser.get("general", "rofi_theme_input")
|
||||
self.rofi_theme = self.configparser.get("general", "rofi_theme")
|
||||
self.rofi_theme_preview = self.configparser.get("general", "rofi_theme_preview")
|
||||
self.server = self.configparser.get("stream", "server")
|
||||
self.skip = self.configparser.getboolean("stream", "skip")
|
||||
self.sort_by = self.configparser.get("anilist", "sort_by")
|
||||
self.sub_lang = self.configparser.get("general", "sub_lang")
|
||||
self.translation_type = self.configparser.get("stream", "translation_type")
|
||||
self.use_fzf = self.configparser.getboolean("general", "use_fzf")
|
||||
self.use_python_mpv = self.configparser.getboolean("stream", "use_python_mpv")
|
||||
self.use_rofi = self.configparser.getboolean("general", "use_rofi")
|
||||
self.use_persistent_provider_store = self.configparser.getboolean(
|
||||
"general", "use_persistent_provider_store"
|
||||
)
|
||||
|
||||
# --- set defaults ---
|
||||
self.downloads_dir = self.get_downloads_dir()
|
||||
self.provider = self.get_provider()
|
||||
self.use_fzf = self.get_use_fzf()
|
||||
self.use_rofi = self.get_use_rofi()
|
||||
self.skip = self.get_skip()
|
||||
self.icons = self.get_icons()
|
||||
self.preview = self.get_preview()
|
||||
self.translation_type = self.get_translation_type()
|
||||
self.sort_by = self.get_sort_by()
|
||||
self.continue_from_history = self.get_continue_from_history()
|
||||
self.auto_next = self.get_auto_next()
|
||||
self.auto_select = self.get_auto_select()
|
||||
self.use_mpv_mod = self.get_use_mpv_mod()
|
||||
self.quality = self.get_quality()
|
||||
self.notification_duration = self.get_notification_duration()
|
||||
self.error = self.get_error()
|
||||
self.server = self.get_server()
|
||||
self.format = self.get_format()
|
||||
self.force_window = self.get_force_window()
|
||||
self.preferred_language = self.get_preferred_language()
|
||||
self.rofi_theme = self.get_rofi_theme()
|
||||
Rofi.rofi_theme = self.rofi_theme
|
||||
self.rofi_theme_input = self.get_rofi_theme_input()
|
||||
Rofi.rofi_theme_input = self.rofi_theme_input
|
||||
self.rofi_theme_confirm = self.get_rofi_theme_confirm()
|
||||
Rofi.rofi_theme_confirm = self.rofi_theme_confirm
|
||||
Rofi.rofi_theme_preview = self.rofi_theme_preview
|
||||
|
||||
os.environ["FZF_DEFAULT_OPTS"] = self.fzf_opts
|
||||
|
||||
# ---- setup user data ------
|
||||
self.watch_history: dict = user_data_helper.user_data.get("watch_history", {})
|
||||
self.anime_list: list = user_data_helper.user_data.get("animelist", [])
|
||||
self.user: dict = user_data_helper.user_data.get("user", {})
|
||||
self.anime_list: list = self.user_data.get("animelist", [])
|
||||
self.user: dict = self.user_data.get("user", {})
|
||||
|
||||
if not os.path.exists(USER_CONFIG_PATH):
|
||||
with open(USER_CONFIG_PATH, "w", encoding="utf-8") as config:
|
||||
config.write(self.__repr__())
|
||||
|
||||
def set_fastanime_config_environs(self):
|
||||
current_config = []
|
||||
for key in self.default_config:
|
||||
current_config.append((f"FASTANIME_{key.upper()}", str(getattr(self, key))))
|
||||
os.environ.update(current_config)
|
||||
|
||||
def update_user(self, user):
|
||||
self.user = user
|
||||
user_data_helper.update_user_info(user)
|
||||
self.user_data["user"] = user
|
||||
self._update_user_data()
|
||||
|
||||
def update_watch_history(
|
||||
self, anime_id: int, episode: str | None, start_time="0", total_time="0"
|
||||
def update_recent(self, recent_anime: list):
|
||||
recent_anime_ids = []
|
||||
_recent_anime = []
|
||||
for anime in recent_anime:
|
||||
if (
|
||||
anime["id"] not in recent_anime_ids
|
||||
and len(recent_anime_ids) <= self.recent
|
||||
):
|
||||
_recent_anime.append(anime)
|
||||
recent_anime_ids.append(anime["id"])
|
||||
|
||||
self.user_data["recent_anime"] = _recent_anime
|
||||
self._update_user_data()
|
||||
|
||||
def media_list_track(
|
||||
self,
|
||||
anime_id: int,
|
||||
episode_no: str,
|
||||
episode_stopped_at="0",
|
||||
episode_total_length="0",
|
||||
progress_tracking="prompt",
|
||||
):
|
||||
self.watch_history.update(
|
||||
{
|
||||
str(anime_id): {
|
||||
"episode": episode,
|
||||
"start_time": start_time,
|
||||
"total_time": total_time,
|
||||
"episode_no": episode_no,
|
||||
"episode_stopped_at": episode_stopped_at,
|
||||
"episode_total_length": episode_total_length,
|
||||
"progress_tracking": progress_tracking,
|
||||
}
|
||||
}
|
||||
)
|
||||
user_data_helper.update_watch_history(self.watch_history)
|
||||
with open(USER_WATCH_HISTORY_PATH, "w") as f:
|
||||
json.dump(self.watch_history, f)
|
||||
|
||||
def update_anime_list(self, anime_id: int, remove=False):
|
||||
if remove:
|
||||
try:
|
||||
self.anime_list.remove(anime_id)
|
||||
print("Succesfully removed :cry:")
|
||||
except Exception:
|
||||
print(anime_id, "Nothing to remove :confused:")
|
||||
else:
|
||||
self.anime_list.append(anime_id)
|
||||
user_data_helper.update_animelist(self.anime_list)
|
||||
print("Succesfully added :smile:")
|
||||
input("Enter to continue...")
|
||||
def initialize_user_data_and_watch_history_recent_anime(self):
|
||||
try:
|
||||
if os.path.isfile(USER_DATA_PATH):
|
||||
with open(USER_DATA_PATH, "r") as f:
|
||||
user_data = json.load(f)
|
||||
self.user_data.update(user_data)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
try:
|
||||
if os.path.isfile(USER_WATCH_HISTORY_PATH):
|
||||
with open(USER_WATCH_HISTORY_PATH, "r") as f:
|
||||
watch_history = json.load(f)
|
||||
self.watch_history.update(watch_history)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
|
||||
def get_provider(self):
|
||||
return self.configparser.get("general", "provider")
|
||||
|
||||
def get_rofi_theme(self):
|
||||
return self.configparser.get("general", "rofi_theme")
|
||||
|
||||
def get_rofi_theme_input(self):
|
||||
return self.configparser.get("general", "rofi_theme_input")
|
||||
|
||||
def get_rofi_theme_confirm(self):
|
||||
return self.configparser.get("general", "rofi_theme_confirm")
|
||||
|
||||
def get_downloads_dir(self):
|
||||
return self.configparser.get("general", "downloads_dir")
|
||||
|
||||
def get_use_fzf(self):
|
||||
return self.configparser.getboolean("general", "use_fzf")
|
||||
|
||||
def get_use_rofi(self):
|
||||
return self.configparser.getboolean("general", "use_rofi")
|
||||
|
||||
def get_skip(self):
|
||||
return self.configparser.getboolean("stream", "skip")
|
||||
|
||||
def get_force_window(self):
|
||||
return self.configparser.get("stream", "force_window")
|
||||
|
||||
def get_icons(self):
|
||||
return self.configparser.getboolean("general", "icons")
|
||||
|
||||
def get_preview(self):
|
||||
return self.configparser.getboolean("general", "preview")
|
||||
|
||||
def get_preferred_language(self):
|
||||
return self.configparser.get("general", "preferred_language")
|
||||
|
||||
def get_sort_by(self):
|
||||
return self.configparser.get("anilist", "sort_by")
|
||||
|
||||
def get_continue_from_history(self):
|
||||
return self.configparser.getboolean("stream", "continue_from_history")
|
||||
|
||||
def get_translation_type(self):
|
||||
return self.configparser.get("stream", "translation_type")
|
||||
|
||||
def get_auto_next(self):
|
||||
return self.configparser.getboolean("stream", "auto_next")
|
||||
|
||||
def get_auto_select(self):
|
||||
return self.configparser.getboolean("stream", "auto_select")
|
||||
|
||||
def get_quality(self):
|
||||
return self.configparser.get("stream", "quality")
|
||||
|
||||
def get_use_mpv_mod(self):
|
||||
return self.configparser.getboolean("stream", "use_mpv_mod")
|
||||
|
||||
def get_notification_duration(self):
|
||||
return self.configparser.getint("general", "notification_duration")
|
||||
|
||||
def get_error(self):
|
||||
return self.configparser.getint("stream", "error")
|
||||
|
||||
def get_server(self):
|
||||
return self.configparser.get("stream", "server")
|
||||
|
||||
def get_format(self):
|
||||
return self.configparser.get("stream", "format")
|
||||
def _update_user_data(self):
|
||||
"""method that updates the actual user data file"""
|
||||
with open(USER_DATA_PATH, "w") as f:
|
||||
json.dump(self.user_data, f)
|
||||
|
||||
def update_config(self, section: str, key: str, value: str):
|
||||
self.configparser.set(section, key, value)
|
||||
@@ -198,7 +245,301 @@ class Config(object):
|
||||
self.configparser.write(config)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Config(server:{self.get_server()},quality:{self.get_quality()},auto_next:{self.get_auto_next()},continue_from_history:{self.get_continue_from_history()},sort_by:{self.get_sort_by()},downloads_dir:{self.get_downloads_dir()})"
|
||||
new_line = "\n"
|
||||
tab = "\t"
|
||||
current_config_state = f"""\
|
||||
#
|
||||
# ███████╗░█████╗░░██████╗████████╗░█████╗░███╗░░██╗██╗███╗░░░███╗███████╗ ░█████╗░░█████╗░███╗░░██╗███████╗██╗░██████╗░
|
||||
# ██╔════╝██╔══██╗██╔════╝╚══██╔══╝██╔══██╗████╗░██║██║████╗░████║██╔════╝ ██╔══██╗██╔══██╗████╗░██║██╔════╝██║██╔════╝░
|
||||
# █████╗░░███████║╚█████╗░░░░██║░░░███████║██╔██╗██║██║██╔████╔██║█████╗░░ ██║░░╚═╝██║░░██║██╔██╗██║█████╗░░██║██║░░██╗░
|
||||
# ██╔══╝░░██╔══██║░╚═══██╗░░░██║░░░██╔══██║██║╚████║██║██║╚██╔╝██║██╔══╝░░ ██║░░██╗██║░░██║██║╚████║██╔══╝░░██║██║░░╚██╗
|
||||
# ██║░░░░░██║░░██║██████╔╝░░░██║░░░██║░░██║██║░╚███║██║██║░╚═╝░██║███████╗ ╚█████╔╝╚█████╔╝██║░╚███║██║░░░░░██║╚██████╔╝
|
||||
# ╚═╝░░░░░╚═╝░░╚═╝╚═════╝░░░░╚═╝░░░╚═╝░░╚═╝╚═╝░░╚══╝╚═╝╚═╝░░░░░╚═╝╚══════╝ ░╚════╝░░╚════╝░╚═╝░░╚══╝╚═╝░░░░░╚═╝░╚═════╝░
|
||||
#
|
||||
[general]
|
||||
# Can you rice it?
|
||||
# for the preview pane
|
||||
preview_separator_color = {self.preview_separator_color}
|
||||
|
||||
preview_header_color = {self.preview_header_color}
|
||||
|
||||
# for the header
|
||||
# be sure to indent
|
||||
header_ascii_art = {new_line.join([tab+line for line in self.header_ascii_art.split(new_line)])}
|
||||
|
||||
header_color = {self.header_color}
|
||||
|
||||
# to be passed to fzf
|
||||
# be sure to indent
|
||||
fzf_opts = {new_line.join([tab+line for line in self.fzf_opts.split(new_line)])}
|
||||
|
||||
# whether to show the icons in the tui [True/False]
|
||||
# more like emojis
|
||||
# by the way if you have any recommendations
|
||||
# to which should be used where please
|
||||
# don't hesitate to share your opinion
|
||||
# cause it's a lot of work
|
||||
# to look for the right one for each menu option
|
||||
# be sure to also give the replacement emoji
|
||||
icons = {self.icons}
|
||||
|
||||
# whether to normalize provider titles [True/False]
|
||||
# basically takes the provider titles and finds the corresponding anilist title then changes the title to that
|
||||
# useful for uniformity especially when downloading from different providers
|
||||
# this also applies to episode titles
|
||||
normalize_titles = {self.normalize_titles}
|
||||
|
||||
# whether to check for updates every time you run the script [True/False]
|
||||
# this is useful for keeping your script up to date
|
||||
# cause there are always new features being added 😄
|
||||
check_for_updates = {self.check_for_updates}
|
||||
|
||||
# can be [allanime, animepahe, hianime, nyaa, yugen]
|
||||
# allanime is the most realible
|
||||
# animepahe provides different links to streams of different quality so a quality can be selected reliably with --quality option
|
||||
# hianime usually provides subs in different languuages and its servers are generally faster
|
||||
# NOTE: currently they are encrypting the video links
|
||||
# though am working on it
|
||||
# however, you can still get the links to the subs
|
||||
# with ```fastanime grab``` command
|
||||
# yugen meh
|
||||
# nyaa those who prefer torrents, though not reliable due to auto selection of results
|
||||
# as most of the data in nyaa is not structured
|
||||
# though works relatively well for new anime
|
||||
# esp with subsplease and horriblesubs
|
||||
# oh and you should have webtorrent cli to use this
|
||||
provider = {self.provider}
|
||||
|
||||
# Display language [english, romaji]
|
||||
# this is passed to anilist directly and is used to set the language which the anime titles will be in
|
||||
# when using the anilist interface
|
||||
preferred_language = {self.preferred_language}
|
||||
|
||||
# Download directory
|
||||
# where you will find your videos after downloading them with 'fastanime download' command
|
||||
downloads_dir = {self.downloads_dir}
|
||||
|
||||
# whether to show a preview window when using fzf or rofi [True/False]
|
||||
# the preview requires you have a commandline image viewer as documented in the README
|
||||
# this is only when using fzf or rofi
|
||||
# if you dont care about image and text previews it doesnt matter
|
||||
# though its awesome
|
||||
# try it and you will see
|
||||
preview = {self.preview}
|
||||
|
||||
# whether to show images in the preview [true/false]
|
||||
# windows users just swtich to linux 😄
|
||||
# cause even if you enable it
|
||||
# it won't look pretty
|
||||
# just be satisfied with the text previews
|
||||
# so forget it exists 🤣
|
||||
image_previews = {self.image_previews}
|
||||
|
||||
# the time to seek when using ffmpegthumbnailer [-1 to 100]
|
||||
# -1 means random and is the default
|
||||
# ffmpegthumbnailer is used to generate previews
|
||||
# and you can select at what time in the video to extract an image
|
||||
# random makes things quite exciting cause you never no at what time it will extract the image from
|
||||
# used by the ```fastanime downloads``` command
|
||||
ffmpegthumbnailer_seek_time = {self.ffmpegthumbnailer_seek_time}
|
||||
|
||||
# whether to use fzf as the interface for the anilist command and others. [True/False]
|
||||
use_fzf = {self.use_fzf}
|
||||
|
||||
# whether to use rofi for the ui [True/False]
|
||||
# it's more useful if you want to create a desktop entry
|
||||
# which can be setup with 'fastanime config --desktop-entry'
|
||||
# though if you want it to be your sole interface even when fastanime is run directly from the terminal
|
||||
use_rofi = {self.use_rofi}
|
||||
|
||||
# rofi themes to use <path>
|
||||
# the values of this option is the path to the rofi config files to use
|
||||
# i choose to split it into 4 since it gives the best look and feel
|
||||
# you can refer to the rofi demo on github to see for your self
|
||||
# i need help designing the default rofi themes
|
||||
# if you fancy yourself a rofi ricer please contribute to making
|
||||
# the default theme better
|
||||
rofi_theme = {self.rofi_theme}
|
||||
|
||||
rofi_theme_preview = {self.rofi_theme_preview}
|
||||
|
||||
rofi_theme_input = {self.rofi_theme_input}
|
||||
|
||||
rofi_theme_confirm = {self.rofi_theme_confirm}
|
||||
|
||||
# the duration in minutes a notification will stay in the screen
|
||||
# used by notifier command
|
||||
notification_duration = {self.notification_duration}
|
||||
|
||||
# used when the provider gives subs of different languages
|
||||
# currently its the case for:
|
||||
# hianime
|
||||
# the values for this option are the short names for languages
|
||||
# regex is used to determine what you selected
|
||||
sub_lang = {self.sub_lang}
|
||||
|
||||
# what is your default media list tracking [track/disabled/prompt]
|
||||
# only affects your anilist anime list
|
||||
# track - means your progress will always be reflected in your anilist anime list
|
||||
# disabled - means progress tracking will no longer be reflected in your anime list
|
||||
# prompt - means for every anime you will be prompted whether you want your progress to be tracked or not
|
||||
default_media_list_tracking = {self.default_media_list_tracking}
|
||||
|
||||
# whether media list tracking should only be updated when the next episode is greater than the previous
|
||||
# this affects only your anilist anime list
|
||||
force_forward_tracking = {self.force_forward_tracking}
|
||||
|
||||
# whether to cache requests [true/false]
|
||||
# this makes the experience better and more faster
|
||||
# as data need not always be fetched from web server
|
||||
# and instead can be gotten locally
|
||||
# from the cached_requests_db
|
||||
cache_requests = {self.cache_requests}
|
||||
|
||||
# whether to use a persistent store (basically a sqlitedb) for storing some data the provider requires
|
||||
# to enable a seamless experience [true/false]
|
||||
# this option exists primarily because i think it may help in the optimization
|
||||
# of fastanime as a library in a website project
|
||||
# for now i don't recommend changing it
|
||||
# leave it as is
|
||||
use_persistent_provider_store = {self.use_persistent_provider_store}
|
||||
|
||||
# no of recent anime to keep [0-50]
|
||||
# 0 will disable recent anime tracking
|
||||
recent = {self.recent}
|
||||
|
||||
|
||||
[stream]
|
||||
# the quality of the stream [1080,720,480,360]
|
||||
# this option is usually only reliable when:
|
||||
# provider=animepahe
|
||||
# since it provides links that actually point to streams of different qualities
|
||||
# while the rest just point to another link that can provide the anime from the same server
|
||||
quality = {self.quality}
|
||||
|
||||
# Auto continue from watch history [True/False]
|
||||
# this will make fastanime to choose the episode that you last watched to completion
|
||||
# and increment it by one
|
||||
# and use that to auto select the episode you want to watch
|
||||
continue_from_history = {self.continue_from_history}
|
||||
|
||||
# which history to use [local/remote]
|
||||
# local history means it will just use the watch history stored locally in your device
|
||||
# the file that stores it is called watch_history.json
|
||||
# and is stored next to your config file
|
||||
# remote means it ignores the last episode stored locally
|
||||
# and instead uses the one in your anilist anime list
|
||||
# this config option is useful if you want to overwrite your local history
|
||||
# or import history covered from another device or platform
|
||||
# since remote history will take precendence over whats available locally
|
||||
preferred_history = {self.preferred_history}
|
||||
|
||||
# Preferred language for anime [dub/sub]
|
||||
translation_type = {self.translation_type}
|
||||
|
||||
# what server to use for a particular provider
|
||||
# allanime: [dropbox, sharepoint, wetransfer, gogoanime, wixmp]
|
||||
# animepahe: [kwik]
|
||||
# hianime: [HD1, HD2, StreamSB, StreamTape]
|
||||
# yugen: [gogoanime]
|
||||
# 'top' can also be used as a value for this option
|
||||
# 'top' will cause fastanime to auto select the first server it sees
|
||||
# this saves on resources and is faster since not all servers are being fetched
|
||||
server = {self.server}
|
||||
|
||||
# Auto select next episode [True/False]
|
||||
# this makes fastanime increment the current episode number
|
||||
# then after using that value to fetch the next episode instead of prompting
|
||||
# this option is useful for binging
|
||||
auto_next = {self.auto_next}
|
||||
|
||||
# Auto select the anime provider results with fuzzy find. [True/False]
|
||||
# Note this won't always be correct
|
||||
# this is because the providers sometime use non-standard names
|
||||
# that are there own preference rather than the official names
|
||||
# But 99% of the time will be accurate
|
||||
# if this happens just turn off auto_select in the menus or from the commandline
|
||||
# and manually select the correct anime title
|
||||
# edit this file <https://github.com/Benex254/FastAnime/blob/master/fastanime/Utility/data.py>
|
||||
# and to the dictionary of the provider
|
||||
# the provider title (key) and their corresponding anilist names (value)
|
||||
# and then please open a pr
|
||||
# issues on the same will be ignored and then closed 😆
|
||||
auto_select = {self.auto_select}
|
||||
|
||||
# whether to skip the opening and ending theme songs [True/False]
|
||||
# NOTE: requires ani-skip to be in path
|
||||
# for python-mpv users am planning to create this functionality n python without the use of an external script
|
||||
# so its disabled for now
|
||||
# and anyways Dan Da Dan
|
||||
# taught as the importance of letting it flow 🙃
|
||||
skip = {self.skip}
|
||||
|
||||
# at what percentage progress should the episode be considered as completed [0-100]
|
||||
# this value is used to determine whether to increment the current episode number and save it to your local list
|
||||
# so you can continue immediately to the next episode without select it the next time you decide to watch the anime
|
||||
# it is also used to determine whether your anilist anime list should be updated or not
|
||||
episode_complete_at = {self.episode_complete_at}
|
||||
|
||||
# whether to use python-mpv [True/False]
|
||||
# to enable superior control over the player
|
||||
# adding more options to it
|
||||
# Enabling this option and you will ask yourself
|
||||
# why you did not discover fastanime sooner 🙃
|
||||
# Since you basically don't have to close the player window
|
||||
# to go to the next or previous episode, switch servers,
|
||||
# change translation type or change to a given episode x
|
||||
# so try it if you haven't already
|
||||
# if you have any issues setting it up
|
||||
# don't be afraid to ask
|
||||
# especially on windows
|
||||
# honestly it can be a pain to set it up there
|
||||
# personally it took me quite sometime to figure it out
|
||||
# this is because of how windows handles shared libraries
|
||||
# so just ask when you find yourself stuck
|
||||
# or just switch to nixos 😄
|
||||
use_python_mpv = {self.use_python_mpv}
|
||||
|
||||
|
||||
# whether to use popen to get the timestamps for continue_from_history
|
||||
# implemented because popen does not work for some reason in nixos and apparently on mac as well
|
||||
# if you are on nixos or mac and you have a solution to this problem please share
|
||||
# i will be glad to hear it 😄
|
||||
# So for now ignore this option
|
||||
# and anyways the new method of getting timestamps is better
|
||||
disable_mpv_popen = {self.disable_mpv_popen}
|
||||
|
||||
# force mpv window
|
||||
# the default 'immediate' just makes mpv to open the window even if the video has not yet loaded
|
||||
# done for asthetics
|
||||
# passed directly to mpv so values are same
|
||||
force_window = immediate
|
||||
|
||||
# the format of downloaded anime and trailer
|
||||
# based on yt-dlp format and passed directly to it
|
||||
# learn more by looking it up on their site
|
||||
# only works for downloaded anime if:
|
||||
# provider=allanime, server=gogoanime
|
||||
# provider=allanime, server=wixmp
|
||||
# provider=hianime
|
||||
# this is because they provider a m3u8 file that contans multiple quality streams
|
||||
format = {self.format}
|
||||
|
||||
# set the player to use for streaming [mpv/vlc]
|
||||
# while this option exists i will still recommend that you use mpv
|
||||
# since you will miss out on some features if you use the others
|
||||
player = {self.player}
|
||||
|
||||
#
|
||||
# HOPE YOU ENJOY FASTANIME AND BE SURE TO STAR THE PROJECT ON GITHUB
|
||||
# https://github.com/Benex254/FastAnime
|
||||
#
|
||||
# Also join the discord server
|
||||
# where the anime tech community lives :)
|
||||
# https://discord.gg/C4rhMA4mmK
|
||||
#
|
||||
"""
|
||||
return current_config_state
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,94 +7,16 @@ import textwrap
|
||||
from threading import Thread
|
||||
|
||||
import requests
|
||||
from yt_dlp.utils import clean_html, sanitize_filename
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from ...constants import APP_CACHE_DIR, S_PLATFORM
|
||||
from ...libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
from ...Utility import anilist_data_helper
|
||||
from ...Utility.utils import remove_html_tags
|
||||
from ..utils.scripts import fzf_preview
|
||||
from ..utils.utils import get_true_fg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# this script was written by the fzf devs as an example on how to preview images
|
||||
# its only here for convinience
|
||||
fzf_preview = r"""
|
||||
#
|
||||
# The purpose of this script is to demonstrate how to preview a file or an
|
||||
# image in the preview window of fzf.
|
||||
#
|
||||
# Dependencies:
|
||||
# - https://github.com/sharkdp/bat
|
||||
# - https://github.com/hpjansson/chafa
|
||||
# - https://iterm2.com/utilities/imgcat
|
||||
fzf-preview(){
|
||||
if [[ $# -ne 1 ]]; then
|
||||
>&2 echo "usage: $0 FILENAME"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
file=${1/#\~\//$HOME/}
|
||||
type=$(file --dereference --mime -- "$file")
|
||||
|
||||
if [[ ! $type =~ image/ ]]; then
|
||||
if [[ $type =~ =binary ]]; then
|
||||
file "$1"
|
||||
exit
|
||||
fi
|
||||
|
||||
# Sometimes bat is installed as batcat.
|
||||
if command -v batcat > /dev/null; then
|
||||
batname="batcat"
|
||||
elif command -v bat > /dev/null; then
|
||||
batname="bat"
|
||||
else
|
||||
cat "$1"
|
||||
exit
|
||||
fi
|
||||
|
||||
${batname} --style="${BAT_STYLE:-numbers}" --color=always --pager=never -- "$file"
|
||||
exit
|
||||
fi
|
||||
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [[ $dim = x ]]; then
|
||||
dim=$(stty size < /dev/tty | awk '{print $2 "x" $1}')
|
||||
elif ! [[ $KITTY_WINDOW_ID ]] && (( FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}') )); then
|
||||
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
|
||||
# * https://github.com/junegunn/fzf/issues/2544
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
# 1. Use kitty icat on kitty terminal
|
||||
if [[ $KITTY_WINDOW_ID ]]; then
|
||||
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
|
||||
# you have to use 'stream'.
|
||||
#
|
||||
# 2. The last line of the output is the ANSI reset code without newline.
|
||||
# This confuses fzf and makes it render scroll offset indicator.
|
||||
# So we remove the last line and append the reset code to its previous line.
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
|
||||
|
||||
# 2. Use chafa with Sixel output
|
||||
elif command -v chafa > /dev/null; then
|
||||
chafa -f sixel -s "$dim" "$file"
|
||||
# Add a new line character so that fzf can display multiple images in the preview window
|
||||
echo
|
||||
|
||||
# 3. If chafa is not found but imgcat is available, use it on iTerm2
|
||||
elif command -v imgcat > /dev/null; then
|
||||
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
|
||||
# user is running iTerm2. But for the sake of simplicity, we just assume
|
||||
# that's the case here.
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
# 4. Cannot find any suitable method to preview the image
|
||||
else
|
||||
file "$file"
|
||||
fi
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ---- aniskip intergration ----
|
||||
def aniskip(mal_id: int, episode: str):
|
||||
@@ -124,7 +46,13 @@ def aniskip(mal_id: int, episode: str):
|
||||
|
||||
# NOTE: May change this to a temp dir but there were issues so later
|
||||
WORKING_DIR = APP_CACHE_DIR # tempfile.gettempdir()
|
||||
|
||||
_HEADER_COLOR = os.environ.get("FASTANIME_PREVIEW_HEADER_COLOR", "215,0,95").split(",")
|
||||
HEADER_COLOR = _HEADER_COLOR[0], _HEADER_COLOR[1], _HEADER_COLOR[2]
|
||||
_SEPARATOR_COLOR = os.environ.get(
|
||||
"FASTANIME_PREVIEW_SEPARATOR_COLOR", "208,208,208"
|
||||
).split(",")
|
||||
SEPARATOR_COLOR = _SEPARATOR_COLOR[0], _SEPARATOR_COLOR[1], _SEPARATOR_COLOR[2]
|
||||
SINGLE_QUOTE = "'"
|
||||
IMAGES_CACHE_DIR = os.path.join(WORKING_DIR, "images")
|
||||
if not os.path.exists(IMAGES_CACHE_DIR):
|
||||
os.mkdir(IMAGES_CACHE_DIR)
|
||||
@@ -141,7 +69,7 @@ def save_image_from_url(url: str, file_name: str):
|
||||
file_name: filename to use
|
||||
"""
|
||||
image = requests.get(url)
|
||||
with open(f"{IMAGES_CACHE_DIR}/{file_name}", "wb") as f:
|
||||
with open(os.path.join(IMAGES_CACHE_DIR, f"{file_name}.png"), "wb") as f:
|
||||
f.write(image.content)
|
||||
|
||||
|
||||
@@ -152,7 +80,14 @@ def save_info_from_str(info: str, file_name: str):
|
||||
info: the information anilist has on the anime
|
||||
file_name: the filename to use
|
||||
"""
|
||||
with open(f"{ANIME_INFO_CACHE_DIR}/{file_name}", "w") as f:
|
||||
with open(
|
||||
os.path.join(
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
file_name,
|
||||
),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
f.write(info)
|
||||
|
||||
|
||||
@@ -168,41 +103,75 @@ def write_search_results(
|
||||
titles: sanitized anime titles
|
||||
workers:number of threads to use defaults to as many as possible
|
||||
"""
|
||||
# NOTE: Will probably make this a configuraable option
|
||||
HEADER_COLOR = 215, 0, 95
|
||||
SEPARATOR_COLOR = 208, 208, 208
|
||||
SEPARATOR_WIDTH = 45
|
||||
# use concurency to download and write as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
future_to_task = {}
|
||||
for anime, title in zip(anilist_results, titles):
|
||||
# actual image url
|
||||
image_url = anime["coverImage"]["large"]
|
||||
future_to_task[executor.submit(save_image_from_url, image_url, title)] = (
|
||||
image_url
|
||||
)
|
||||
if os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower() == "true":
|
||||
image_url = anime["coverImage"]["large"]
|
||||
future_to_task[
|
||||
executor.submit(save_image_from_url, image_url, title)
|
||||
] = image_url
|
||||
|
||||
mediaListName = "Not in any of your lists"
|
||||
progress = "UNKNOWN"
|
||||
if anime_list := anime["mediaListEntry"]:
|
||||
mediaListName = anime_list["status"]
|
||||
progress = anime_list["progress"]
|
||||
# handle the text data
|
||||
template = f"""
|
||||
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
|
||||
{get_true_fg('Title(jp):',*HEADER_COLOR)} {anime['title']['romaji']}
|
||||
{get_true_fg('Title(eng):',*HEADER_COLOR)} {anime['title']['english']}
|
||||
{get_true_fg('Popularity:',*HEADER_COLOR)} {anime['popularity']}
|
||||
{get_true_fg('Favourites:',*HEADER_COLOR)} {anime['favourites']}
|
||||
{get_true_fg('Status:',*HEADER_COLOR)} {anime['status']}
|
||||
{get_true_fg('Episodes:',*HEADER_COLOR)} {anime['episodes']}
|
||||
{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres'])}
|
||||
{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode'])}
|
||||
{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate'])}
|
||||
{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate'])}
|
||||
{get_true_fg("-"*SEPARATOR_WIDTH,*SEPARATOR_COLOR,bold=False)}
|
||||
{get_true_fg('Description:',*HEADER_COLOR)}
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
echo "{get_true_fg('Title(jp):',*HEADER_COLOR)} {(anime['title']['romaji'] or "").replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('Title(eng):',*HEADER_COLOR)} {(anime['title']['english'] or "").replace('"',SINGLE_QUOTE)}"
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
echo "{get_true_fg('Popularity:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['popularity'])}"
|
||||
echo "{get_true_fg('Favourites:',*HEADER_COLOR)} {anilist_data_helper.format_number_with_commas(anime['favourites'])}"
|
||||
echo "{get_true_fg('Status:',*HEADER_COLOR)} {str(anime['status']).replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('Next Episode:',*HEADER_COLOR)} {anilist_data_helper.extract_next_airing_episode(anime['nextAiringEpisode']).replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('Genres:',*HEADER_COLOR)} {anilist_data_helper.format_list_data_with_comma(anime['genres']).replace('"',SINGLE_QUOTE)}"
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
echo "{get_true_fg('Episodes:',*HEADER_COLOR)} {(anime['episodes']) or 'UNKNOWN'}"
|
||||
echo "{get_true_fg('Start Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['startDate']).replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('End Date:',*HEADER_COLOR)} {anilist_data_helper.format_anilist_date_object(anime['endDate']).replace('"',SINGLE_QUOTE)}"
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
echo "{get_true_fg('Media List:',*HEADER_COLOR)} {mediaListName.replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('Progress:',*HEADER_COLOR)} {progress}"
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo
|
||||
# echo "{get_true_fg('Description:',*HEADER_COLOR).replace('"',SINGLE_QUOTE)}"
|
||||
"""
|
||||
template = textwrap.dedent(template)
|
||||
template = f"""
|
||||
{template}
|
||||
{textwrap.fill(remove_html_tags(
|
||||
str(anime['description'])), width=45)}
|
||||
echo "
|
||||
{textwrap.fill(clean_html(
|
||||
(anime['description']) or "").replace('"',SINGLE_QUOTE), width=45)}
|
||||
"
|
||||
"""
|
||||
future_to_task[executor.submit(save_info_from_str, template, title)] = title
|
||||
|
||||
@@ -246,7 +215,196 @@ def get_rofi_icons(
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
|
||||
def get_fzf_preview(
|
||||
# get rofi icons
|
||||
def get_fzf_manga_preview(manga_results, workers=None, wait=False):
|
||||
"""A helper function to make sure that the images are downloaded so they can be used as icons
|
||||
|
||||
Args:
|
||||
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
|
||||
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
|
||||
anilist_results: the anilist results from an anilist action
|
||||
"""
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
for manga in manga_results:
|
||||
image_url = manga["poster"]
|
||||
future_to_url[
|
||||
executor.submit(
|
||||
save_image_from_url,
|
||||
image_url,
|
||||
sanitize_filename(manga["title"]),
|
||||
)
|
||||
] = image_url
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
background_worker = Thread(
|
||||
target=_worker,
|
||||
)
|
||||
background_worker.daemon = True
|
||||
# ensure images and info exists
|
||||
background_worker.start()
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then fzf-preview %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
|
||||
|
||||
# get rofi icons
|
||||
def get_fzf_episode_preview(
|
||||
anilist_result: AnilistBaseMediaDataSchema, episodes, workers=None, wait=False
|
||||
):
|
||||
"""A helper function to make sure that the images are downloaded so they can be used as icons
|
||||
|
||||
Args:
|
||||
titles (list[str]): sanitized titles of the anime; NOTE: its important that they are sanitized since they are used as the filenames of the images
|
||||
workers ([TODO:parameter]): Number of threads to use to download the images; defaults to as many as possible
|
||||
anilist_results: the anilist results from an anilist action
|
||||
"""
|
||||
|
||||
# HEADER_COLOR = 215, 0, 95
|
||||
import re
|
||||
|
||||
def _worker():
|
||||
# use concurrency to download the images as fast as possible
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
# load the jobs
|
||||
future_to_url = {}
|
||||
|
||||
for episode in episodes:
|
||||
episode_title = ""
|
||||
image_url = ""
|
||||
for episode_detail in anilist_result["streamingEpisodes"]:
|
||||
if re.match(f".*Episode {episode} .*", episode_detail["title"]):
|
||||
episode_title = episode_detail["title"]
|
||||
image_url = episode_detail["thumbnail"]
|
||||
|
||||
if episode_title and image_url:
|
||||
future_to_url[
|
||||
executor.submit(save_image_from_url, image_url, episode)
|
||||
] = image_url
|
||||
template = textwrap.dedent(
|
||||
f"""
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo "{get_true_fg('Anime Title(eng):',*HEADER_COLOR)} {('' or anilist_result['title']['english']).replace('"',SINGLE_QUOTE)}"
|
||||
echo "{get_true_fg('Anime Title(jp):',*HEADER_COLOR)} {(anilist_result['title']['romaji'] or '').replace('"',SINGLE_QUOTE)}"
|
||||
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
echo "{str(episode_title).replace('"',SINGLE_QUOTE)}"
|
||||
ll=2
|
||||
while [ $ll -le $FZF_PREVIEW_COLUMNS ];do
|
||||
echo -n -e "{get_true_fg("─",*SEPARATOR_COLOR,bold=False)}"
|
||||
((ll++))
|
||||
done
|
||||
"""
|
||||
)
|
||||
future_to_url[
|
||||
executor.submit(save_info_from_str, template, str(episode))
|
||||
] = str(episode)
|
||||
|
||||
# execute the jobs
|
||||
for future in concurrent.futures.as_completed(future_to_url):
|
||||
url = future_to_url[future]
|
||||
try:
|
||||
future.result()
|
||||
except Exception as e:
|
||||
logger.error("%r generated an exception: %s" % (url, e))
|
||||
|
||||
background_worker = Thread(
|
||||
target=_worker,
|
||||
)
|
||||
background_worker.daemon = True
|
||||
# ensure images and info exists
|
||||
background_worker.start()
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
if S_PLATFORM == "win32":
|
||||
preview = """
|
||||
%s
|
||||
title={}
|
||||
show_image_previews="%s"
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ $show_image_previews = "true" ];then
|
||||
if [ -s "%s\\\\\\${title}.png" ]; then
|
||||
if command -v "chafa">/dev/null;then
|
||||
chafa -s $dim "%s\\\\\\${title}.png"
|
||||
else
|
||||
echo please install chafa to enjoy image previews
|
||||
fi
|
||||
echo
|
||||
else
|
||||
echo Loading...
|
||||
fi
|
||||
fi
|
||||
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
|
||||
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
)
|
||||
else:
|
||||
preview = """
|
||||
title={}
|
||||
%s
|
||||
show_image_previews="%s"
|
||||
if [ $show_image_previews = "true" ];then
|
||||
if [ -s %s/${title}.png ]; then fzf-preview %s/${title}.png
|
||||
else echo Loading...
|
||||
fi
|
||||
fi
|
||||
if [ -f %s/${title} ]; then source %s/${title}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
|
||||
|
||||
def get_fzf_anime_preview(
|
||||
anilist_results: list[AnilistBaseMediaDataSchema], titles, wait=False
|
||||
):
|
||||
"""A helper function that constructs data to be used for the fzf preview
|
||||
@@ -260,6 +418,7 @@ def get_fzf_preview(
|
||||
THe fzf preview script to use
|
||||
"""
|
||||
# ensure images and info exists
|
||||
|
||||
background_worker = Thread(
|
||||
target=write_search_results, args=(anilist_results, titles)
|
||||
)
|
||||
@@ -268,21 +427,56 @@ def get_fzf_preview(
|
||||
|
||||
# the preview script is in bash so making sure fzf doesnt use any other shell lang to process the preview script
|
||||
os.environ["SHELL"] = shutil.which("bash") or "bash"
|
||||
preview = """
|
||||
%s
|
||||
if [ -s %s/{} ]; then fzf-preview %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
if [ -s %s/{} ]; then cat %s/{}
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
)
|
||||
if S_PLATFORM == "win32":
|
||||
preview = """
|
||||
%s
|
||||
title={}
|
||||
show_image_previews="%s"
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [ $show_image_previews = "true" ];then
|
||||
if [ -s "%s\\\\\\${title}.png" ]; then
|
||||
if command -v "chafa">/dev/null;then
|
||||
chafa -s $dim "%s\\\\\\${title}.png"
|
||||
else
|
||||
echo please install chafa to enjoy image previews
|
||||
fi
|
||||
echo
|
||||
else
|
||||
echo Loading...
|
||||
fi
|
||||
fi
|
||||
if [ -s "%s\\\\\\$title" ]; then source "%s\\\\\\$title"
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
|
||||
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
IMAGES_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
ANIME_INFO_CACHE_DIR.replace("\\", "\\\\\\"),
|
||||
)
|
||||
else:
|
||||
preview = """
|
||||
%s
|
||||
title={}
|
||||
show_image_previews="%s"
|
||||
if [ $show_image_previews = "true" ];then
|
||||
if [ -s "%s/${title}.png" ]; then fzf-preview "%s/${title}.png"
|
||||
else echo Loading...
|
||||
fi
|
||||
fi
|
||||
if [ -s "%s/$title" ]; then source "%s/$title"
|
||||
else echo Loading...
|
||||
fi
|
||||
""" % (
|
||||
fzf_preview,
|
||||
os.environ.get("FASTANIME_IMAGE_PREVIEWS", "true").lower(),
|
||||
IMAGES_CACHE_DIR,
|
||||
IMAGES_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
ANIME_INFO_CACHE_DIR,
|
||||
)
|
||||
if wait:
|
||||
background_worker.join()
|
||||
return preview
|
||||
|
||||
12
fastanime/cli/utils/feh.py
Normal file
12
fastanime/cli/utils/feh.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
from sys import exit
|
||||
|
||||
|
||||
def feh_manga_viewer(image_links: list[str], window_title: str):
|
||||
FEH_EXECUTABLE = shutil.which("feh")
|
||||
if not FEH_EXECUTABLE:
|
||||
print("feh not found")
|
||||
exit(1)
|
||||
commands = [FEH_EXECUTABLE, *image_links, "--title", window_title]
|
||||
subprocess.run(commands)
|
||||
@@ -1,120 +1,212 @@
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ...constants import S_PLATFORM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
mpv_av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
|
||||
|
||||
|
||||
def stream_video(MPV, url, mpv_args, custom_args):
|
||||
process = subprocess.Popen(
|
||||
[MPV, url, *mpv_args, *custom_args],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
last_time = None
|
||||
av_time_pattern = re.compile(r"AV: ([0-9:]*) / ([0-9:]*) \(([0-9]*)%\)")
|
||||
last_time = "0"
|
||||
total_time = "0"
|
||||
if os.environ.get("FASTANIME_DISABLE_MPV_POPEN", "False") == "False":
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
MPV,
|
||||
url,
|
||||
*mpv_args,
|
||||
*custom_args,
|
||||
"--no-terminal",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
if not process.stderr:
|
||||
continue
|
||||
output = process.stderr.readline()
|
||||
try:
|
||||
while True:
|
||||
if not process.stderr:
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
output = process.stderr.readline()
|
||||
|
||||
if output:
|
||||
# Match the timestamp in the output
|
||||
match = av_time_pattern.search(output.strip())
|
||||
if output:
|
||||
# Match the timestamp in the output
|
||||
match = mpv_av_time_pattern.search(output.strip())
|
||||
if match:
|
||||
current_time = match.group(1)
|
||||
total_time = match.group(2)
|
||||
last_time = current_time
|
||||
|
||||
# Check if the process has terminated
|
||||
retcode = process.poll()
|
||||
if retcode is not None:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
logger.error(f"An error occurred: {e}")
|
||||
finally:
|
||||
process.terminate()
|
||||
process.wait()
|
||||
else:
|
||||
proc = subprocess.run(
|
||||
[MPV, url, *mpv_args, *custom_args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
if proc.stdout:
|
||||
for line in reversed(proc.stdout.split("\n")):
|
||||
match = mpv_av_time_pattern.search(line.strip())
|
||||
if match:
|
||||
current_time = match.group(1)
|
||||
last_time = match.group(1)
|
||||
total_time = match.group(2)
|
||||
match.group(3)
|
||||
last_time = current_time
|
||||
# print(f"Current stream time: {current_time}, Total time: {total_time}, Progress: {percentage}%")
|
||||
|
||||
# Check if the process has terminated
|
||||
retcode = process.poll()
|
||||
if retcode is not None:
|
||||
print("Finshed at: ", last_time)
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
process.terminate()
|
||||
|
||||
break
|
||||
return last_time, total_time
|
||||
|
||||
|
||||
def run_mpv(
|
||||
link: str,
|
||||
title: str | None = "",
|
||||
title: str = "",
|
||||
start_time: str = "0",
|
||||
ytdl_format="",
|
||||
custom_args=[],
|
||||
headers={},
|
||||
subtitles=[],
|
||||
player="",
|
||||
):
|
||||
# Determine if mpv is available
|
||||
MPV = shutil.which("mpv")
|
||||
|
||||
# If title is None, set a default value
|
||||
|
||||
# Regex to check if the link is a YouTube URL
|
||||
youtube_regex = r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+"
|
||||
|
||||
if not MPV:
|
||||
# Determine if the link is a YouTube URL
|
||||
if re.match(youtube_regex, link):
|
||||
# Android specific commands to launch mpv with a YouTube URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"com.google.android.youtube/.UrlActivity",
|
||||
]
|
||||
if link.endswith(".torrent"):
|
||||
WEBTORRENT_CLI = shutil.which("webtorrent")
|
||||
if not WEBTORRENT_CLI:
|
||||
import time
|
||||
|
||||
print(
|
||||
"webtorrent cli is not installed which is required for downloading and streaming from nyaa\nplease install it or use another provider"
|
||||
)
|
||||
time.sleep(120)
|
||||
return "0", "0"
|
||||
cmd = [WEBTORRENT_CLI, link, f"--{player}"]
|
||||
subprocess.run(cmd, encoding="utf-8")
|
||||
return "0", "0"
|
||||
if player == "vlc":
|
||||
VLC = shutil.which("vlc")
|
||||
if not VLC and not S_PLATFORM == "win32":
|
||||
# Determine if the link is a YouTube URL
|
||||
if re.match(youtube_regex, link):
|
||||
# Android specific commands to launch mpv with a YouTube URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"com.google.android.youtube/.UrlActivity",
|
||||
]
|
||||
return "0", "0"
|
||||
else:
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
|
||||
"-e",
|
||||
"title",
|
||||
title,
|
||||
]
|
||||
|
||||
subprocess.run(args)
|
||||
return "0", "0"
|
||||
else:
|
||||
# Android specific commands to launch mpv with a regular URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"is.xyz.mpv/.MPVActivity",
|
||||
]
|
||||
|
||||
subprocess.run(args)
|
||||
return "0", "0"
|
||||
args = ["vlc", link]
|
||||
for subtitle in subtitles:
|
||||
args.append("--sub-file")
|
||||
args.append(subtitle["url"])
|
||||
break
|
||||
if title:
|
||||
args.append("--video-title")
|
||||
args.append(title)
|
||||
subprocess.run(args, encoding="utf-8")
|
||||
return "0", "0"
|
||||
else:
|
||||
# General mpv command with custom arguments
|
||||
mpv_args = []
|
||||
if start_time != "0":
|
||||
mpv_args.append(f"--start={start_time}")
|
||||
if title:
|
||||
mpv_args.append(f"--title={title}")
|
||||
if ytdl_format:
|
||||
mpv_args.append(f"--ytdl-format={ytdl_format}")
|
||||
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
|
||||
return stop_time, total_time
|
||||
# Determine if mpv is available
|
||||
MPV = shutil.which("mpv")
|
||||
if not MPV and not S_PLATFORM == "win32":
|
||||
# Determine if the link is a YouTube URL
|
||||
if re.match(youtube_regex, link):
|
||||
# Android specific commands to launch mpv with a YouTube URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"com.google.android.youtube/.UrlActivity",
|
||||
]
|
||||
return "0", "0"
|
||||
else:
|
||||
# Android specific commands to launch mpv with a regular URL
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
link,
|
||||
"-n",
|
||||
"is.xyz.mpv/.MPVActivity",
|
||||
]
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
run_mpv(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"Example Video",
|
||||
"--fullscreen",
|
||||
"--volume=50",
|
||||
)
|
||||
subprocess.run(args)
|
||||
return "0", "0"
|
||||
else:
|
||||
# General mpv command with custom arguments
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
for subtitle in subtitles:
|
||||
mpv_args.append(f"--sub-file={subtitle['url']}")
|
||||
if start_time != "0":
|
||||
mpv_args.append(f"--start={start_time}")
|
||||
if title:
|
||||
mpv_args.append(f"--title={title}")
|
||||
if ytdl_format:
|
||||
mpv_args.append(f"--ytdl-format={ytdl_format}")
|
||||
stop_time, total_time = stream_video(MPV, link, mpv_args, custom_args)
|
||||
return stop_time, total_time
|
||||
|
||||
@@ -3,13 +3,14 @@ from typing import TYPE_CHECKING
|
||||
import mpv
|
||||
|
||||
from ...anilist import AniList
|
||||
from .utils import filter_by_quality
|
||||
from .utils import filter_by_quality, move_preferred_subtitle_lang_to_top
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
from ...AnimeProvider import AnimeProvider
|
||||
from ..config import Config
|
||||
from .tools import FastAnimeRuntimeState
|
||||
|
||||
|
||||
def format_time(duration_in_secs: float):
|
||||
@@ -22,6 +23,7 @@ def format_time(duration_in_secs: float):
|
||||
class MpvPlayer(object):
|
||||
anime_provider: "AnimeProvider"
|
||||
config: "Config"
|
||||
subs = []
|
||||
mpv_player: "mpv.MPV"
|
||||
last_stop_time: str = "0"
|
||||
last_total_time: str = "0"
|
||||
@@ -66,7 +68,11 @@ class MpvPlayer(object):
|
||||
current_episode_number = (
|
||||
fastanime_runtime_state.provider_current_episode_number
|
||||
)
|
||||
config.update_watch_history(anime_id_anilist, str(current_episode_number))
|
||||
config.media_list_track(
|
||||
anime_id_anilist,
|
||||
episode_no=str(current_episode_number),
|
||||
progress_tracking=fastanime_runtime_state.progress_tracking,
|
||||
)
|
||||
elif type == "reload":
|
||||
if current_episode_number not in total_episodes:
|
||||
self.mpv_player.show_text("Episode not available")
|
||||
@@ -82,7 +88,11 @@ class MpvPlayer(object):
|
||||
|
||||
self.mpv_player.show_text(f"Fetching episode {ep_no}")
|
||||
current_episode_number = ep_no
|
||||
config.update_watch_history(anime_id_anilist, str(ep_no))
|
||||
config.media_list_track(
|
||||
anime_id_anilist,
|
||||
episode_no=str(ep_no),
|
||||
progress_tracking=fastanime_runtime_state.progress_tracking,
|
||||
)
|
||||
fastanime_runtime_state.provider_current_episode_number = str(ep_no)
|
||||
else:
|
||||
self.mpv_player.show_text("Fetching previous episode...")
|
||||
@@ -95,29 +105,35 @@ class MpvPlayer(object):
|
||||
current_episode_number = (
|
||||
fastanime_runtime_state.provider_current_episode_number
|
||||
)
|
||||
config.update_watch_history(anime_id_anilist, str(current_episode_number))
|
||||
config.media_list_track(
|
||||
anime_id_anilist,
|
||||
episode_no=str(current_episode_number),
|
||||
progress_tracking=fastanime_runtime_state.progress_tracking,
|
||||
)
|
||||
# update episode progress
|
||||
if config.user and current_episode_number:
|
||||
AniList.update_anime_list(
|
||||
{
|
||||
"mediaId": anime_id_anilist,
|
||||
"progress": current_episode_number,
|
||||
"progress": int(float(current_episode_number)),
|
||||
}
|
||||
)
|
||||
# get them juicy streams
|
||||
episode_streams = anime_provider.get_episode_streams(
|
||||
provider_anime,
|
||||
provider_anime["id"],
|
||||
current_episode_number,
|
||||
translation_type,
|
||||
fastanime_runtime_state.selected_anime_anilist,
|
||||
)
|
||||
if not episode_streams:
|
||||
self.mpv_player.show_text("No streams were found")
|
||||
return None
|
||||
return
|
||||
|
||||
# always select the first
|
||||
if server == "top":
|
||||
selected_server = next(episode_streams)
|
||||
selected_server = next(episode_streams, None)
|
||||
if not selected_server:
|
||||
self.mpv_player.show_text("Sth went wrong when loading the episode")
|
||||
return
|
||||
else:
|
||||
episode_streams_dict = {
|
||||
episode_stream["server"]: episode_stream
|
||||
@@ -128,25 +144,46 @@ class MpvPlayer(object):
|
||||
self.mpv_player.show_text(
|
||||
f"Invalid server!!; servers available are: {episode_streams_dict.keys()}",
|
||||
)
|
||||
return None
|
||||
return
|
||||
self.current_media_title = selected_server["episode_title"]
|
||||
if config.normalize_titles:
|
||||
import re
|
||||
|
||||
for episode_detail in fastanime_runtime_state.selected_anime_anilist[
|
||||
"streamingEpisodes"
|
||||
]:
|
||||
if re.match(
|
||||
f"Episode {current_episode_number} ", episode_detail["title"]
|
||||
):
|
||||
self.current_media_title = episode_detail["title"]
|
||||
break
|
||||
|
||||
links = selected_server["links"]
|
||||
|
||||
stream_link_ = filter_by_quality(quality, links)
|
||||
if not stream_link_:
|
||||
self.mpv_player.show_text("Quality not found")
|
||||
return
|
||||
self.mpv_player._set_property("start", "0")
|
||||
stream_link = stream_link_["link"]
|
||||
fastanime_runtime_state.provider_current_episode_stream_link = stream_link
|
||||
self.subs = move_preferred_subtitle_lang_to_top(
|
||||
selected_server["subtitles"], config.sub_lang
|
||||
)
|
||||
return stream_link
|
||||
|
||||
def create_player(
|
||||
self,
|
||||
stream_link,
|
||||
anime_provider: "AnimeProvider",
|
||||
fastanime_runtime_state,
|
||||
fastanime_runtime_state: "FastAnimeRuntimeState",
|
||||
config: "Config",
|
||||
title,
|
||||
start_time,
|
||||
headers={},
|
||||
subtitles=[],
|
||||
):
|
||||
self.subs = subtitles
|
||||
self.anime_provider = anime_provider
|
||||
self.fastanime_runtime_state = fastanime_runtime_state
|
||||
self.config = config
|
||||
@@ -165,12 +202,6 @@ class MpvPlayer(object):
|
||||
osc=True,
|
||||
ytdl=True,
|
||||
)
|
||||
mpv_player.force_window = config.force_window
|
||||
# mpv_player.cache = "yes"
|
||||
# mpv_player.cache_pause = "no"
|
||||
mpv_player.title = title
|
||||
|
||||
mpv_player.play(stream_link)
|
||||
|
||||
# -- events --
|
||||
@mpv_player.event_callback("file-loaded")
|
||||
@@ -179,6 +210,22 @@ class MpvPlayer(object):
|
||||
self.player_fetching = False
|
||||
if isinstance(d, float):
|
||||
self.last_total_time = format_time(d)
|
||||
try:
|
||||
if not mpv_player.core_shutdown:
|
||||
if self.subs:
|
||||
for i, subtitle in enumerate(self.subs):
|
||||
if i == 0:
|
||||
flag = "select"
|
||||
else:
|
||||
flag = "auto"
|
||||
mpv_player.sub_add(
|
||||
subtitle["url"], flag, None, subtitle["language"]
|
||||
)
|
||||
self.subs = []
|
||||
except mpv.ShutdownError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@mpv_player.property_observer("time-pos")
|
||||
def handle_time_start_update(*args):
|
||||
@@ -207,7 +254,9 @@ class MpvPlayer(object):
|
||||
def _next_episode():
|
||||
url = self.get_episode("next")
|
||||
if url:
|
||||
mpv_player.loadfile(url, options=f"title={self.current_media_title}")
|
||||
mpv_player.loadfile(
|
||||
url,
|
||||
)
|
||||
mpv_player.title = self.current_media_title
|
||||
|
||||
@mpv_player.on_key_press("shift+p")
|
||||
@@ -230,9 +279,9 @@ class MpvPlayer(object):
|
||||
@mpv_player.on_key_press("shift+t")
|
||||
def _toggle_translation_type():
|
||||
translation_type = "sub" if config.translation_type == "dub" else "dub"
|
||||
mpv_player.show_text("Changing translation type...")
|
||||
anime = anime_provider.get_anime(
|
||||
fastanime_runtime_state.provider_anime_search_result["id"],
|
||||
fastanime_runtime_state.selected_anime_anilist,
|
||||
)
|
||||
if not anime:
|
||||
mpv_player.show_text("Failed to update translation type")
|
||||
@@ -315,7 +364,23 @@ class MpvPlayer(object):
|
||||
mpv_player.register_message_handler("select-quality", select_quality)
|
||||
|
||||
self.mpv_player = mpv_player
|
||||
return mpv_player
|
||||
mpv_player.force_window = config.force_window
|
||||
# mpv_player.cache = "yes"
|
||||
# mpv_player.cache_pause = "no"
|
||||
mpv_player.title = title
|
||||
mpv_headers = ""
|
||||
if headers:
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_player.http_header_fields = mpv_headers
|
||||
|
||||
mpv_player.play(stream_link)
|
||||
|
||||
if not start_time == "0":
|
||||
mpv_player.start = start_time
|
||||
|
||||
mpv_player.wait_for_shutdown()
|
||||
mpv_player.terminate()
|
||||
|
||||
|
||||
player = MpvPlayer()
|
||||
|
||||
@@ -5,20 +5,29 @@ import requests
|
||||
|
||||
|
||||
def print_img(url: str):
|
||||
executable = shutil.which("chafa")
|
||||
curl = shutil.which("curl")
|
||||
# curl -sL "$1" | chafa /dev/stdin
|
||||
"""helper funtion to print an image given its url
|
||||
|
||||
if executable is None or curl is None:
|
||||
print("chafa or curl not found")
|
||||
return
|
||||
Args:
|
||||
url: [TODO:description]
|
||||
"""
|
||||
if EXECUTABLE := shutil.which("icat"):
|
||||
subprocess.run([EXECUTABLE, url])
|
||||
else:
|
||||
EXECUTABLE = shutil.which("chafa")
|
||||
|
||||
res = requests.get(url)
|
||||
if res.status_code != 200:
|
||||
print("Error fetching image")
|
||||
return
|
||||
img_bytes = res.content
|
||||
if not img_bytes:
|
||||
print("No image found")
|
||||
img_bytes = subprocess.check_output([curl, "-sL", url])
|
||||
subprocess.run([executable, url, "--size=15x15"], input=img_bytes)
|
||||
if EXECUTABLE is None:
|
||||
print("chafanot found")
|
||||
return
|
||||
|
||||
res = requests.get(url)
|
||||
if res.status_code != 200:
|
||||
print("Error fetching image")
|
||||
return
|
||||
img_bytes = res.content
|
||||
"""
|
||||
Change made in call to chafa. Chafa dev dropped abilty
|
||||
to pull from urls. Keeping old line here just in case.
|
||||
|
||||
subprocess.run([EXECUTABLE, url, "--size=15x15"], input=img_bytes)
|
||||
"""
|
||||
subprocess.run([EXECUTABLE, "--size=15x15"], input=img_bytes)
|
||||
|
||||
53
fastanime/cli/utils/scripts.py
Normal file
53
fastanime/cli/utils/scripts.py
Normal file
@@ -0,0 +1,53 @@
|
||||
fzf_preview = r"""
|
||||
#
|
||||
# Adapted from the preview script in the fzf repo
|
||||
#
|
||||
# Dependencies:
|
||||
# - https://github.com/hpjansson/chafa
|
||||
# - https://iterm2.com/utilities/imgcat
|
||||
#
|
||||
fzf-preview() {
|
||||
file=${1/#\~\//$HOME/}
|
||||
dim=${FZF_PREVIEW_COLUMNS}x${FZF_PREVIEW_LINES}
|
||||
if [[ $dim = x ]]; then
|
||||
dim=$(stty size </dev/tty | awk '{print $2 "x" $1}')
|
||||
elif ! [[ $KITTY_WINDOW_ID ]] && ((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size </dev/tty | awk '{print $1}'))); then
|
||||
# Avoid scrolling issue when the Sixel image touches the bottom of the screen
|
||||
# * https://github.com/junegunn/fzf/issues/2544
|
||||
dim=${FZF_PREVIEW_COLUMNS}x$((FZF_PREVIEW_LINES - 1))
|
||||
fi
|
||||
|
||||
# 1. Use kitty icat on kitty terminal
|
||||
if [[ $KITTY_WINDOW_ID ]]; then
|
||||
# 1. 'memory' is the fastest option but if you want the image to be scrollable,
|
||||
# you have to use 'stream'.
|
||||
#
|
||||
# 2. The last line of the output is the ANSI reset code without newline.
|
||||
# This confuses fzf and makes it render scroll offset indicator.
|
||||
# So we remove the last line and append the reset code to its previous line.
|
||||
kitty icat --clear --transfer-mode=memory --unicode-placeholder --stdin=no --place="$dim@0x0" "$file" | sed '$d' | sed $'$s/$/\e[m/'
|
||||
|
||||
# 2. Use chafa with Sixel output
|
||||
elif command -v chafa >/dev/null; then
|
||||
case "$(uname -a)" in
|
||||
# termux does not support sixel graphics
|
||||
# and produces weird output
|
||||
*ndroid*) chafa -s "$dim" "$file";;
|
||||
*) chafa -f sixel -s "$dim" "$file";;
|
||||
esac
|
||||
# Add a new line character so that fzf can display multiple images in the preview window
|
||||
echo
|
||||
|
||||
# 3. If chafa is not found but imgcat is available, use it on iTerm2
|
||||
elif command -v imgcat >/dev/null; then
|
||||
# NOTE: We should use https://iterm2.com/utilities/it2check to check if the
|
||||
# user is running iTerm2. But for the sake of simplicity, we just assume
|
||||
# that's the case here.
|
||||
imgcat -W "${dim%%x*}" -H "${dim##*x}" "$file"
|
||||
|
||||
# 4. Cannot find any suitable method to preview the image
|
||||
else
|
||||
echo install chafa or imgcat or install kitty terminal so you can enjoy image previews
|
||||
fi
|
||||
}
|
||||
"""
|
||||
44
fastanime/cli/utils/syncplay.py
Normal file
44
fastanime/cli/utils/syncplay.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from .tools import exit_app
|
||||
|
||||
|
||||
def SyncPlayer(url: str, anime_title=None, headers={}, subtitles=[], *args):
|
||||
# TODO: handle m3u8 multi quality streams
|
||||
#
|
||||
# check for SyncPlay
|
||||
SYNCPLAY_EXECUTABLE = shutil.which("syncplay")
|
||||
if not SYNCPLAY_EXECUTABLE:
|
||||
print("Syncplay not found")
|
||||
exit_app(1)
|
||||
return "0", "0"
|
||||
# start SyncPlayer
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
for subtitle in subtitles:
|
||||
mpv_args.append(f"--sub-file={subtitle['url']}")
|
||||
if not anime_title:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
]
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
"--",
|
||||
f"--force-media-title={anime_title}",
|
||||
*mpv_args,
|
||||
]
|
||||
)
|
||||
|
||||
# for compatability
|
||||
return "0", "0"
|
||||
@@ -1,39 +1,49 @@
|
||||
class FastAnimeRuntimeState(dict):
|
||||
"""dot.notation access to dictionary attributes"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
def __getattr__(self, attr):
|
||||
try:
|
||||
return self.__getitem__(attr)
|
||||
except KeyError:
|
||||
raise AttributeError(
|
||||
"%r object has no attribute %r" % (self.__class__.__name__, attr)
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self.__setitem__(attr, value)
|
||||
from ...libs.anilist.types import AnilistBaseMediaDataSchema
|
||||
from ...libs.anime_provider.types import Anime, EpisodeStream, SearchResult, Server
|
||||
|
||||
|
||||
def exit_app(*args):
|
||||
import os
|
||||
import shutil
|
||||
class FastAnimeRuntimeState(object):
|
||||
"""A class that manages fastanime runtime during anilist command runtime"""
|
||||
|
||||
provider_current_episode_stream_link: str
|
||||
provider_current_server: "Server"
|
||||
provider_current_server_name: str
|
||||
provider_available_episodes: list[str]
|
||||
provider_current_episode_number: str
|
||||
provider_server_episode_streams: list["EpisodeStream"]
|
||||
provider_anime_title: str
|
||||
provider_anime: "Anime"
|
||||
provider_anime_search_result: "SearchResult"
|
||||
progress_tracking: str = ""
|
||||
|
||||
selected_anime_anilist: "AnilistBaseMediaDataSchema"
|
||||
selected_anime_id_anilist: int
|
||||
selected_anime_title_anilist: str
|
||||
# current_anilist_data: "AnilistDataSchema | AnilistMediaList"
|
||||
anilist_results_data: "Any"
|
||||
|
||||
|
||||
def exit_app(exit_code=0, *args):
|
||||
import sys
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from ...constants import APP_NAME, ICON_PATH, USER_NAME
|
||||
|
||||
def is_running_in_terminal():
|
||||
console = Console()
|
||||
if not console.is_terminal:
|
||||
try:
|
||||
shutil.get_terminal_size()
|
||||
return (
|
||||
sys.stdin.isatty()
|
||||
and sys.stdout.isatty()
|
||||
and os.getenv("TERM") is not None
|
||||
from plyer import notification
|
||||
except ImportError:
|
||||
print(
|
||||
"Plyer is not installed; install it for desktop notifications to be enabled"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if not is_running_in_terminal():
|
||||
from plyer import notification
|
||||
|
||||
exit(1)
|
||||
notification.notify(
|
||||
app_name=APP_NAME,
|
||||
app_icon=ICON_PATH,
|
||||
@@ -41,20 +51,6 @@ def exit_app(*args):
|
||||
title="Shutting down",
|
||||
) # pyright:ignore
|
||||
else:
|
||||
from rich import print
|
||||
|
||||
print("Have a good day :smile:", USER_NAME)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def get_formatted_str(string: str, style):
|
||||
from rich.text import Text
|
||||
|
||||
# Create a Text object with desired style
|
||||
text = Text(string, style="bold red")
|
||||
|
||||
# Convert the Text object to an ANSI string
|
||||
ansi_output = text.__rich_console__(None, None) # pyright:ignore
|
||||
|
||||
# Join the ANSI strings to form the final output
|
||||
"".join(segment.text for segment in ansi_output)
|
||||
console.clear()
|
||||
console.print("Have a good day :smile:", USER_NAME)
|
||||
sys.exit(exit_code)
|
||||
|
||||
@@ -2,9 +2,6 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from InquirerPy import inquirer
|
||||
from thefuzz import fuzz
|
||||
|
||||
from ...Utility.data import anime_normalizer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,7 +19,47 @@ BG_GREEN = "\033[48;2;120;233;12;m"
|
||||
GREEN = "\033[38;2;45;24;45;m"
|
||||
|
||||
|
||||
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
|
||||
def get_requested_quality_or_default_to_first(url, quality):
|
||||
import yt_dlp
|
||||
|
||||
with yt_dlp.YoutubeDL({"quiet": True, "silent": True, "no_warnings": True}) as ydl:
|
||||
m3u8_info = ydl.extract_info(url, False)
|
||||
if not m3u8_info:
|
||||
return
|
||||
|
||||
m3u8_formats = m3u8_info["formats"]
|
||||
quality = int(quality)
|
||||
quality_u = quality - 80
|
||||
quality_l = quality + 80
|
||||
for m3u8_format in m3u8_formats:
|
||||
if m3u8_format["height"] == quality or (
|
||||
m3u8_format["height"] < quality_u and m3u8_format["height"] > quality_l
|
||||
):
|
||||
return m3u8_format["url"]
|
||||
else:
|
||||
return m3u8_formats[0]["url"]
|
||||
|
||||
|
||||
def move_preferred_subtitle_lang_to_top(sub_list, lang_str):
|
||||
"""Moves the dictionary with the given ID to the front of the list.
|
||||
|
||||
Args:
|
||||
sub_list: list of subs
|
||||
lang_str: the sub lang pref
|
||||
|
||||
Returns:
|
||||
The modified list.
|
||||
"""
|
||||
import re
|
||||
|
||||
for i, d in enumerate(sub_list):
|
||||
if re.search(lang_str, d["language"], re.IGNORECASE):
|
||||
sub_list.insert(0, sub_list.pop(i))
|
||||
break
|
||||
return sub_list
|
||||
|
||||
|
||||
def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]", default=True):
|
||||
"""Helper function used to filter a list of EpisodeStream objects to one that has a corresponding quality
|
||||
|
||||
Args:
|
||||
@@ -33,8 +70,25 @@ def filter_by_quality(quality: str, stream_links: "list[EpisodeStream]"):
|
||||
an EpisodeStream object or None incase the quality was not found
|
||||
"""
|
||||
for stream_link in stream_links:
|
||||
if stream_link["quality"] == quality:
|
||||
q = float(quality)
|
||||
Q = float(stream_link["quality"])
|
||||
# some providers have inaccurate/weird/non-standard eg qualities 718 instead of 720
|
||||
if Q <= q + 80 and Q >= q - 80:
|
||||
return stream_link
|
||||
else:
|
||||
if stream_links and default:
|
||||
from rich import print
|
||||
|
||||
try:
|
||||
print("[yellow bold]WARNING Qualities were:[/] ", stream_links)
|
||||
print(
|
||||
"[cyan bold]Using default of quality:[/] ",
|
||||
stream_links[0]["quality"],
|
||||
)
|
||||
return stream_links[0]
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return
|
||||
|
||||
|
||||
def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
|
||||
@@ -54,7 +108,7 @@ def format_bytes_to_human(num_of_bytes: float, suffix: str = "B"):
|
||||
return f"{num_of_bytes:.1f}Yi{suffix}"
|
||||
|
||||
|
||||
def get_true_fg(string: str, r: int, g: int, b: int, bold: bool = True) -> str:
|
||||
def get_true_fg(string: str, r, g, b, bold: bool = True) -> str:
|
||||
"""Custom helper function that enables colored text in the terminal
|
||||
|
||||
Args:
|
||||
@@ -92,7 +146,7 @@ def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
|
||||
from click import clear
|
||||
|
||||
clear()
|
||||
action = inquirer.fuzzy(
|
||||
action = inquirer.fuzzy( # pyright:ignore
|
||||
prompt,
|
||||
choices,
|
||||
height="100%",
|
||||
@@ -101,30 +155,3 @@ def fuzzy_inquirer(choices: list, prompt: str, **kwargs):
|
||||
**kwargs,
|
||||
).execute()
|
||||
return action
|
||||
|
||||
|
||||
# WARNING: This function is depracated use the one from the main utility package
|
||||
def anime_title_percentage_match(
|
||||
possible_user_requested_anime_title: str, title: tuple
|
||||
) -> float:
|
||||
"""Returns the percentage match between the possible title and user title
|
||||
|
||||
Args:
|
||||
possible_user_requested_anime_title (str): an Animdl search result title
|
||||
title (str): the anime title the user wants
|
||||
|
||||
Returns:
|
||||
int: the percentage match
|
||||
"""
|
||||
if normalized_anime_title := anime_normalizer.get(
|
||||
possible_user_requested_anime_title
|
||||
):
|
||||
possible_user_requested_anime_title = normalized_anime_title
|
||||
for key, value in locals().items():
|
||||
logger.info(f"{key}: {value}")
|
||||
# compares both the romaji and english names and gets highest Score
|
||||
percentage_ratio = max(
|
||||
fuzz.ratio(title[0].lower(), possible_user_requested_anime_title.lower()),
|
||||
fuzz.ratio(title[1].lower(), possible_user_requested_anime_title.lower()),
|
||||
)
|
||||
return percentage_ratio
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from platform import system
|
||||
|
||||
from platformdirs import PlatformDirs
|
||||
import click
|
||||
|
||||
from . import APP_NAME, AUTHOR
|
||||
from . import APP_NAME, __version__
|
||||
|
||||
PLATFORM = system()
|
||||
dirs = PlatformDirs(appname=APP_NAME, appauthor=AUTHOR, ensure_exists=True)
|
||||
|
||||
|
||||
# ---- app deps ----
|
||||
APP_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
CONFIGS_DIR = os.path.join(APP_DIR, "configs")
|
||||
ASSETS_DIR = os.path.join(APP_DIR, "assets")
|
||||
|
||||
|
||||
@@ -20,23 +19,68 @@ if PLATFORM == "Windows":
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.ico")
|
||||
else:
|
||||
ICON_PATH = os.path.join(ASSETS_DIR, "logo.png")
|
||||
PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
|
||||
# PREVIEW_IMAGE = os.path.join(ASSETS_DIR, "preview")
|
||||
|
||||
|
||||
# ----- user configs and data -----
|
||||
APP_DATA_DIR = dirs.user_config_dir
|
||||
if not APP_DATA_DIR:
|
||||
APP_DATA_DIR = dirs.user_data_dir
|
||||
|
||||
S_PLATFORM = sys.platform
|
||||
APP_DATA_DIR = click.get_app_dir(APP_NAME, roaming=False)
|
||||
if S_PLATFORM == "win32":
|
||||
# app data
|
||||
# app_data_dir_base = os.getenv("LOCALAPPDATA")
|
||||
# if not app_data_dir_base:
|
||||
# raise RuntimeError("Could not determine app data dir please report to devs")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, AUTHOR, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
APP_CACHE_DIR = os.path.join(APP_DATA_DIR, "cache")
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.join(Path().home(), "Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
elif S_PLATFORM == "darwin":
|
||||
# app data
|
||||
# app_data_dir_base = os.path.expanduser("~/Library/Application Support")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME, __version__)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.path.expanduser("~/Library/Caches")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME, __version__)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.path.expanduser("~/Movies")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
else:
|
||||
# # app data
|
||||
# app_data_dir_base = os.environ.get("XDG_CONFIG_HOME", "")
|
||||
# if not app_data_dir_base.strip():
|
||||
# app_data_dir_base = os.path.expanduser("~/.config")
|
||||
# APP_DATA_DIR = os.path.join(app_data_dir_base, APP_NAME)
|
||||
#
|
||||
# cache dir
|
||||
cache_dir_base = os.environ.get("XDG_CACHE_HOME", "")
|
||||
if not cache_dir_base.strip():
|
||||
cache_dir_base = os.path.expanduser("~/.cache")
|
||||
APP_CACHE_DIR = os.path.join(cache_dir_base, APP_NAME)
|
||||
|
||||
# videos dir
|
||||
video_dir_base = os.environ.get("XDG_VIDEOS_DIR", "")
|
||||
if not video_dir_base.strip():
|
||||
video_dir_base = os.path.expanduser("~/Videos")
|
||||
USER_VIDEOS_DIR = os.path.join(video_dir_base, APP_NAME)
|
||||
|
||||
# ensure paths exist
|
||||
Path(APP_DATA_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(APP_CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
Path(USER_VIDEOS_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# useful paths
|
||||
USER_DATA_PATH = os.path.join(APP_DATA_DIR, "user_data.json")
|
||||
USER_WATCH_HISTORY_PATH = os.path.join(APP_DATA_DIR, "watch_history.json")
|
||||
USER_CONFIG_PATH = os.path.join(APP_DATA_DIR, "config.ini")
|
||||
NOTIFIER_LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "notifier.log")
|
||||
|
||||
# cache dir
|
||||
APP_CACHE_DIR = dirs.user_cache_dir
|
||||
|
||||
# video dir
|
||||
USER_VIDEOS_DIR = os.path.join(dirs.user_videos_dir, APP_NAME)
|
||||
LOG_FILE_PATH = os.path.join(APP_DATA_DIR, "fastanime.log")
|
||||
|
||||
|
||||
USER_NAME = os.environ.get("USERNAME", "Anime fun")
|
||||
|
||||
14
fastanime/fastanime.py
Executable file
14
fastanime/fastanime.py
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the application root directory to Python path
|
||||
if getattr(sys, "frozen", False):
|
||||
application_path = os.path.dirname(sys.executable)
|
||||
sys.path.insert(0, application_path)
|
||||
|
||||
# Import and run the main application
|
||||
from fastanime import FastAnime
|
||||
|
||||
if __name__ == "__main__":
|
||||
FastAnime()
|
||||
@@ -15,6 +15,7 @@ from .queries_graphql import (
|
||||
delete_list_entry_query,
|
||||
get_logged_in_user_query,
|
||||
get_medialist_item_query,
|
||||
get_user_info,
|
||||
media_list_mutation,
|
||||
media_list_query,
|
||||
most_favourite_query,
|
||||
@@ -34,8 +35,9 @@ if TYPE_CHECKING:
|
||||
AnilistMediaLists,
|
||||
AnilistMediaListStatus,
|
||||
AnilistNotifications,
|
||||
AnilistUser,
|
||||
AnilistUser_,
|
||||
AnilistUserData,
|
||||
AnilistViewerData,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
@@ -77,7 +79,7 @@ class AniListApi:
|
||||
return
|
||||
if not success or not user:
|
||||
return
|
||||
user_info: AnilistUser = user["data"]["Viewer"]
|
||||
user_info: "AnilistUser_" = user["data"]["Viewer"]
|
||||
self.user_id = user_info["id"]
|
||||
return user_info
|
||||
|
||||
@@ -91,7 +93,7 @@ class AniListApi:
|
||||
"""
|
||||
return self._make_authenticated_request(notification_query)
|
||||
|
||||
def update_login_info(self, user: "AnilistUser", token: str):
|
||||
def update_login_info(self, user: "AnilistUser_", token: str):
|
||||
"""method used to login a user enabling authenticated requests
|
||||
|
||||
Args:
|
||||
@@ -103,7 +105,18 @@ class AniListApi:
|
||||
self.session.headers.update(self.headers)
|
||||
self.user_id = user["id"]
|
||||
|
||||
def get_logged_in_user(self) -> tuple[bool, "AnilistUserData"] | tuple[bool, None]:
|
||||
def get_user_info(self) -> tuple[bool, "AnilistUserData"] | tuple[bool, None]:
|
||||
"""get the details of the user who is currently logged in
|
||||
|
||||
Returns:
|
||||
an anilist user
|
||||
"""
|
||||
|
||||
return self._make_authenticated_request(get_user_info, {"userId": self.user_id})
|
||||
|
||||
def get_logged_in_user(
|
||||
self,
|
||||
) -> tuple[bool, "AnilistViewerData"] | tuple[bool, None]:
|
||||
"""get the details of the user who is currently logged in
|
||||
|
||||
Returns:
|
||||
@@ -293,6 +306,7 @@ class AniListApi:
|
||||
|
||||
def search(
|
||||
self,
|
||||
max_results=50,
|
||||
query: str | None = None,
|
||||
sort: str | None = None,
|
||||
genre_in: list[str] | None = None,
|
||||
@@ -309,9 +323,14 @@ class AniListApi:
|
||||
status_not_in: list[str] | None = None,
|
||||
endDate_greater: int | None = None,
|
||||
endDate_lesser: int | None = None,
|
||||
start_greater: int | None = None,
|
||||
start_lesser: int | None = None,
|
||||
startDate_greater: int | None = None,
|
||||
startDate_lesser: int | None = None,
|
||||
startDate: str | None = None,
|
||||
seasonYear: str | None = None,
|
||||
page: int | None = None,
|
||||
season: str | None = None,
|
||||
format_in: list[str] | None = None,
|
||||
on_list: bool | None = None,
|
||||
type="ANIME",
|
||||
**kwargs,
|
||||
):
|
||||
@@ -320,7 +339,7 @@ class AniListApi:
|
||||
"""
|
||||
variables = {}
|
||||
for key, val in list(locals().items())[1:]:
|
||||
if val is not None and key not in ["variables"]:
|
||||
if (val or val is False) and key not in ["variables"]:
|
||||
variables[key] = val
|
||||
search_results = self.get_data(search_query, variables=variables)
|
||||
return search_results
|
||||
|
||||
@@ -3,7 +3,6 @@ This module contains all the preset queries for the sake of neatness and convini
|
||||
Mostly for internal usage
|
||||
"""
|
||||
|
||||
# TODO: Format the queries
|
||||
mark_as_read_mutation = """
|
||||
mutation{
|
||||
UpdateUser{
|
||||
@@ -17,7 +16,6 @@ query($id:Int){
|
||||
pageInfo{
|
||||
total
|
||||
}
|
||||
|
||||
reviews(mediaId:$id){
|
||||
summary
|
||||
user{
|
||||
@@ -35,50 +33,48 @@ query($id:Int){
|
||||
|
||||
"""
|
||||
notification_query = """
|
||||
query{
|
||||
Page(perPage:5){
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
notifications(resetNotificationCount:true,type:AIRING) {
|
||||
... on AiringNotification {
|
||||
id
|
||||
type
|
||||
episode
|
||||
contexts
|
||||
createdAt
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
query {
|
||||
Page(perPage: 5) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
notifications(resetNotificationCount: true, type: AIRING) {
|
||||
... on AiringNotification {
|
||||
id
|
||||
type
|
||||
episode
|
||||
contexts
|
||||
createdAt
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage {
|
||||
medium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
get_medialist_item_query = """
|
||||
query($mediaId:Int){
|
||||
MediaList(mediaId:$mediaId){
|
||||
id
|
||||
}
|
||||
query ($mediaId: Int) {
|
||||
MediaList(mediaId: $mediaId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
delete_list_entry_query = """
|
||||
mutation($id:Int){
|
||||
DeleteMediaListEntry(id:$id){
|
||||
deleted
|
||||
|
||||
}
|
||||
mutation ($id: Int) {
|
||||
DeleteMediaListEntry(id: $id) {
|
||||
deleted
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -97,9 +93,85 @@ query{
|
||||
}
|
||||
"""
|
||||
|
||||
get_user_info = """
|
||||
query ($userId: Int) {
|
||||
User(id: $userId) {
|
||||
name
|
||||
about
|
||||
avatar {
|
||||
large
|
||||
medium
|
||||
}
|
||||
bannerImage
|
||||
statistics {
|
||||
anime {
|
||||
count
|
||||
minutesWatched
|
||||
episodesWatched
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
genre
|
||||
}
|
||||
tags {
|
||||
tag {
|
||||
id
|
||||
}
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
manga {
|
||||
count
|
||||
meanScore
|
||||
chaptersRead
|
||||
volumesRead
|
||||
tags {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
genres {
|
||||
count
|
||||
meanScore
|
||||
}
|
||||
}
|
||||
}
|
||||
favourites {
|
||||
anime {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
manga {
|
||||
nodes {
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
media_list_mutation = """
|
||||
mutation($mediaId:Int,$scoreRaw:Int,$repeat:Int,$progress:Int,$status:MediaListStatus){
|
||||
SaveMediaListEntry(mediaId:$mediaId,scoreRaw:$scoreRaw,progress:$progress,repeat:$repeat,status:$status){
|
||||
mutation (
|
||||
$mediaId: Int
|
||||
$scoreRaw: Int
|
||||
$repeat: Int
|
||||
$progress: Int
|
||||
$status: MediaListStatus
|
||||
) {
|
||||
SaveMediaListEntry(
|
||||
mediaId: $mediaId
|
||||
scoreRaw: $scoreRaw
|
||||
progress: $progress
|
||||
repeat: $repeat
|
||||
status: $status
|
||||
) {
|
||||
id
|
||||
status
|
||||
mediaId
|
||||
@@ -116,21 +188,19 @@ mutation($mediaId:Int,$scoreRaw:Int,$repeat:Int,$progress:Int,$status:MediaListS
|
||||
month
|
||||
day
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
media_list_query = """
|
||||
query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
|
||||
query ($userId: Int, $status: MediaListStatus, $type: MediaType) {
|
||||
Page {
|
||||
pageInfo {
|
||||
currentPage
|
||||
total
|
||||
currentPage
|
||||
total
|
||||
}
|
||||
mediaList(userId: $userId, status: $status, type: $type) {
|
||||
mediaId
|
||||
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
@@ -147,10 +217,15 @@ query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
@@ -172,9 +247,10 @@ query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
|
||||
}
|
||||
status
|
||||
description
|
||||
mediaListEntry{
|
||||
id
|
||||
progress
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
@@ -198,7 +274,6 @@ query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
|
||||
day
|
||||
}
|
||||
createdAt
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +281,7 @@ query ($userId: Int, $status: MediaListStatus,$type:MediaType) {
|
||||
|
||||
|
||||
optional_variables = "\
|
||||
$max_results:Int,\
|
||||
$page:Int,\
|
||||
$sort:[MediaSort],\
|
||||
$id_in:[Int],\
|
||||
@@ -220,71 +296,84 @@ $popularity_greater:Int,\
|
||||
$popularity_lesser:Int,\
|
||||
$averageScore_greater:Int,\
|
||||
$averageScore_lesser:Int,\
|
||||
$seasonYear:Int,\
|
||||
$startDate_greater:FuzzyDateInt,\
|
||||
$startDate_lesser:FuzzyDateInt,\
|
||||
$startDate:FuzzyDateInt,\
|
||||
$endDate_greater:FuzzyDateInt,\
|
||||
$endDate_lesser:FuzzyDateInt,\
|
||||
$format_in:[MediaFormat],\
|
||||
$type:MediaType\
|
||||
$season:MediaSeason\
|
||||
$on_list:Boolean\
|
||||
"
|
||||
# FuzzyDateInt = (yyyymmdd)
|
||||
# MediaStatus = (FINISHED,RELEASING,NOT_YET_RELEASED,CANCELLED,HIATUS)
|
||||
|
||||
search_query = (
|
||||
"""
|
||||
query($query:String,%s){
|
||||
Page(perPage:30,page:$page){
|
||||
pageInfo{
|
||||
Page(perPage: $max_results, page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(
|
||||
search:$query,
|
||||
id_in:$id_in,
|
||||
genre_in:$genre_in,
|
||||
genre_not_in:$genre_not_in,
|
||||
tag_in:$tag_in,
|
||||
tag_not_in:$tag_not_in,
|
||||
status_in:$status_in,
|
||||
status:$status,
|
||||
status_not_in:$status_not_in,
|
||||
popularity_greater:$popularity_greater,
|
||||
popularity_lesser:$popularity_lesser,
|
||||
averageScore_greater:$averageScore_greater,
|
||||
averageScore_lesser:$averageScore_lesser,
|
||||
startDate_greater:$startDate_greater,
|
||||
startDate_lesser:$startDate_lesser,
|
||||
endDate_greater:$endDate_greater,
|
||||
endDate_lesser:$endDate_lesser,
|
||||
sort:$sort,
|
||||
type:$type
|
||||
)
|
||||
{
|
||||
search: $query
|
||||
id_in: $id_in
|
||||
genre_in: $genre_in
|
||||
genre_not_in: $genre_not_in
|
||||
tag_in: $tag_in
|
||||
tag_not_in: $tag_not_in
|
||||
status_in: $status_in
|
||||
status: $status
|
||||
startDate: $startDate
|
||||
status_not_in: $status_not_in
|
||||
popularity_greater: $popularity_greater
|
||||
popularity_lesser: $popularity_lesser
|
||||
averageScore_greater: $averageScore_greater
|
||||
averageScore_lesser: $averageScore_lesser
|
||||
startDate_greater: $startDate_greater
|
||||
startDate_lesser: $startDate_lesser
|
||||
endDate_greater: $endDate_greater
|
||||
endDate_lesser: $endDate_lesser
|
||||
format_in: $format_in
|
||||
sort: $sort
|
||||
season: $season
|
||||
seasonYear: $seasonYear
|
||||
type: $type
|
||||
onList:$on_list
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
genres
|
||||
studios{
|
||||
nodes{
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
isAnimationStudio
|
||||
}
|
||||
@@ -317,17 +406,16 @@ query($query:String,%s){
|
||||
)
|
||||
|
||||
trending_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15){
|
||||
|
||||
media(sort:TRENDING_DESC,type:$type,genre_not_in:["hentai"]){
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(sort: TRENDING_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
@@ -336,9 +424,14 @@ query($type:MediaType){
|
||||
id
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
description
|
||||
studios {
|
||||
@@ -348,17 +441,18 @@ query($type:MediaType){
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
@@ -377,34 +471,43 @@ query($type:MediaType){
|
||||
|
||||
# mosts
|
||||
most_favourite_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15){
|
||||
media(sort:FAVOURITES_DESC,type:$type,genre_not_in:["hentai"]){
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(sort: FAVOURITES_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
episodes
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
@@ -412,7 +515,7 @@ query($type:MediaType){
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
@@ -436,34 +539,39 @@ query($type:MediaType){
|
||||
"""
|
||||
|
||||
most_scored_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15){
|
||||
media(sort:SCORE_DESC,type:$type,genre_not_in:["hentai"]){
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(sort: SCORE_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
episodes
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
@@ -471,7 +579,7 @@ query($type:MediaType){
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
@@ -495,34 +603,39 @@ query($type:MediaType){
|
||||
"""
|
||||
|
||||
most_popular_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15){
|
||||
media(sort:POPULARITY_DESC,type:$type,genre_not_in:["hentai"]){
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(sort: POPULARITY_DESC, type: $type, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
episodes
|
||||
genres
|
||||
mediaListEntry{
|
||||
synonyms
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
studios {
|
||||
nodes {
|
||||
name
|
||||
@@ -530,8 +643,8 @@ query($type:MediaType){
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
}
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
@@ -547,39 +660,52 @@ query($type:MediaType){
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
most_recently_updated_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15){
|
||||
media(sort:UPDATED_AT_DESC,type:$type,averageScore_greater:50,genre_not_in:["hentai"],status:RELEASING){
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(
|
||||
sort: UPDATED_AT_DESC
|
||||
type: $type
|
||||
averageScore_greater: 50
|
||||
genre_not_in: ["hentai"]
|
||||
status: RELEASING
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
id
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
|
||||
favourites
|
||||
averageScore
|
||||
description
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
studios {
|
||||
nodes {
|
||||
@@ -588,7 +714,7 @@ query($type:MediaType){
|
||||
}
|
||||
}
|
||||
tags {
|
||||
name
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
@@ -612,37 +738,42 @@ query($type:MediaType){
|
||||
"""
|
||||
|
||||
recommended_query = """
|
||||
query($type:MediaType){
|
||||
Page(perPage:15) {
|
||||
media( type: $type,genre_not_in:["hentai"]) {
|
||||
recommendations(sort:RATING_DESC){
|
||||
nodes{
|
||||
media{
|
||||
query ($type: MediaType) {
|
||||
Page(perPage: 15) {
|
||||
media(type: $type, genre_not_in: ["hentai"]) {
|
||||
recommendations(sort: RATING_DESC) {
|
||||
nodes {
|
||||
media {
|
||||
id
|
||||
idMal
|
||||
title{
|
||||
idMal
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
native
|
||||
}
|
||||
coverImage{
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
mediaListEntry{
|
||||
id
|
||||
progress
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
description
|
||||
episodes
|
||||
trailer{
|
||||
trailer {
|
||||
site
|
||||
id
|
||||
}
|
||||
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
@@ -672,9 +803,9 @@ query($type:MediaType){
|
||||
"""
|
||||
|
||||
anime_characters_query = """
|
||||
query($id:Int,$type:MediaType){
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page {
|
||||
media(id:$id, type: $type) {
|
||||
media(id: $id, type: $type) {
|
||||
characters {
|
||||
nodes {
|
||||
name {
|
||||
@@ -707,13 +838,18 @@ query($id:Int,$type:MediaType){
|
||||
|
||||
|
||||
anime_relations_query = """
|
||||
query ($id: Int,$type:MediaType) {
|
||||
query ($id: Int, $type: MediaType) {
|
||||
Page(perPage: 20) {
|
||||
media(id: $id, sort: POPULARITY_DESC, type: $type,genre_not_in:["hentai"]) {
|
||||
media(
|
||||
id: $id
|
||||
sort: POPULARITY_DESC
|
||||
type: $type
|
||||
genre_not_in: ["hentai"]
|
||||
) {
|
||||
relations {
|
||||
nodes {
|
||||
id
|
||||
idMal
|
||||
idMal
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
@@ -723,10 +859,11 @@ query ($id: Int,$type:MediaType) {
|
||||
medium
|
||||
large
|
||||
}
|
||||
mediaListEntry{
|
||||
id
|
||||
progress
|
||||
}
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
description
|
||||
episodes
|
||||
trailer {
|
||||
@@ -734,28 +871,33 @@ query ($id: Int,$type:MediaType) {
|
||||
id
|
||||
}
|
||||
genres
|
||||
synonyms
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
tags {
|
||||
name
|
||||
}
|
||||
startDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
endDate {
|
||||
year
|
||||
month
|
||||
day
|
||||
}
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -781,7 +923,7 @@ query ($id: Int,$type:MediaType) {
|
||||
"""
|
||||
|
||||
upcoming_anime_query = """
|
||||
query ($page: Int,$type:MediaType) {
|
||||
query ($page: Int, $type: MediaType) {
|
||||
Page(page: $page) {
|
||||
pageInfo {
|
||||
total
|
||||
@@ -789,9 +931,14 @@ query ($page: Int,$type:MediaType) {
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(type: $type, status: NOT_YET_RELEASED,sort:POPULARITY_DESC,genre_not_in:["hentai"]) {
|
||||
media(
|
||||
type: $type
|
||||
status: NOT_YET_RELEASED
|
||||
sort: POPULARITY_DESC
|
||||
genre_not_in: ["hentai"]
|
||||
) {
|
||||
id
|
||||
idMal
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
@@ -804,14 +951,20 @@ query ($page: Int,$type:MediaType) {
|
||||
site
|
||||
id
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
favourites
|
||||
averageScore
|
||||
genres
|
||||
synonyms
|
||||
episodes
|
||||
description
|
||||
studios {
|
||||
@@ -845,19 +998,20 @@ query ($page: Int,$type:MediaType) {
|
||||
"""
|
||||
|
||||
anime_query = """
|
||||
query($id:Int){
|
||||
Page{
|
||||
media(id:$id) {
|
||||
query ($id: Int) {
|
||||
Page {
|
||||
media(id: $id) {
|
||||
id
|
||||
idMal
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
mediaListEntry{
|
||||
mediaListEntry {
|
||||
status
|
||||
id
|
||||
progress
|
||||
}
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
@@ -871,7 +1025,6 @@ query($id:Int){
|
||||
node {
|
||||
name {
|
||||
full
|
||||
|
||||
}
|
||||
gender
|
||||
dateOfBirth {
|
||||
@@ -924,6 +1077,11 @@ query($id:Int){
|
||||
countryOfOrigin
|
||||
averageScore
|
||||
popularity
|
||||
streamingEpisodes {
|
||||
title
|
||||
thumbnail
|
||||
}
|
||||
|
||||
favourites
|
||||
source
|
||||
hashtag
|
||||
|
||||
@@ -19,7 +19,7 @@ class AnilistImage(TypedDict):
|
||||
large: str
|
||||
|
||||
|
||||
class AnilistUser(TypedDict):
|
||||
class AnilistUser_(TypedDict):
|
||||
id: int
|
||||
name: str
|
||||
bannerImage: str | None
|
||||
@@ -28,11 +28,26 @@ class AnilistUser(TypedDict):
|
||||
|
||||
|
||||
class AnilistViewer(TypedDict):
|
||||
Viewer: AnilistUser
|
||||
Viewer: AnilistUser_
|
||||
|
||||
|
||||
class AnilistViewerData(TypedDict):
|
||||
data: AnilistViewer
|
||||
|
||||
|
||||
class AnilistUser(TypedDict):
|
||||
name: str
|
||||
about: str | None
|
||||
avatar: AnilistImage
|
||||
bannerImage: str | None
|
||||
|
||||
|
||||
class AnilistUserInfo(TypedDict):
|
||||
User: AnilistUser
|
||||
|
||||
|
||||
class AnilistUserData(TypedDict):
|
||||
data: AnilistViewer
|
||||
data: AnilistUserInfo
|
||||
|
||||
|
||||
class AnilistMediaTrailer(TypedDict):
|
||||
@@ -69,7 +84,7 @@ class AnilistMediaNextAiringEpisode(TypedDict):
|
||||
|
||||
class AnilistReview(TypedDict):
|
||||
summary: str
|
||||
user: AnilistUser
|
||||
user: AnilistUser_
|
||||
|
||||
|
||||
class AnilistReviewNodes(TypedDict):
|
||||
@@ -114,16 +129,17 @@ class AnilistCharactersEdges(TypedDict):
|
||||
edges: list[AnilistCharactersEdge]
|
||||
|
||||
|
||||
class AnilistMediaList_(TypedDict):
|
||||
id: int
|
||||
progress: int
|
||||
|
||||
|
||||
AnilistMediaListStatus = Literal[
|
||||
"CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"
|
||||
]
|
||||
|
||||
|
||||
class AnilistMediaList_(TypedDict):
|
||||
id: int
|
||||
progress: int
|
||||
status: AnilistMediaListStatus
|
||||
|
||||
|
||||
class AnilistMediaListProperties(TypedDict):
|
||||
status: AnilistMediaListStatus
|
||||
score: float
|
||||
@@ -136,6 +152,11 @@ class AnilistMediaListProperties(TypedDict):
|
||||
hiddenFromStatusLists: bool
|
||||
|
||||
|
||||
class StreamingEpisode(TypedDict):
|
||||
title: str
|
||||
thumbnail: str
|
||||
|
||||
|
||||
class AnilistBaseMediaDataSchema(TypedDict):
|
||||
"""
|
||||
This a convenience class is used to type the received Anilist data to enhance dev experience
|
||||
@@ -159,6 +180,8 @@ class AnilistBaseMediaDataSchema(TypedDict):
|
||||
status: str
|
||||
nextAiringEpisode: AnilistMediaNextAiringEpisode
|
||||
season: str
|
||||
streamingEpisodes: list[StreamingEpisode]
|
||||
chapters: int
|
||||
seasonYear: int
|
||||
duration: int
|
||||
synonyms: list[str]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from .allanime.constants import SERVERS_AVAILABLE as ALLANIME_SERVERS
|
||||
from .animepahe.constants import SERVERS_AVAILABLE as ANIMEPAHE_SERVERS
|
||||
from .hianime.constants import SERVERS_AVAILABLE as HIANIME_SERVERS
|
||||
|
||||
anime_sources = {
|
||||
"allanime": "api.AllAnimeAPI",
|
||||
"animepahe": "api.AnimePaheApi",
|
||||
"hianime": "api.HiAnimeApi",
|
||||
"nyaa": "api.NyaaApi",
|
||||
"yugen": "api.YugenApi"
|
||||
}
|
||||
SERVERS_AVAILABLE = [
|
||||
"sharepoint",
|
||||
"dropbox",
|
||||
"gogoanime",
|
||||
"weTransfer",
|
||||
"wixmp",
|
||||
"kwik",
|
||||
]
|
||||
SERVERS_AVAILABLE = [*ALLANIME_SERVERS, *ANIMEPAHE_SERVERS, *HIANIME_SERVERS]
|
||||
|
||||
@@ -7,24 +7,14 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
from ...anime_provider.base_provider import AnimeProvider
|
||||
from ..utils import decode_hex_string, give_random_quality
|
||||
from .constants import (
|
||||
ALLANIME_API_ENDPOINT,
|
||||
ALLANIME_BASE,
|
||||
ALLANIME_REFERER,
|
||||
USER_AGENT,
|
||||
)
|
||||
from ..decorators import debug_provider
|
||||
from ..utils import give_random_quality, one_digit_symmetric_xor
|
||||
from .constants import ALLANIME_API_ENDPOINT, ALLANIME_BASE, ALLANIME_REFERER
|
||||
from .gql_queries import ALLANIME_EPISODES_GQL, ALLANIME_SEARCH_GQL, ALLANIME_SHOW_GQL
|
||||
from .normalizer import normalize_anime, normalize_search_results
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Iterator
|
||||
|
||||
from ....libs.anime_provider.allanime.types import AllAnimeEpisode
|
||||
from ....libs.anime_provider.types import Anime, Server
|
||||
from .types import AllAnimeEpisode
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -36,7 +26,11 @@ class AllAnimeAPI(AnimeProvider):
|
||||
Provides a fast and effective interface to AllAnime site.
|
||||
"""
|
||||
|
||||
PROVIDER = "allanime"
|
||||
api_endpoint = ALLANIME_API_ENDPOINT
|
||||
HEADERS = {
|
||||
"Referer": ALLANIME_REFERER,
|
||||
}
|
||||
|
||||
def _fetch_gql(self, query: str, variables: dict):
|
||||
"""main abstraction over all requests to the allanime api
|
||||
@@ -48,30 +42,21 @@ class AllAnimeAPI(AnimeProvider):
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
try:
|
||||
response = self.session.get(
|
||||
self.api_endpoint,
|
||||
params={
|
||||
"variables": json.dumps(variables),
|
||||
"query": query,
|
||||
},
|
||||
headers={"Referer": ALLANIME_REFERER, "User-Agent": USER_AGENT},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()["data"]
|
||||
else:
|
||||
logger.error("allanime(ERROR): ", response.text)
|
||||
return {}
|
||||
except Timeout:
|
||||
logger.error(
|
||||
"allanime(Error):Timeout exceeded this could mean allanime is down or you have lost internet connection"
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"allanime:Error: {e}")
|
||||
response = self.session.get(
|
||||
self.api_endpoint,
|
||||
params={
|
||||
"variables": json.dumps(variables),
|
||||
"query": query,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.ok:
|
||||
return response.json()["data"]
|
||||
else:
|
||||
logger.error("[ALLANIME-ERROR]: ", response.text)
|
||||
return {}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def search_for_anime(
|
||||
self,
|
||||
user_query: str,
|
||||
@@ -104,13 +89,25 @@ class AllAnimeAPI(AnimeProvider):
|
||||
"translationtype": translationtype,
|
||||
"countryorigin": countryorigin,
|
||||
}
|
||||
try:
|
||||
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
|
||||
return normalize_search_results(search_results) # pyright:ignore
|
||||
except Exception as e:
|
||||
logger.error(f"FA(AllAnime): {e}")
|
||||
return {}
|
||||
search_results = self._fetch_gql(ALLANIME_SEARCH_GQL, variables)
|
||||
page_info = search_results["shows"]["pageInfo"]
|
||||
results = []
|
||||
for result in search_results["shows"]["edges"]:
|
||||
normalized_result = {
|
||||
"id": result["_id"],
|
||||
"title": result["name"],
|
||||
"type": result["__typename"],
|
||||
"availableEpisodes": result["availableEpisodes"],
|
||||
}
|
||||
results.append(normalized_result)
|
||||
|
||||
normalized_search_results = {
|
||||
"pageInfo": page_info,
|
||||
"results": results,
|
||||
}
|
||||
return normalized_search_results
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_anime(self, allanime_show_id: str):
|
||||
"""get an anime details given its id
|
||||
|
||||
@@ -121,15 +118,23 @@ class AllAnimeAPI(AnimeProvider):
|
||||
[TODO:return]
|
||||
"""
|
||||
variables = {"showId": allanime_show_id}
|
||||
try:
|
||||
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
|
||||
return normalize_anime(anime["show"])
|
||||
except Exception as e:
|
||||
logger.error(f"FA(AllAnime): {e}")
|
||||
return None
|
||||
anime = self._fetch_gql(ALLANIME_SHOW_GQL, variables)
|
||||
id: str = anime["show"]["_id"]
|
||||
title: str = anime["show"]["name"]
|
||||
availableEpisodesDetail = anime["show"]["availableEpisodesDetail"]
|
||||
self.store.set(allanime_show_id, "anime_info", {"title": title})
|
||||
type = anime.get("__typename")
|
||||
normalized_anime = {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"availableEpisodesDetail": availableEpisodesDetail,
|
||||
"type": type,
|
||||
}
|
||||
return normalized_anime
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def _get_anime_episode(
|
||||
self, allanime_show_id: str, episode_string: str, translation_type: str = "sub"
|
||||
self, allanime_show_id: str, episode, translation_type: str = "sub"
|
||||
) -> "AllAnimeEpisode | dict":
|
||||
"""get the episode details and sources info
|
||||
|
||||
@@ -144,18 +149,15 @@ class AllAnimeAPI(AnimeProvider):
|
||||
variables = {
|
||||
"showId": allanime_show_id,
|
||||
"translationType": translation_type,
|
||||
"episodeString": episode_string,
|
||||
"episodeString": episode,
|
||||
}
|
||||
try:
|
||||
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
|
||||
return episode["episode"]
|
||||
except Exception as e:
|
||||
logger.error(f"FA(AllAnime): {e}")
|
||||
return {}
|
||||
episode = self._fetch_gql(ALLANIME_EPISODES_GQL, variables)
|
||||
return episode["episode"]
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_episode_streams(
|
||||
self, anime: "Anime", episode_number: str, translation_type="sub"
|
||||
) -> "Iterator[Server] | None":
|
||||
self, anime_id, episode_number: str, translation_type="sub"
|
||||
):
|
||||
"""get the streams of an episode
|
||||
|
||||
Args:
|
||||
@@ -166,7 +168,10 @@ class AllAnimeAPI(AnimeProvider):
|
||||
Yields:
|
||||
[TODO:description]
|
||||
"""
|
||||
anime_id = anime["id"]
|
||||
|
||||
anime_title = (self.store.get(anime_id, "anime_info", "") or {"title": ""})[
|
||||
"title"
|
||||
]
|
||||
allanime_episode = self._get_anime_episode(
|
||||
anime_id, episode_number, translation_type
|
||||
)
|
||||
@@ -174,177 +179,117 @@ class AllAnimeAPI(AnimeProvider):
|
||||
return []
|
||||
|
||||
embeds = allanime_episode["sourceUrls"]
|
||||
try:
|
||||
for embed in embeds:
|
||||
try:
|
||||
# filter the working streams no need to get all since the others are mostly hsl
|
||||
# TODO: should i just get all the servers and handle the hsl??
|
||||
if embed.get("sourceName", "") not in (
|
||||
"Sak",
|
||||
"Kir",
|
||||
"S-mp4",
|
||||
"Luf-mp4",
|
||||
"Default",
|
||||
):
|
||||
continue
|
||||
url = embed.get("sourceUrl")
|
||||
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith("--"):
|
||||
url = url[2:]
|
||||
@debug_provider(self.PROVIDER.upper())
|
||||
def _get_server(embed):
|
||||
# filter the working streams no need to get all since the others are mostly hsl
|
||||
# TODO: should i just get all the servers and handle the hsl??
|
||||
if embed.get("sourceName", "") not in (
|
||||
# priorities based on death note
|
||||
"Sak", # 7
|
||||
"S-mp4", # 7.9
|
||||
"Luf-mp4", # 7.7
|
||||
"Default", # 8.5
|
||||
"Yt-mp4", # 7.9
|
||||
"Kir", # NA
|
||||
# "Vid-mp4" # 4
|
||||
# "Ok", # 3.5
|
||||
# "Ss-Hls", # 5.5
|
||||
# "Mp4", # 4
|
||||
):
|
||||
return
|
||||
url = embed.get("sourceUrl")
|
||||
#
|
||||
if not url:
|
||||
return
|
||||
if url.startswith("--"):
|
||||
url = url[2:]
|
||||
url = one_digit_symmetric_xor(56, url)
|
||||
|
||||
# get the stream url for an episode of the defined source names
|
||||
parsed_url = decode_hex_string(url)
|
||||
embed_url = f"https://{ALLANIME_BASE}{parsed_url.replace('clock', 'clock.json')}"
|
||||
resp = self.session.get(
|
||||
embed_url,
|
||||
headers={
|
||||
"Referer": ALLANIME_REFERER,
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
match embed["sourceName"]:
|
||||
case "Luf-mp4":
|
||||
logger.debug("allanime:Found streams from gogoanime")
|
||||
yield {
|
||||
"server": "gogoanime",
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
case "Kir":
|
||||
logger.debug("allanime:Found streams from wetransfer")
|
||||
yield {
|
||||
"server": "wetransfer",
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
case "S-mp4":
|
||||
logger.debug("allanime:Found streams from sharepoint")
|
||||
yield {
|
||||
"server": "sharepoint",
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
case "Sak":
|
||||
logger.debug("allanime:Found streams from dropbox")
|
||||
yield {
|
||||
"server": "dropbox",
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
case "Default":
|
||||
logger.debug("allanime:Found streams from wixmp")
|
||||
yield {
|
||||
"server": "wixmp",
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f'{anime["title"]}'
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
} # pyright:ignore
|
||||
except Timeout:
|
||||
logger.error(
|
||||
"Timeout has been exceeded this could mean allanime is down or you have lost internet connection"
|
||||
)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"FA(Allanime): {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"FA(Allanime): {e}")
|
||||
return []
|
||||
if "tools.fast4speed.rsvp" in url:
|
||||
return {
|
||||
"server": "Yt",
|
||||
"episode_title": f"{anime_title}; Episode {episode_number}",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"links": [
|
||||
{
|
||||
"link": url,
|
||||
"quality": "1080",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# get the stream url for an episode of the defined source names
|
||||
embed_url = f"https://{ALLANIME_BASE}{url.replace('clock', 'clock.json')}"
|
||||
resp = self.session.get(
|
||||
embed_url,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
anime_provider = AllAnimeAPI()
|
||||
# lets see if it works :)
|
||||
import subprocess
|
||||
import sys
|
||||
if resp.ok:
|
||||
match embed["sourceName"]:
|
||||
case "Luf-mp4":
|
||||
logger.debug("allanime:Found streams from gogoanime")
|
||||
return {
|
||||
"server": "gogoanime",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f"{anime_title}"
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Kir":
|
||||
logger.debug("allanime:Found streams from wetransfer")
|
||||
return {
|
||||
"server": "wetransfer",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f"{anime_title}"
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "S-mp4":
|
||||
logger.debug("allanime:Found streams from sharepoint")
|
||||
return {
|
||||
"server": "sharepoint",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f"{anime_title}"
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Sak":
|
||||
logger.debug("allanime:Found streams from dropbox")
|
||||
return {
|
||||
"server": "dropbox",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f"{anime_title}"
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
case "Default":
|
||||
logger.debug("allanime:Found streams from wixmp")
|
||||
return {
|
||||
"server": "wixmp",
|
||||
"headers": {"Referer": f"https://{ALLANIME_BASE}/"},
|
||||
"subtitles": [],
|
||||
"episode_title": (
|
||||
allanime_episode["notes"] or f"{anime_title}"
|
||||
)
|
||||
+ f"; Episode {episode_number}",
|
||||
"links": give_random_quality(resp.json()["links"]),
|
||||
}
|
||||
|
||||
from InquirerPy import inquirer, validator
|
||||
|
||||
anime = input("Enter the anime name: ")
|
||||
translation = input("Enter the translation type: ")
|
||||
|
||||
search_results = anime_provider.search_for_anime(
|
||||
anime, translation_type=translation.strip()
|
||||
)
|
||||
|
||||
if not search_results:
|
||||
raise Exception("No results found")
|
||||
|
||||
search_results = search_results["results"]
|
||||
options = {show["title"]: show for show in search_results}
|
||||
anime = inquirer.fuzzy(
|
||||
"Enter the anime title",
|
||||
list(options.keys()),
|
||||
validate=validator.EmptyInputValidator(),
|
||||
).execute()
|
||||
if anime is None:
|
||||
print("No anime was selected")
|
||||
sys.exit(1)
|
||||
|
||||
anime_result = options[anime]
|
||||
anime_data = anime_provider.get_anime(anime_result["id"])
|
||||
if not anime_data:
|
||||
raise Exception("Anime not found")
|
||||
availableEpisodesDetail = anime_data["availableEpisodesDetail"]
|
||||
if not availableEpisodesDetail.get(translation.strip()):
|
||||
raise Exception("No episodes found")
|
||||
|
||||
stream_link = True
|
||||
while stream_link != "quit":
|
||||
print("select episode")
|
||||
episode = inquirer.fuzzy(
|
||||
"Choose an episode",
|
||||
availableEpisodesDetail[translation.strip()],
|
||||
validate=validator.EmptyInputValidator(),
|
||||
).execute()
|
||||
if episode is None:
|
||||
print("No episode was selected")
|
||||
sys.exit(1)
|
||||
|
||||
if not anime_data:
|
||||
print("Sth went wrong")
|
||||
break
|
||||
episode_streams_ = anime_provider.get_episode_streams(
|
||||
anime_data, episode, translation.strip()
|
||||
)
|
||||
if episode_streams_ is None:
|
||||
raise Exception("Episode not found")
|
||||
|
||||
episode_streams = list(episode_streams_)
|
||||
stream_links = []
|
||||
for server in episode_streams:
|
||||
stream_links.extend([link["link"] for link in server["links"]])
|
||||
stream_links.append("back")
|
||||
stream_link = inquirer.fuzzy(
|
||||
"Choose a link to stream",
|
||||
stream_links,
|
||||
validate=validator.EmptyInputValidator(),
|
||||
).execute()
|
||||
if stream_link == "quit":
|
||||
print("Have a nice day")
|
||||
sys.exit()
|
||||
if not stream_link:
|
||||
raise Exception("No stream was selected")
|
||||
|
||||
title = episode_streams[0].get(
|
||||
"episode_title", "%s: Episode %s" % (anime_data["title"], episode)
|
||||
)
|
||||
subprocess.run(["mpv", f"--title={title}", stream_link])
|
||||
for embed in embeds:
|
||||
if server := _get_server(embed):
|
||||
yield server
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp", "Yt"]
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_REFERER = "https://allanime.to/"
|
||||
ALLANIME_API_ENDPOINT = "https://api.{}/api/".format(ALLANIME_BASE)
|
||||
USER_AGENT = random_user_agent()
|
||||
SERVERS_AVAILABLE = ["sharepoint", "dropbox", "gogoanime", "weTransfer", "wixmp"]
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
ALLANIME_SEARCH_GQL = """
|
||||
query(
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
query (
|
||||
$search: SearchInput
|
||||
$limit: Int
|
||||
$page: Int
|
||||
$translationType: VaildTranslationTypeEnumType
|
||||
$countryOrigin: VaildCountryOriginEnumType
|
||||
) {
|
||||
shows(
|
||||
search: $search
|
||||
limit: $limit
|
||||
page: $page
|
||||
translationType: $translationType
|
||||
countryOrigin: $countryOrigin
|
||||
) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
edges {
|
||||
_id
|
||||
name
|
||||
availableEpisodes
|
||||
__typename
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
ALLANIME_EPISODES_GQL = """\
|
||||
query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
|
||||
episodeString
|
||||
sourceUrls
|
||||
notes
|
||||
}
|
||||
}"""
|
||||
query (
|
||||
$showId: String!
|
||||
$translationType: VaildTranslationTypeEnumType!
|
||||
$episodeString: String!
|
||||
) {
|
||||
episode(
|
||||
showId: $showId
|
||||
translationType: $translationType
|
||||
episodeString: $episodeString
|
||||
) {
|
||||
episodeString
|
||||
sourceUrls
|
||||
notes
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
ALLANIME_SHOW_GQL = """
|
||||
query ($showId: String!) {
|
||||
show(
|
||||
_id: $showId
|
||||
) {
|
||||
|
||||
_id
|
||||
name
|
||||
availableEpisodesDetail
|
||||
|
||||
}
|
||||
show(_id: $showId) {
|
||||
_id
|
||||
name
|
||||
availableEpisodesDetail
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
from ..types import Anime, AnimeEpisodeDetails, SearchResults
|
||||
from .types import AllAnimeEpisode, AllAnimeSearchResults, AllAnimeShow
|
||||
|
||||
# TODO: scrap this module and do the transformations directly from the provider class
|
||||
|
||||
|
||||
def normalize_search_results(search_results: AllAnimeSearchResults) -> SearchResults:
|
||||
page_info = search_results["shows"]["pageInfo"]
|
||||
results = []
|
||||
for result in search_results["shows"]["edges"]:
|
||||
normalized_result = {
|
||||
"id": result["_id"],
|
||||
"title": result["name"],
|
||||
"type": result["__typename"],
|
||||
"availableEpisodes": result["availableEpisodes"],
|
||||
}
|
||||
results.append(normalized_result)
|
||||
|
||||
normalized_search_results: SearchResults = {
|
||||
"pageInfo": page_info, # pyright:ignore
|
||||
"results": results,
|
||||
}
|
||||
|
||||
return normalized_search_results
|
||||
|
||||
|
||||
def normalize_anime(anime: AllAnimeShow) -> Anime:
|
||||
id: str = anime["_id"]
|
||||
title: str = anime["name"]
|
||||
availableEpisodesDetail: AnimeEpisodeDetails = anime[
|
||||
"availableEpisodesDetail"
|
||||
] # pyright:ignore
|
||||
type = anime.get("__typename")
|
||||
normalized_anime: Anime = { # pyright:ignore
|
||||
"id": id,
|
||||
"title": title,
|
||||
"availableEpisodesDetail": availableEpisodesDetail,
|
||||
"type": type,
|
||||
}
|
||||
return normalized_anime
|
||||
|
||||
|
||||
def normalize_episode(episode: AllAnimeEpisode):
|
||||
pass
|
||||
0
fastanime/libs/anime_provider/animepahe/__init__.py
Normal file
0
fastanime/libs/anime_provider/animepahe/__init__.py
Normal file
@@ -1,88 +1,130 @@
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from yt_dlp.utils import (
|
||||
extract_attributes,
|
||||
get_element_by_id,
|
||||
get_element_text_and_html_by_tag,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
from ..base_provider import AnimeProvider
|
||||
from ..decorators import debug_provider
|
||||
from .constants import (
|
||||
ANIMEPAHE_BASE,
|
||||
ANIMEPAHE_ENDPOINT,
|
||||
REQUEST_HEADERS,
|
||||
SERVER_HEADERS,
|
||||
)
|
||||
from .utils import process_animepahe_embed_page
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..types import Anime
|
||||
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimeSearchResult
|
||||
from .types import AnimePaheAnimePage, AnimePaheSearchPage, AnimePaheSearchResult
|
||||
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KWIK_RE = re.compile(r"Player\|(.+?)'")
|
||||
|
||||
|
||||
# TODO: hack this to completion
|
||||
class AnimePaheApi(AnimeProvider):
|
||||
search_page: "AnimePaheSearchPage"
|
||||
anime: "AnimePaheAnimePage"
|
||||
HEADERS = REQUEST_HEADERS
|
||||
PROVIDER = "animepahe"
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def search_for_anime(self, user_query: str, *args):
|
||||
try:
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
|
||||
headers = {**REQUEST_HEADERS}
|
||||
response = self.session.get(url, headers=headers)
|
||||
if not response.status_code == 200:
|
||||
return
|
||||
data: "AnimePaheSearchPage" = response.json()
|
||||
self.search_page = data
|
||||
|
||||
return {
|
||||
"pageInfo": {
|
||||
"total": data["total"],
|
||||
"perPage": data["per_page"],
|
||||
"currentPage": data["current_page"],
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"availableEpisodes": list(range(result["episodes"])),
|
||||
"id": result["session"],
|
||||
"title": result["title"],
|
||||
"type": result["type"],
|
||||
"year": result["year"],
|
||||
"score": result["score"],
|
||||
"status": result["status"],
|
||||
"season": result["season"],
|
||||
"poster": result["poster"],
|
||||
}
|
||||
for result in data["data"]
|
||||
],
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AnimePahe(search): {e}")
|
||||
return {}
|
||||
|
||||
def get_anime(self, session_id: str, *args):
|
||||
try:
|
||||
anime_result: "AnimeSearchResult" = [
|
||||
anime
|
||||
for anime in self.search_page["data"]
|
||||
if anime["session"] == session_id
|
||||
][0]
|
||||
url = (
|
||||
f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page=1"
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=search&q={user_query}"
|
||||
response = self.session.get(
|
||||
url,
|
||||
)
|
||||
if not response.ok:
|
||||
return
|
||||
data: "AnimePaheSearchPage" = response.json()
|
||||
self.search_page = data
|
||||
for animepahe_search_result in data["data"]:
|
||||
self.store.set(
|
||||
str(animepahe_search_result["session"]),
|
||||
"search_result",
|
||||
animepahe_search_result,
|
||||
)
|
||||
response = self.session.get(url, headers=REQUEST_HEADERS)
|
||||
if not response.status_code == 200:
|
||||
|
||||
return {
|
||||
"pageInfo": {
|
||||
"total": data["total"],
|
||||
"perPage": data["per_page"],
|
||||
"currentPage": data["current_page"],
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"availableEpisodes": list(range(result["episodes"])),
|
||||
"id": result["session"],
|
||||
"title": result["title"],
|
||||
"type": result["type"],
|
||||
"year": result["year"],
|
||||
"score": result["score"],
|
||||
"status": result["status"],
|
||||
"season": result["season"],
|
||||
"poster": result["poster"],
|
||||
}
|
||||
for result in data["data"]
|
||||
],
|
||||
}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_anime(self, session_id: str, *args):
|
||||
page = 1
|
||||
if d := self.store.get(str(session_id), "search_result"):
|
||||
anime_result: "AnimePaheSearchResult" = d
|
||||
data: "AnimePaheAnimePage" = {} # pyright:ignore
|
||||
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
|
||||
|
||||
def _pages_loader(
|
||||
url,
|
||||
page,
|
||||
):
|
||||
response = self.session.get(
|
||||
url,
|
||||
)
|
||||
if response.ok:
|
||||
if not data:
|
||||
data.update(response.json())
|
||||
else:
|
||||
if ep_data := response.json().get("data"):
|
||||
data["data"].extend(ep_data)
|
||||
if response.json()["next_page_url"]:
|
||||
# TODO: Refine this
|
||||
time.sleep(
|
||||
random.choice(
|
||||
[
|
||||
0.25,
|
||||
0.1,
|
||||
0.5,
|
||||
0.75,
|
||||
1,
|
||||
]
|
||||
)
|
||||
)
|
||||
page += 1
|
||||
url = f"{ANIMEPAHE_ENDPOINT}m=release&id={session_id}&sort=episode_asc&page={page}"
|
||||
_pages_loader(
|
||||
url,
|
||||
page,
|
||||
)
|
||||
|
||||
_pages_loader(
|
||||
url,
|
||||
page,
|
||||
)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
data: "AnimePaheAnimePage" = response.json()
|
||||
self.anime = data
|
||||
episodes = list(map(str, range(data["total"])))
|
||||
data["title"] = anime_result["title"] # pyright:ignore
|
||||
self.store.set(str(session_id), "anime_info", data)
|
||||
episodes = list(map(str, [episode["episode"] for episode in data["data"]]))
|
||||
title = ""
|
||||
return {
|
||||
"id": session_id,
|
||||
@@ -98,7 +140,7 @@ class AnimePaheApi(AnimeProvider):
|
||||
},
|
||||
"episodesInfo": [
|
||||
{
|
||||
"title": episode["title"] or f"{title};{episode['episode']}",
|
||||
"title": f"{episode['title'] or title};{episode['episode']}",
|
||||
"episode": episode["episode"],
|
||||
"id": episode["session"],
|
||||
"translation_type": episode["audio"],
|
||||
@@ -108,28 +150,30 @@ class AnimePaheApi(AnimeProvider):
|
||||
for episode in data["data"]
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"AnimePahe(anime): {e}")
|
||||
return {}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_episode_streams(
|
||||
self, anime: "Anime", episode_number: str, translation_type, *args
|
||||
self, anime_id, episode_number: str, translation_type, *args
|
||||
):
|
||||
anime_title = ""
|
||||
episode = None
|
||||
# extract episode details from memory
|
||||
episode = [
|
||||
episode
|
||||
for episode in self.anime["data"]
|
||||
if float(episode["episode"]) == float(episode_number)
|
||||
]
|
||||
if d := self.store.get(str(anime_id), "anime_info"):
|
||||
anime_title = d["title"]
|
||||
episode = [
|
||||
episode
|
||||
for episode in d["data"]
|
||||
if float(episode["episode"]) == float(episode_number)
|
||||
]
|
||||
|
||||
if not episode:
|
||||
logger.error(f"AnimePahe(streams): episode {episode_number} doesn't exist")
|
||||
raise Exception("Episode not found")
|
||||
logger.error(f"[ANIMEPAHE-ERROR]: episode {episode_number} doesn't exist")
|
||||
return []
|
||||
episode = episode[0]
|
||||
|
||||
anime_id = anime["id"]
|
||||
# fetch the episode page
|
||||
url = f"{ANIMEPAHE_BASE}/play/{anime_id}/{episode['session']}"
|
||||
response = self.session.get(url, headers=REQUEST_HEADERS)
|
||||
response = self.session.get(url)
|
||||
# get the element containing links to juicy streams
|
||||
c = get_element_by_id("resolutionMenu", response.text)
|
||||
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
|
||||
@@ -141,10 +185,16 @@ class AnimePaheApi(AnimeProvider):
|
||||
|
||||
# get the episode title
|
||||
episode_title = (
|
||||
episode["title"] or f"{anime['title']}; Episode {episode['episode']}"
|
||||
f"{episode['title'] or anime_title}; Episode {episode['episode']}"
|
||||
)
|
||||
# get all links
|
||||
streams = {"server": "kwik", "links": [], "episode_title": episode_title}
|
||||
streams = {
|
||||
"server": "kwik",
|
||||
"links": [],
|
||||
"episode_title": episode_title,
|
||||
"subtitles": [],
|
||||
"headers": {},
|
||||
}
|
||||
for res_dict in res_dicts:
|
||||
# get embed url
|
||||
embed_url = res_dict["data-src"]
|
||||
@@ -154,55 +204,27 @@ class AnimePaheApi(AnimeProvider):
|
||||
continue
|
||||
|
||||
if not embed_url:
|
||||
logger.warn(
|
||||
"AnimePahe: embed url not found please report to the developers"
|
||||
logger.warning(
|
||||
"[ANIMEPAHE-WARN]: embed url not found please report to the developers"
|
||||
)
|
||||
raise Exception("Episode not found")
|
||||
return []
|
||||
# get embed page
|
||||
embed_response = self.session.get(embed_url, headers=SERVER_HEADERS)
|
||||
embed = embed_response.text
|
||||
# search for the encoded js
|
||||
encoded_js = None
|
||||
for _ in range(7):
|
||||
content, html = get_element_text_and_html_by_tag("script", embed)
|
||||
if not content:
|
||||
embed = embed.replace(html, "")
|
||||
continue
|
||||
encoded_js = content
|
||||
break
|
||||
if not encoded_js:
|
||||
logger.warn(
|
||||
"AnimePahe: Encoded js not found please report to the developers"
|
||||
)
|
||||
raise Exception("Episode not found")
|
||||
# execute the encoded js with node for now or maybe forever in odrder to get a more workable info
|
||||
NODE = shutil.which("node")
|
||||
if not NODE:
|
||||
logger.warn(
|
||||
"AnimePahe: animepahe currently requires node js to extract them juicy streams"
|
||||
)
|
||||
raise Exception("Episode not found")
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", encoded_js],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
embed_response = self.session.get(
|
||||
embed_url, headers={"User-Agent": self.USER_AGENT, **SERVER_HEADERS}
|
||||
)
|
||||
# decoded js
|
||||
evaluted_js = result.stderr
|
||||
if not evaluted_js:
|
||||
logger.warn(
|
||||
"AnimePahe: could not decode encoded js using node please report to developers"
|
||||
)
|
||||
raise Exception("Episode not found")
|
||||
# get that juicy stream
|
||||
match = JUICY_STREAM_REGEX.search(evaluted_js)
|
||||
if not match:
|
||||
logger.warn(
|
||||
"AnimePahe: could not find the juicy stream please report to developers"
|
||||
)
|
||||
raise Exception("Episode not found")
|
||||
# get the actual hls stream link
|
||||
juicy_stream = match.group(1)
|
||||
if not response.ok:
|
||||
continue
|
||||
embed_page = embed_response.text
|
||||
|
||||
decoded_js = process_animepahe_embed_page(embed_page)
|
||||
if not decoded_js:
|
||||
logger.error("[ANIMEPAHE-ERROR]: failed to decode embed page")
|
||||
return
|
||||
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
|
||||
if not juicy_stream:
|
||||
logger.error("[ANIMEPAHE-ERROR]: failed to find juicy stream")
|
||||
return
|
||||
juicy_stream = juicy_stream.group(1)
|
||||
# add the link
|
||||
streams["links"].append(
|
||||
{
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
USER_AGENT = random_user_agent()
|
||||
ANIMEPAHE = "animepahe.ru"
|
||||
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
|
||||
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api?"
|
||||
|
||||
SERVERS_AVAILABLE = ["kwik"]
|
||||
REQUEST_HEADERS = {
|
||||
"Cookie": "__ddgid_=VvX0ebHrH2DsFZo4; __ddgmark_=3savRpSVFhvZcn5x; __ddg2_=buBJ3c4pNBYKFZNp; __ddg1_=rbVADKr9URtt55zoIGFa; SERVERID=janna; XSRF-TOKEN=eyJpdiI6IjV5bFNtd0phUHgvWGJxc25wL0VJSUE9PSIsInZhbHVlIjoicEJTZktlR2hxR2JZTWhnL0JzazlvZU5TQTR2bjBWZ2dDb0RwUXVUUWNSclhQWUhLRStYSmJmWmUxWkpiYkFRYU12RjFWejlSWHorME1wZG5qQ1U0TnFlNnBFR2laQjN1MjdyNjc5TjVPdXdJb2o5VkU1bEduRW9pRHNDTHh6Sy8iLCJtYWMiOiI0OTc0ZmNjY2UwMGJkOWY2MWNkM2NlMjk2ZGMyZGJmMWE0NTdjZTdkNGI2Y2IwNTIzZmFiZWU5ZTE2OTk0YmU4IiwidGFnIjoiIn0%3D; laravel_session=eyJpdiI6ImxvdlpqREFnTjdaeFJubUlXQWlJVWc9PSIsInZhbHVlIjoiQnE4R3VHdjZ4M1NDdEVWM1ZqMUxtNnVERnJCcmtCUHZKNzRPR2RFbzNFcStTL29xdnVTbWhsNVRBUXEybVZWNU1UYVlTazFqYlN5UjJva1k4czNGaXBTbkJJK01oTUd3VHRYVHBoc3dGUWxHYnFlS2NJVVNFbTFqMVBWdFpuVUgiLCJtYWMiOiI1NDdjZTVkYmNhNjUwZTMxZmRlZmVmMmRlMGNiYjAwYjlmYjFjY2U0MDc1YTQzZThiMTIxMjJlYTg1NTA4YjBmIiwidGFnIjoiIn0%3D; latest=5592 ",
|
||||
"Host": ANIMEPAHE,
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application , text/javascript, */*; q=0.01",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"Accept-Encoding": "Utf-8",
|
||||
"Referer": ANIMEPAHE_BASE,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"DNT": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
@@ -21,19 +17,17 @@ REQUEST_HEADERS = {
|
||||
"TE": "trailers",
|
||||
}
|
||||
SERVER_HEADERS = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Host": "kwik.si",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"Accept-Encoding": "Utf-8",
|
||||
"DNT": "1",
|
||||
"Alt-Used": "kwik.si",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": ANIMEPAHE_BASE,
|
||||
"Cookie": "kwik_session=eyJpdiI6IlZ5UDd0c0lKTDB1NXlhTHZPeWxFc2c9PSIsInZhbHVlIjoieDJZbGhZUG1QZDNaeWtqR3lwWFNnREdhaHBxNVZRMWNDOHVucGpiMHRJOVdhVmpBc3lpTko1VExRMTFWcE1yUVJtVitoTWdOOU5ObTQ0Q0dHU0MzZU0yRUVvNmtWcUdmY3R4UWx4YklJTmpUL0ZodjhtVEpjWU96cEZoUUhUbVYiLCJtYWMiOiI2OGY2YThkOGU0MTgwOThmYzcyZThmNzFlZjlhMzQzMDgwNjlmMTc4NTIzMzc2YjE3YjNmMWQyNTk4NzczMmZiIiwidGFnIjoiIn0%3D; srv=s0; cf_clearance=QMoZtUpZrX0Mh4XJiFmFSSmoWndISPne5FcsGmKKvTQ-1723297585-1.0.1.1-6tVUnP.aef9XeNj0CnN.19D1el_r53t.lhqddX.J88gohH9UnsPWKeJ4yT0pTbcaGRbPuXTLOS.U72.wdy.gMg",
|
||||
"Referer": "https://animepahe.ru/",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "iframe",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Priority": "u=4",
|
||||
"TE": "trailers",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class AnimeSearchResult(TypedDict):
|
||||
class AnimePaheSearchResult(TypedDict):
|
||||
id: int
|
||||
title: str
|
||||
type: str
|
||||
@@ -21,7 +21,7 @@ class AnimePaheSearchPage(TypedDict):
|
||||
last_page: int
|
||||
_from: int
|
||||
to: int
|
||||
data: list[AnimeSearchResult]
|
||||
data: list[AnimePaheSearchResult]
|
||||
|
||||
|
||||
class Episode(TypedDict):
|
||||
|
||||
75
fastanime/libs/anime_provider/animepahe/utils.py
Normal file
75
fastanime/libs/anime_provider/animepahe/utils.py
Normal file
@@ -0,0 +1,75 @@
|
||||
# from ..utils import int2base
|
||||
import re
|
||||
|
||||
from yt_dlp.utils import encode_base_n, get_element_text_and_html_by_tag
|
||||
|
||||
|
||||
def animepahe_key_creator(c: int, a: int):
|
||||
if c < a:
|
||||
val_a = ""
|
||||
else:
|
||||
val_a = animepahe_key_creator(int(c / a), a)
|
||||
c = c % a
|
||||
if c > 35:
|
||||
val_b = chr(c + 29)
|
||||
else:
|
||||
val_b = encode_base_n(c, 36)
|
||||
return val_a + val_b
|
||||
|
||||
|
||||
def animepahe_embed_decoder(
|
||||
encoded_js_p: str,
|
||||
base_a: int,
|
||||
no_of_keys_c: int,
|
||||
values_to_replace_with_k: list,
|
||||
):
|
||||
decode_mapper_d: dict = {}
|
||||
for i in range(no_of_keys_c):
|
||||
key = animepahe_key_creator(i, base_a)
|
||||
val = values_to_replace_with_k[i] or key
|
||||
decode_mapper_d[key] = val
|
||||
return re.sub(
|
||||
r"\b\w+\b", lambda match: decode_mapper_d[match.group(0)], encoded_js_p
|
||||
)
|
||||
|
||||
|
||||
PARAMETERS_REGEX = re.compile(r"eval\(function\(p,a,c,k,e,d\)\{.*\}\((.*?)\)\)$")
|
||||
ENCODE_JS_REGEX = re.compile(r"'(.*?);',(\d+),(\d+),'(.*)'\.split")
|
||||
|
||||
|
||||
def process_animepahe_embed_page(embed_page: str):
|
||||
encoded_js_string = ""
|
||||
embed_page_content = embed_page
|
||||
for _ in range(8):
|
||||
text, html = get_element_text_and_html_by_tag("script", embed_page_content)
|
||||
if not text:
|
||||
embed_page_content = re.sub(html, "", embed_page_content)
|
||||
continue
|
||||
encoded_js_string = text.strip()
|
||||
break
|
||||
if not encoded_js_string:
|
||||
return
|
||||
obsfucated_js_parameter_match = PARAMETERS_REGEX.search(encoded_js_string)
|
||||
if not obsfucated_js_parameter_match:
|
||||
return
|
||||
parameter_string = obsfucated_js_parameter_match.group(1)
|
||||
encoded_js_parameter_string = ENCODE_JS_REGEX.search(parameter_string)
|
||||
if not encoded_js_parameter_string:
|
||||
return
|
||||
p: str = encoded_js_parameter_string.group(1)
|
||||
a: int = int(encoded_js_parameter_string.group(2))
|
||||
c: int = int(encoded_js_parameter_string.group(3))
|
||||
k: list = encoded_js_parameter_string.group(4).split("|")
|
||||
return animepahe_embed_decoder(p, a, c, k).replace("\\", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Testing time
|
||||
filepath = input("Enter file name: ")
|
||||
if filepath:
|
||||
with open(filepath, "r") as file:
|
||||
data = file.read()
|
||||
else:
|
||||
data = """<script>eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('f $7={H:a(2){4 B(9.7.h(y z("(?:(?:^|.*;)\\\\s*"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=\\\\s*([^;]*).*$)|^.*$"),"$1"))||G},E:a(2,q,3,6,5,t){k(!2||/^(?:8|r\\-v|o|m|p)$/i.D(2)){4 w}f b="";k(3){F(3.J){j K:b=3===P?"; 8=O, I N Q M:u:u A":"; r-v="+3;n;j L:b="; 8="+3;n;j S:b="; 8="+3.Z();n}}9.7=d(2)+"="+d(q)+b+(5?"; m="+5:"")+(6?"; o="+6:"")+(t?"; p":"");4 x},Y:a(2,6,5){k(!2||!11.C(2)){4 w}9.7=d(2)+"=; 8=12, R 10 W l:l:l A"+(5?"; m="+5:"")+(6?"; o="+6:"");4 x},C:a(2){4(y z("(?:^|;\\\\s*)"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=")).D(9.7)},X:a(){f c=9.7.h(/((?:^|\\s*;)[^\\=]+)(?=;|$)|^\\s*|\\s*(?:\\=[^;]*)?(?:\\1|$)/g,"").T(/\\s*(?:\\=[^;]*)?;\\s*/);U(f e=0;e<c.V;e++){c[e]=B(c[e])}4 c}};',62,65,'||sKey|vEnd|return|sDomain|sPath|cookie|expires|document|function|sExpires|aKeys|encodeURIComponent|nIdx|var||replace||case|if|00|domain|break|path|secure|sValue|max||bSecure|59|age|false|true|new|RegExp|GMT|decodeURIComponent|hasItem|test|setItem|switch|null|getItem|31|constructor|Number|String|23|Dec|Fri|Infinity|9999|01|Date|split|for|length|1970|keys|removeItem|toUTCString|Jan|this|Thu'.split('|'),0,{}));eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('h o=\'1D://1C-E.1B.1A.1z/1y/E/1x/1w/1v.1u\';h d=s.r(\'d\');h 0=B 1t(d,{\'1s\':{\'1r\':i},\'1q\':\'16:9\',\'D\':1,\'1p\':5,\'1o\':{\'1n\':\'1m\'},1l:[\'7-1k\',\'7\',\'1j\',\'1i-1h\',\'1g\',\'1f-1e\',\'1d\',\'D\',\'1c\',\'1b\',\'1a\',\'19\',\'C\',\'18\'],\'C\':{\'17\':i}});8(!A.15()){d.14=o}x{j z={13:12,11:10,Z:Y,X:i,W:i};h c=B A(z);c.V(o);c.U(d);g.c=c}0.3("T",6=>{g.S.R.Q("P")});0.O=1;k v(b,n,m){8(b.y){b.y(n,m,N)}x 8(b.w){b.w(\'3\'+n,m)}}j 4=k(l){g.M.L(l,\'*\')};v(g,\'l\',k(e){j a=e.a;8(a===\'7\')0.7();8(a===\'f\')0.f();8(a===\'u\')0.u()});0.3(\'t\',6=>{4(\'t\')});0.3(\'7\',6=>{4(\'7\')});0.3(\'f\',6=>{4(\'f\')});0.3(\'K\',6=>{4(0.q);s.r(\'.J-I\').H=G(0.q.F(2))});0.3(\'p\',6=>{4(\'p\')});',62,102,'player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|01|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength|180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|b92a392054c041a3f9c6eecabeb0e127183f44e547828447b10bca8d77523e6f|03|stream|org|nextcdn|files|eu|https'.split('|'),0,{}))</script>"""
|
||||
|
||||
print(process_animepahe_embed_page(data))
|
||||
@@ -1,8 +1,34 @@
|
||||
import os
|
||||
|
||||
import requests
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
from ...constants import APP_CACHE_DIR
|
||||
from .providers_store import ProviderStore
|
||||
|
||||
|
||||
class AnimeProvider:
|
||||
session: requests.Session
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.session = requests.session()
|
||||
PROVIDER = ""
|
||||
USER_AGENT = random_user_agent()
|
||||
HEADERS = {}
|
||||
|
||||
def __init__(self, cache_requests, use_persistent_provider_store) -> None:
|
||||
if cache_requests.lower() == "true":
|
||||
from ..common.requests_cacher import CachedRequestsSession
|
||||
|
||||
self.session = CachedRequestsSession(
|
||||
os.path.join(APP_CACHE_DIR, "cached_requests.db")
|
||||
)
|
||||
else:
|
||||
self.session = requests.session()
|
||||
self.session.headers.update({"User-Agent": self.USER_AGENT, **self.HEADERS})
|
||||
if use_persistent_provider_store.lower() == "true":
|
||||
self.store = ProviderStore(
|
||||
"persistent",
|
||||
self.PROVIDER,
|
||||
os.path.join(APP_CACHE_DIR, "anime_providers_store.db"),
|
||||
)
|
||||
else:
|
||||
self.store = ProviderStore("memory")
|
||||
|
||||
15
fastanime/libs/anime_provider/common.py
Normal file
15
fastanime/libs/anime_provider/common.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import logging
|
||||
|
||||
from requests import get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_anime_info_from_bal(anilist_id):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{anilist_id}.json"
|
||||
response = get(url, timeout=11)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
39
fastanime/libs/anime_provider/decorators.py
Normal file
39
fastanime/libs/anime_provider/decorators.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_provider(provider_name: str):
|
||||
def _provider_function_decorator(provider_function):
|
||||
@functools.wraps(provider_function)
|
||||
def _provider_function_wrapper(*args, **kwargs):
|
||||
if not os.environ.get("FASTANIME_DEBUG"):
|
||||
try:
|
||||
return provider_function(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}")
|
||||
else:
|
||||
return provider_function(*args, **kwargs)
|
||||
|
||||
return _provider_function_wrapper
|
||||
|
||||
return _provider_function_decorator
|
||||
|
||||
|
||||
def ensure_internet_connection(provider_function):
|
||||
@functools.wraps(provider_function)
|
||||
def _wrapper(*args, **kwargs):
|
||||
import requests
|
||||
|
||||
try:
|
||||
requests.get("https://google.com", timeout=5)
|
||||
except requests.ConnectionError:
|
||||
from sys import exit
|
||||
|
||||
print("You are not connected to the internet;Aborting...")
|
||||
exit(1)
|
||||
return provider_function(*args, **kwargs)
|
||||
|
||||
return _wrapper
|
||||
0
fastanime/libs/anime_provider/hianime/__init__.py
Normal file
0
fastanime/libs/anime_provider/hianime/__init__.py
Normal file
243
fastanime/libs/anime_provider/hianime/api.py
Normal file
243
fastanime/libs/anime_provider/hianime/api.py
Normal file
@@ -0,0 +1,243 @@
|
||||
import logging
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from itertools import cycle
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from yt_dlp.utils import (
|
||||
clean_html,
|
||||
extract_attributes,
|
||||
get_element_by_class,
|
||||
get_element_html_by_class,
|
||||
get_elements_by_class,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
from ..base_provider import AnimeProvider
|
||||
from ..decorators import debug_provider
|
||||
from ..utils import give_random_quality
|
||||
from .constants import SERVERS_AVAILABLE
|
||||
from .types import HiAnimeStream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LINK_TO_STREAMS_REGEX = re.compile(r".*://(.*)/embed-(2|4|6)/e-([0-9])/(.*)\?.*")
|
||||
IMAGE_HTML_ELEMENT_REGEX = re.compile(r"<img.*?>")
|
||||
|
||||
|
||||
class ParseAnchorAndImgTag(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.img_tag = None
|
||||
self.a_tag = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "img":
|
||||
self.img_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
if tag == "a":
|
||||
self.a_tag = {attr[0]: attr[1] for attr in attrs}
|
||||
|
||||
|
||||
class HiAnimeApi(AnimeProvider):
|
||||
# HEADERS = {"Referer": "https://hianime.to/home"}
|
||||
|
||||
PROVIDER = "hianime"
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def search_for_anime(self, anime_title: str, *args):
|
||||
query = quote_plus(anime_title)
|
||||
url = f"https://hianime.to/search?keyword={query}"
|
||||
response = self.session.get(url)
|
||||
if not response.ok:
|
||||
return
|
||||
search_page = response.text
|
||||
search_results_html_items = get_elements_by_class("flw-item", search_page)
|
||||
results = []
|
||||
for search_results_html_item in search_results_html_items:
|
||||
film_poster_html = get_element_by_class(
|
||||
"film-poster", search_results_html_item
|
||||
)
|
||||
|
||||
if not film_poster_html:
|
||||
continue
|
||||
# get availableEpisodes
|
||||
episodes_html = get_element_html_by_class("tick-sub", film_poster_html)
|
||||
episodes = clean_html(episodes_html) or 12
|
||||
|
||||
# get anime id and poster image url
|
||||
parser = ParseAnchorAndImgTag()
|
||||
parser.feed(film_poster_html)
|
||||
image_data = parser.img_tag
|
||||
anime_link_data = parser.a_tag
|
||||
if not image_data or not anime_link_data:
|
||||
continue
|
||||
|
||||
episodes = int(episodes)
|
||||
|
||||
# finally!!
|
||||
image_link = image_data["data-src"]
|
||||
anime_id = anime_link_data["data-id"]
|
||||
title = anime_link_data["title"]
|
||||
|
||||
result = {
|
||||
"availableEpisodes": list(range(1, episodes)),
|
||||
"id": anime_id,
|
||||
"title": title,
|
||||
"poster": image_link,
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
|
||||
self.store.set(result["id"], "search_result", result)
|
||||
return {"pageInfo": {}, "results": results}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_anime(self, hianime_id, *args):
|
||||
anime_result = {}
|
||||
if d := self.store.get(str(hianime_id), "search_result"):
|
||||
anime_result = d
|
||||
anime_url = f"https://hianime.to/ajax/v2/episode/list/{hianime_id}"
|
||||
response = self.session.get(anime_url, timeout=10)
|
||||
if response.ok:
|
||||
response_json = response.json()
|
||||
hianime_anime_page = response_json["html"]
|
||||
episodes_info_container_html = get_element_html_by_class(
|
||||
"ss-list", hianime_anime_page
|
||||
)
|
||||
episodes_info_html_list = get_elements_html_by_class(
|
||||
"ep-item", episodes_info_container_html
|
||||
)
|
||||
# keys: [ data-number: episode_number, data-id: episode_id, title: episode_title , href:episode_page_url]
|
||||
episodes_info_dicts = [
|
||||
extract_attributes(episode_dict)
|
||||
for episode_dict in episodes_info_html_list
|
||||
]
|
||||
episodes = [episode["data-number"] for episode in episodes_info_dicts]
|
||||
episodes_info = [
|
||||
{
|
||||
"id": episode["data-id"],
|
||||
"title": (
|
||||
(episode["title"] or "").replace(
|
||||
f"Episode {episode['data-number']}", ""
|
||||
)
|
||||
or anime_result["title"]
|
||||
)
|
||||
+ f"; Episode {episode['data-number']}",
|
||||
"episode": episode["data-number"],
|
||||
}
|
||||
for episode in episodes_info_dicts
|
||||
]
|
||||
self.store.set(
|
||||
str(hianime_id),
|
||||
"anime_info",
|
||||
episodes_info,
|
||||
)
|
||||
return {
|
||||
"id": hianime_id,
|
||||
"availableEpisodesDetail": {
|
||||
"dub": episodes,
|
||||
"sub": episodes,
|
||||
"raw": episodes,
|
||||
},
|
||||
"poster": anime_result["poster"],
|
||||
"title": anime_result["title"],
|
||||
"episodes_info": episodes_info,
|
||||
}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_episode_streams(self, anime_id, episode, translation_type, *args):
|
||||
if d := self.store.get(str(anime_id), "anime_info"):
|
||||
episodes_info = d
|
||||
episode_details = [
|
||||
episode_details
|
||||
for episode_details in episodes_info
|
||||
if episode_details["episode"] == episode
|
||||
]
|
||||
if not episode_details:
|
||||
return
|
||||
episode_details = episode_details[0]
|
||||
episode_url = f"https://hianime.to/ajax/v2/episode/servers?episodeId={episode_details['id']}"
|
||||
response = self.session.get(episode_url)
|
||||
if response.ok:
|
||||
response_json = response.json()
|
||||
episode_page_html = response_json["html"]
|
||||
servers_containers_html = get_elements_html_by_class(
|
||||
"ps__-list", episode_page_html
|
||||
)
|
||||
if not servers_containers_html:
|
||||
return
|
||||
# sub servers
|
||||
try:
|
||||
servers_html_sub = get_elements_html_by_class(
|
||||
"server-item", servers_containers_html[0]
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("HiAnime: sub not found")
|
||||
servers_html_sub = None
|
||||
|
||||
# dub servers
|
||||
try:
|
||||
servers_html_dub = get_elements_html_by_class(
|
||||
"server-item", servers_containers_html[1]
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("HiAnime: dub not found")
|
||||
servers_html_dub = None
|
||||
|
||||
if translation_type == "dub":
|
||||
servers_html = servers_html_dub
|
||||
else:
|
||||
servers_html = servers_html_sub
|
||||
if not servers_html:
|
||||
return
|
||||
|
||||
@debug_provider(self.PROVIDER.upper())
|
||||
def _get_server(server_name, server_html):
|
||||
# keys: [ data-type: translation_type, data-id: embed_id, data-server-id: server_id ]
|
||||
servers_info = extract_attributes(server_html)
|
||||
embed_url = f"https://hianime.to/ajax/v2/episode/sources?id={servers_info['data-id']}"
|
||||
embed_response = self.session.get(embed_url)
|
||||
if embed_response.ok:
|
||||
embed_json = embed_response.json()
|
||||
raw_link_to_streams = embed_json["link"]
|
||||
match = LINK_TO_STREAMS_REGEX.match(raw_link_to_streams)
|
||||
if not match:
|
||||
return
|
||||
provider_domain = match.group(1)
|
||||
embed_type = match.group(2)
|
||||
episode_number = match.group(3)
|
||||
source_id = match.group(4)
|
||||
|
||||
link_to_streams = f"https://{provider_domain}/embed-{embed_type}/ajax/e-{episode_number}/getSources?id={source_id}"
|
||||
link_to_streams_response = self.session.get(link_to_streams)
|
||||
if link_to_streams_response.ok:
|
||||
juicy_streams_json: "HiAnimeStream" = (
|
||||
link_to_streams_response.json()
|
||||
)
|
||||
# TODO: Hianime decided to fucking encrypt shit
|
||||
# so got to fix it later
|
||||
return {
|
||||
"headers": {},
|
||||
"subtitles": [
|
||||
{
|
||||
"url": track["file"],
|
||||
"language": track["label"],
|
||||
}
|
||||
for track in juicy_streams_json["tracks"]
|
||||
if track["kind"] == "captions"
|
||||
],
|
||||
"server": server_name,
|
||||
"episode_title": episode_details["title"],
|
||||
"links": give_random_quality(
|
||||
[
|
||||
{"link": link["file"]}
|
||||
for link in juicy_streams_json["tracks"]
|
||||
]
|
||||
),
|
||||
}
|
||||
|
||||
for server_name, server_html in zip(
|
||||
cycle(SERVERS_AVAILABLE), servers_html
|
||||
):
|
||||
if server := _get_server(server_name, server_html):
|
||||
yield server
|
||||
1
fastanime/libs/anime_provider/hianime/constants.py
Normal file
1
fastanime/libs/anime_provider/hianime/constants.py
Normal file
@@ -0,0 +1 @@
|
||||
SERVERS_AVAILABLE = ["HD1", "HD2", "StreamSB", "StreamTape"]
|
||||
26
fastanime/libs/anime_provider/hianime/types.py
Normal file
26
fastanime/libs/anime_provider/hianime/types.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class HiAnimeSkipTime(TypedDict):
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
class HiAnimeSource(TypedDict):
|
||||
file: str
|
||||
type: str
|
||||
|
||||
|
||||
class HiAnimeTrack(TypedDict):
|
||||
file: str
|
||||
label: str
|
||||
kind: Literal["captions", "thumbnails", "audio"]
|
||||
|
||||
|
||||
class HiAnimeStream(TypedDict):
|
||||
sources: list[HiAnimeSource]
|
||||
tracks: list[HiAnimeTrack]
|
||||
encrypted: bool
|
||||
intro: HiAnimeSkipTime
|
||||
outro: HiAnimeSkipTime
|
||||
server: int
|
||||
345
fastanime/libs/anime_provider/nyaa/api.py
Normal file
345
fastanime/libs/anime_provider/nyaa/api.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import os
|
||||
import re
|
||||
from logging import getLogger
|
||||
|
||||
from yt_dlp.utils import (
|
||||
extract_attributes,
|
||||
get_element_html_by_attribute,
|
||||
get_element_html_by_class,
|
||||
get_element_text_and_html_by_tag,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
from ...common.mini_anilist import search_for_anime_with_anilist
|
||||
from ..base_provider import AnimeProvider
|
||||
from ..decorators import debug_provider
|
||||
from ..types import SearchResults
|
||||
from .constants import NYAA_ENDPOINT
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
EXTRACT_USEFUL_INFO_PATTERN_1 = re.compile(
|
||||
r"\[(\w+)\] (.+) - (\d+) [\[\(](\d+)p[\]\)].*"
|
||||
)
|
||||
|
||||
EXTRACT_USEFUL_INFO_PATTERN_2 = re.compile(
|
||||
r"\[(\w+)\] (.+)E(\d+) [\[\(]?(\d+)p.*[\]\)]?.*"
|
||||
)
|
||||
|
||||
|
||||
class NyaaApi(AnimeProvider):
|
||||
search_results: SearchResults
|
||||
PROVIDER = "nyaa"
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def search_for_anime(self, user_query: str, *args, **_):
|
||||
self.search_results = search_for_anime_with_anilist(
|
||||
user_query, True
|
||||
) # pyright: ignore
|
||||
self.user_query = user_query
|
||||
return self.search_results
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_anime(self, anilist_id: str, *_):
|
||||
for anime in self.search_results["results"]:
|
||||
if anime["id"] == anilist_id:
|
||||
self.titles = [anime["title"], *anime["otherTitles"], self.user_query]
|
||||
return {
|
||||
"id": anime["id"],
|
||||
"title": anime["title"],
|
||||
"poster": anime["poster"],
|
||||
"availableEpisodesDetail": {
|
||||
"dub": anime["availableEpisodes"],
|
||||
"sub": anime["availableEpisodes"],
|
||||
"raw": anime["availableEpisodes"],
|
||||
},
|
||||
}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_episode_streams(
|
||||
self,
|
||||
anime_id: str,
|
||||
episode_number: str,
|
||||
translation_type: str,
|
||||
trusted_only=bool(int(os.environ.get("FA_NYAA_TRUSTED_ONLY", "0"))),
|
||||
allow_dangerous=bool(int(os.environ.get("FA_NYAA_ALLOW_DANGEROUS", "0"))),
|
||||
sort_by="seeders",
|
||||
*args,
|
||||
):
|
||||
anime_title = self.titles[0]
|
||||
logger.debug(f"Searching nyaa for query: '{anime_title} {episode_number}'")
|
||||
servers = {}
|
||||
|
||||
torrents_table = ""
|
||||
for title in self.titles:
|
||||
try:
|
||||
url_arguments: dict[str, str] = {
|
||||
"c": "1_2", # Language (English)
|
||||
"q": f"{title} {'0' if len(episode_number)==1 else ''}{episode_number}", # Search Query
|
||||
}
|
||||
# url_arguments["q"] = anime_title
|
||||
|
||||
# if trusted_only:
|
||||
# url_arguments["f"] = "2" # Trusted uploaders only
|
||||
|
||||
# What to sort torrents by
|
||||
if sort_by == "seeders":
|
||||
url_arguments["s"] = "seeders"
|
||||
elif sort_by == "date":
|
||||
url_arguments["s"] = "id"
|
||||
elif sort_by == "size":
|
||||
url_arguments["s"] = "size"
|
||||
elif sort_by == "comments":
|
||||
url_arguments["s"] = "comments"
|
||||
|
||||
logger.debug(f"URL Arguments: {url_arguments}")
|
||||
|
||||
response = self.session.get(NYAA_ENDPOINT, params=url_arguments)
|
||||
if not response.ok:
|
||||
logger.error(f"[NYAA]: {response.text}")
|
||||
return
|
||||
|
||||
try:
|
||||
torrents_table = get_element_text_and_html_by_tag(
|
||||
"table", response.text
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[NYAA]: {e}")
|
||||
continue
|
||||
|
||||
if not torrents_table:
|
||||
continue
|
||||
|
||||
for anime_torrent in get_elements_html_by_class(
|
||||
"success", torrents_table[1]
|
||||
):
|
||||
td_title = get_element_html_by_attribute(
|
||||
"colspan", "2", anime_torrent
|
||||
)
|
||||
if not td_title:
|
||||
continue
|
||||
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
if "class" in title_anchor_tag_attrs:
|
||||
td_title = td_title.replace(title_anchor_tag[1], "")
|
||||
title_anchor_tag = get_element_text_and_html_by_tag(
|
||||
"a", td_title
|
||||
)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
anime_title_info = title_anchor_tag_attrs["title"]
|
||||
if not anime_title_info:
|
||||
continue
|
||||
match = EXTRACT_USEFUL_INFO_PATTERN_1.search(
|
||||
anime_title_info.strip()
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
server = match[1]
|
||||
match[2]
|
||||
_episode_number = match[3]
|
||||
quality = match[4]
|
||||
if float(episode_number) != float(_episode_number):
|
||||
continue
|
||||
|
||||
links_td = get_element_html_by_class("text-center", anime_torrent)
|
||||
if not links_td:
|
||||
continue
|
||||
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
|
||||
if not torrent_anchor_tag:
|
||||
continue
|
||||
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
|
||||
if not torrent_anchor_tag_atrrs:
|
||||
continue
|
||||
torrent_file_url = (
|
||||
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
|
||||
)
|
||||
if server in servers:
|
||||
link = {
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
if link not in servers[server]["links"]:
|
||||
servers[server]["links"].append(link)
|
||||
else:
|
||||
servers[server] = {
|
||||
"server": server,
|
||||
"headers": {},
|
||||
"episode_title": f"{anime_title}; Episode {episode_number}",
|
||||
"subtitles": [],
|
||||
"links": [
|
||||
{
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
],
|
||||
}
|
||||
for anime_torrent in get_elements_html_by_class(
|
||||
"default", torrents_table[1]
|
||||
):
|
||||
td_title = get_element_html_by_attribute(
|
||||
"colspan", "2", anime_torrent
|
||||
)
|
||||
if not td_title:
|
||||
continue
|
||||
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
if "class" in title_anchor_tag_attrs:
|
||||
td_title = td_title.replace(title_anchor_tag[1], "")
|
||||
title_anchor_tag = get_element_text_and_html_by_tag(
|
||||
"a", td_title
|
||||
)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
anime_title_info = title_anchor_tag_attrs["title"]
|
||||
if not anime_title_info:
|
||||
continue
|
||||
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
|
||||
anime_title_info.strip()
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
server = match[1]
|
||||
match[2]
|
||||
_episode_number = match[3]
|
||||
quality = match[4]
|
||||
if float(episode_number) != float(_episode_number):
|
||||
continue
|
||||
|
||||
links_td = get_element_html_by_class("text-center", anime_torrent)
|
||||
if not links_td:
|
||||
continue
|
||||
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
|
||||
if not torrent_anchor_tag:
|
||||
continue
|
||||
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
|
||||
if not torrent_anchor_tag_atrrs:
|
||||
continue
|
||||
torrent_file_url = (
|
||||
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
|
||||
)
|
||||
if server in servers:
|
||||
link = {
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
if link not in servers[server]["links"]:
|
||||
servers[server]["links"].append(link)
|
||||
else:
|
||||
servers[server] = {
|
||||
"server": server,
|
||||
"headers": {},
|
||||
"episode_title": f"{anime_title}; Episode {episode_number}",
|
||||
"subtitles": [],
|
||||
"links": [
|
||||
{
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
],
|
||||
}
|
||||
if not allow_dangerous:
|
||||
break
|
||||
for anime_torrent in get_elements_html_by_class(
|
||||
"danger", torrents_table[1]
|
||||
):
|
||||
td_title = get_element_html_by_attribute(
|
||||
"colspan", "2", anime_torrent
|
||||
)
|
||||
if not td_title:
|
||||
continue
|
||||
title_anchor_tag = get_element_text_and_html_by_tag("a", td_title)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
if "class" in title_anchor_tag_attrs:
|
||||
td_title = td_title.replace(title_anchor_tag[1], "")
|
||||
title_anchor_tag = get_element_text_and_html_by_tag(
|
||||
"a", td_title
|
||||
)
|
||||
|
||||
if not title_anchor_tag:
|
||||
continue
|
||||
title_anchor_tag_attrs = extract_attributes(title_anchor_tag[1])
|
||||
if not title_anchor_tag_attrs:
|
||||
continue
|
||||
anime_title_info = title_anchor_tag_attrs["title"]
|
||||
if not anime_title_info:
|
||||
continue
|
||||
match = EXTRACT_USEFUL_INFO_PATTERN_2.search(
|
||||
anime_title_info.strip()
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
server = match[1]
|
||||
match[2]
|
||||
_episode_number = match[3]
|
||||
quality = match[4]
|
||||
if float(episode_number) != float(_episode_number):
|
||||
continue
|
||||
|
||||
links_td = get_element_html_by_class("text-center", anime_torrent)
|
||||
if not links_td:
|
||||
continue
|
||||
torrent_anchor_tag = get_element_text_and_html_by_tag("a", links_td)
|
||||
if not torrent_anchor_tag:
|
||||
continue
|
||||
torrent_anchor_tag_atrrs = extract_attributes(torrent_anchor_tag[1])
|
||||
if not torrent_anchor_tag_atrrs:
|
||||
continue
|
||||
torrent_file_url = (
|
||||
f'{NYAA_ENDPOINT}{torrent_anchor_tag_atrrs["href"]}'
|
||||
)
|
||||
if server in servers:
|
||||
link = {
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
if link not in servers[server]["links"]:
|
||||
servers[server]["links"].append(link)
|
||||
else:
|
||||
servers[server] = {
|
||||
"server": server,
|
||||
"headers": {},
|
||||
"episode_title": f"{anime_title}; Episode {episode_number}",
|
||||
"subtitles": [],
|
||||
"links": [
|
||||
{
|
||||
"translation_type": "sub",
|
||||
"link": torrent_file_url,
|
||||
"quality": quality,
|
||||
}
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"[NYAA]: {e}")
|
||||
continue
|
||||
|
||||
for server in servers:
|
||||
yield servers[server]
|
||||
1
fastanime/libs/anime_provider/nyaa/constants.py
Normal file
1
fastanime/libs/anime_provider/nyaa/constants.py
Normal file
@@ -0,0 +1 @@
|
||||
NYAA_ENDPOINT = "https://nyaa.si"
|
||||
126
fastanime/libs/anime_provider/nyaa/utils.py
Normal file
126
fastanime/libs/anime_provider/nyaa/utils.py
Normal file
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import libtorrent # pyright: ignore
|
||||
from rich import print
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
TextColumn,
|
||||
TimeRemainingColumn,
|
||||
TransferSpeedColumn,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("nyaa")
|
||||
|
||||
|
||||
def download_torrent(
|
||||
filename: str,
|
||||
result_filename: str | None = None,
|
||||
show_progress: bool = True,
|
||||
base_path: str = "Anime",
|
||||
) -> str:
|
||||
session = libtorrent.session({"listen_interfaces": "0.0.0.0:6881"})
|
||||
logger.debug("Started libtorrent session")
|
||||
|
||||
base_path = os.path.expanduser(base_path)
|
||||
logger.debug(f"Downloading output to: '{base_path}'")
|
||||
|
||||
info = libtorrent.torrent_info(filename)
|
||||
|
||||
logger.debug("Started downloading torrent")
|
||||
handle: libtorrent.torrent_handle = session.add_torrent(
|
||||
{"ti": info, "save_path": base_path}
|
||||
)
|
||||
|
||||
status: libtorrent.session_status = handle.status()
|
||||
|
||||
progress_bar = Progress(
|
||||
"[progress.description]{task.description}",
|
||||
BarColumn(bar_width=None),
|
||||
"[progress.percentage]{task.percentage:>3.1f}%",
|
||||
"•",
|
||||
DownloadColumn(),
|
||||
"•",
|
||||
TransferSpeedColumn(),
|
||||
"•",
|
||||
TimeRemainingColumn(),
|
||||
"•",
|
||||
TextColumn("[green]Peers: {task.fields[peers]}[/green]"),
|
||||
)
|
||||
|
||||
if show_progress:
|
||||
with progress_bar:
|
||||
download_task = progress_bar.add_task(
|
||||
"downloading",
|
||||
filename=status.name,
|
||||
total=status.total_wanted,
|
||||
peers=0,
|
||||
start=False,
|
||||
)
|
||||
|
||||
while not status.total_done:
|
||||
# Checking files
|
||||
status = handle.status()
|
||||
description = "[bold yellow]Checking files[/bold yellow]"
|
||||
progress_bar.update(
|
||||
download_task,
|
||||
completed=status.total_done,
|
||||
peers=status.num_peers,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Started download
|
||||
progress_bar.start_task(download_task)
|
||||
description = f"[bold blue]Downloading[/bold blue] [bold yellow]{result_filename}[/bold yellow]"
|
||||
|
||||
while not status.is_seeding:
|
||||
status = handle.status()
|
||||
|
||||
progress_bar.update(
|
||||
download_task,
|
||||
completed=status.total_done,
|
||||
peers=status.num_peers,
|
||||
description=description,
|
||||
)
|
||||
|
||||
alerts = session.pop_alerts()
|
||||
|
||||
alert: libtorrent.alert
|
||||
for alert in alerts:
|
||||
if (
|
||||
alert.category()
|
||||
& libtorrent.alert.category_t.error_notification
|
||||
):
|
||||
logger.debug(f"[Alert] {alert}")
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
progress_bar.update(
|
||||
download_task,
|
||||
description=f"[bold blue]Finished Downloading[/bold blue] [bold green]{result_filename}[/bold green]",
|
||||
completed=status.total_wanted,
|
||||
)
|
||||
|
||||
if result_filename:
|
||||
old_name = f"{base_path}/{status.name}"
|
||||
new_name = f"{base_path}/{result_filename}"
|
||||
|
||||
os.rename(old_name, new_name)
|
||||
|
||||
logger.debug(f"Finished torrent download, renamed '{old_name}' to '{new_name}'")
|
||||
|
||||
return new_name
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("You need to pass in the .torrent file path.")
|
||||
sys.exit(1)
|
||||
|
||||
download_torrent(sys.argv[1])
|
||||
114
fastanime/libs/anime_provider/providers_store.py
Normal file
114
fastanime/libs/anime_provider/providers_store.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProviderStoreDB:
|
||||
def __init__(
|
||||
self,
|
||||
provider_name,
|
||||
cache_db_path: str,
|
||||
max_lifetime: int = 604800,
|
||||
max_size: int = (1024**2) * 10,
|
||||
table_name: str = "fastanime_providers_store",
|
||||
clean_db=False,
|
||||
):
|
||||
from ..common.sqlitedb_helper import SqliteDB
|
||||
|
||||
self.cache_db_path = cache_db_path
|
||||
self.clean_db = clean_db
|
||||
self.provider_name = provider_name
|
||||
self.max_lifetime = max_lifetime
|
||||
self.max_size = max_size
|
||||
self.table_name = table_name
|
||||
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
|
||||
|
||||
# Prepare the cache table if it doesn't exist
|
||||
self._create_store_table()
|
||||
|
||||
def _create_store_table(self):
|
||||
"""Create cache table if it doesn't exist."""
|
||||
with self.sqlite_db_connection as conn:
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
||||
id TEXT,
|
||||
data_type TEXT,
|
||||
provider_name TEXT,
|
||||
data TEXT,
|
||||
cache_expiry INTEGER
|
||||
)"""
|
||||
)
|
||||
|
||||
def get(self, id: str, data_type: str, default=None):
|
||||
with self.sqlite_db_connection as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
data
|
||||
FROM {self.table_name}
|
||||
WHERE
|
||||
id = ?
|
||||
AND data_type = ?
|
||||
AND provider_name = ?
|
||||
AND cache_expiry > ?
|
||||
""",
|
||||
(id, data_type, self.provider_name, int(time.time())),
|
||||
)
|
||||
cached_data = cursor.fetchone()
|
||||
|
||||
if cached_data:
|
||||
logger.debug("Found existing request in cache")
|
||||
(json_data,) = cached_data
|
||||
return json.loads(json_data)
|
||||
return default
|
||||
|
||||
def set(self, id: str, data_type: str, data):
|
||||
with self.sqlite_db_connection as connection:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
INSERT INTO {self.table_name}
|
||||
VALUES ( ?, ?,?, ?, ?)
|
||||
""",
|
||||
(
|
||||
id,
|
||||
data_type,
|
||||
self.provider_name,
|
||||
json.dumps(data),
|
||||
int(time.time()) + self.max_lifetime,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ProviderStoreMem:
|
||||
def __init__(self) -> None:
|
||||
from collections import defaultdict
|
||||
|
||||
self._store = defaultdict(dict)
|
||||
|
||||
def get(self, id: str, data_type: str, default=None):
|
||||
return self._store[id][data_type]
|
||||
|
||||
def set(self, id: str, data_type: str, data):
|
||||
self._store[id][data_type] = data
|
||||
|
||||
|
||||
def ProviderStore(store_type, *args, **kwargs):
|
||||
if store_type == "persistent":
|
||||
return ProviderStoreDB(*args, **kwargs)
|
||||
else:
|
||||
return ProviderStoreMem()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
store = ProviderStore("persistent", "test_provider", "provider_store")
|
||||
store.set("123", "test", {"hello": "world"})
|
||||
print(store.get("123", "test"))
|
||||
print("-------------------------------")
|
||||
store = ProviderStore("memory")
|
||||
store.set("1", "test", {"hello": "world"})
|
||||
print(store.get("1", "test"))
|
||||
@@ -19,6 +19,7 @@ class PageInfo(TypedDict):
|
||||
class SearchResult(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
otherTitles: list[str]
|
||||
availableEpisodes: list[str]
|
||||
type: str
|
||||
score: int
|
||||
@@ -39,9 +40,20 @@ class AnimeEpisodeDetails(TypedDict):
|
||||
raw: list[str]
|
||||
|
||||
|
||||
class AnimeEpisode(TypedDict):
|
||||
#
|
||||
# class AnimeEpisode(TypedDict):
|
||||
# id: str
|
||||
# title: str
|
||||
#
|
||||
|
||||
|
||||
class AnimeEpisodeInfo(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
episode: str
|
||||
poster: str | None
|
||||
duration: str | None
|
||||
translation_type: str | None
|
||||
|
||||
|
||||
class Anime(TypedDict):
|
||||
@@ -49,7 +61,7 @@ class Anime(TypedDict):
|
||||
title: str
|
||||
availableEpisodesDetail: AnimeEpisodeDetails
|
||||
type: str | None
|
||||
episodesInfo: list[AnimeEpisode] | None
|
||||
episodesInfo: list[AnimeEpisodeInfo] | None
|
||||
poster: str
|
||||
year: str
|
||||
|
||||
@@ -60,12 +72,19 @@ class EpisodeStream(TypedDict):
|
||||
hls: bool | None
|
||||
mp4: bool | None
|
||||
priority: int | None
|
||||
headers: dict | None
|
||||
quality: Literal["360", "720", "1080", "unknown"]
|
||||
translation_type: Literal["dub", "sub"]
|
||||
|
||||
|
||||
class Subtitle(TypedDict):
|
||||
url: str
|
||||
language: str
|
||||
|
||||
|
||||
class Server(TypedDict):
|
||||
headers: dict
|
||||
subtitles: list[Subtitle]
|
||||
audio: list
|
||||
server: str
|
||||
episode_title: str | None
|
||||
episode_title: str
|
||||
links: list[EpisodeStream]
|
||||
|
||||
@@ -35,15 +35,23 @@ hex_to_char = {
|
||||
}
|
||||
|
||||
|
||||
def give_random_quality(links: list[dict]):
|
||||
qualities = cycle(["1080", "720", "360"])
|
||||
def give_random_quality(links):
|
||||
qualities = cycle(["1080", "720", "480", "360"])
|
||||
|
||||
return [
|
||||
{"link": link["link"], "quality": quality}
|
||||
for link, quality in zip(links, qualities)
|
||||
{**episode_stream, "quality": quality}
|
||||
for episode_stream, quality in zip(links, qualities)
|
||||
]
|
||||
|
||||
|
||||
def one_digit_symmetric_xor(password: int, target: str):
|
||||
def genexp():
|
||||
for segment in bytearray.fromhex(target):
|
||||
yield segment ^ password
|
||||
|
||||
return bytes(genexp()).decode("utf-8")
|
||||
|
||||
|
||||
def decode_hex_string(hex_string):
|
||||
"""some of the sources encrypt the urls into hex codes this function decrypts the urls
|
||||
|
||||
|
||||
216
fastanime/libs/anime_provider/yugen/api.py
Normal file
216
fastanime/libs/anime_provider/yugen/api.py
Normal file
@@ -0,0 +1,216 @@
|
||||
import base64
|
||||
from itertools import cycle
|
||||
from yt_dlp.utils import (
|
||||
get_element_text_and_html_by_tag,
|
||||
get_elements_text_and_html_by_attribute,
|
||||
extract_attributes,
|
||||
get_element_by_attribute,
|
||||
)
|
||||
import re
|
||||
|
||||
from yt_dlp.utils.traversal import get_element_html_by_attribute
|
||||
from .constants import YUGEN_ENDPOINT, SEARCH_URL
|
||||
from ..decorators import debug_provider
|
||||
from ..base_provider import AnimeProvider
|
||||
|
||||
|
||||
# ** Adapted from anipy-cli **
|
||||
class YugenApi(AnimeProvider):
|
||||
"""
|
||||
Provides a fast and effective interface to YugenApi site.
|
||||
"""
|
||||
|
||||
PROVIDER = "yugen"
|
||||
api_endpoint = YUGEN_ENDPOINT
|
||||
# HEADERS = {
|
||||
# "Referer": ALLANIME_REFERER,
|
||||
# }
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def search_for_anime(
|
||||
self,
|
||||
user_query: str,
|
||||
translation_type: str = "sub",
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
**kwargs,
|
||||
):
|
||||
results = []
|
||||
has_next = True
|
||||
page = 0
|
||||
while has_next:
|
||||
page += 1
|
||||
response = self.session.get(
|
||||
SEARCH_URL, params={"q": user_query, "page": page}
|
||||
)
|
||||
search_results = response.json()
|
||||
has_next = search_results["hasNext"]
|
||||
|
||||
results_html = search_results["query"]
|
||||
anime = get_elements_text_and_html_by_attribute(
|
||||
"class", "anime-meta", results_html, tag="a"
|
||||
)
|
||||
id_regex = re.compile(r"(\d+)\/([^\/]+)")
|
||||
for _a in anime:
|
||||
if not _a:
|
||||
continue
|
||||
a = extract_attributes(_a[1])
|
||||
|
||||
if not a:
|
||||
continue
|
||||
uri = a["href"]
|
||||
identifier = id_regex.search(uri) # pyright:ignore
|
||||
if identifier is None:
|
||||
continue
|
||||
|
||||
if len(identifier.groups()) != 2:
|
||||
continue
|
||||
|
||||
identifier = base64.b64encode(
|
||||
f"{identifier.group(1)}/{identifier.group(2)}".encode()
|
||||
).decode()
|
||||
|
||||
anime_title = a["title"]
|
||||
languages = {"sub": 1, "dub": 0}
|
||||
excl = get_element_by_attribute(
|
||||
"class", "ani-exclamation", _a[1], tag="div"
|
||||
)
|
||||
if excl is not None:
|
||||
if "dub" in excl.lower():
|
||||
languages["dub"] = 1
|
||||
#
|
||||
results.append(
|
||||
{
|
||||
"id": identifier,
|
||||
"title": anime_title,
|
||||
"availableEpisodes": languages,
|
||||
}
|
||||
)
|
||||
|
||||
page += 1
|
||||
|
||||
return {
|
||||
"pageInfo": {"total": len(results)},
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_anime(self, anime_id: str, **kwargs):
|
||||
identifier = base64.b64decode(anime_id).decode()
|
||||
response = self.session.get(f"{YUGEN_ENDPOINT}/anime/{identifier}")
|
||||
html_page = response.text
|
||||
data_map = {
|
||||
"id": anime_id,
|
||||
"title": None,
|
||||
"poster": None,
|
||||
"genres": [],
|
||||
"synopsis": None,
|
||||
"release_year": None,
|
||||
"status": None,
|
||||
"otherTitles": [],
|
||||
"availableEpisodesDetail": {},
|
||||
}
|
||||
|
||||
sub_match = re.search(
|
||||
r'<div class="ap-.+?">Episodes</div><span class="description" .+?>(\d+)</span></div>',
|
||||
html_page,
|
||||
)
|
||||
|
||||
if sub_match:
|
||||
eps = int(sub_match.group(1))
|
||||
data_map["availableEpisodesDetail"]["sub"] = list(map(str,range(1, eps + 1)))
|
||||
|
||||
dub_match = re.search(
|
||||
r'<div class="ap-.+?">Episodes \(Dub\)</div><span class="description" .+?>(\d+)</span></div>',
|
||||
html_page,
|
||||
)
|
||||
|
||||
if dub_match:
|
||||
eps = int(dub_match.group(1))
|
||||
data_map["availableEpisodesDetail"]["dub"] = list(map(str,range(1, eps + 1)))
|
||||
|
||||
name = get_element_text_and_html_by_tag("h1", html_page)
|
||||
if name is not None:
|
||||
data_map["title"] = name[0].strip()
|
||||
|
||||
synopsis = get_element_by_attribute("class", "description", html_page, tag="p")
|
||||
if synopsis is not None:
|
||||
data_map["synopsis"] = synopsis
|
||||
|
||||
# FIXME: This is not working because ytdl is too strict on also getting a closing tag
|
||||
try:
|
||||
image = get_element_html_by_attribute(
|
||||
"class", "cover", html_page, tag="img"
|
||||
)
|
||||
img_attrs = extract_attributes(image)
|
||||
if img_attrs is not None:
|
||||
data_map["image"] = img_attrs.get("src")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
data = get_elements_text_and_html_by_attribute(
|
||||
"class", "data", html_page, tag="div"
|
||||
)
|
||||
for d in data:
|
||||
title = get_element_text_and_html_by_tag("div", d[1])
|
||||
desc = get_element_text_and_html_by_tag("span", d[1])
|
||||
if title is None or desc is None:
|
||||
continue
|
||||
title = title[0]
|
||||
desc = desc[0]
|
||||
if title in ["Native", "Romaji"]:
|
||||
data_map["alternative_names"].append(desc)
|
||||
elif title == "Synonyms":
|
||||
data_map["alternative_names"].extend(desc.split(","))
|
||||
elif title == "Premiered":
|
||||
try:
|
||||
data_map["release_year"] = int(desc.split()[-1])
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
elif title == "Status":
|
||||
data_map["status"] = title
|
||||
elif title == "Genres":
|
||||
data_map["genres"].extend([g.strip() for g in desc.split(",")])
|
||||
|
||||
return data_map
|
||||
|
||||
@debug_provider(PROVIDER.upper())
|
||||
def get_episode_streams(
|
||||
self, anime_id, episode_number: str, translation_type="sub"
|
||||
):
|
||||
"""get the streams of an episode
|
||||
|
||||
Args:
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
anime: [TODO:description]
|
||||
episode_number: [TODO:description]
|
||||
|
||||
Yields:
|
||||
[TODO:description]
|
||||
"""
|
||||
|
||||
identifier = base64.b64decode(anime_id).decode()
|
||||
|
||||
id_num, anime_title = identifier.split("/")
|
||||
if translation_type == "dub":
|
||||
video_query = f"{id_num}|{episode_number}|dub"
|
||||
else:
|
||||
video_query = f"{id_num}|{episode_number}"
|
||||
#
|
||||
|
||||
res = self.session.post(
|
||||
f"{YUGEN_ENDPOINT}/api/embed/",
|
||||
data={
|
||||
"id": base64.b64encode(video_query.encode()).decode(),
|
||||
"ac": "0",
|
||||
},
|
||||
headers={"x-requested-with": "XMLHttpRequest"},
|
||||
)
|
||||
res = res.json()
|
||||
yield {
|
||||
"server": "gogoanime",
|
||||
"episode_title": f"{anime_title}; Episode {episode_number}",
|
||||
"headers": {},
|
||||
"subtitles": [],
|
||||
"links": [{"quality": quality, "link": link} for quality,link in zip(cycle(["1080","720","480","360"]),res["hls"])],
|
||||
}
|
||||
5
fastanime/libs/anime_provider/yugen/constants.py
Normal file
5
fastanime/libs/anime_provider/yugen/constants.py
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
YUGEN_ENDPOINT: str = "https://yugenanime.tv"
|
||||
|
||||
SEARCH_URL = YUGEN_ENDPOINT + "/api/discover/"
|
||||
SERVERS_AVAILABLE = ["gogoanime"]
|
||||
15
fastanime/libs/common/common.py
Normal file
15
fastanime/libs/common/common.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import logging
|
||||
|
||||
from requests import get
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_anime_info_from_bal(anilist_id):
|
||||
try:
|
||||
url = f"https://raw.githubusercontent.com/bal-mackup/mal-backup/master/anilist/anime/{anilist_id}.json"
|
||||
response = get(url, timeout=11)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
323
fastanime/libs/common/mini_anilist.py
Normal file
323
fastanime/libs/common/mini_anilist.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from requests import post
|
||||
from thefuzz import fuzz
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..anilist.types import AnilistDataSchema
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ANILIST_ENDPOINT = "https://graphql.anilist.co"
|
||||
"""
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
episodes
|
||||
status
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def search_for_manga_with_anilist(manga_title: str):
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
currentPage
|
||||
}
|
||||
media(search: $query, type: MANGA,genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
chapters
|
||||
status
|
||||
coverImage {
|
||||
medium
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": {"query": manga_title}},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
return {
|
||||
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
|
||||
"results": [
|
||||
{
|
||||
"id": anime_result["id"],
|
||||
"poster": anime_result["coverImage"]["large"],
|
||||
"title": (
|
||||
anime_result["title"]["romaji"]
|
||||
or anime_result["title"]["english"]
|
||||
)
|
||||
+ f" [Chapters: {anime_result['chapters']}]",
|
||||
"type": "manga",
|
||||
"availableChapters": list(
|
||||
range(
|
||||
1,
|
||||
(
|
||||
anime_result["chapters"]
|
||||
if anime_result["chapters"]
|
||||
else 0
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
for anime_result in anilist_data["data"]["Page"]["media"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def search_for_anime_with_anilist(anime_title: str, prefer_eng_titles=False):
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME, genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
episodes
|
||||
status
|
||||
synonyms
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
airingAt
|
||||
episode
|
||||
}
|
||||
coverImage {
|
||||
large
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": {"query": anime_title}},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code == 200:
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
return {
|
||||
"pageInfo": anilist_data["data"]["Page"]["pageInfo"],
|
||||
"results": [
|
||||
{
|
||||
"id": str(anime_result["id"]),
|
||||
"title": (
|
||||
(
|
||||
anime_result["title"]["english"]
|
||||
or anime_result["title"]["romaji"]
|
||||
)
|
||||
if prefer_eng_titles
|
||||
else (
|
||||
anime_result["title"]["romaji"]
|
||||
or anime_result["title"]["english"]
|
||||
)
|
||||
),
|
||||
"otherTitles": [
|
||||
(
|
||||
(
|
||||
anime_result["title"]["romaji"]
|
||||
or anime_result["title"]["english"]
|
||||
)
|
||||
if prefer_eng_titles
|
||||
else (
|
||||
anime_result["title"]["english"]
|
||||
or anime_result["title"]["romaji"]
|
||||
)
|
||||
),
|
||||
*(anime_result["synonyms"] or []),
|
||||
],
|
||||
"type": "anime",
|
||||
"poster": anime_result["coverImage"]["large"],
|
||||
"availableEpisodes": list(
|
||||
map(
|
||||
str,
|
||||
range(
|
||||
1,
|
||||
(
|
||||
anime_result["episodes"]
|
||||
if not anime_result["status"] == "RELEASING"
|
||||
and anime_result["episodes"]
|
||||
else (
|
||||
(
|
||||
anime_result["nextAiringEpisode"]["episode"]
|
||||
- 1
|
||||
if anime_result["nextAiringEpisode"]
|
||||
else 0
|
||||
)
|
||||
if not anime_result["episodes"]
|
||||
else anime_result["episodes"]
|
||||
)
|
||||
)
|
||||
+ 1,
|
||||
),
|
||||
)
|
||||
),
|
||||
}
|
||||
for anime_result in anilist_data["data"]["Page"]["media"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_mal_id_and_anilist_id(anime_title: str) -> "dict[str,int] | None":
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
currentPage
|
||||
hasNextPage
|
||||
}
|
||||
media(search: $query, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
variables = {"query": anime_title}
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
if response.status_code == 200:
|
||||
anime = max(
|
||||
anilist_data["data"]["Page"]["media"],
|
||||
key=lambda anime: max(
|
||||
(
|
||||
fuzz.ratio(anime, str(anime["title"]["romaji"])),
|
||||
fuzz.ratio(anime_title, str(anime["title"]["english"])),
|
||||
)
|
||||
),
|
||||
)
|
||||
return {"id_anilist": anime["id"], "id_mal": anime["idMal"]}
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
|
||||
|
||||
def get_basic_anime_info_by_title(anime_title: str):
|
||||
"""the abstraction over all none authenticated requests and that returns data of a similar type
|
||||
|
||||
Args:
|
||||
query: the anilist query
|
||||
variables: the anilist api variables
|
||||
|
||||
Returns:
|
||||
a boolean indicating success and none or an anilist object depending on success
|
||||
"""
|
||||
query = """
|
||||
query ($query: String) {
|
||||
Page(perPage: 50) {
|
||||
pageInfo {
|
||||
total
|
||||
}
|
||||
media(search: $query, type: ANIME,genre_not_in: ["hentai"]) {
|
||||
id
|
||||
idMal
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
}
|
||||
streamingEpisodes {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
from ...Utility.data import anime_normalizer
|
||||
|
||||
# normalize the title
|
||||
anime_title = anime_normalizer.get(anime_title, anime_title)
|
||||
try:
|
||||
variables = {"query": anime_title}
|
||||
response = post(
|
||||
ANILIST_ENDPOINT,
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=10,
|
||||
)
|
||||
anilist_data: "AnilistDataSchema" = response.json()
|
||||
if response.status_code == 200:
|
||||
anime = max(
|
||||
anilist_data["data"]["Page"]["media"],
|
||||
key=lambda anime: max(
|
||||
(
|
||||
fuzz.ratio(
|
||||
anime_title.lower(), str(anime["title"]["romaji"]).lower()
|
||||
),
|
||||
fuzz.ratio(
|
||||
anime_title.lower(), str(anime["title"]["english"]).lower()
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
return {
|
||||
"idAnilist": anime["id"],
|
||||
"idMal": anime["idMal"],
|
||||
"title": {
|
||||
"english": anime["title"]["english"],
|
||||
"romaji": anime["title"]["romaji"],
|
||||
},
|
||||
"episodes": [
|
||||
{"title": episode["title"]}
|
||||
for episode in anime["streamingEpisodes"]
|
||||
if episode
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Something unexpected occured {e}")
|
||||
209
fastanime/libs/common/requests_cacher.py
Normal file
209
fastanime/libs/common/requests_cacher.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
from .sqlitedb_helper import SqliteDB
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
caching_mimetypes = {
|
||||
"application": {
|
||||
"json",
|
||||
"xml",
|
||||
"x-www-form-urlencoded",
|
||||
"x-javascript",
|
||||
"javascript",
|
||||
},
|
||||
"text": {"html", "css", "javascript", "plain", "xml", "xsl", "x-javascript"},
|
||||
}
|
||||
|
||||
|
||||
class CachedRequestsSession(requests.Session):
|
||||
__request_functions__ = (
|
||||
"get",
|
||||
"options",
|
||||
"head",
|
||||
"post",
|
||||
"put",
|
||||
"patch",
|
||||
"delete",
|
||||
)
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
def caching_params(name: str):
|
||||
def wrapper(self, *args, **kwargs):
|
||||
return cls.request(self, name, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
for func in cls.__request_functions__:
|
||||
setattr(cls, func, caching_params(func))
|
||||
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_db_path: str,
|
||||
max_lifetime: int = 259200,
|
||||
max_size: int = (1024**2) * 10,
|
||||
table_name: str = "fastanime_requests_cache",
|
||||
clean_db=False,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.cache_db_path = cache_db_path
|
||||
self.max_lifetime = max_lifetime
|
||||
self.max_size = max_size
|
||||
self.table_name = table_name
|
||||
self.sqlite_db_connection = SqliteDB(self.cache_db_path)
|
||||
|
||||
# Prepare the cache table if it doesn't exist
|
||||
self._create_cache_table()
|
||||
|
||||
def _create_cache_table(self):
|
||||
"""Create cache table if it doesn't exist."""
|
||||
with self.sqlite_db_connection as conn:
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.table_name} (
|
||||
url TEXT,
|
||||
status_code INTEGER,
|
||||
request_headers TEXT,
|
||||
response_headers TEXT,
|
||||
data BLOB,
|
||||
redirection_policy INT,
|
||||
cache_expiry INTEGER
|
||||
)"""
|
||||
)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method,
|
||||
url,
|
||||
params=None,
|
||||
force_caching=False,
|
||||
fresh=int(os.environ.get("FASTANIME_FRESH_REQUESTS", 0)),
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
if params:
|
||||
url += "?" + urlencode(params)
|
||||
|
||||
redirection_policy = int(kwargs.get("force_redirects", False))
|
||||
|
||||
with self.sqlite_db_connection as conn:
|
||||
cursor = conn.cursor()
|
||||
time_before_access_db = datetime.now()
|
||||
|
||||
logger.debug("Checking for existing request in cache")
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT
|
||||
status_code,
|
||||
request_headers,
|
||||
response_headers,
|
||||
data,
|
||||
redirection_policy
|
||||
FROM {self.table_name}
|
||||
WHERE
|
||||
url = ?
|
||||
AND redirection_policy = ?
|
||||
AND cache_expiry > ?
|
||||
""",
|
||||
(url, redirection_policy, int(time.time())),
|
||||
)
|
||||
cached_request = cursor.fetchone()
|
||||
time_after_access_db = datetime.now()
|
||||
|
||||
if cached_request and not fresh:
|
||||
logger.debug("Found existing request in cache")
|
||||
(
|
||||
status_code,
|
||||
request_headers,
|
||||
response_headers,
|
||||
data,
|
||||
redirection_policy,
|
||||
) = cached_request
|
||||
|
||||
response = requests.Response()
|
||||
response.headers.update(json.loads(response_headers))
|
||||
response.status_code = status_code
|
||||
response._content = data
|
||||
|
||||
if "timeout" in kwargs:
|
||||
kwargs.pop("timeout")
|
||||
if "headers" in kwargs:
|
||||
kwargs.pop("headers")
|
||||
_request = requests.Request(
|
||||
method, url, headers=json.loads(request_headers), *args, **kwargs
|
||||
)
|
||||
response.request = _request.prepare()
|
||||
response.elapsed = time_after_access_db - time_before_access_db
|
||||
|
||||
return response
|
||||
|
||||
# Perform the request and cache it
|
||||
response = super().request(method, url, *args, **kwargs)
|
||||
if response.ok and (
|
||||
force_caching
|
||||
or self.is_content_type_cachable(
|
||||
response.headers.get("content-type"), caching_mimetypes
|
||||
)
|
||||
and len(response.content) < self.max_size
|
||||
):
|
||||
logger.debug("Caching the current request")
|
||||
cursor.execute(
|
||||
f"""
|
||||
INSERT INTO {self.table_name}
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
url,
|
||||
response.status_code,
|
||||
json.dumps(dict(response.request.headers)),
|
||||
json.dumps(dict(response.headers)),
|
||||
response.content,
|
||||
redirection_policy,
|
||||
int(time.time()) + self.max_lifetime,
|
||||
),
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def is_content_type_cachable(content_type, caching_mimetypes):
|
||||
"""Checks whether the given encoding is supported by the cacher"""
|
||||
if content_type is None:
|
||||
return True
|
||||
|
||||
mime, contents = content_type.split("/")
|
||||
|
||||
contents = re.sub(r";.*$", "", contents)
|
||||
|
||||
return mime in caching_mimetypes and any(
|
||||
content in caching_mimetypes[mime] for content in contents.split("+")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
with CachedRequestsSession("cache.db") as session:
|
||||
response = session.get(
|
||||
"https://google.com",
|
||||
)
|
||||
|
||||
response_b = session.get(
|
||||
"https://google.com",
|
||||
)
|
||||
|
||||
print("A: ", response.elapsed)
|
||||
print("B: ", response_b.elapsed)
|
||||
|
||||
print(response_b.text[0:30])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user