Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c1f8d09e6 | ||
|
|
6bb2c89a8c | ||
|
|
9f56b74ff0 | ||
|
|
4d03b86498 | ||
|
|
fab86090a3 | ||
|
|
71d258385c | ||
|
|
bc55ed6e81 | ||
|
|
197bfa9f8a | ||
|
|
f84c60e6bc | ||
|
|
d8b94cbbca | ||
|
|
dd4462f42a | ||
|
|
0f9e08b9fa | ||
|
|
01333ab1d1 | ||
|
|
d8bf9e18c4 | ||
|
|
bc909397d5 | ||
|
|
f3d88f9825 | ||
|
|
eb7bef72b3 | ||
|
|
f6ec094bc7 | ||
|
|
3f1bf1781a | ||
|
|
21167fc208 | ||
|
|
c7c6ff92c4 | ||
|
|
78319731c0 | ||
|
|
b619a11db1 | ||
|
|
022420aa4c | ||
|
|
a7e46d9c18 | ||
|
|
5e2826be4e | ||
|
|
5e314e2bca | ||
|
|
3d23854d89 | ||
|
|
80a25d24a3 | ||
|
|
1ad7929c66 | ||
|
|
0670bd735c | ||
|
|
400a600bfe | ||
|
|
b9a3f170ab | ||
|
|
9309ba15b5 | ||
|
|
b2971e0233 | ||
|
|
06f67624d4 | ||
|
|
597c1bc9fd | ||
|
|
6fccd08e96 | ||
|
|
0e9294d7a2 | ||
|
|
c76a354d1b | ||
|
|
215def909e | ||
|
|
edd394ca74 | ||
|
|
af69046025 | ||
|
|
6379c28fed | ||
|
|
23b22dfc70 | ||
|
|
da06b0b6e1 | ||
|
|
68640202c3 | ||
|
|
2595ac5bf7 | ||
|
|
19f2898b73 | ||
|
|
69ec3ebfd7 | ||
|
|
d048bccaa1 | ||
|
|
2c2f2be26d | ||
|
|
7e2c03d54c | ||
|
|
62619421d6 | ||
|
|
84cea644e7 | ||
|
|
85326b9bc6 | ||
|
|
06c602e663 | ||
|
|
54161f13e4 | ||
|
|
d74d93da59 | ||
|
|
0a5fc0fa3c | ||
|
|
52fa6912be | ||
|
|
62bb1f7944 | ||
|
|
6fa88dd959 | ||
|
|
a853c01e52 | ||
|
|
a971b22d72 | ||
|
|
0d64a9bd32 | ||
|
|
16dc63c177 | ||
|
|
b5456635c7 | ||
|
|
d865086a50 | ||
|
|
82272cdf4e | ||
|
|
81aac99da8 | ||
|
|
962bde00a7 | ||
|
|
1d9c911ea1 | ||
|
|
cf3a963173 | ||
|
|
a88e72e4c2 | ||
|
|
269b1447f6 | ||
|
|
e589a92147 | ||
|
|
7fcd5c3475 | ||
|
|
e695577881 | ||
|
|
bcd8637b31 | ||
|
|
8d4f2a8f04 | ||
|
|
6d077fd3e2 | ||
|
|
73ce357789 | ||
|
|
53823f02c1 | ||
|
|
148619029d | ||
|
|
f08062ee71 | ||
|
|
2aa02d6ab9 | ||
|
|
520bfcbb52 | ||
|
|
7d82a356b1 | ||
|
|
be4cacf9dc | ||
|
|
f3b398d344 | ||
|
|
1ffb122cec | ||
|
|
84b8bd9950 | ||
|
|
ab76689f07 | ||
|
|
8c838a82f7 | ||
|
|
9996af900f | ||
|
|
4f0a752033 | ||
|
|
3b8a565843 | ||
|
|
4b5ff6348e | ||
|
|
4a2c981dff | ||
|
|
f93d524f68 | ||
|
|
03a3d32ce4 | ||
|
|
8615960300 | ||
|
|
1442346f07 | ||
|
|
89df10e377 | ||
|
|
7bab3d63e6 | ||
|
|
4bdfe5449e | ||
|
|
d8afdce467 |
|
Before Width: | Height: | Size: 971 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 690 KiB |
|
Before Width: | Height: | Size: 718 KiB |
|
Before Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 763 KiB |
|
Before Width: | Height: | Size: 518 KiB |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 578 KiB |
|
Before Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 566 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
35
.github/workflows/build.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
[](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).
|
||||
|
||||
29
completions/fastanime.bash
Normal 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;
|
||||
|
||||
18
completions/fastanime.fish
Normal 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
@@ -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
|
||||
|
||||
58
fastanime/AnimeProvider.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = []
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ...")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
104
fastanime/Utility/app_updater.py
Normal 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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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]"
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
@@ -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}
|
||||
@@ -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"),
|
||||
# )
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
from kivy.properties import ObjectProperty
|
||||
|
||||
from ...View.base_screen import BaseScreenView
|
||||
|
||||
|
||||
class HomeScreenView(BaseScreenView):
|
||||
main_container = ObjectProperty()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .filters import Filters
|
||||
from .pagination import SearchResultsPagination
|
||||
from .trending_sidebar import TrendingAnimeSideBar
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
from kivymd.uix.recycleview import MDRecycleView
|
||||
|
||||
|
||||
class TrendingAnimeSideBar(MDRecycleView):
|
||||
pass
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1 +0,0 @@
|
||||
from .media_card import MediaCard,MediaCardsContainer
|
||||
@@ -1,5 +0,0 @@
|
||||
from kivy.uix.modalview import ModalView
|
||||
|
||||
|
||||
class AnimdlDialogPopup(ModalView):
|
||||
pass
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .media_card import MediaCard,MediaCardsContainer
|
||||
@@ -1,2 +0,0 @@
|
||||
from .media_player import MediaPopupVideoPlayer
|
||||
from .media_popup import MediaPopup
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -1,3 +0,0 @@
|
||||
<Tooltip>
|
||||
MDTooltipPlain:
|
||||
text:root.tooltip_text
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,3 +0,0 @@
|
||||
<Tooltip>
|
||||
MDTooltipPlain:
|
||||
text:root.tooltip_text
|
||||
@@ -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"
|
||||