Compare commits

..

108 Commits

Author SHA1 Message Date
Benex254
6c1f8d09e6 chore: update package info 2024-07-26 14:13:50 +03:00
Benex254
6bb2c89a8c feat(mpv): improve streaming on mobile 2024-07-26 14:10:49 +03:00
Benex254
9f56b74ff0 feat(utils): add logging 2024-07-26 14:10:12 +03:00
Benex254
4d03b86498 chore(anime_provider): remove print statements from provider and switch to logging 2024-07-26 14:09:44 +03:00
Benex254
fab86090a3 chore: remove legacy code 2024-07-26 14:07:57 +03:00
Benex254
71d258385c chore(constants): create constants module to store useful constants 2024-07-26 14:07:37 +03:00
Benex254
bc55ed6e81 chore(updater): update updater info 2024-07-26 14:04:53 +03:00
Benex254
197bfa9f8a chore: update pyproject.toml 2024-07-26 09:27:56 +03:00
Benex254
f84c60e6bc chore: update dependencies 2024-07-26 09:24:15 +03:00
Benex254
d8b94cbbca update pyproject.toml file 2024-07-26 09:19:49 +03:00
Benex254
dd4462f42a chore: reorganize imports 2024-07-26 09:07:49 +03:00
Benex254
0f9e08b9fa chore: reorganize codebase to make anilist top level 2024-07-26 09:07:09 +03:00
Benex254
01333ab1d1 chore: clean up legacy files 2024-07-26 08:42:19 +03:00
Benex254
d8bf9e18c4 chore: clean up legacy code 2024-07-26 08:41:39 +03:00
BenedictX
bc909397d5 Update pyproject.toml 2024-07-25 19:29:33 +03:00
Benex254
f3d88f9825 feat(cli): switch to using AnimeProvider obj 2024-07-25 18:12:27 +03:00
Benex254
eb7bef72b3 feat(anilist_interface): remove legacy methods 2024-07-25 18:10:11 +03:00
Benex254
f6ec094bc7 feat(allanime): change typing from generator to iterator 2024-07-25 18:03:29 +03:00
Benex254
3f1bf1781a feat: implement AnimeProvider obj to manage providers 2024-07-25 17:59:32 +03:00
Benex254
21167fc208 docs: update readme 2024-07-25 14:23:34 +03:00
Benex254
c7c6ff92c4 feat(config): set default format to accept .mp4 2024-07-25 13:22:53 +03:00
Benex254
78319731c0 feat: add yt-dlp format option 2024-07-25 13:10:21 +03:00
Benex254
b619a11db1 chore: use latest version of python for publising 2024-07-25 11:25:28 +03:00
Benex254
022420aa4c feat(anilist): implement anilist random subcommand 2024-07-25 11:23:24 +03:00
Benex254
a7e46d9c18 feat(anilist_interface): ensure the app does not exit when trailer not found 2024-07-25 11:23:24 +03:00
Benex254
5e2826be4e feat(mpv): add typing for mpv title option 2024-07-25 11:23:24 +03:00
Benex254
5e314e2bca docs: update readme 2024-07-25 11:23:24 +03:00
Benex254
3d23854d89 feat(cli): rename translation_type option to translation-type 2024-07-25 11:23:24 +03:00
Benex254
80a25d24a3 chore: renamed build action 2024-07-25 11:23:24 +03:00
BenedictX
1ad7929c66 ci: Update publish.yml 2024-07-25 02:21:33 +03:00
BenedictX
0670bd735c ci: Create publish.yml 2024-07-25 02:14:53 +03:00
Benex254
400a600bfe chore: update lock file 2024-07-24 21:28:30 +03:00
Benex254
b9a3f170ab fix(anilist): correct graphql query for most scored 2024-07-24 21:25:50 +03:00
Benex254
9309ba15b5 fix(mpv): correct order of args 2024-07-24 21:21:09 +03:00
Benex254
b2971e0233 refactor: remove all traces of the gui and api sub packages 2024-07-24 21:06:04 +03:00
Benex254
06f67624d4 refactor: remove config unused config dir 2024-07-24 21:05:35 +03:00
Benex254
597c1bc9fd refactor: remove unused mpv lib 2024-07-24 21:05:05 +03:00
Benex254
6fccd08e96 chore: update config removing gui dependencies 2024-07-24 21:04:41 +03:00
Benex254
0e9294d7a2 feat(anilist_interface): add random anime option for main interforce 2024-07-24 20:54:36 +03:00
Benex254
c76a354d1b refactor: remove the gui and api then move to separate project 2024-07-24 20:53:29 +03:00
Benex254
215def909e feat(cli): include the fzf preview script inside the project for convinience 2024-07-24 20:34:07 +03:00
Benex254
edd394ca74 feat(cli): make preview window for fzf optional 2024-07-24 20:21:38 +03:00
Benex254
af69046025 feat(cli): make the downloads command use the config download path 2024-07-24 20:04:56 +03:00
Benex254
6379c28fed build: increase retention and rename built artifact 2024-07-24 17:36:17 +03:00
Benex254
23b22dfc70 feat: exclude the wheel distribution since its platform dependent 2024-07-24 17:27:55 +03:00
Benex254
da06b0b6e1 feat: add github workflow to build app after every push 2024-07-24 17:19:55 +03:00
Benex254
68640202c3 chore: remove unused github workflows 2024-07-24 17:06:11 +03:00
Benex254
2595ac5bf7 feat: make the downloads command use the mpv module to enable compatibility with mobile 2024-07-24 17:05:10 +03:00
Benex254
19f2898b73 feat: add --path option that prints config location and exits 2024-07-24 17:00:05 +03:00
Benex254
69ec3ebfd7 feat: make the mpv player module work on android 2024-07-24 16:52:43 +03:00
Benex254
d048bccaa1 fix: drop curl_cffi as dependency due to issues on android 2024-07-24 16:51:40 +03:00
Benex254
2c2f2be26d docs: add pip and pipx installation instructions 2024-07-23 10:36:56 +03:00
Benex254
7e2c03d54c feat: use curl_cffi to enable browser impersonation 2024-07-22 22:51:20 +03:00
Benex254
62619421d6 docs: correct discord widget 2024-07-22 20:28:37 +03:00
Benex254
84cea644e7 docs: correct discord link 2024-07-22 20:26:22 +03:00
Benex254
85326b9bc6 docs: add links for respective projects 2024-07-22 20:18:28 +03:00
Benex254
06c602e663 feat(allanime-provider): add episode number to avoid confusion when streaming downloaded content 2024-07-22 10:13:23 +03:00
Benex254
54161f13e4 docs: update readme to include changes in the codebase 2024-07-22 09:54:46 +03:00
Benex254
d74d93da59 chore: arrange dependencies to groups and make some opt in 2024-07-21 22:37:44 +03:00
Benex254
0a5fc0fa3c feat(cli): make it opt in to use fzf and instead make fuzzy inquirer as default 2024-07-21 21:55:09 +03:00
Benex254
52fa6912be feat(cli): add bing mode to search subcommand using episode ranges 2024-07-19 15:00:04 +03:00
Benex254
62bb1f7944 fix(cli): fix bool options not editing config at runtime 2024-07-19 12:15:23 +03:00
Benex254
6fa88dd959 feat(cli): add auto-select provider results 2024-07-19 10:56:22 +03:00
Benex254
a853c01e52 Merge remote-tracking branch 'origin'
keep to date with origin
2024-07-19 10:50:41 +03:00
Benex254
a971b22d72 Revert "feat(cli): add auto-select provider results"
This reverts commit 0d64a9bd32.
2024-07-19 10:45:48 +03:00
Benex254
0d64a9bd32 feat(cli): add auto-select provider results 2024-07-19 10:42:51 +03:00
BenedictX
16dc63c177 docs: Update README.md 2024-07-18 00:37:44 +03:00
BenedictX
b5456635c7 docs: Update README.md 2024-07-18 00:18:33 +03:00
Benex254
d865086a50 docs: update readme 2024-07-18 00:12:01 +03:00
Benex254
82272cdf4e docs: update readme 2024-07-17 23:45:42 +03:00
Benex254
81aac99da8 docs: Update readme 2024-07-17 23:32:59 +03:00
Benex254
962bde00a7 feat(cli): add downloads subcommand 2024-07-17 17:52:30 +03:00
Benex254
1d9c911ea1 feat(cli): add download subcommand 2024-07-17 16:28:19 +03:00
Benex254
cf3a963173 feat(cli): add graceful exit 2024-07-17 15:01:03 +03:00
Benex254
a88e72e4c2 feat(cli): add search subcommand 2024-07-17 14:42:47 +03:00
Benex254
269b1447f6 feat(cli): normalize allanime api output 2024-07-17 14:07:35 +03:00
Benex254
e589a92147 feat(cli): improve ui and ux 2024-07-17 00:42:55 +03:00
Benex254
7fcd5c3475 feat(cli): add help messages to anilist command and subcommands 2024-07-17 00:17:34 +03:00
Benex254
e695577881 feat(cli): complete config command 2024-07-17 00:03:24 +03:00
Benex254
bcd8637b31 feat(cli): add script to generate shell completions 2024-07-16 21:32:22 +03:00
Benex254
8d4f2a8f04 style: format code and sort imports 2024-07-16 20:43:51 +03:00
Benex254
6d077fd3e2 feat: minor ui and ux improvements plus add edit config option 2024-07-16 19:33:35 +03:00
Benex254
73ce357789 index on master: 53823f0 chore:recover lost stash changes 2024-07-12 16:32:29 +03:00
Benex254
53823f02c1 chore:recover lost stash changes 2024-07-11 17:04:15 +03:00
Benex254
148619029d feat:switch to pure fzf for menus 2024-07-11 16:15:47 +03:00
Benex254
f08062ee71 feat:standardize the user data helper to kork for both cli and gui 2024-07-07 15:59:00 +03:00
Benex254
2aa02d6ab9 feat(cli):complete subcommands for anilist command 2024-06-30 23:14:01 +03:00
Benex254
520bfcbb52 feat(cli):improve anilist interfaces api 2024-06-29 22:00:48 +03:00
Benex254
7d82a356b1 feat(cli):finsh player controls sub interfaces 2024-06-28 23:43:19 +03:00
Benex254
be4cacf9dc feat(updater):implement basic script to update app 2024-06-28 18:05:00 +03:00
Benex254
f3b398d344 chore(deps):drop plyer as a dependency and switch to platformdirs 2024-06-28 15:46:48 +03:00
Benex254
1ffb122cec feat(cli):add quality and translation type selection 2024-06-28 15:11:27 +03:00
Benex254
84b8bd9950 feat(cli):add quality and translation type selection 2024-06-28 15:11:27 +03:00
Benex254
ab76689f07 chore:add bandit as pre-commit-hook 2024-06-28 15:11:27 +03:00
BenedictX
8c838a82f7 chore:Create stale.yml 2024-06-23 21:12:50 +03:00
BenedictX
9996af900f chore:Create greetings.yml 2024-06-23 21:12:15 +03:00
Benex254
4f0a752033 chore:update pre-commit-hook deps to latest 2024-06-23 20:27:33 +03:00
Benex254
3b8a565843 chore:switch to poetry as build tool and package manager 2024-06-23 20:13:09 +03:00
Benex254
4b5ff6348e feat:switch to poetry as build tool and package manager 2024-06-23 17:46:01 +03:00
Benex254
4a2c981dff feat:create cli subpackage 2024-06-19 20:43:23 +03:00
Benex254
f93d524f68 chore:updated setup.py 2024-06-17 15:37:34 +03:00
Benex254
03a3d32ce4 feat:add new anime to normalizer 2024-06-17 15:37:34 +03:00
Benex254
8615960300 feat:implement work around for packaging 2024-06-17 15:37:34 +03:00
BenedictX
1442346f07 Update LICENSE 2024-06-14 12:02:10 +03:00
BenedictX
89df10e377 Create python-package.yml 2024-06-14 11:56:03 +03:00
BenedictX
7bab3d63e6 Update README.md 2024-06-13 22:07:50 +03:00
BenedictX
4bdfe5449e Update README.md 2024-06-13 21:49:40 +03:00
BenedictX
d8afdce467 Update README.md 2024-06-13 20:22:21 +03:00
159 changed files with 4467 additions and 3022 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 763 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

35
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: debug_build
on: push
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v4
# see details (matrix, python-version, python-version-file, etc.)
# https://github.com/actions/setup-python
- 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
with:
path: ./.venv
key: venv-${{ hashFiles('poetry.lock') }}
- name: Install the project dependencies
run: poetry install
- name: build app
run: poetry 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

66
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# GitHub recommends pinning actions to a commit SHA.
# To get a newer version, you will need to update the SHA.
# You can also reference a tag or branch, but the action may change without warning.
name: Upload Python Package
on:
release:
types: [published]
permissions:
contents: read
jobs:
release-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build release distributions
run: |
# NOTE: put your own distribution build steps here.
python -m pip install build
python -m build
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: release-dists
path: dist/
pypi-publish:
runs-on: ubuntu-latest
needs:
- release-build
permissions:
# IMPORTANT: this permission is mandatory for trusted publishing
id-token: write
# Dedicated environments with protections for publishing are strongly recommended.
environment:
name: pypi
# OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
# url: https://pypi.org/p/YOURPROJECT
steps:
- name: Retrieve release distributions
uses: actions/download-artifact@v4
with:
name: release-dists
path: dist/
- name: Publish release distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1

2
.gitignore vendored
View File

@@ -19,6 +19,7 @@ anixstream.ini
build/
develop-eggs/
dist/
bin/
downloads/
eggs/
.eggs/
@@ -174,3 +175,4 @@ app/user_data.json
app/View/SearchScreen/.search_screen.py.un~
app/View/SearchScreen/search_screen.py~
app/user_data.json
.buildozer

37
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,37 @@
default_language_version:
python: python3.10
repos:
- repo: https://github.com/pycqa/isort
rev: 5.12.0 # You can replace this with the latest version
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"] # Ensure compatibility with Black
- repo: https://github.com/PyCQA/autoflake
rev: v2.2.1
hooks:
- id: autoflake
args: ["--in-place","--remove-unused-variables", "--remove-all-unused-imports"]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.4.10
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
hooks:
- id: black
name: black
language_version: python3.10
# ------ TODO: re-add this -----
# - repo: https://github.com/PyCQA/bandit
# rev: 1.7.9 # Update me!
# hooks:
# - id: bandit
# args: ["-c", "pyproject.toml"]
# additional_dependencies: ["bandit[toml]"]

37
LICENSE
View File

@@ -1,19 +1,24 @@
Copyright (c) 2018 The Python Packaging Authority
This is free and unencumbered software released into the public domain.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org>

320
README.md
View File

@@ -1,80 +1,320 @@
# Fast Anime
Welcome to Fast Anime, your new favorite destination for streaming and downloading anime.
Welcome to **FastAnime**, an anime scrapper that brings a browser experience to the terminal.
[intro.webm](https://github.com/user-attachments/assets/036af7fc-83ff-4f9b-bda6-0c913f7d0f38)
Heavily inspired by [animdl](https://github.com/justfoolingaround/animdl), [magic-tape](https://gitlab.com/christosangel/magic-tape/-/tree/main?ref_type=heads) and [ani-cli](https://github.com/pystardust/ani-cli).
> [!IMPORTANT]
>
> This project currently scrapes allanime and is in no way related to them. The site is in the public domain and can be access by any one with a browser.
> [!NOTE]
>
> The docs are still being worked on and are far from completion.
## Table of Contents
- [Installation](#installation)
- [Using pip](#using-pip)
- [Using pipx](#using-pipx)
- [Pre-built binaries](#pre-built-binaries)
- [Installation using your favourite package manager](#installation-using-your-favourite-package-manager)
- [Using pipx](#using-pipx)
- [Using pip](#using-pip)
- [Installing the building edge version](#installing-the-bleeding-edge-version)
- [Building from the source](#building-from-the-source)
- [Major Dependencies](#major-dependencies)
- [External Dependencies](#external-dependencies)
- [Usage](#usage)
- [The Commandline interface](#the-commandline-interface-fire)
- [The anilist command](#the-anilist-command)
- [download subcommand](#download-subcommand)
- [search subcommand](#search-subcommand)
- [downloads subcommand](#downloads-subcommand)
- [config subcommand](#config-subcommand)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Receiving Support](#receiving-support)
- [Supporting the Project](#supporting-the-project)
- [demo video](#demo-video)
## Installation
### Using pip
The app can run wherever python can run. So all you need to have is python installed on your device.
On android you can use [termux](https://github.com/termux/termux-app).
If you have any difficulty consult for help on the [discord channel](https://discord.gg/HRjySFjQ)
Fast Anime can be installed using pip with the following command:
### Installation using your favourite package manager
Currently the app is only published on [pypi](https://pypi.org/project/fastanime/).
The app is published approximately after every 14 days, which will include accumulative changes during that period.
#### Using pipx
Preferred method of installation since [Pipx](https://github.com/pypa/pipx) creates an isolated environment for each app it installs.
```bash
pip install <package-name>
#then you can launch the app using the following command:
python -m fastanime
pipx install fastanime
```
> [!TIP]
> To add a desktop entry, execute the following command:
>
> ```bash
> python -m fastanime.ensure_desktop_icon
> ```
#### Using pip
### Using pipx
```bash
pip install fastanime
```
Currently, this method is not functional. Please use the pip installation method instead.
### Installing the bleeding edge version
### Pre-built binaries
To install the latest build which are created on every push by Github actions, download the [fastanime_debug_build](https://github.com/Benex254/FastAnime/actions) of your choosing from the Github actions page.
Then:
We will soon release pre-built binaries for Linux and Windows.
```bash
unzip fastanime_debug_build
# outputs fastanime<version>.tar.gz
pipx install fastanime<version>.tar.gz
# --- or ---
pip install fastanime<version>.tar.gz
```
### Building from the source
Requirements:
- [git](https://git-scm.com/)
- [python 3.10 and above](https://www.python.org/)
- [poetry](https://python-poetry.org/docs/#installation)
To build from the source, follow these steps:
1. Clone the repository: `git clone https://github.com/Benex254/FastAnime.git`
2. Navigate into the folder: `cd FastAnime`
3. create the virtual environment: `python -m venv .venv`
4. Activate the virtual environment: `source .venv/bin/activate` or on windows `.venv/scripts/activate`
5. Install dependencies: `pip install -r requirements.txt`
6. Build the app with: `python -m build`; ensure build is installed first.
7. Navigate to the dist folder: `cd dist`
8. Install the app with: `pip install fastanime-0.2.0-py3-none-any.whl`
3. Then build and Install the app:
## Major Dependencies
```bash
# Normal Installation
poetry build
cd dist
pip install fastanime<version>.whl
- Kivy and KivyMD for the UI
- PyShortcuts for creating a desktop icon
- FuzzyWuzzy for auto-selecting search results from the provider
# Editable installation (easiest for updates)
# just do a git pull in the Project dir
# the latter will require rebuilding the app
pip install -e .
```
4. Enjoy! Verify installation with:
```bash
fastanime --version
```
> [!Tip]
>
> Download the completions from [here](https://github.com/Benex254/FastAnime/tree/master/completions) for your shell.
> To add completions:
>
> - Fish Users: `cp $FASTANIME_PATH/completions/fastanime.fish ~/.config/fish/completions/`
> - Bash Users: Add `source $FASTANIME_PATH/completions/fastanime.bash` to your `.bashrc`
> - Zsh Users: Add `source $FASTANIME_PATH/completions/fastanime.zsh` to your `.zshrc`
### External Dependencies
The only required external dependency, unless you won't be streaming, is [MPV](https://mpv.io/installation/), which i recommend installing with [uosc](https://github.com/tomasklaen/uosc) and [thumbfast](https://github.com/po5/thumbfast) for the best experience since they add a better interface to it.
> [!NOTE]
>
> The project currently sees no reason to support any other video
> player because we believe nothing beats **MPV** and it provides
> everything you could ever need with a small footprint.
> But if you have a reason feel free to encourage as to do so.
**Other dependecies that will just make your experience better:**
- [fzf](https://github.com/junegunn/fzf) :fire: which is used as a better alternative to the ui.
- [chafa](https://github.com/hpjansson/chafa) currently the best cross platform and cross terminal image viewer for the terminal.
- [icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) an image viewer that only works in [kitty terminal](https://sw.kovidgoyal.net/kitty/), which is currently the best terminal in my opinion, and by far the best image renderer for the terminal thanks to kitty's terminal graphics protocol. Its terminal graphics is so op that you can [run a browser on it]()!!
- [bash](https://www.gnu.org/software/bash/) is used as the preview script language.
## Usage
The app offers both a graphical interface (under development) and a robust command-line interface.
> [!NOTE]
>
> The GUI is in development; use the CLI for now.
> However, you can try it out before i decided to change my objective by checking out this [release](https://github.com/Benex254/FastAnime/tree/v0.20.0).
> But be reassured for those who aren't terminal chads, i will still complete the GUI for the fun of it
### The Commandline interface :fire:
Designed for power users who prefer efficiency over browser-based streaming and still want the experience in their terminal.
Overview of main commands:
- `fastanime anilist`: Powerful command for browsing and exploring anime due to Anilist intergration.
- `fastanime download`: Download anime.
- `fastanime search`: Powerful command meant for binging since it doesn't require the interfaces
- `fastanime downloads`: View downloaded anime and watch with mpv.
- `fastanime config`: Quickly edit configuration settings.
Configuration is directly passed into this command at run time to overide your config.
Available options include:
- `--server;-s <server>` set the default server to auto select
- `--continue;-c/--no-continue;-no-c` whether to continue from the last episode you were watching
- `--quality;-q <0|1|2|3>` the link to choose from server
- `--translation-type;- <dub|sub` what language for anime
- `--auto-select;-a/--no-auto-select;-no-a` auto select title from provider results
- `--auto-next;-A;/--no-auto-next;-no-A` auto select next episode
- `-downloads-dir;-d <path>` set the folder to download anime into
- `--fzf` use fzf for the ui
- `--default` use the default ui
- `--preview` show a preview when using fzf
- `--no-preview` dont show a preview when using fzf
- `--format <yt-dlp format string>` set the format of anime downloaded and streamed based on yt-dlp format. works when `--server gogoanime`
#### The anilist command
Stream, browse, and discover anime efficiently from the terminal using the [AniList API](https://github.com/AniList/ApiV2-GraphQL-Docs).
##### Running without any subcommand
Run `fastanime anilist` to access the main interface.
##### Subcommands
The subcommands are mainly their as convenience. Since all the features already exist in the main interface.
- `fastanime anilist trending`: Top 15 trending anime.
- `fastanime anilist recent`: Top 15 recently updated anime.
- `fastanime anilist search`: Search for anime (top 50 results).
- `fastanime anilist upcoming`: Top 15 upcoming anime.
- `fastanime anilist popular`: Top 15 popular anime.
- `fastanime anilist favourites`: Top 15 favorite anime.
- `fastanime anilist random`: get random anime
#### download subcommand
Download anime to watch later dub or sub with this one command.
Its optimized for scripting due to fuzzy matching.
So every step of the way has been and can be automated.
> [!NOTE]
>
> The download feature is powered by [yt-dlp](https://github.com/yt-dlp/yt-dlp) so all the bells and whistles that it provides are readily available in the project.
> Like continuing from where you left of while downloading, after lets say you lost your internet connection.
**Syntax:**
```bash
# Download all available episodes
fastanime download <anime-title>
# Download specific episode range
# be sure to observe the range Syntax
fastanime download <anime-title> -r <episodes-start>-<episodes-end>
```
#### search subcommand
Powerful command mainly aimed at binging anime. Since it doesn't require interaction with the interfaces.
**Syntax:**
```bash
# basic form where you will still be promted for the episode number
fastanime search <anime-title>
# binge all episodes with this command
fastanime search <anime-title> -
# binge a specific episode range with this command
# be sure to observe the range Syntax
fastanime search <anime-title> <episodes-start>-<episodes-end>
```
#### downloads subcommand
View and stream the anime you downloaded using MPV.
**Syntax:**
```bash
fastanime downloads
# to get the path to the downloads folder set
fastanime downloads --path
# useful when you want to use the value for other programs
```
#### config subcommand
Edit FastAnime configuration settings using your preferred editor (based on `$EDITOR` environment variable so be sure to set it).
**Syntax:**
```bash
fastanime config
# to get config path which is useful if you want to use it for another program.
fastanime config --path
```
> [!Note]
>
> If it opens [vim](https://www.vim.org/download.php) you can exit by typing `:q` in case you don't know.
## Configuration
The app includes sensible defaults but can be customized extensively. Configuration is stored in `.ini` format at `~/.config/FastAnime/config.ini` on linux and mac or somewhere on windows.
```ini
[stream]
continue_from_history = True # Auto continue from watch history
translation_type = sub # Preferred language for anime (options: dub, sub)
server = top # Default server (options: dropbox, sharepoint, wetransfer.gogoanime, top)
auto_next = False # Auto-select next episode
# Auto select the anime provider results with fuzzyfind.
# Note this wont always be correct.But 99% of the time will be.
auto_select=True
# 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 server=gogoanime
# since its the only one that offers different formats
# the others tend not to
format=best[height<=1080]/bestvideo[height<=1080]+bestaudio/best # default
[general]
preferred_language = romaji # Display language (options: english, romaji)
downloads_dir = <Default-videos-dir>/FastAnime # Download directory
use_fzf=False # whether to use fzf as the interface for the anilist command and others.
preview=false # whether to show a preview window when using fzf
[anilist]
# Not implemented yet
```
## Contributing
We welcome your issues and feature requests. However, we currently have no plans to add another provider, so issues related to this may not be addressed due to time constraints. If you wish to contribute directly, please open an issue detailing the changes you wish to add and request a PR.
We welcome your issues and feature requests. However, due to time constraints, we currently do not plan to add another provider.
If you wish to contribute directly, please first open an issue describing your proposed changes so it can be discussed.
## Receiving Support
If you have any inquiries, join our Discord server: `<Discord-server-link>`
For inquiries, join our [Discord Server](https://discord.gg/4NUTj5Pt).
<p align="center">
<a href="https://discord.gg/HRjySFjQ">
<img src="https://invidget.switchblade.xyz/HRjySFjQ">
</a>
</p>
## Supporting the Project
If you want to support the project, please consider leaving a star on our GitHub repository or [buying us a coffee](https://ko-fi.com/benex254). We appreciate both!
## demo-video
[![Watch the video](https://img.youtube.com/vi/aHRlxmxo6rY/0.jpg)](https://www.youtube.com/watch?v=aHRlxmxo6rY)
Show your support by starring our GitHub repository or [buying us a coffee](https://ko-fi.com/benex254).

View File

@@ -0,0 +1,29 @@
_fastanime_completion() {
local IFS=$'\n'
local response
response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _FASTANIME_COMPLETE=bash_complete $1)
for completion in $response; do
IFS=',' read type value <<< "$completion"
if [[ $type == 'dir' ]]; then
COMPREPLY=()
compopt -o dirnames
elif [[ $type == 'file' ]]; then
COMPREPLY=()
compopt -o default
elif [[ $type == 'plain' ]]; then
COMPREPLY+=($value)
fi
done
return 0
}
_fastanime_completion_setup() {
complete -o nosort -F _fastanime_completion fastanime
}
_fastanime_completion_setup;

View File

@@ -0,0 +1,18 @@
function _fastanime_completion;
set -l response (env _FASTANIME_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) fastanime);
for completion in $response;
set -l metadata (string split "," $completion);
if test $metadata[1] = "dir";
__fish_complete_directories $metadata[2];
else if test $metadata[1] = "file";
__fish_complete_path $metadata[2];
else if test $metadata[1] = "plain";
echo $metadata[2];
end;
end;
end;
complete --no-files --command fastanime --arguments "(_fastanime_completion)";

41
completions/fastanime.zsh Normal file
View File

@@ -0,0 +1,41 @@
#compdef fastanime
_fastanime_completion() {
local -a completions
local -a completions_with_descriptions
local -a response
(( ! $+commands[fastanime] )) && return 1
response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _FASTANIME_COMPLETE=zsh_complete fastanime)}")
for type key descr in ${response}; do
if [[ "$type" == "plain" ]]; then
if [[ "$descr" == "_" ]]; then
completions+=("$key")
else
completions_with_descriptions+=("$key":"$descr")
fi
elif [[ "$type" == "dir" ]]; then
_path_files -/
elif [[ "$type" == "file" ]]; then
_path_files -f
fi
done
if [ -n "$completions_with_descriptions" ]; then
_describe -V unsorted completions_with_descriptions -U
fi
if [ -n "$completions" ]; then
compadd -U -V unsorted -a completions
fi
}
if [[ $zsh_eval_context[-1] == loadautofunc ]]; then
# autoload from fpath, call function directly
_fastanime_completion "$@"
else
# eval/source/. command, register function for later
compdef _fastanime_completion fastanime
fi

2
fa Executable file
View File

@@ -0,0 +1,2 @@
#! /usr/bin/bash
poetry run fastanime $*

View File

@@ -0,0 +1,58 @@
from typing import Iterator
from .libs.anime_provider import anime_sources
from .libs.anime_provider.types import Anime, SearchResults, Server
class AnimeProvider:
"""
Class that manages all anime sources adding some extra functionality to them.
"""
PROVIDERS = list(anime_sources.keys())
provider = PROVIDERS[0]
def __init__(self, provider, dynamic=False, retries=0) -> None:
self.provider = provider
self.dynamic = dynamic
self.retries = retries
self.load_provider_obj()
def load_provider_obj(self):
anime_provider = anime_sources[self.provider]()
self.anime_provider = anime_provider
def search_for_anime(
self, user_query, translation_type: str = "sub", nsfw=True, unknown=True
) -> SearchResults | None:
anime_provider = self.anime_provider
try:
results = anime_provider.search_for_anime(
user_query, translation_type, nsfw, unknown
)
except Exception as e:
print(e)
results = None
return results # pyright:ignore
def get_anime(self, anime_id: str) -> Anime | None:
anime_provider = self.anime_provider
try:
results = anime_provider.get_anime(anime_id)
except Exception as e:
print(e)
results = None
return results
def get_episode_streams(
self, anime, episode: str, translation_type: str
) -> Iterator[Server] | None:
anime_provider = self.anime_provider
try:
results = anime_provider.get_episode_streams(
anime, episode, translation_type
)
except Exception as e:
print(e)
results = None
return results # pyright:ignore

View File

@@ -1,5 +0,0 @@
from .anime_screen import AnimeScreenController
from .downloads_screen import DownloadsScreenController
from .home_screen import HomeScreenController
from .my_list_screen import MyListScreenController
from .search_screen import SearchScreenController

View File

@@ -1,35 +0,0 @@
from kivy.cache import Cache
from ..Model import AnimeScreenModel
from ..View import AnimeScreenView
Cache.register("data.anime", limit=20, timeout=600)
class AnimeScreenController:
"""The controller for the anime screen"""
def __init__(self, model: AnimeScreenModel):
self.model = model
self.view = AnimeScreenView(controller=self, model=self.model)
def get_view(self) -> AnimeScreenView:
return self.view
def fetch_streams(self, anime_title, is_dub=False, episode="1"):
if self.view.is_dub:
is_dub = self.view.is_dub.active
if anime_data := self.model.get_anime_data_from_provider(
anime_title, is_dub
):
self.view.current_anime_data = anime_data
if current_links := self.model.get_episode_streams(episode, is_dub):
self.view.current_links = current_links
# TODO: add auto start
#
# self.view.current_link = self.view.current_links[0]["gogoanime"][0]
def update_anime_view(self, id, title, caller_screen_name):
self.fetch_streams(title)
self.view.current_title = title
self.view.caller_screen_name = caller_screen_name

View File

@@ -1,13 +0,0 @@
from ..Model import DownloadsScreenModel
from ..View import DownloadsScreenView
class DownloadsScreenController:
"""The controller for the download screen"""
def __init__(self, model: DownloadsScreenModel):
self.model = model
self.view = DownloadsScreenView(controller=self, model=self.model)
def get_view(self) -> DownloadsScreenView:
return self.view

View File

@@ -1,143 +0,0 @@
from inspect import isgenerator
from kivy.clock import Clock
from kivy.logger import Logger
from ..Model import HomeScreenModel
from ..Utility import show_notification
from ..View import HomeScreenView
from ..View.components import MediaCardsContainer
# TODO:Move the update home screen to homescreen.py
class HomeScreenController:
"""
The `HomeScreenController` class represents a controller implementation.
Coordinates work of the view with the model.
The controller implements the strategy pattern. The controller connects to
the view to control its actions.
"""
populate_errors = []
_discover_anime_list = []
def __init__(self, model: HomeScreenModel):
self.model = model # Model.main_screen.MainScreenModel
self.view = HomeScreenView(controller=self, model=self.model)
self._discover_anime_list = [
self.highest_scored_anime,
self.popular_anime,
self.favourite_anime,
self.upcoming_anime,
self.recently_updated_anime,
self.trending_anime,
]
self.get_more_anime()
def get_view(self) -> HomeScreenView:
return self.view
def popular_anime(self):
most_popular_cards_container = MediaCardsContainer()
most_popular_cards_container.list_name = "Most Popular"
most_popular_cards_generator = self.model.get_most_popular_anime()
if isgenerator(most_popular_cards_generator):
for card in most_popular_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
most_popular_cards_container.container.data.append(card)
self.view.main_container.add_widget(most_popular_cards_container)
else:
Logger.error("Home Screen:Failed to load most popular anime")
self.populate_errors.append("Most Popular Anime")
def favourite_anime(self):
most_favourite_cards_container = MediaCardsContainer()
most_favourite_cards_container.list_name = "Most Favourites"
most_favourite_cards_generator = self.model.get_most_favourite_anime()
if isgenerator(most_favourite_cards_generator):
for card in most_favourite_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
most_favourite_cards_container.container.data.append(card)
self.view.main_container.add_widget(most_favourite_cards_container)
else:
Logger.error("Home Screen:Failed to load most favourite anime")
self.populate_errors.append("Most favourite Anime")
def trending_anime(self):
trending_cards_container = MediaCardsContainer()
trending_cards_container.list_name = "Trending"
trending_cards_generator = self.model.get_trending_anime()
if isgenerator(trending_cards_generator):
for card in trending_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
trending_cards_container.container.data.append(card)
self.view.main_container.add_widget(trending_cards_container)
else:
Logger.error("Home Screen:Failed to load trending anime")
self.populate_errors.append("trending Anime")
def highest_scored_anime(self):
most_scored_cards_container = MediaCardsContainer()
most_scored_cards_container.list_name = "Most Scored"
most_scored_cards_generator = self.model.get_most_scored_anime()
if isgenerator(most_scored_cards_generator):
for card in most_scored_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
most_scored_cards_container.container.data.append(card)
self.view.main_container.add_widget(most_scored_cards_container)
else:
Logger.error("Home Screen:Failed to load highest scored anime")
self.populate_errors.append("Most scored Anime")
def recently_updated_anime(self):
most_recently_updated_cards_container = MediaCardsContainer()
most_recently_updated_cards_container.list_name = "Most Recently Updated"
most_recently_updated_cards_generator = (
self.model.get_most_recently_updated_anime()
)
if isgenerator(most_recently_updated_cards_generator):
for card in most_recently_updated_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
most_recently_updated_cards_container.container.data.append(card)
self.view.main_container.add_widget(most_recently_updated_cards_container)
else:
Logger.error("Home Screen:Failed to load recently updated anime")
self.populate_errors.append("Most recently updated Anime")
def upcoming_anime(self):
upcoming_cards_container = MediaCardsContainer()
upcoming_cards_container.list_name = "Upcoming Anime"
upcoming_cards_generator = self.model.get_upcoming_anime()
if isgenerator(upcoming_cards_generator):
for card in upcoming_cards_generator:
card["screen"] = self.view
card["viewclass"] = "MediaCard"
upcoming_cards_container.container.data.append(card)
self.view.main_container.add_widget(upcoming_cards_container)
else:
Logger.error("Home Screen:Failed to load upcoming anime")
self.populate_errors.append("upcoming Anime")
def get_more_anime(self):
self.populate_errors = []
if self._discover_anime_list:
task = self._discover_anime_list.pop()
Clock.schedule_once(lambda _: task())
else:
show_notification("Home Screen Info", "No more anime to load")
if self.populate_errors:
show_notification(
"Failed to fetch all home screen data",
f"Theres probably a problem with your internet connection or anilist servers are down.\nFailed include:{', '.join(self.populate_errors)}",
)
self.populate_errors = []

View File

@@ -1,46 +0,0 @@
from inspect import isgenerator
from math import ceil
from kivy.logger import Logger
# from kivy.clock import Clock
from kivy.utils import difference
from ..Model import MyListScreenModel
from ..Utility import user_data_helper
from ..View import MyListScreenView
class MyListScreenController:
"""
The `MyListScreenController` class represents a controller implementation.
Coordinates work of the view with the model.
The controller implements the strategy pattern. The controller connects to
the view to control its actions.
"""
def __init__(self, model: MyListScreenModel):
self.model = model
self.view = MyListScreenView(controller=self, model=self.model)
# if len(self.requested_update_my_list_screen()) > 30:
self.requested_update_my_list_screen()
def get_view(self) -> MyListScreenView:
return self.view
def requested_update_my_list_screen(self):
_user_anime_list = user_data_helper.get_user_anime_list()
if animes_to_add := difference(
_user_anime_list, self.model.already_in_user_anime_list
):
no_of_updates = ceil(len(animes_to_add) / 30)
Logger.info("MyList Screen:Change detected updating screen")
for i in range(no_of_updates):
_animes_to_add = animes_to_add[i * 30 : (i + 1) * 30]
anime_cards = self.model.update_my_anime_list_view(_animes_to_add)
if isgenerator(anime_cards):
for result_card in anime_cards:
result_card["screen"] = self.view
self.view.update_layout(result_card)
self.model.already_in_user_anime_list = _user_anime_list
return animes_to_add

View File

@@ -1,45 +0,0 @@
from inspect import isgenerator
from kivy.clock import Clock
from kivy.logger import Logger
from ..Model import SearchScreenModel
from ..View import SearchScreenView
class SearchScreenController:
"""The search screen controller"""
def __init__(self, model: SearchScreenModel):
self.model = model
self.view = SearchScreenView(controller=self, model=self.model)
def get_view(self) -> SearchScreenView:
return self.view
def update_trending_anime(self):
"""Gets and adds the trending anime to the search screen"""
trending_cards_generator = self.model.get_trending_anime()
if isgenerator(trending_cards_generator):
# self.view.trending_anime_sidebar.data = []
for card in trending_cards_generator:
card["screen"] = self.view
# card["pos_hint"] = {"center_x": 0.5}
self.view.update_trending_sidebar(card)
else:
Logger.error("Home Screen:Failed to load trending anime")
def requested_search_for_anime(self, anime_title, **kwargs):
self.view.is_searching = True
search_Results = self.model.search_for_anime(anime_title, **kwargs)
if isgenerator(search_Results):
for result_card in search_Results:
result_card["screen"] = self.view
self.view.update_layout(result_card)
Clock.schedule_once(
lambda _: self.view.update_pagination(self.model.pagination_info)
)
self.update_trending_anime()
else:
Logger.error(f"Home Screen:Failed to search for {anime_title}")
self.view.is_searching = False

View File

@@ -1,5 +0,0 @@
from .anime_screen import AnimeScreenModel
from .download_screen import DownloadsScreenModel
from .home_screen import HomeScreenModel
from .my_list_screen import MyListScreenModel
from .search_screen import SearchScreenModel

View File

@@ -1,112 +0,0 @@
from fuzzywuzzy import fuzz
from kivy.cache import Cache
from kivy.logger import Logger
from ..libs.anilist import AniList
from ..libs.anime_provider.allanime.api import anime_provider
from .base_model import BaseScreenModel
from ..Utility.data import anime_normalizer
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
print(locals())
# 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()),
)
print(percentage_ratio)
return percentage_ratio
Cache.register("streams.anime", limit=10)
class AnimeScreenModel(BaseScreenModel):
"""the Anime screen model"""
data = {}
anime_id = 0
current_anime_data = None
current_anime_id = "0"
current_title = ""
def get_anime_data_from_provider(self, anime_title: tuple, is_dub, id=None):
try:
if self.current_title == anime_title and self.current_anime_data:
return self.current_anime_data
translation_type = "dub" if is_dub else "sub"
search_results = anime_provider.search_for_anime(
anime_title[0], translation_type
)
if search_results:
_search_results = search_results["shows"]["edges"]
result = max(
_search_results,
key=lambda x: anime_title_percentage_match(x["name"], anime_title),
)
self.current_anime_id = result["_id"]
self.current_anime_data = anime_provider.get_anime(result["_id"])
self.current_title = anime_title
return self.current_anime_data
return {}
except Exception as e:
Logger.info("anime_screen error: %s" % e)
return {}
def get_episode_streams(self, episode, is_dub):
translation_type = "dub" if is_dub else "sub"
try:
if cached_episode := Cache.get(
"streams.anime", f"{self.current_title}{episode}{is_dub}"
):
return cached_episode
if self.current_anime_data:
episode_streams = anime_provider.get_anime_episode(
self.current_anime_id, episode, translation_type
)
streams = anime_provider.get_episode_streams(episode_streams)
if streams:
_streams = list(streams)
streams = []
for stream in _streams:
streams.append(
{
f"{stream[0]}": [
_stream["link"] for _stream in stream[1]["links"]
]
}
)
Cache.append(
"streams.anime",
f"{self.current_title}{episode}{is_dub}",
streams,
)
return streams
return []
except Exception as e:
Logger.info("anime_screen error: %s" % e)
return []
# should return {type:{provider:streamlink}}
def get_anime_data(self, id: int):
return AniList.get_anime(id)

View File

@@ -1,33 +0,0 @@
# The model implements the observer pattern. This means that the class must
# support adding, removing, and alerting observers. In this case, the model is
# completely independent of controllers and views. It is important that all
# registered observers implement a specific method that will be called by the
# model when they are notified (in this case, it is the `model_is_changed`
# method). For this, observers must be descendants of an abstract class,
# inheriting which, the `model_is_changed` method must be overridden.
class BaseScreenModel:
"""Implements a base class for model modules."""
_observers = []
def add_observer(self, observer) -> None:
self._observers.append(observer)
def remove_observer(self, observer) -> None:
self._observers.remove(observer)
def notify_observers(self, name_screen: str) -> None:
"""
Method that will be called by the observer when the model data changes.
:param name_screen:
name of the view for which the method should be called
:meth:`model_is_changed`.
"""
for observer in self._observers:
if observer.name == name_screen:
observer.model_is_changed()
break

View File

@@ -1,21 +0,0 @@
from .base_model import BaseScreenModel
class DownloadsScreenModel(BaseScreenModel):
"""
Handles the download screen logic
"""
def update_download_progress(self, d):
print(
d["filename"],
d["downloaded_bytes"],
d["total_bytes"],
d.get("total_bytes"),
d["elapsed"],
d["eta"],
d["speed"],
d.get("percent"),
)
if d["status"] == "finished":
print("Done downloading, now converting ...")

View File

@@ -1,79 +0,0 @@
from ..libs.anilist import AniList
from ..Utility.media_card_loader import media_card_loader
from .base_model import BaseScreenModel
class HomeScreenModel(BaseScreenModel):
"""The home screen model"""
def get_trending_anime(self):
success, data = AniList.get_trending()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def get_most_favourite_anime(self):
success, data = AniList.get_most_favourite()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def get_most_recently_updated_anime(self):
success, data = AniList.get_most_recently_updated()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def get_most_popular_anime(self):
success, data = AniList.get_most_popular()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def get_most_scored_anime(self):
success, data = AniList.get_most_scored()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def get_upcoming_anime(self):
success, data = AniList.get_upcoming_anime(1)
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data

View File

@@ -1,24 +0,0 @@
from ..libs.anilist import AniList
from ..Utility import media_card_loader, show_notification
from .base_model import BaseScreenModel
class MyListScreenModel(BaseScreenModel):
already_in_user_anime_list = []
def update_my_anime_list_view(self, not_yet_in_user_anime_list: list):
success, self.data = AniList.search(
id_in=not_yet_in_user_anime_list, sort="SCORE_DESC"
)
if success:
return self.media_card_generator()
else:
show_notification(
"Failed to update my list screen view", self.data["Error"]
)
return None
def media_card_generator(self):
for anime_item in self.data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)

View File

@@ -1,33 +0,0 @@
from ..libs.anilist import AniList
from ..Utility import media_card_loader, show_notification
from .base_model import BaseScreenModel
class SearchScreenModel(BaseScreenModel):
data = {}
def get_trending_anime(self):
success, data = AniList.get_trending()
if success:
def _data_generator():
for anime_item in data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
return _data_generator()
else:
return data
def search_for_anime(self, anime_title, **kwargs):
success, self.data = AniList.search(query=anime_title, **kwargs)
if success:
return self.media_card_generator()
else:
show_notification(
f"Failed to search for {anime_title}", self.data.get("Error")
)
def media_card_generator(self):
for anime_item in self.data["data"]["Page"]["media"]:
yield media_card_loader.media_card(anime_item)
self.pagination_info = self.data["data"]["Page"]["pageInfo"]

View File

@@ -1,4 +0,0 @@
from .data import themes_available
from .media_card_loader import media_card_loader
from .show_notification import show_notification
from .utils import write_crash

View File

@@ -30,6 +30,6 @@ def format_list_data_with_comma(data: list | None):
def extract_next_airing_episode(airing_episode: AnilistMediaNextAiringEpisode):
if airing_episode:
return f"Episode: {airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
return f"{airing_episode['episode']} on {format_anilist_timestamp(airing_episode['airingAt'])}"
else:
return "Completed"

View File

@@ -0,0 +1,104 @@
import pathlib
import re
import shlex
import shutil
import sys
from subprocess import PIPE, Popen
import requests
from rich import print
from .. import APP_NAME, AUTHOR, GIT_REPO, __version__
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",
},
)
if request.status_code == 200:
release_json = request.json()
return (release_json["tag_name"] == __version__, release_json)
else:
print(request.text)
return (False, {})
def is_git_repo(author, repository):
# Check if the current directory contains a .git folder
if not pathlib.Path("./.git").exists():
return False
repository_qualname = f"{author}/{repository}"
# Read the .git/config file to find the remote repository URL
config_path = pathlib.Path("./.git/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:
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
def update_app():
is_latest, release_json = check_for_updates()
if is_latest:
print("[green]App is up to date[/]")
return
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")
args = [
executable,
"pull",
]
print(f"Pulling latest changes from the repository via git: {shlex.join(args)}")
if not executable:
return print("[red]Cannot find git.[/]")
process = Popen(
args,
stdout=PIPE,
stderr=PIPE,
)
process.communicate()
else:
executable = sys.executable
args = [
executable,
"-m",
"pip",
"install",
APP_NAME,
"--user",
"--no-warn-script-location",
]
process = Popen(args)
process.communicate()

View File

@@ -5,9 +5,13 @@ Just contains some useful data used across the codebase
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",
}
anilist_sort_normalizer = {"search match": "SEARCH_MATCH"}
themes_available = [
"Aliceblue",
"Antiquewhite",

View File

@@ -1,31 +1,31 @@
from threading import Thread
import logging
from queue import Queue
from threading import Thread
import yt_dlp
from ... import downloads_dir
from ..utils import sanitize_filename
from ..show_notification import show_notification
logger = logging.getLogger(__name__)
class MyLogger:
def debug(self, msg):
print(msg)
pass
def warning(self, msg):
print(msg)
pass
def error(self, msg):
print(msg)
pass
def main_progress_hook(data):
match data["status"]:
case "error":
show_notification(
"Something went wrong while downloading the video", data["filename"]
)
logger.error("sth went wrong")
case "finished":
show_notification("Downloaded", data["filename"])
logger.info("download complete")
# Options for yt-dlp
@@ -40,7 +40,7 @@ class YtDLPDownloader:
try:
task(*args)
except Exception as e:
show_notification("Something went wrong", f"Reason: {e}")
logger.error(f"Something went wrong {e}")
self.downloads_queue.task_done()
def __init__(self):
@@ -49,24 +49,24 @@ class YtDLPDownloader:
self._thread.start()
# Function to download the file
def _download_file(self, url: str, title, custom_progress_hook, silent):
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])
ydl_opts = {
"outtmpl": f"{downloads_dir}/{anime_title}/{anime_title}-episode {title[1]}.%(ext)s", # Specify the output path and template
"outtmpl": f"{download_dir}/{anime_title}/{episode_title}.%(ext)s", # Specify the output path and template
"progress_hooks": [
main_progress_hook,
custom_progress_hook,
], # Progress hook
"silent": silent,
"verbose": False,
"format": vid_format,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
def download_file(self, url: str, title, custom_progress_hook, silent=True):
self.downloads_queue.put(
(self._download_file, (url, title, custom_progress_hook, silent))
)
def download_file(self, url: str, title, silent=True):
self.downloads_queue.put((self._download_file, (url, title, silent)))
downloader = YtDLPDownloader()

View File

@@ -1,39 +0,0 @@
import yt_dlp
class MyLogger:
def debug(self, msg):
print(msg)
def warning(self, msg):
print(msg)
def error(self, msg):
print(msg)
def my_hook(d):
if d["status"] == "finished":
print("Done downloading, now converting ...")
# URL of the HLS stream
url = "https://example.com/path/to/stream.m3u8"
# Options for yt-dlp
ydl_opts = {
"format": "best", # Choose the best quality available
"outtmpl": "/path/to/downloaded/video.%(ext)s", # Specify the output path and template
"logger": MyLogger(), # Custom logger
"progress_hooks": [my_hook], # Progress hook
}
# Function to download the HLS video
def download_hls_video(url, options):
with yt_dlp.YoutubeDL(options) as ydl:
ydl.download([url])
# Call the function
download_hls_video(url, ydl_opts)

View File

@@ -1,54 +0,0 @@
"""
Contains helper functions to make your life easy when adding kivy markup to text
"""
from kivy.utils import get_hex_from_color
def bolden(text: str):
return f"[b]{text}[/b]"
def italicize(text: str):
return f"[i]{text}[/i]"
def underline(text: str):
return f"[u]{text}[/u]"
def strike_through(text: str):
return f"[s]{text}[/s]"
def sub_script(text: str):
return f"[sub]{text}[/sub]"
def super_script(text: str):
return f"[sup]{text}[/sup]"
def color_text(text: str, color: tuple):
hex_color = get_hex_from_color(color)
return f"[color={hex_color}]{text}[/color]"
def font(text: str, font_name: str):
return f"[font={font_name}]{text}[/font]"
def font_family(text: str, family: str):
return f"[font_family={family}]{text}[/font_family]"
def font_context(text: str, context: str):
return f"[font_context={context}]{text}[/font_context]"
def font_size(text: str, size: int):
return f"[size={size}]{text}[/size]"
def text_ref(text: str, ref: str):
return f"[ref={ref}]{text}[/ref]"

View File

@@ -1,136 +0,0 @@
from kivy.cache import Cache
from kivy.logger import Logger
import yt_dlp
from ..libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from ..Utility import anilist_data_helper, user_data_helper
Cache.register("trailer_urls.anime", timeout=360)
class MediaCardDataLoader(object):
"""this class loads an anime media card and gets the trailer url from pytube"""
def media_card(
self,
anime_item: AnilistBaseMediaDataSchema,
):
media_card_data = {}
media_card_data["viewclass"] = "MediaCard"
media_card_data["anime_id"] = anime_id = anime_item["id"]
# TODO: ADD language preference
if anime_item["title"].get("romaji"):
media_card_data["title"] = anime_item["title"]["romaji"]
media_card_data["_title"] = (
anime_item["title"]["romaji"],
str(anime_item["title"]["english"]),
)
else:
media_card_data["title"] = anime_item["title"]["english"]
media_card_data["_title"] = (
anime_item["title"]["english"],
str(anime_item["title"]["romaji"]),
)
media_card_data["cover_image_url"] = anime_item["coverImage"]["medium"]
media_card_data["popularity"] = str(anime_item["popularity"])
media_card_data["favourites"] = str(anime_item["favourites"])
media_card_data["episodes"] = str(anime_item["episodes"])
if anime_item.get("description"):
media_card_data["description"] = anime_item["description"]
else:
media_card_data["description"] = "None"
# TODO: switch to season and year
#
media_card_data["first_aired_on"] = (
f'{anilist_data_helper.format_anilist_date_object(anime_item["startDate"])}'
)
media_card_data["studios"] = anilist_data_helper.format_list_data_with_comma(
[
studio["name"]
for studio in anime_item["studios"]["nodes"]
if studio["isAnimationStudio"]
]
)
media_card_data["producers"] = anilist_data_helper.format_list_data_with_comma(
[
studio["name"]
for studio in anime_item["studios"]["nodes"]
if not studio["isAnimationStudio"]
]
)
media_card_data["next_airing_episode"] = "{}".format(
anilist_data_helper.extract_next_airing_episode(
anime_item["nextAiringEpisode"]
)
)
if anime_item.get("tags"):
media_card_data["tags"] = anilist_data_helper.format_list_data_with_comma(
[tag["name"] for tag in anime_item["tags"]]
)
media_card_data["media_status"] = anime_item["status"]
if anime_item.get("genres"):
media_card_data["genres"] = anilist_data_helper.format_list_data_with_comma(
anime_item["genres"]
)
if anime_id in user_data_helper.get_user_anime_list():
media_card_data["is_in_my_list"] = True
else:
media_card_data["is_in_my_list"] = False
if anime_item["averageScore"]:
stars = int(anime_item["averageScore"] / 100 * 6)
media_card_data["stars"] = [0, 0, 0, 0, 0, 0]
if stars:
for i in range(stars):
media_card_data["stars"][i] = 1
if trailer := anime_item.get("trailer"):
trailer_url = "https://youtube.com/watch?v=" + trailer["id"]
media_card_data["_trailer_url"] = trailer_url
else:
media_card_data["_trailer_url"] = ""
return media_card_data
def _get_stream_link(self, video_url):
ydl_opts = {
"format": "best", # You can specify the format you want here
"quiet": False, # Suppress yt-dlp output
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info_dict = ydl.extract_info(video_url, download=False)
if info_dict:
video_url = info_dict.get("url", "")
else:
return ""
return video_url
def get_trailer_from_pytube(self, trailer_url, anime):
if trailer := Cache.get("trailer_urls.anime", trailer_url):
return trailer
try:
trailer = self._get_stream_link(trailer_url)
Logger.info(f"Pytube Success:For {anime}")
if trailer:
Cache.append("trailer_urls.anime", trailer_url, trailer)
return trailer
except Exception as e:
Logger.error(f"Pytube Failure:For {anime} reason: {e}")
return ""
media_card_loader = MediaCardDataLoader()

View File

@@ -1,16 +0,0 @@
# Of course, "very flexible Python" allows you to do without an abstract
# superclass at all or use the clever exception `NotImplementedError`. In my
# opinion, this can negatively affect the architecture of the application.
# I would like to point out that using Kivy, one could use the on-signaling
# model. In this case, when the state changes, the model will send a signal
# that can be received by all attached observers. This approach seems less
# universal - you may want to use a different library in the future.
class Observer:
"""Abstract superclass for all observers."""
def model_is_changed(self):
"""
The method that will be called on the observer when the model changes.
"""

View File

@@ -1,29 +0,0 @@
from kivy.clock import Clock
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarSupportingText, MDSnackbarText
def show_notification(title, details):
"""helper function to display notifications
Args:
title (str): the title of your message
details (str): the details of your message
"""
def _show(dt):
MDSnackbar(
MDSnackbarText(
text=title,
adaptive_height=True,
),
MDSnackbarSupportingText(
text=details, shorten=False, max_lines=0, adaptive_height=True
),
duration=5,
y="10dp",
pos_hint={"bottom": 1, "right": 0.99},
padding=[0, 0, "8dp", "8dp"],
size_hint_x=0.4,
).open()
Clock.schedule_once(_show, 1)

View File

@@ -1,42 +1,35 @@
"""
Contains Helper functions to read and write the user data files
"""
import json
import logging
import os
from datetime import date, datetime
from kivy.logger import Logger
from ..constants import USER_DATA_PATH
from kivy.storage.jsonstore import JsonStore
from .. import data_folder
today = date.today()
now = datetime.now()
logger = logging.getLogger(__name__)
# TODO:confirm data integrity
if os.path.exists(os.path.join(data_folder, "user_data.json")):
user_data = JsonStore(os.path.join(data_folder, "user_data.json"))
else:
user_data_path = os.path.join(data_folder, "user_data.json")
user_data = JsonStore(user_data_path)
class UserData:
user_data = {"watch_history": {}, "animelist": []}
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_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)
# Get the user data
def get_user_anime_list() -> list:
try:
return user_data.get("user_anime_list")[
"user_anime_list"
] # returns a list of anime ids
except Exception as e:
Logger.warning(f"User Data:Read failure:{e}")
return []
def update_user_anime_list(updated_list: list):
try:
updated_list_ = list(set(updated_list))
user_data.put("user_anime_list", user_anime_list=updated_list_)
except Exception as e:
Logger.warning(f"User Data:Update failure:{e}")
user_data_helper = UserData()

View File

@@ -1,11 +1,27 @@
import logging
import os
import re
import shutil
from datetime import datetime
import re
from functools import lru_cache
from thefuzz import fuzz
from fastanime.libs.anilist.anilist_data_schema import AnilistBaseMediaDataSchema
from .data import anime_normalizer
logger = logging.getLogger(__name__)
# TODO: make it use color_text instead of fixed vals
# from .kivy_markup_helper import color_text
@lru_cache()
def remove_html_tags(text: str):
clean = re.compile("<.*?>")
return re.sub(clean, "", text)
# utility functions
def write_crash(e: Exception):
index = datetime.today()
@@ -36,6 +52,7 @@ def move_file(source_path, dest_path):
return (0, e)
@lru_cache()
def sanitize_filename(filename: str):
"""
Sanitize a string to be safe for use as a file name.
@@ -71,7 +88,7 @@ def sanitize_filename(filename: str):
}
# Replace invalid characters with an underscore
sanitized = re.sub(invalid_chars, "_", filename)
sanitized = re.sub(invalid_chars, " ", filename)
# Remove leading and trailing whitespace
sanitized = sanitized.strip()
@@ -89,7 +106,35 @@ def sanitize_filename(filename: str):
return sanitized
# Example usage
unsafe_filename = "CON:example?file*name.txt"
safe_filename = sanitize_filename(unsafe_filename)
print(safe_filename) # Output: 'CON_example_file_name.txt'
def anime_title_percentage_match(
possible_user_requested_anime_title: str, anime: AnilistBaseMediaDataSchema
) -> 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
# 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_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'

View File

@@ -1,110 +0,0 @@
<AnimeBoxLayout@MDBoxLayout>:
adaptive_height:True
orientation:'vertical'
<AnimeLabel@MDLabel>:
adaptive_height:True
bold:True
<EpisodeButton>:
pos_hint:{"center_y":0.5,"center_x":0.5}
on_press:root.change_episode_callback(root.text)
radius: 10
MDButtonText:
text:root.text
<AnimeScreenView>:
md_bg_color: self.theme_cls.backgroundColor
episodes_container:episodes_container
video_player:video_player
is_dub:is_dub
MDBoxLayout:
padding:"10dp"
orientation: 'vertical'
MDBoxLayout:
adaptive_height:True
MDIconButton:
icon:"arrow-left"
on_press:root.manager_screens.current = root.caller_screen_name
MDBoxLayout:
VideoPlayer:
id:video_player
source:root.current_link
AnimeBoxLayout:
padding: "20dp"
radius:5
spacing:"10dp"
md_bg_color: self.theme_cls.surfaceContainerLowColor
AnimeBoxLayout:
orientation:'horizontal'
MDIconButton:
icon:"skip-previous"
on_press:root.previous_episode()
MDIconButton:
icon:"skip-next"
on_press: root.next_episode()
MDIconButton:
icon:"download"
on_press:
if root.current_link: app.download_anime_video(root.current_link,(root.current_title[0],root.current_episode))
MDButton:
on_press:
if root.current_link: app.play_on_mpv(root.current_link)
MDButtonText:
text:"Play on MPV"
AnimeLabel:
text:"Dub: "
padding: "10dp"
adaptive_width:True
MDSwitch:
id:is_dub
AnimeBoxLayout:
AnimeLabel:
text:"servers: "
MDSegmentedButton:
id:pl
multiselect:False
MDSegmentedButtonItem:
on_active:
pl.selected_segments = [self]
root.update_current_video_stream("gogoanime")
MDSegmentButtonLabel:
text:"GoGoAnime"
MDSegmentedButtonItem:
id:dropbox
on_active:
root.update_current_video_stream("dropbox")
pl.selected_segments = [self]
MDSegmentButtonLabel:
text:"DropBox"
MDSegmentedButtonItem:
on_active:
root.update_current_video_stream("sharepoint")
pl.selected_segments = [self]
MDSegmentButtonLabel:
text:"Share Point"
MDSegmentedButtonItem:
on_active:
root.update_current_video_stream("wetransfer")
pl.selected_segments = [self]
MDSegmentButtonLabel:
text:"weTransfer"
MDDivider:
MDRecycleView:
id: episodes_container
size_hint_y:None
height:"50dp"
key_viewclass:"viewclass"
RecycleBoxLayout:
size_hint: None,1
key_size:"width"
spacing:"10dp"
width:self.minimum_width
default_size_hint:0,0
default_size:30,30
default_pos_hint:{"center_y":0.5,"center_x":0.5}

View File

@@ -1,81 +0,0 @@
from kivy.properties import ListProperty, ObjectProperty, StringProperty
from kivy.uix.widget import Factory
from kivymd.uix.button import MDButton
from ...View.base_screen import BaseScreenView
class EpisodeButton(MDButton):
text = StringProperty()
change_episode_callback = ObjectProperty()
Factory.register("EpisodeButton", cls=EpisodeButton)
class AnimeScreenView(BaseScreenView):
"""The anime screen view"""
current_link = StringProperty()
current_links = ListProperty([])
current_anime_data = ObjectProperty()
caller_screen_name = ObjectProperty()
current_title = ()
episodes_container = ObjectProperty()
total_episodes = 0
current_episode = 1
video_player = ObjectProperty()
current_server = "dropbox"
is_dub = ObjectProperty()
def __init__(self, **kwargs):
super().__init__(**kwargs)
# self.update_episodes(100)
def update_episodes(self, episodes_list):
self.episodes_container.data = []
self.total_episodes = len(episodes_list)
for episode in episodes_list:
self.episodes_container.data.append(
{
"viewclass": "EpisodeButton",
"text": str(episode),
"change_episode_callback": lambda x=episode: self.update_current_episode(
x
),
}
)
def next_episode(self):
next_episode = self.current_episode + 1
if next_episode <= self.total_episodes:
self.update_current_episode(str(next_episode))
def previous_episode(self):
previous_episode = self.current_episode - 1
if previous_episode > 0:
self.update_current_episode(str(previous_episode))
def on_current_anime_data(self, instance, value):
data = value["show"]
self.update_episodes(data["availableEpisodesDetail"]["sub"][::-1])
self.current_episode = int("1")
self.update_current_video_stream(self.current_server)
self.video_player.state = "play"
def update_current_episode(self, episode):
self.current_episode = int(episode)
self.controller.fetch_streams(self.current_title, self.is_dub.active, episode)
self.update_current_video_stream(self.current_server)
self.video_player.state = "play"
def update_current_video_stream(self, server, is_dub=False):
for link in self.current_links:
if stream_link := link.get(server):
self.current_server = server
self.current_link = stream_link[0]
break
def add_to_user_anime_list(self, *args):
self.app.add_anime_to_user_anime_list(self.model.anime_id)

View File

@@ -1,23 +0,0 @@
#:import color_text fastanime.Utility.kivy_markup_helper.color_text
<TaskText@MDLabel>:
adaptive_height:True
max_lines:0
shorten:False
markup:True
font_style: "Label"
role: "large"
bold:True
<TaskCard>:
adaptive_height:True
radius:8
padding:"20dp"
md_bg_color:self.theme_cls.surfaceContainerHighColor
TaskText:
size_hint_x:.8
text:"{} Episode: {}".format(root.file[0],root.file[1])

View File

@@ -1,13 +0,0 @@
from kivy.properties import StringProperty, ListProperty
from kivymd.uix.boxlayout import MDBoxLayout
class TaskCard(MDBoxLayout):
file = ListProperty(("", ""))
eta = StringProperty()
def __init__(self, file: str, *args, **kwargs):
super().__init__(*args, **kwargs)
self.file = file
# self.eta = eta
#

View File

@@ -1,58 +0,0 @@
#:import get_color_from_hex kivy.utils.get_color_from_hex
#:import StringProperty kivy.properties.StringProperty
<DownloadsScreenLabel@MDLabel>:
adaptive_height:True
max_lines:0
shorten:False
markup:True
font_style: "Label"
role: "small"
bold:True
<DownloadsScreenView>
md_bg_color: self.theme_cls.backgroundColor
main_container:main_container
download_progress_label:download_progress_label
progress_bar:progress_bar
MDBoxLayout:
NavRail:
screen:root
MDAnchorLayout:
anchor_y: 'top'
MDBoxLayout:
orientation: 'vertical'
SearchBar:
MDScrollView:
size_hint:.95,1
MDBoxLayout:
id:main_container
orientation:"vertical"
padding:"40dp"
pos_hint:{"center_x":.5}
spacing:"10dp"
adaptive_height:True
HeaderLabel:
text:"Download Tasks"
halign:"left"
MDIcon:
padding:"10dp"
pos_hint:{"center_y":.5}
icon:"clock"
MDBoxLayout:
size_hint_y:None
height:"40dp"
spacing:"10dp"
padding:"10dp"
md_bg_color:self.theme_cls.secondaryContainerColor
DownloadsScreenLabel:
id:download_progress_label
size_hint_x: .8
text:"Try Downloading sth :)"
pos_hint: {'center_y': .5}
MDLinearProgressIndicator:
id: progress_bar
size_hint_x: .2
size_hint_y:None
height:"10dp"
type: "determinate"
pos_hint: {'center_y': .5}

View File

@@ -1,41 +0,0 @@
from kivy.clock import Clock
from kivy.properties import ObjectProperty
from kivy.utils import format_bytes_to_human
from ...View.base_screen import BaseScreenView
from .components.task_card import TaskCard
class DownloadsScreenView(BaseScreenView):
main_container = ObjectProperty()
progress_bar = ObjectProperty()
download_progress_label = ObjectProperty()
def new_download_task(self, filename):
Clock.schedule_once(
lambda _: self.main_container.add_widget(TaskCard(filename))
)
def on_episode_download_progress(self, data):
percentage_completion = round(
(data.get("downloaded_bytes", 0) / data.get("total_bytes", 0)) * 100
)
speed = format_bytes_to_human(data.get("speed", 0)) if data.get("speed") else 0
progress_text = f"Downloading: {data.get('filename', 'unknown')} ({format_bytes_to_human(data.get('downloaded_bytes',0)) if data.get('downloaded_bytes') else 0}/{format_bytes_to_human(data.get('total_bytes',0)) if data.get('total_bytes') else 0})\n Elapsed: {round(data.get('elapsed',0)) if data.get('elapsed') else 0}s ETA: {data.get('eta',0) if data.get('eta') else 0}s Speed: {speed}/s"
self.progress_bar.value = max(min(percentage_completion, 100), 0)
self.download_progress_label.text = progress_text
def update_layout(self, widget):
self.user_anime_list_container.add_widget(widget)
#
# d["filename"],
# d["downloaded_bytes"],
# d["total_bytes"],
# d.get("total_bytes"),
# d["elapsed"],
# d["eta"],
# d["speed"],
# d.get("percent"),
# )

View File

@@ -1,29 +0,0 @@
<HomeScreenView>
md_bg_color: self.theme_cls.backgroundColor
main_container:main_container
MDBoxLayout:
NavRail:
screen:root
MDAnchorLayout:
anchor_y: 'top'
padding:"10dp"
MDBoxLayout:
orientation: 'vertical'
id:p
SearchBar:
MDScrollView:
size_hint:1,1
MDBoxLayout:
id:main_container
padding:"50dp","5dp","50dp","150dp"
spacing:"10dp"
orientation: 'vertical'
size_hint_y:None
height:self.minimum_height
MDButton:
on_press:
if root.controller: root.controller.get_more_anime()
MDButtonText:
text:"Load More Anime"

View File

@@ -1,7 +0,0 @@
from kivy.properties import ObjectProperty
from ...View.base_screen import BaseScreenView
class HomeScreenView(BaseScreenView):
main_container = ObjectProperty()

View File

@@ -1,32 +0,0 @@
<MyListScreenView>
md_bg_color: self.theme_cls.backgroundColor
user_anime_list_container:user_anime_list_container
MDBoxLayout:
size_hint:1,1
NavRail:
screen:root
MDAnchorLayout:
anchor_y: 'top'
padding:"10dp"
size_hint:1,1
MDBoxLayout:
spacing:"40dp"
orientation: 'vertical'
size_hint:.95,1
SearchBar:
MDRecycleView:
pos_hint:{"center_x":.5}
size_hint:1,1
id:user_anime_list_container
key_viewclass:"viewclass"
MDRecycleGridLayout:
pos_hint: {'center_x': 0.5}
spacing: '40dp'
padding: "25dp","50dp","75dp","200dp"
default_size_hint:None,None
default_size:dp(100),dp(150)
cols:3 if root.width <= 1100 else 5
size_hint_y:None
height:max(self.parent.parent.height,self.minimum_height)

View File

@@ -1,21 +0,0 @@
from kivy.clock import Clock
from kivy.properties import ObjectProperty
from ...View.base_screen import BaseScreenView
class MyListScreenView(BaseScreenView):
user_anime_list_container = ObjectProperty()
def model_is_changed(self) -> None:
"""
Called whenever any change has occurred in the data model.
The view in this method tracks these changes and updates the UI
according to these changes.
"""
def on_enter(self):
Clock.schedule_once(lambda _: self.controller.requested_update_my_list_screen())
def update_layout(self, widget):
self.user_anime_list_container.data.append(widget)

View File

@@ -1,3 +0,0 @@
from .filters import Filters
from .pagination import SearchResultsPagination
from .trending_sidebar import TrendingAnimeSideBar

View File

@@ -1,27 +0,0 @@
<FilterDropDown>:
MDDropDownItemText:
text: root.text
<FilterLabel@MDLabel>:
adaptive_width:True
<Filters>:
adaptive_height:True
spacing:"10dp"
size_hint_x:.95
pos_hint:{"center_x":.5}
padding:"10dp"
md_bg_color:self.theme_cls.surfaceContainerLowColor
FilterLabel:
text:"Sort By"
FilterDropDown:
id:sort_filter
text:root.filters["sort"]
on_release: root.open_filter_menu(self,"sort")
FilterLabel:
text:"Status"
FilterDropDown:
id:status_filter
text:root.filters["status"]
on_release: root.open_filter_menu(self,"status")

View File

@@ -1,82 +0,0 @@
from kivy.properties import DictProperty, StringProperty
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.dropdownitem import MDDropDownItem
from kivymd.uix.menu import MDDropdownMenu
class FilterDropDown(MDDropDownItem):
text: str = StringProperty()
class Filters(MDBoxLayout):
filters: dict = DictProperty({"sort": "SEARCH_MATCH", "status": "FINISHED"})
def open_filter_menu(self, menu_item, filter_name):
items = []
match filter_name:
case "sort":
items = [
"ID",
"ID_DESC",
"TITLE_ROMANJI",
"TITLE_ROMANJI_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",
"TRENDING",
"TRENDING_DESC",
"EPISODES",
"EPISODES_DESC",
"DURATION",
"DURATION_DESC",
"STATUS",
"STATUS_DESC",
"UPDATED_AT",
"UPDATED_AT_DESC",
"SEARCH_MATCH",
"POPULARITY",
"POPULARITY_DESC",
"FAVOURITES",
"FAVOURITES_DESC",
]
case "status":
items = [
"FINISHED",
"RELEASING",
"NOT_YET_RELEASED",
"CANCELLED",
"HIATUS",
]
case _:
items = []
if items:
menu_items = [
{
"text": f"{item}",
"on_release": lambda filter_value=f"{item}": self.filter_menu_callback(
filter_name, filter_value
),
}
for item in items
]
MDDropdownMenu(caller=menu_item, items=menu_items).open()
def filter_menu_callback(self, filter_name, filter_value):
match filter_name:
case "sort":
self.ids.sort_filter.text = filter_value
self.filters["sort"] = filter_value
case "status":
self.ids.status_filter.text = filter_value
self.filters["status"] = filter_value

View File

@@ -1,21 +0,0 @@
<PaginationLabel@MDLabel>:
max_lines:0
shorten:False
adaptive_height:True
font_style: "Label"
pos_hint:{"center_y":.5}
halign:"center"
role: "medium"
<SearchResultsPagination>:
md_bg_color:self.theme_cls.surfaceContainerLowColor
radius:8
adaptive_height:True
MDIconButton:
icon:"arrow-left"
on_release:root.search_view.previous_page()
PaginationLabel:
text:"Page {} of {}".format(root.current_page,root.total_pages)
MDIconButton:
icon:"arrow-right"
on_release:root.search_view.next_page()

View File

@@ -1,8 +0,0 @@
from kivy.properties import NumericProperty, ObjectProperty
from kivymd.uix.boxlayout import MDBoxLayout
class SearchResultsPagination(MDBoxLayout):
current_page = NumericProperty()
total_pages = NumericProperty()
search_view = ObjectProperty()

View File

@@ -1,23 +0,0 @@
<TrendingAnimeSideBar>:
md_bg_color:self.theme_cls.surfaceContainerLowColor
padding:"25dp","25dp","25dp","200dp"
pos_hint: {'center_x': 0.5}
key_viewclass:"viewclass"
size_hint: None,1
width:"250dp"
pos_hint: {'center_x':.5}
RecycleBoxLayout:
orientation: 'vertical'
key_size:"height"
key_viewclass:"viewclass"
pos_hint: {'center_x': 0.8}
size_hint:None,None
default_size_hint:None, None
default_pos_hint:{"center_x":0.8}
default_size:dp(150),dp(100)
width:"250dp"
spacing:"10dp"
height:max(self.minimum_height,500)
#padding:"0dp","10dp","100dp","10dp"
# height:max(self.parent.parent.height,self.minimum_height+100)

View File

@@ -1,5 +0,0 @@
from kivymd.uix.recycleview import MDRecycleView
class TrendingAnimeSideBar(MDRecycleView):
pass

View File

@@ -1,76 +0,0 @@
<SearchScreenView>
md_bg_color: self.theme_cls.backgroundColor
search_results_container:search_results_container
trending_anime_sidebar:trending_anime_sidebar
search_results_pagination:search_results_pagination
filters:filters
MDBoxLayout:
size_hint:1,1
NavRail:
screen:root
MDAnchorLayout:
anchor_y: 'top'
padding:"10dp"
size_hint:1,1
MDBoxLayout:
orientation: 'vertical'
size_hint:1,1
SearchBar:
MDBoxLayout:
spacing:"20dp"
padding:"75dp","10dp","100dp","0dp"
MDBoxLayout:
orientation: 'vertical'
size_hint:1,1
Filters:
id:filters
MDBoxLayout:
spacing:"20dp"
MDScrollView:
size_hint:1,1
MDBoxLayout:
orientation: 'vertical'
size_hint_y:None
height:max(self.parent.parent.height,self.minimum_height)
MDRecycleView:
id:search_results_container
key_viewclass:"viewclass"
MDRecycleGridLayout:
pos_hint: {'center_x': 0.5}
spacing: '40dp'
padding: "25dp","50dp","75dp","200dp"
default_size_hint:None,None
default_size:dp(100),dp(150)
cols:3 if root.width <= 1100 else 5
size_hint_y:None
height:max(self.parent.parent.height,self.minimum_height)
SearchResultsPagination:
id:search_results_pagination
search_view:root
MDBoxLayout:
orientation:"vertical"
size_hint_y:1
size_hint_x:None
width: dp(250)
HeaderLabel:
text:"Trending"
halign:"center"
#TrendingAnimeSideBar:
# id:trending_anime_sidebar
MDRecycleView:
id:trending_anime_sidebar
key_viewclass:"viewclass"
MDRecycleGridLayout:
md_bg_color:self.theme_cls.surfaceContainerLowColor
pos_hint: {'center_x': 0.5}
spacing: '40dp'
padding: "75dp","25dp","25dp","200dp"
default_size_hint:None,None
default_pos_hint:{"center_x":0.5,"center_y":0.5}
default_size:dp(100),dp(150)
cols:1
size_hint_y:None
height:max(self.parent.parent.height,self.minimum_height)

View File

@@ -1,68 +0,0 @@
from kivy.clock import Clock
from kivy.properties import ObjectProperty, StringProperty
from ...View.base_screen import BaseScreenView
from .components import Filters, SearchResultsPagination, TrendingAnimeSideBar
class SearchScreenView(BaseScreenView):
trending_anime_sidebar: TrendingAnimeSideBar = ObjectProperty()
search_results_pagination: SearchResultsPagination = ObjectProperty()
filters: Filters = ObjectProperty()
search_results_container = ObjectProperty()
search_term: str = StringProperty()
is_searching = False
has_next_page = False
current_page = 0
total_pages = 0
def handle_search_for_anime(self, search_widget=None, page=None):
if search_widget:
search_term = search_widget.text
elif page:
search_term = self.search_term
else:
return
if search_term and not (self.is_searching):
self.search_term = search_term
self.search_results_container.data = []
if filters := self.filters.filters:
Clock.schedule_once(
lambda _: self.controller.requested_search_for_anime(
search_term, **filters, page=page
)
)
else:
Clock.schedule_once(
lambda _: self.controller.requested_search_for_anime(
search_term, page=page
)
)
def update_layout(self, widget):
self.search_results_container.data.append(widget)
def update_pagination(self, pagination_info):
self.search_results_pagination.current_page = self.current_page = (
pagination_info["currentPage"]
)
self.search_results_pagination.total_pages = self.total_pages = max(
int(pagination_info["total"] / 30), 1
)
self.has_next_page = pagination_info["hasNextPage"]
def next_page(self):
if self.has_next_page:
page = self.current_page + 1
self.handle_search_for_anime(page=page)
def previous_page(self):
if self.current_page > 1:
page = self.current_page - 1
self.handle_search_for_anime(page=page)
def update_trending_sidebar(self, trending_anime):
self.trending_anime_sidebar.data.append(trending_anime)

View File

@@ -1,6 +0,0 @@
# screens
from .AnimeScreen.anime_screen import AnimeScreenView
from .DownloadsScreen.download_screen import DownloadsScreenView
from .HomeScreen.home_screen import HomeScreenView
from .MylistScreen.my_list_screen import MyListScreenView
from .SearchScreen.search_screen import SearchScreenView

View File

@@ -1,76 +0,0 @@
from kivy.properties import ObjectProperty, StringProperty
from kivymd.app import MDApp
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.button import MDIconButton
from kivymd.uix.navigationrail import MDNavigationRail, MDNavigationRailItem
from kivymd.uix.screen import MDScreen
from kivymd.uix.tooltip import MDTooltip
from ..Utility.observer import Observer
class NavRail(MDNavigationRail):
screen = ObjectProperty()
class SearchBar(MDBoxLayout):
screen = ObjectProperty()
class Tooltip(MDTooltip):
pass
class TooltipMDIconButton(Tooltip, MDIconButton):
tooltip_text = StringProperty()
class CommonNavigationRailItem(MDNavigationRailItem):
icon = StringProperty()
text = StringProperty()
class HeaderLabel(MDBoxLayout):
text = StringProperty()
halign = StringProperty("left")
class BaseScreenView(MDScreen, Observer):
"""
A base class that implements a visual representation of the model data.
The view class must be inherited from this class.
"""
controller = ObjectProperty()
"""
Controller object - :class:`~Controller.controller_screen.ClassScreenControler`.
:attr:`controller` is an :class:`~kivy.properties.ObjectProperty`
and defaults to `None`.
"""
model = ObjectProperty()
"""
Model object - :class:`~Model.model_screen.ClassScreenModel`.
:attr:`model` is an :class:`~kivy.properties.ObjectProperty`
and defaults to `None`.
"""
manager_screens = ObjectProperty()
"""
Screen manager object - :class:`~kivymd.uix.screenmanager.MDScreenManager`.
:attr:`manager_screens` is an :class:`~kivy.properties.ObjectProperty`
and defaults to `None`.
"""
def __init__(self, **kw):
super().__init__(**kw)
# Often you need to get access to the application object from the view
# class. You can do this using this attribute.
from ..__main__ import FastAnime
self.app: FastAnime = MDApp.get_running_app() # type: ignore
# Adding a view class as observer.
self.model.add_observer(self)

View File

@@ -1 +0,0 @@
from .media_card import MediaCard,MediaCardsContainer

View File

@@ -1,5 +0,0 @@
from kivy.uix.modalview import ModalView
class AnimdlDialogPopup(ModalView):
pass

View File

@@ -1,19 +0,0 @@
<MDLabel>:
allow_copy:True
allow_selection:True
<HeaderLabel>:
adaptive_height:True
md_bg_color:self.theme_cls.secondaryContainerColor
MDLabel:
text:root.text
adaptive_height:True
halign:root.halign
max_lines:0
shorten:False
bold:True
font_style: "Label"
role: "large"
padding:"10dp"

View File

@@ -1 +0,0 @@
from .media_card import MediaCard,MediaCardsContainer

View File

@@ -1,2 +0,0 @@
from .media_player import MediaPopupVideoPlayer
from .media_popup import MediaPopup

View File

@@ -1,22 +0,0 @@
<MediaCardsContainer>
size_hint:1,None
height: dp(250)
container:container
orientation: 'vertical'
padding:"10dp"
spacing:"5dp"
MDLabel:
bold:True
adaptive_height:True
text:root.list_name
MDRecycleView:
id:container
key_viewclass:"viewclass"
key_size:"width"
RecycleBoxLayout:
size_hint:None,1
width:self.minimum_width
default_size_hint:None, None
default_size:dp(150),dp(100)
spacing:"10dp"
padding:"0dp","10dp","100dp","10dp"

View File

@@ -1,13 +0,0 @@
from kivy.uix.videoplayer import VideoPlayer
class MediaPopupVideoPlayer(VideoPlayer):
def __init__(self, **kwargs):
super().__init__(**kwargs)
# FIXME: find way to make fullscreen stable
#
self.allow_fullscreen = False
def on_fullscreen(self, instance, value):
super().on_fullscreen(instance, value)
# self.state = "pause"

View File

@@ -1,174 +0,0 @@
#:import get_hex_from_color kivy.utils.get_hex_from_color
#:set yellow [.9,.9,0,.9]
<SingleLineLabel@MDLabel>:
shorten:True
shorten_from:"right"
adaptive_height:True
<PopupBoxLayout@MDBoxLayout>
adaptive_height:True
<Video>:
fit_mode:"fill"
# TODO: subdivide each main component to its own file
<MediaPopup>
size_hint: None, None
height: dp(530)
width: dp(400)
radius:[5,5,5,5]
md_bg_color:self.theme_cls.backgroundColor
anchor_y: 'top'
player:player
MDBoxLayout:
orientation: 'vertical'
MDRelativeLayout:
size_hint_y: None
height: dp(280)
line_color:root.caller.has_trailer_color
line_width:1
# TODO: Remove the test source
MediaPopupVideoPlayer:
id:player
source: root.caller.trailer_url #if root.caller.trailer_url else 'https://www088.vipanicdn.net/streamhls/abae70787c7bd2fcd4fab986c2a5aeba/ep.7.1703900604.m3u8'
thumbnail:app.default_anime_image
#state:"play" if root.caller.trailer_url else "stop"
on_state:
root.caller._get_trailer()
# fit_mode:"fill"
size_hint_y: None
height: dp(280)
PopupBoxLayout:
padding: "10dp","5dp"
spacing:"5dp"
pos_hint: {'left': 1,'top': 1}
MDIcon:
icon: "star"
color:yellow
disabled: not(root.caller.stars[0])
MDIcon:
color:yellow
disabled: not(root.caller.stars[1])
icon: "star"
MDIcon:
color:yellow
disabled: not(root.caller.stars[2])
icon: "star"
MDIcon:
color:yellow
disabled: not(root.caller.stars[3])
icon: "star"
MDIcon:
color:yellow
icon: "star"
disabled: not(root.caller.stars[4])
MDIcon:
color: yellow
icon: "star"
disabled: not(root.caller.stars[5])
MDLabel:
text: f"{root.caller.episodes} Episodes"
halign:"right"
font_style:"Label"
role:"medium"
bold:True
pos_hint: {'center_y': 0.5}
adaptive_height:True
color: 0,0,0,.7
PopupBoxLayout:
padding:"5dp"
pos_hint: {'bottom': 1}
SingleLineLabel:
text:root.caller.media_status
opacity:.8
halign:"left"
font_style:"Label"
role:"medium"
bold:True
pos_hint: {'center_y': .5}
SingleLineLabel:
text:root.caller.first_aired_on
opacity:.8
halign:"right"
font_style:"Label"
role:"medium"
bold:True
pos_hint: {'center_y': .5}
# header
MDBoxLayout:
orientation: 'vertical'
padding:"10dp"
spacing:"10dp"
PopupBoxLayout:
PopupBoxLayout:
pos_hint: {'center_y': 0.5}
TooltipMDIconButton:
tooltip_text:"Play"
icon: "play-circle"
on_press:
root.dismiss()
app.show_anime_screen(root.caller.anime_id,root.caller._title,root.caller.screen.name)
TooltipMDIconButton:
tooltip_text:"Add to your anime list"
icon: "plus-circle" if not(root.caller.is_in_my_list) else "check-circle"
on_release:
root.caller.is_in_my_list = not(root.caller.is_in_my_list)
self.icon = "plus-circle" if not(root.caller.is_in_my_list) else "check-circle"
TooltipMDIconButton:
disabled:True
tooltip_text:"Coming soon"
icon: "bell-circle" if not(root.caller.is_in_my_notify) else "bell-check"
PopupBoxLayout:
pos_hint: {'center_y': 0.5}
orientation: 'vertical'
SingleLineLabel:
font_style:"Label"
role:"small"
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Genres: "+"[/color]"+root.caller.genres
markup:True
PopupBoxLayout:
SingleLineLabel:
font_style:"Label"
role:"small"
markup:True
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Popularity: "+"[/color]"+root.caller.popularity
SingleLineLabel:
font_style:"Label"
markup:True
role:"small"
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Favourites: "+"[/color]"+root.caller.favourites
MDScrollView:
size_hint:1,1
do_scroll_y:True
MDLabel:
font_style:"Body"
role:"small"
text:root.caller.description
adaptive_height:True
# footer
PopupBoxLayout:
orientation:"vertical"
SingleLineLabel:
font_style:"Label"
markup:True
role:"small"
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Next Airing Episode: "+"[/color]"+root.caller.next_airing_episode
SingleLineLabel:
font_style:"Label"
role:"small"
markup:True
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Studios: " + "[/color]"+root.caller.studios
SingleLineLabel:
font_style:"Label"
markup:True
role:"small"
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Producers: " + "[/color]"+root.caller.producers
SingleLineLabel:
font_style:"Label"
markup:True
role:"small"
text: f"[color={get_hex_from_color(self.theme_cls.primaryColor)}]"+"Tags: "+"[/color]"+root.caller.tags

View File

@@ -1,124 +0,0 @@
from kivy.animation import Animation
from kivy.clock import Clock
from kivy.properties import ObjectProperty
from kivy.uix.modalview import ModalView
from kivy.utils import QueryDict
from kivymd.theming import ThemableBehavior
from kivymd.uix.behaviors import (
BackgroundColorBehavior,
CommonElevationBehavior,
HoverBehavior,
StencilBehavior,
)
class MediaPopup(
ThemableBehavior,
HoverBehavior,
StencilBehavior,
CommonElevationBehavior,
BackgroundColorBehavior,
ModalView,
):
caller = ObjectProperty(
QueryDict(
{
"anime_id": "",
"title": "",
"is_play": "",
"trailer_url": "",
"episodes": "",
"favourites": "",
"popularity": "",
"media_status": "",
"is_in_my_list": False,
"is_in_my_notify": False,
"genres": "",
"first_aired_on": "",
"description": "",
"tags": "",
"studios": "",
"next_airing_episode": "",
"producers": "",
"stars": [0, 0, 0, 0, 0, 0],
"cover_image_url": "",
"preview_image": "",
"has_trailer_color": [0, 0, 0, 0],
}
)
)
player = ObjectProperty()
def __init__(self, *args, **kwarg):
# self.caller = caller
super(MediaPopup, self).__init__(*args, **kwarg)
self.player.bind(fullscreen=self.handle_clean_fullscreen_transition)
def update_caller(self, caller):
self.caller = caller
def on_caller(self, *args):
self.apply_class_lang_rules()
def open(self, *_args, **kwargs):
"""Display the modal in the Window.
When the view is opened, it will be faded in with an animation. If you
don't want the animation, use::
view.open(animation=False)
"""
if self.caller:
from kivy.core.window import Window
if self._is_open:
return
self._window = Window
self._is_open = True
self.dispatch("on_pre_open")
Window.add_widget(self)
Window.bind(on_resize=self._align_center, on_keyboard=self._handle_keyboard)
self.center = self.caller.to_window(*self.caller.center)
self.fbind("center", self._align_center)
self.fbind("size", self._align_center)
if kwargs.get("animation", True):
ani = Animation(_anim_alpha=1.0, d=self._anim_duration)
ani.bind(on_complete=lambda *_args: self.dispatch("on_open"))
ani.start(self)
else:
self._anim_alpha = 1.0
self.dispatch("on_open")
else:
super().open(*_args, **kwargs)
def _align_center(self, *_args):
if self.caller:
if self._is_open:
self.center = self.caller.to_window(*self.caller.center)
else:
super()._align_center(*_args)
def on_leave(self, *args):
def _leave(dt):
self.player.state = "stop"
# if self.player._video:
# self.player._video.unload()
if not self.hovering:
self.dismiss()
Clock.schedule_once(_leave, 2)
def handle_clean_fullscreen_transition(self, instance, fullscreen):
if not fullscreen:
if not self._is_open:
instance.state = "stop"
if vid := instance._video:
vid.unload()
else:
instance.state = "stop"
if vid := instance._video:
vid.unload()
self.dismiss()

View File

@@ -1,3 +0,0 @@
<Tooltip>
MDTooltipPlain:
text:root.tooltip_text

View File

@@ -1,27 +0,0 @@
<MediaCard>
adaptive_height:True
spacing:"5dp"
size_hint_x: None
width:dp(100)
height: dp(150)
FitImage:
source:root.cover_image_url
fit_mode:"fill"
size_hint: None, None
width: dp(100)
height: dp(150)
MDDivider:
color:root.theme_cls.primaryColor if root._trailer_url else [0.5, 0.5, 0.5, 0.5]
size_hint: None, 1
width: dp(100)
SingleLineLabel:
font_style:"Label"
role:"medium"
text:root.title
max_lines:2
halign:"center"
color:self.theme_cls.secondaryColor
size_hint_x: None
shorten:True
width: dp(100)

View File

@@ -1,153 +0,0 @@
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import (
BooleanProperty,
ListProperty,
NumericProperty,
ObjectProperty,
StringProperty,
)
from kivymd.app import MDApp
from kivymd.uix.behaviors import HoverBehavior
from kivymd.uix.boxlayout import MDBoxLayout
from .components.media_popup import MediaPopup
class MediaCard(HoverBehavior, MDBoxLayout):
screen = ObjectProperty()
anime_id = NumericProperty()
title = StringProperty()
is_play = ObjectProperty()
trailer_url = StringProperty()
_trailer_url: str | None = StringProperty(allow_none=True)
episodes = StringProperty()
favourites = StringProperty()
popularity = StringProperty()
media_status = StringProperty("Releasing")
is_in_my_list = BooleanProperty(False)
is_in_my_notify = BooleanProperty(False)
genres = StringProperty()
first_aired_on = StringProperty()
description = StringProperty()
producers = StringProperty()
studios = StringProperty()
next_airing_episode = StringProperty()
tags = StringProperty()
stars = ListProperty([0, 0, 0, 0, 0, 0])
cover_image_url = StringProperty()
preview_image = StringProperty()
has_trailer_color = ListProperty([0.5, 0.5, 0.5, 0.5])
_popup_opened = False
_title = ()
def __init__(self, trailer_url=None, **kwargs):
super().__init__(**kwargs)
self.orientation = "vertical"
self.app: MDApp | None = MDApp.get_running_app()
if trailer_url:
self.trailer_url = trailer_url
self.adaptive_size = True
def on_touch_down(self, touch):
if touch.is_mouse_scrolling:
return False
if not self.collide_point(touch.x, touch.y):
return False
if self in touch.ud:
return False
if not self.app:
return False
disabled = True
# FIXME: double tap not working
#
if not disabled and touch.is_double_tap:
self.app.show_anime_screen(self.anime_id, self.screen.name)
touch.grab(self)
touch.ud[self] = True
self.last_touch = touch
elif self.collide_point(*touch.pos):
if not self._popup_opened:
Clock.schedule_once(self.open)
self._popup_opened = True
touch.grab(self)
touch.ud[self] = True
self.last_touch = touch
return True
else:
super().on_touch_down(touch)
# FIXME: Figure a good way implement this
# for now its debugy so scraping it till fix
def on_enter(self):
def _open_popup(dt):
if self.hovering:
window = self.get_parent_window()
if window:
for widget in window.children: # type: ignore
if isinstance(widget, MediaPopup):
return
self.open()
# Clock.schedule_once(_open_popup, 5)
def on_popup_open(self, popup: MediaPopup):
popup.center = self.center
def on_dismiss(self, popup: MediaPopup):
popup.player.state = "stop"
self._popup_opened = False
# if popup.player._video:
# popup.player._video.unload()
def set_preview_image(self, image):
self.preview_image = image
def set_trailer_url(self, trailer_url):
self.trailer_url = trailer_url
def open(self, *_):
if app := self.app:
popup: MediaPopup = app.media_card_popup
# self.popup.caller = self
popup.update_caller(self)
popup.title = self.title
popup.bind(on_dismiss=self.on_dismiss, on_open=self.on_popup_open)
popup.open(self)
def _get_trailer(self):
if self.trailer_url:
return
if trailer := self._trailer_url:
# trailer stuff
from ....Utility.media_card_loader import media_card_loader
if trailer_url := media_card_loader.get_trailer_from_pytube(
trailer, self.title
):
self.trailer_url = trailer_url
else:
self._trailer_url = ""
# ---------------respond to user actions and call appropriate model-------------------------
def on_is_in_my_list(self, instance, in_user_anime_list):
if self.screen:
if in_user_anime_list:
self.screen.app.add_anime_to_user_anime_list(self.anime_id)
else:
self.screen.app.remove_anime_from_user_anime_list(self.anime_id)
def on_trailer_url(self, *args):
pass
Factory.register("MediaCard", MediaCard)
class MediaCardsContainer(MDBoxLayout):
container = ObjectProperty()
list_name = StringProperty()

View File

@@ -1,35 +0,0 @@
<CommonNavigationRailItem>
MDNavigationRailItemIcon:
icon:root.icon
MDNavigationRailItemLabel:
text: root.text
<NavRail>:
anchor:"top"
type: "labeled"
md_bg_color: self.theme_cls.secondaryContainerColor
MDNavigationRailFabButton:
icon: "home"
on_press:
root.screen.manager_screens.current = "home screen"
CommonNavigationRailItem:
icon: "magnify"
text: "Search"
on_press:
root.screen.manager_screens.current = "search screen"
CommonNavigationRailItem:
icon: "bookmark"
text: "MyList"
on_press:
root.screen.manager_screens.current = "my list screen"
CommonNavigationRailItem:
icon: "download-circle"
text: "Downloads"
on_press:
root.screen.manager_screens.current = "downloads screen"
CommonNavigationRailItem:
icon: "cog"
text: "settings"
on_press:app.open_settings()

View File

@@ -1,3 +0,0 @@
<Tooltip>
MDTooltipPlain:
text:root.tooltip_text

View File

@@ -1,18 +0,0 @@
<SearchBar>:
pos_hint: {'center_x': 0.5,'top': 1}
padding: "10dp"
adaptive_height:True
size_hint_x:.75
spacing: '20dp'
MDTextField:
size_hint_x:1
required:True
on_text_validate:
app.search_for_anime(args[0])
MDTextFieldLeadingIcon:
icon: "magnify"
MDTextFieldHintText:
text: "Search for anime"
MDIconButton:
pos_hint: {'center_y': 0.5}
icon: "account-circle"

Some files were not shown because too many files have changed in this diff Show More