Compare commits

...

48 Commits

Author SHA1 Message Date
Benexl
a13bdb1aa0 chore: bump version 2025-10-26 19:12:56 +03:00
Benexl
627b09a723 fix(menu): runtime setting of provider 2025-10-26 19:03:51 +03:00
Benedict Xavier
aecec5c75b Add video showcase and Rofi details to README 2025-10-24 16:16:45 +03:00
Benexl
49b298ed52 chore: update lock file 2025-10-24 13:32:43 +03:00
Benexl
9a90fa196b chore: update dev deps specification to latest uv spec 2025-10-24 13:26:28 +03:00
Benexl
4ac059e873 feat(dev): automate media tag enum creation 2025-10-24 13:25:58 +03:00
Benexl
8b39a28e32 Merge pull request #157 from Abdisto/master
Adding missing media-tag
2025-10-23 01:03:02 +03:00
Abdist
066cc89b74 Update tags.json 2025-10-20 00:00:52 +02:00
Abdist
db16758d9f Fix missing closing quote in REVERSE_ISEKAI
ups
2025-10-19 23:50:41 +02:00
Abdist
78e17b2ba0 Update tags.json 2025-10-19 23:48:05 +02:00
Abdist
c5326eb8d9 Update types.py 2025-10-19 23:44:58 +02:00
Benexl
4a2d95e75e fix(animepahe-provider): update kwik.si to kwik.cx in headers 2025-10-12 12:08:05 +03:00
Benexl
3a92ba69df fix(fzf-selector): ensure consistent encoding in subprocess calls 2025-10-07 21:18:55 +03:00
Benexl
cf59f4822e feat: update repo url 2025-10-07 20:57:24 +03:00
Benexl
1cea6d0179 Merge pull request #152 from umop3plsdn/fix-category 2025-09-26 14:56:17 +03:00
David Grindle
4bc1edcc4e Fix: added the Kabuki category that was missing 2025-09-25 17:16:17 -04:00
Benexl
0c546af99c Merge pull request #149 from viu-media/minor-fixes 2025-09-21 11:53:50 +03:00
Type-Delta
1b49e186c8 change: animepahe provider domain from '.ru' to '.si' 2025-09-20 15:16:54 +07:00
Benexl
fe831f9658 Merge pull request #137 from axtrat/provider/animeunity 2025-09-07 13:57:10 +03:00
Benexl
72f0e2e5b9 Merge branch 'master' into provider/animeunity 2025-09-07 13:56:45 +03:00
Benexl
8530da23ef Merge pull request #141 from mkuritsu/master 2025-08-30 14:59:40 +03:00
mkuritsu
1e01b6e54a fix(nix): bump version and force use of python 3.12 to fix mpv gpu issues 2025-08-30 01:36:37 +01:00
axtrat
aa6ba9018d feat: limit quality selection to what's available from servers
This change affects all providers. It limits the selection if the servers don't
implement multiple qualities, ensuring that only qualities actually available
are displayed to the user.
2025-08-25 19:46:43 +02:00
axtrat
354ba6256a fix: Normalized some titles 2025-08-25 17:43:11 +02:00
axtrat
eae31420f9 fix: Error: o streaming servers 2025-08-25 15:19:25 +02:00
axtrat
01432a0fec feat: Added video quality source options 2025-08-25 15:07:38 +02:00
Benexl
c158d3fb99 Merge branch 'master' into provider/animeunity 2025-08-25 09:58:43 +03:00
axtrat
877bc043a0 fix: restoreded changes to update.py 2025-08-24 21:54:29 +02:00
axtrat
4968f8030a fix: Addes VIXCLOUD to available ProviderServer 2025-08-24 21:15:06 +02:00
axtrat
c5c7644d0d fix: Cannot fetch anime with a certain title
- added a replacing word dictionary
 - added a manual cache dictionary ID -> SearchResult to get more accurate results.
2025-08-24 18:19:39 +02:00
axtrat
ff2a5d635a feat/fix: Added special episodes to selection 2025-08-22 14:16:10 +02:00
axtrat
8626d1991c fix: Failing to get the episode list for anime that is ongoing or has more than 119 episodes. 2025-08-22 13:48:20 +02:00
Benexl
75d15a100d Merge pull request #135 from Aethar01/master 2025-08-22 13:38:29 +03:00
Aethar
25d9895c52 updated readme with correct AUR install instructions 2025-08-22 07:58:06 +09:00
axtrat
f1b796d72b feat: Initial implementation of AnimeUnity provider 2025-08-21 10:19:25 +02:00
Benexl
3f63198563 Merge pull request #132 from 0xDracula/docs/nixos-installation-instructions
docs: update installation instructions for nixos
2025-08-18 20:50:37 +03:00
Abdallah Ebrahim
8d61463156 Update README.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-18 20:25:10 +03:00
0xDracula
2daa51d384 docs: update installation instructions for nixos 2025-08-18 20:17:11 +03:00
Benexl
43a0d77e1b dev(envrc): isolate development files 2025-08-18 16:27:43 +03:00
Benexl
eaedf3268d feat(config): switch to toml format 2025-08-18 14:06:31 +03:00
Benexl
ade0465ea4 chore: set py version for pyright 2025-08-18 13:24:50 +03:00
Benexl
5e82db4ea8 chore: add repomixignore 2025-08-18 13:23:48 +03:00
Benexl
a10e56cb6f refactor:set min supported python version to 3.11 2025-08-18 13:19:56 +03:00
Benexl
fbd95e1966 feat(config-loader): allow env vars 2025-08-18 13:04:00 +03:00
Benexl
d37a441ccf fix(state): check for is None instead 2025-08-18 12:33:15 +03:00
Benexl
cbc1ceccbb feat(cli): auto check for updates 2025-08-18 02:14:56 +03:00
Benexl
249a207cad fix(update-command): use viu-media when updating 2025-08-18 01:28:59 +03:00
Benexl
c8a42c4920 Update README.md 2025-08-18 01:16:47 +03:00
45 changed files with 5632 additions and 4674 deletions

2
.envrc
View File

@@ -1,3 +1,5 @@
VIU_APP_NAME="viu-dev"
export VIU_APP_NAME
if command -v nix >/dev/null;then
use flake
fi

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
python-version: ["3.10", "3.11"] # List the Python versions you want to test
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4

1
.repomixignore Normal file
View File

@@ -0,0 +1 @@
**/generated/**/*

View File

@@ -6,7 +6,7 @@ First off, thank you for considering contributing to Viu! We welcome any help, w
There are many ways to contribute to the Viu project:
* **Reporting Bugs:** If you find a bug, please create an issue in our [issue tracker](https://github.com/Benexl/Viu/issues).
* **Reporting Bugs:** If you find a bug, please create an issue in our [issue tracker](https://github.com/viu-media/Viu/issues).
* **Suggesting Enhancements:** Have an idea for a new feature or an improvement to an existing one? We'd love to hear it.
* **Writing Code:** Help us fix bugs or implement new features.
* **Improving Documentation:** Enhance our README, add examples, or clarify our contribution guidelines.
@@ -16,7 +16,7 @@ There are many ways to contribute to the Viu project:
We follow the standard GitHub Fork & Pull Request workflow.
1. **Create an Issue:** Before starting work on a new feature or a significant bug fix, please [create an issue](https://github.com/Benexl/Viu/issues/new/choose) to discuss your idea. This allows us to give feedback and prevent duplicate work. For small bugs or documentation typos, you can skip this step.
1. **Create an Issue:** Before starting work on a new feature or a significant bug fix, please [create an issue](https://github.com/viu-media/Viu/issues/new/choose) to discuss your idea. This allows us to give feedback and prevent duplicate work. For small bugs or documentation typos, you can skip this step.
2. **Fork the Repository:** Create your own fork of the Viu repository.

View File

@@ -1,15 +1,3 @@
>[!IMPORTANT]
> looking for a new project name
>
>if you have any that is not already being used by someone on pypi please share on discord
>
>and let me warn yah am not good at naming things so help before disaster strikes again lol
>
>i dont want it to end up like viu where i added cli lol since viu was taken
>
>
<p align="center">
<h1 align="center">Viu</h1>
</p>
@@ -22,10 +10,10 @@
[![PyPI - Version](https://img.shields.io/pypi/v/viu-media)](https://pypi.org/project/viu-media/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/viu-media)](https://pypi.org/project/viu-media/)
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Benexl/Viu/test.yml?label=Tests)](https://github.com/Benexl/Viu/actions)
[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/viu-media/Viu/test.yml?label=Tests)](https://github.com/viu-media/Viu/actions)
[![Discord](https://img.shields.io/discord/1250887070906323096?label=Discord&logo=discord)](https://discord.gg/HBEmAwvbHV)
[![GitHub Issues](https://img.shields.io/github/issues/Benexl/Viu)](https://github.com/Benexl/Viu/issues)
[![PyPI - License](https://img.shields.io/pypi/l/viu)](https://github.com/Benexl/Viu/blob/master/LICENSE)
[![GitHub Issues](https://img.shields.io/github/issues/viu-media/Viu)](https://github.com/viu-media/Viu/issues)
[![PyPI - License](https://img.shields.io/pypi/l/viu)](https://github.com/viu-media/Viu/blob/master/LICENSE)
</div>
@@ -35,6 +23,14 @@
</a>
</p>
[viu-showcase.webm](https://github.com/user-attachments/assets/5da0ec87-7780-4310-9ca2-33fae7cadd5f)
<details>
<summary>Rofi</summary>
[viu-showcase-rofi.webm](https://github.com/user-attachments/assets/01f197d9-5ac9-45e6-a00b-8e8cd5ab459c)
</details>
## Core Features
@@ -84,18 +80,32 @@ uv tool install "viu-media[notifications]" # For desktop notifications
<summary><b>Platform-Specific and Alternative Installers</b></summary>
#### Nix / NixOS
##### Ephemeral / One-Off Run (No Installation)
```bash
nix profile install github:Benexl/viu
nix run github:viu-media/viu
```
##### Imperative Installation
```bash
nix profile install github:viu-media/viu
```
##### Declarative Installation
###### in your flake.nix
```nix
viu.url = "github:viu-media/viu";
```
###### in your system or home-manager packages
```nix
inputs.viu.packages.${pkgs.system}.default
```
#### Arch Linux (AUR)
Use an AUR helper like `yay` or `paru`.
```bash
# Stable version (recommended)
yay -S viu
yay -S viu-media
# Git version (latest commit)
yay -S viu-git
yay -S viu-media-git
```
#### Using pipx (for isolated environments)
@@ -114,7 +124,7 @@ uv tool install "viu-media[notifications]" # For desktop notifications
Requires [Git](https://git-scm.com/), [Python 3.10+](https://www.python.org/), and [uv](https://astral.sh/blog/uv).
```bash
git clone https://github.com/Benexl/Viu.git --depth 1
git clone https://github.com/viu-media/Viu.git --depth 1
cd Viu
uv tool install .
viu --version

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env -S uv run --script
import httpx
import json
from viu_media.core.utils.graphql import execute_graphql
from pathlib import Path
from collections import defaultdict
DEV_DIR = Path(__file__).resolve().parent
media_tags_type_py = (
DEV_DIR.parent / "viu_media" / "libs" / "media_api" / "_media_tags.py"
)
media_tags_gql = DEV_DIR / "graphql" / "anilist" / "media_tags.gql"
generated_tags_json = DEV_DIR / "generated" / "anilist" / "tags.json"
media_tags_response = execute_graphql(
"https://graphql.anilist.co", httpx.Client(), media_tags_gql, {}
)
media_tags_response.raise_for_status()
template = """\
# DO NOT EDIT THIS FILE !!! ( 。 •̀ ᴖ •́ 。)
# ITS AUTOMATICALLY GENERATED BY RUNNING ./dev/generate_anilist_media_tags.py
# FROM THE PROJECT ROOT
# SO RUN THAT INSTEAD TO UPDATE THE FILE WITH THE LATEST MEDIA TAGS :)
from enum import Enum
class MediaTag(Enum):\
"""
# 4 spaces
tab = " "
tags = defaultdict(list)
for tag in media_tags_response.json()["data"]["MediaTagCollection"]:
tags[tag["category"]].append(
{
"name": tag["name"],
"description": tag["description"],
"is_adult": tag["isAdult"],
}
)
# save copy of data used to generate the class
json.dump(tags, generated_tags_json.open("w", encoding="utf-8"), indent=2)
for key, value in tags.items():
template = f"{template}\n{tab}#\n{tab}# {key.upper()}\n{tab}#\n"
for tag in value:
name = tag["name"]
_tag_name = name.replace("-", "_").replace(" ", "_").upper()
if _tag_name.startswith(("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")):
_tag_name = f"_{_tag_name}"
tag_name = ""
# sanitize invalid characters for attribute names
for char in _tag_name:
if char.isidentifier() or char.isdigit():
tag_name += char
desc = tag["description"].replace("\n", "")
is_adult = tag["is_adult"]
template = f'{template}\n{tab}# {desc} (is_adult: {is_adult})\n{tab}{tag_name} = "{name}"\n'
media_tags_type_py.write_text(template, "utf-8")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
query {
MediaTagCollection {
name
description
category
isAdult
}
}

0
dev/make_release Normal file → Executable file
View File

8
flake.lock generated
View File

@@ -20,17 +20,17 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1753345091,
"narHash": "sha256-CdX2Rtvp5I8HGu9swBmYuq+ILwRxpXdJwlpg8jvN4tU=",
"lastModified": 1756386758,
"narHash": "sha256-1wxxznpW2CKvI9VdniaUnTT2Os6rdRJcRUf65ZK9OtE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
"rev": "dfb2f12e899db4876308eba6d93455ab7da304cd",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
"type": "github"
}
},

View File

@@ -2,8 +2,7 @@
description = "Viu Project Flake";
inputs = {
# The nixpkgs unstable latest commit breaks the plyer python package
nixpkgs.url = "github:nixos/nixpkgs/3ff0e34b1383648053bba8ed03f201d3466f90c9";
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
@@ -17,21 +16,21 @@
system:
let
pkgs = nixpkgs.legacyPackages.${system};
inherit (pkgs) lib python3Packages;
inherit (pkgs) lib python312Packages;
version = "3.1.0";
in
{
packages.default = python3Packages.buildPythonApplication {
packages.default = python312Packages.buildPythonApplication {
pname = "viu";
inherit version;
pyproject = true;
src = self;
build-system = with python3Packages; [ hatchling ];
build-system = with python312Packages; [ hatchling ];
dependencies = with python3Packages; [
dependencies = with python312Packages; [
click
inquirerpy
requests
@@ -69,8 +68,8 @@
meta = {
description = "Your browser anime experience from the terminal";
homepage = "https://github.com/Benexl/Viu";
changelog = "https://github.com/Benexl/Viu/releases/tag/v${version}";
homepage = "https://github.com/viu-media/Viu";
changelog = "https://github.com/viu-media/Viu/releases/tag/v${version}";
mainProgram = "viu";
license = lib.licenses.unlicense;
maintainers = with lib.maintainers; [ theobori ];

View File

@@ -1,10 +1,10 @@
[project]
name = "viu-media"
version = "3.2.7"
version = "3.2.8"
description = "A browser anime site experience from the terminal"
license = "UNLICENSE"
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
dependencies = [
"click>=8.1.7",
"httpx>=0.28.1",
@@ -49,8 +49,8 @@ torrents = [
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = [
[dependency-groups]
dev = [
"pre-commit>=4.0.1",
"pyinstaller>=6.11.1",
"pyright>=1.1.384",

View File

@@ -1,5 +1,5 @@
{
"venvPath": ".",
"venv": ".venv",
"pythonVersion": "3.10"
"pythonVersion": "3.11"
}

View File

@@ -1,7 +1,7 @@
[tox]
requires =
tox>=4
env_list = lint, pyright, py{310,311}
env_list = lint, pyright, py{311,312}
[testenv]
description = run unit tests

2866
uv.lock generated

File diff suppressed because it is too large Load Diff

0
viu Normal file → Executable file
View File

View File

@@ -1,6 +1,6 @@
import sys
if sys.version_info < (3, 10):
if sys.version_info < (3, 11):
raise ImportError(
"You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by Viu"
) # noqa: F541

View File

@@ -1,4 +1,3 @@
██╗░░░██╗██╗██╗░░░██╗
██║░░░██║██║██║░░░██║
╚██╗░██╔╝██║██║░░░██║

View File

@@ -13,5 +13,12 @@
"Azumanga Daiou The Animation": "Azumanga Daioh",
"Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2",
"Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3"
},
"animeunity": {
"Kaiju No. 8": "Kaiju No.8",
"Naruto Shippuden": "Naruto: Shippuden",
"Psycho-Pass: Sinners of the System Case.1 - Crime and Punishment": "PSYCHO-PASS Sinners of the System: Case.1 Crime and Punishment",
"Psycho-Pass: Sinners of the System Case.2 - First Guardian": "PSYCHO-PASS Sinners of the System: Case.2 First Guardian",
"Psycho-Pass: Sinners of the System Case.3 - On the Other Side of Love and Hate": "PSYCHO-PASS Sinners of the System: Case.3 Beyond the Pale of Vengeance"
}
}

View File

@@ -6,7 +6,7 @@ import click
from click.core import ParameterSource
from ..core.config import AppConfig
from ..core.constants import PROJECT_NAME, USER_CONFIG, __version__
from ..core.constants import CLI_NAME, USER_CONFIG, __version__
from .config import ConfigLoader
from .options import options_from_model
from .utils.exception import setup_exceptions_handler
@@ -47,7 +47,7 @@ commands = {
root="viu_media.cli.commands",
invoke_without_command=True,
lazy_subcommands=commands,
context_settings=dict(auto_envvar_prefix=PROJECT_NAME),
context_settings=dict(auto_envvar_prefix=CLI_NAME),
)
@click.version_option(__version__, "--version")
@click.option("--no-config", is_flag=True, help="Don't load the user config file.")
@@ -108,6 +108,49 @@ def cli(ctx: click.Context, **options: "Unpack[Options]"):
else loader.load(cli_overrides)
)
ctx.obj = config
if config.general.check_for_updates:
import time
from ..core.constants import APP_CACHE_DIR
last_updated_at_file = APP_CACHE_DIR / "last_update"
should_check_for_update = False
if last_updated_at_file.exists():
try:
last_updated_at_time = float(
last_updated_at_file.read_text(encoding="utf-8")
)
if (
time.time() - last_updated_at_time
) > config.general.update_check_interval * 3600:
should_check_for_update = True
except Exception as e:
logger.warning(f"Failed to check for update: {e}")
else:
should_check_for_update = True
if should_check_for_update:
last_updated_at_file.write_text(str(time.time()), encoding="utf-8")
from .service.feedback import FeedbackService
from .utils.update import check_for_updates, print_release_json, update_app
feedback = FeedbackService(config)
feedback.info("Checking for updates...")
is_latest, release_json = check_for_updates()
if not is_latest:
from ..libs.selectors.selector import create_selector
selector = create_selector(config)
if release_json and selector.confirm(
"Theres an update available would you like to see the release notes before deciding to update?"
):
print_release_json(release_json)
selector.ask("Enter to continue...")
if selector.confirm("Would you like to update?"):
update_app()
if ctx.invoked_subcommand is None:
from .commands.anilist import cmd

View File

@@ -72,7 +72,7 @@ def config(
):
from ...core.constants import USER_CONFIG
from ..config.editor import InteractiveConfigEditor
from ..config.generate import generate_config_ini_from_app_model
from ..config.generate import generate_config_toml_from_app_model
if path:
print(USER_CONFIG)
@@ -81,9 +81,9 @@ def config(
from rich.syntax import Syntax
console = Console()
config_ini = generate_config_ini_from_app_model(user_config)
config_toml = generate_config_toml_from_app_model(user_config)
syntax = Syntax(
config_ini,
config_toml,
"ini",
theme=user_config.general.pygment_style,
line_numbers=True,
@@ -99,12 +99,14 @@ def config(
elif interactive:
editor = InteractiveConfigEditor(current_config=user_config)
new_config = editor.run()
with open(USER_CONFIG, "w", encoding="utf-8") as file:
file.write(generate_config_ini_from_app_model(new_config))
USER_CONFIG.write_text(
generate_config_toml_from_app_model(new_config), encoding="utf-8"
)
click.echo(f"Configuration saved successfully to {USER_CONFIG}")
elif update:
with open(USER_CONFIG, "w", encoding="utf-8") as file:
file.write(generate_config_ini_from_app_model(user_config))
USER_CONFIG.write_text(
generate_config_toml_from_app_model(user_config), encoding="utf-8"
)
print("update successfull")
else:
click.edit(filename=str(USER_CONFIG))
@@ -123,9 +125,9 @@ def _generate_desktop_entry():
from rich.prompt import Confirm
from ...core.constants import (
CLI_NAME,
ICON_PATH,
PLATFORM,
PROJECT_NAME,
USER_APPLICATIONS,
__version__,
)
@@ -149,7 +151,7 @@ def _generate_desktop_entry():
desktop_entry = dedent(
f"""
[Desktop Entry]
Name={PROJECT_NAME.title()}
Name={CLI_NAME.title()}
Type=Application
version={__version__}
Path={Path().home()}
@@ -160,7 +162,7 @@ def _generate_desktop_entry():
Categories=Entertainment
"""
)
desktop_entry_path = USER_APPLICATIONS / f"{PROJECT_NAME}.desktop"
desktop_entry_path = USER_APPLICATIONS / f"{CLI_NAME}.desktop"
if desktop_entry_path.exists():
if not Confirm.ask(
f"The file already exists {desktop_entry_path}; or would you like to rewrite it",

View File

@@ -5,10 +5,8 @@ from typing import TYPE_CHECKING
import click
from rich import print
from rich.console import Console
from rich.markdown import Markdown
from ..utils.update import check_for_updates, update_app
from ..utils.update import check_for_updates, print_release_json, update_app
if TYPE_CHECKING:
from ...core.config import AppConfig
@@ -74,7 +72,6 @@ def update(
check_only: Whether to only check for updates without updating
release_notes: Whether to show release notes for the latest version
"""
try:
if release_notes:
print("[cyan]Fetching latest release notes...[/]")
is_latest, release_json = check_for_updates()
@@ -83,26 +80,8 @@ def update(
print(
"[yellow]Could not fetch release information. Please check your internet connection.[/]"
)
sys.exit(1)
version = release_json.get("tag_name", "unknown")
release_name = release_json.get("name", version)
release_body = release_json.get("body", "No release notes available.")
published_at = release_json.get("published_at", "unknown")
console = Console()
print(f"[bold cyan]Release: {release_name}[/]")
print(f"[dim]Version: {version}[/]")
print(f"[dim]Published: {published_at}[/]")
print()
# Display release notes as markdown if available
if release_body.strip():
markdown = Markdown(release_body)
console.print(markdown)
else:
print("[dim]No release notes available for this version.[/]")
print_release_json(release_json)
return
@@ -114,18 +93,14 @@ def update(
print(
"[yellow]Could not check for updates. Please check your internet connection.[/]"
)
sys.exit(1)
if is_latest:
print("[green]Viu is up to date![/]")
print(
f"[dim]Current version: {release_json.get('tag_name', 'unknown')}[/]"
)
print(f"[dim]Current version: {release_json.get('tag_name', 'unknown')}[/]")
else:
latest_version = release_json.get("tag_name", "unknown")
print(f"[yellow]Update available: {latest_version}[/]")
print("[dim]Run 'viu update' to update[/]")
sys.exit(1)
else:
print("[cyan]Checking for updates and updating if necessary...[/]")
success, release_json = update_app(force=force)
@@ -134,27 +109,9 @@ def update(
print(
"[red]Could not check for updates. Please check your internet connection.[/]"
)
sys.exit(1)
if success:
latest_version = release_json.get("tag_name", "unknown")
print(f"[green]Successfully updated to version {latest_version}![/]")
else:
if force:
print(
"[red]Update failed. Please check the error messages above.[/]"
)
sys.exit(1)
# If not forced and update failed, it might be because already up to date
# The update_app function already prints appropriate messages
except KeyboardInterrupt:
print("\n[yellow]Update cancelled by user.[/]")
sys.exit(1)
except Exception as e:
print(f"[red]An error occurred during update: {e}[/]")
# Get trace option from parent context
trace = ctx.parent.params.get("trace", False) if ctx.parent else False
if trace:
raise
sys.exit(1)
print("[red]Update failed. Please check the error messages above.[/]")

View File

@@ -1,4 +1,4 @@
from .generate import generate_config_ini_from_app_model
from .generate import generate_config_toml_from_app_model
from .loader import ConfigLoader
__all__ = ["ConfigLoader", "generate_config_ini_from_app_model"]
__all__ = ["ConfigLoader", "generate_config_toml_from_app_model"]

View File

@@ -1,4 +1,6 @@
# viu_media/cli/config/generate.py
import itertools
import json
import textwrap
from enum import Enum
from pathlib import Path
@@ -8,7 +10,7 @@ from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from ...core.config import AppConfig
from ...core.constants import APP_ASCII_ART, DISCORD_INVITE, PROJECT_NAME, REPO_HOME
from ...core.constants import APP_ASCII_ART, CLI_NAME, DISCORD_INVITE, REPO_HOME
# The header for the config file.
config_asci = "\n".join(
@@ -20,15 +22,17 @@ CONFIG_HEADER = f"""
{config_asci}
#
# ==============================================================================
# This file was auto-generated from the application's configuration model.
# This is the Viu configuration file. It uses the TOML format.
# You can modify these values to customize the behavior of Viu.
# For path-based options, you can use '~' for your home directory.
# For more information on the available options, please refer to the
# official documentation on GitHub.
# ==============================================================================
""".lstrip()
CONFIG_FOOTER = f"""
# ==============================================================================
#
# HOPE YOU ENJOY {PROJECT_NAME} AND BE SURE TO STAR THE PROJECT ON GITHUB
# HOPE YOU ENJOY {CLI_NAME} AND BE SURE TO STAR THE PROJECT ON GITHUB
# {REPO_HOME}
#
# Also join the discord server
@@ -39,21 +43,22 @@ CONFIG_FOOTER = f"""
""".lstrip()
def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
"""Generate a configuration file content from a Pydantic model."""
def generate_config_toml_from_app_model(app_model: AppConfig) -> str:
"""Generate a TOML configuration file content from a Pydantic model with comments."""
config_ini_content = [CONFIG_HEADER]
config_content_parts = [CONFIG_HEADER]
for section_name, section_model in app_model:
section_comment = section_model.model_config.get("title", "")
section_title = section_model.model_config.get("title", section_name.title())
config_ini_content.append(f"\n#\n# {section_comment}\n#")
config_ini_content.append(f"[{section_name}]")
config_content_parts.append(f"\n#\n# {section_title}\n#")
config_content_parts.append(f"[{section_name}]")
for field_name, field_info in itertools.chain(
section_model.model_fields.items(),
section_model.model_computed_fields.items(),
):
# --- Generate Comments ---
description = field_info.description or ""
if description:
wrapped_comment = textwrap.fill(
@@ -62,7 +67,7 @@ def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
initial_indent="# ",
subsequent_indent="# ",
)
config_ini_content.append(f"\n{wrapped_comment}")
config_content_parts.append(f"\n{wrapped_comment}")
field_type_comment = _get_field_type_comment(field_info)
if field_type_comment:
@@ -72,35 +77,65 @@ def generate_config_ini_from_app_model(app_model: AppConfig) -> str:
initial_indent="# ",
subsequent_indent="# ",
)
config_ini_content.append(wrapped_comment)
config_content_parts.append(wrapped_comment)
if (
hasattr(field_info, "default")
and field_info.default != PydanticUndefined
and field_info.default is not PydanticUndefined
):
default_val = (
field_info.default.value
if isinstance(field_info.default, Enum)
else field_info.default
)
wrapped_comment = textwrap.fill(
f"Default: {field_info.default.value if isinstance(field_info.default, Enum) else field_info.default}",
f"Default: {_format_toml_value(default_val)}",
width=78,
initial_indent="# ",
subsequent_indent="# ",
)
config_ini_content.append(wrapped_comment)
config_content_parts.append(wrapped_comment)
# --- Generate Key-Value Pair ---
field_value = getattr(section_model, field_name)
if isinstance(field_value, bool):
value_str = str(field_value).lower()
elif isinstance(field_value, Path):
value_str = str(field_value)
elif field_value is None:
value_str = ""
elif isinstance(field_value, Enum):
value_str = field_value.value
if field_value is None:
config_content_parts.append(f"# {field_name} =")
else:
value_str = str(field_value)
value_str = _format_toml_value(field_value)
config_content_parts.append(f"{field_name} = {value_str}")
config_ini_content.append(f"{field_name} = {value_str}")
config_content_parts.extend(["\n", CONFIG_FOOTER])
return "\n".join(config_content_parts)
config_ini_content.extend(["\n", CONFIG_FOOTER])
return "\n".join(config_ini_content)
def _format_toml_value(value: Any) -> str:
"""
Manually formats a Python value into a TOML-compliant string.
This avoids needing an external TOML writer dependency.
"""
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, Enum):
return f'"{value.value}"'
# Handle strings and Paths, differentiating between single and multi-line
if isinstance(value, (str, Path)):
str_val = str(value)
if "\n" in str_val:
# For multi-line strings, use triple quotes.
# Also, escape any triple quotes that might be in the string itself.
escaped_val = str_val.replace('"""', '\\"\\"\\"')
return f'"""\n{escaped_val}"""'
else:
# For single-line strings, use double quotes and escape relevant characters.
escaped_val = str_val.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped_val}"'
# Fallback for any other types
return f'"{str(value)}"'
def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str:
@@ -111,7 +146,6 @@ def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str:
else field_info.return_type
)
# Handle Literal and Enum types
possible_values = []
if field_type is not None:
if isinstance(field_type, type) and issubclass(field_type, Enum):
@@ -122,9 +156,8 @@ def _get_field_type_comment(field_info: FieldInfo | ComputedFieldInfo) -> str:
possible_values = list(args)
if possible_values:
return f"Possible values: [ {', '.join(map(str, possible_values))} ]"
# Handle basic types and numeric ranges
formatted_values = ", ".join(json.dumps(v) for v in possible_values)
return f"Possible values: [ {formatted_values} ]"
type_name = _get_type_name(field_type)
range_info = _get_range_info(field_info)

View File

@@ -1,4 +1,5 @@
import configparser
import logging
import tomllib
from pathlib import Path
from typing import Dict
@@ -9,12 +10,14 @@ from ...core.config import AppConfig
from ...core.constants import USER_CONFIG
from ...core.exceptions import ConfigError
logger = logging.getLogger(__name__)
class ConfigLoader:
"""
Handles loading the application configuration from an .ini file.
Handles loading the application configuration from a .toml file.
It ensures a default configuration exists, reads the .ini file,
It ensures a default configuration exists, reads the .toml file,
and uses Pydantic to parse and validate the data into a type-safe
AppConfig object.
"""
@@ -24,26 +27,19 @@ class ConfigLoader:
Initializes the loader with the path to the configuration file.
Args:
config_path: The path to the user's config.ini file.
config_path: The path to the user's config.toml file.
"""
self.config_path = config_path
self.parser = configparser.ConfigParser(
interpolation=None,
# Allow boolean values without a corresponding value (e.g., `enabled` vs `enabled = true`)
allow_no_value=True,
# Behave like a dictionary, preserving case sensitivity of keys
dict_type=dict,
)
def _handle_first_run(self) -> AppConfig:
"""Handles the configuration process when no config file is found."""
"""Handles the configuration process when no config.toml file is found."""
click.echo(
"[bold yellow]Welcome to Viu![/bold yellow] No configuration file found."
)
from InquirerPy import inquirer
from .editor import InteractiveConfigEditor
from .generate import generate_config_ini_from_app_model
from .generate import generate_config_toml_from_app_model
choice = inquirer.select( # type: ignore
message="How would you like to proceed?",
@@ -60,16 +56,17 @@ class ConfigLoader:
else:
app_config = AppConfig()
config_ini_content = generate_config_ini_from_app_model(app_config)
config_toml_content = generate_config_toml_from_app_model(app_config)
try:
self.config_path.parent.mkdir(parents=True, exist_ok=True)
self.config_path.write_text(config_ini_content, encoding="utf-8")
self.config_path.write_text(config_toml_content, encoding="utf-8")
click.echo(
f"Configuration file created at: [green]{self.config_path}[/green]"
)
except Exception as e:
raise ConfigError(
f"Could not create configuration file at {self.config_path!s}. Please check permissions. Error: {e}",
f"Could not create configuration file at {self.config_path!s}. "
f"Please check permissions. Error: {e}",
)
return app_config
@@ -78,32 +75,34 @@ class ConfigLoader:
"""
Loads the configuration and returns a populated, validated AppConfig object.
Args:
update: A dictionary of CLI overrides to apply to the loaded config.
Returns:
An instance of AppConfig with values from the user's .ini file.
An instance of AppConfig with values from the user's .toml file.
Raises:
click.ClickException: If the configuration file contains validation errors.
ConfigError: If the configuration file contains validation or parsing errors.
"""
if not self.config_path.exists():
return self._handle_first_run()
try:
self.parser.read(self.config_path, encoding="utf-8")
except configparser.Error as e:
with self.config_path.open("rb") as f:
config_dict = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
raise ConfigError(
f"Error parsing configuration file '{self.config_path}':\n{e}"
)
# Convert the configparser object into a nested dictionary that mirrors
# the structure of our AppConfig Pydantic model.
config_dict = {
section: dict(self.parser.items(section))
for section in self.parser.sections()
}
# Apply CLI overrides on top of the loaded configuration
if update:
for key in config_dict:
if key in update:
config_dict[key].update(update[key])
for section, values in update.items():
if section in config_dict:
config_dict[section].update(values)
else:
config_dict[section] = values
try:
app_config = AppConfig.model_validate(config_dict)
return app_config

View File

@@ -308,6 +308,8 @@ def _change_provider(ctx: Context, state: State) -> MenuAction:
"Select Provider", [provider.value for provider in ProviderName]
)
ctx.config.general.provider = ProviderName(new_provider)
# force a reset of the provider
ctx._provider = None
return InternalDirective.RELOAD
return action

View File

@@ -249,7 +249,8 @@ def _change_quality(ctx: Context, state: State) -> MenuAction:
return InternalDirective.BACK
new_quality = selector.choose(
"Select a different server:", list(["360", "480", "720", "1080"])
"Select a different quality:",
[link.quality for link in state.provider.server.links],
)
if new_quality:
ctx.config.stream.quality = new_quality # type:ignore

View File

@@ -66,13 +66,13 @@ class MediaApiState(StateModel):
@property
def search_result(self) -> dict[int, MediaItem]:
if not self.search_result_:
if self.search_result_ is None:
raise RuntimeError("Malformed state, please report")
return self.search_result_
@property
def search_params(self) -> Union[MediaSearchParams, UserMediaListSearchParams]:
if not self.search_params_:
if self.search_params_ is None:
raise RuntimeError("Malformed state, please report")
return self.search_params_
@@ -84,7 +84,7 @@ class MediaApiState(StateModel):
@property
def media_id(self) -> int:
if not self.media_id_:
if self.media_id_ is None:
raise RuntimeError("Malformed state, please report")
return self.media_id_
@@ -105,13 +105,13 @@ class ProviderState(StateModel):
@property
def search_results(self) -> SearchResults:
if not self.search_results_:
if self.search_results_ is None:
raise RuntimeError("Malformed state, please report")
return self.search_results_
@property
def anime(self) -> Anime:
if not self.anime_:
if self.anime_ is None:
raise RuntimeError("Malformed state, please report")
return self.anime_
@@ -123,13 +123,13 @@ class ProviderState(StateModel):
@property
def servers(self) -> Dict[str, Server]:
if not self.servers_:
if self.servers_ is None:
raise RuntimeError("Malformed state, please report")
return self.servers_
@property
def server_name(self) -> str:
if not self.server_name_:
if self.server_name_ is None:
raise RuntimeError("Malformed state, please report")
return self.server_name_

View File

@@ -34,12 +34,12 @@ class FeedbackService:
try:
from plyer import notification
from ....core.constants import ICON_PATH, PROJECT_NAME
from ....core.constants import CLI_NAME, ICON_PATH
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
title=f"{CLI_NAME} notification".title(),
message=message,
app_name=PROJECT_NAME,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
)
@@ -60,12 +60,12 @@ class FeedbackService:
try:
from plyer import notification
from ....core.constants import ICON_PATH, PROJECT_NAME
from ....core.constants import CLI_NAME, ICON_PATH
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
title=f"{CLI_NAME} notification".title(),
message=message,
app_name=PROJECT_NAME,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
)
@@ -87,12 +87,12 @@ class FeedbackService:
try:
from plyer import notification
from ....core.constants import ICON_PATH, PROJECT_NAME
from ....core.constants import CLI_NAME, ICON_PATH
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
title=f"{CLI_NAME} notification".title(),
message=message,
app_name=PROJECT_NAME,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
)
@@ -113,12 +113,12 @@ class FeedbackService:
try:
from plyer import notification
from ....core.constants import ICON_PATH, PROJECT_NAME
from ....core.constants import CLI_NAME, ICON_PATH
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
title=f"{CLI_NAME} notification".title(),
message=message,
app_name=PROJECT_NAME,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
)
@@ -169,12 +169,12 @@ class FeedbackService:
try:
from plyer import notification
from ....core.constants import ICON_PATH, PROJECT_NAME
from ....core.constants import CLI_NAME, ICON_PATH
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
title=f"{CLI_NAME} notification".title(),
message="No current way to display info in rofi, use fzf and the terminal instead",
app_name=PROJECT_NAME,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
)

View File

@@ -8,14 +8,41 @@ import sys
from httpx import get
from rich import print
from rich.console import Console
from rich.markdown import Markdown
from ...core.constants import AUTHOR, GIT_REPO, PROJECT_NAME_LOWER, __version__
from ...core.constants import (
AUTHOR,
CLI_NAME_LOWER,
GIT_REPO,
PROJECT_NAME,
__version__,
)
API_URL = f"https://api.{GIT_REPO}/repos/{AUTHOR}/{PROJECT_NAME_LOWER}/releases/latest"
API_URL = f"https://api.{GIT_REPO}/repos/{AUTHOR}/{CLI_NAME_LOWER}/releases/latest"
def print_release_json(release_json):
version = release_json.get("tag_name", "unknown")
release_name = release_json.get("name", version)
release_body = release_json.get("body", "No release notes available.")
published_at = release_json.get("published_at", "unknown")
console = Console()
print(f"[bold cyan]Release: {release_name}[/]")
print(f"[dim]Version: {version}[/]")
print(f"[dim]Published: {published_at}[/]")
print()
# Display release notes as markdown if available
if release_body and release_body.strip():
markdown = Markdown(release_body)
console.print(markdown)
def check_for_updates():
USER_AGENT = f"{PROJECT_NAME_LOWER} user"
USER_AGENT = f"{CLI_NAME_LOWER} user"
try:
response = get(
API_URL,
@@ -96,9 +123,9 @@ def update_app(force=False):
return False, release_json
process = subprocess.run(
[NIX, "profile", "upgrade", PROJECT_NAME_LOWER], check=False
[NIX, "profile", "upgrade", CLI_NAME_LOWER], check=False
)
elif is_git_repo(AUTHOR, PROJECT_NAME_LOWER):
elif is_git_repo(AUTHOR, CLI_NAME_LOWER):
GIT_EXECUTABLE = shutil.which("git")
args = [
GIT_EXECUTABLE,
@@ -117,11 +144,9 @@ def update_app(force=False):
)
elif UV := shutil.which("uv"):
process = subprocess.run(
[UV, "tool", "upgrade", PROJECT_NAME_LOWER], check=False
)
process = subprocess.run([UV, "tool", "upgrade", PROJECT_NAME], check=False)
elif PIPX := shutil.which("pipx"):
process = subprocess.run([PIPX, "upgrade", PROJECT_NAME_LOWER], check=False)
process = subprocess.run([PIPX, "upgrade", PROJECT_NAME], check=False)
else:
PYTHON_EXECUTABLE = sys.executable
@@ -130,7 +155,7 @@ def update_app(force=False):
"-m",
"pip",
"install",
PROJECT_NAME_LOWER,
PROJECT_NAME,
"-U",
"--no-warn-script-location",
]

View File

@@ -32,6 +32,7 @@ def GENERAL_IMAGE_RENDERER():
GENERAL_MANGA_VIEWER = "feh"
GENERAL_CHECK_FOR_UPDATES = True
GENERAL_UPDATE_CHECK_INTERVAL = 12
GENERAL_CACHE_REQUESTS = True
GENERAL_MAX_CACHE_LIFETIME = "03:00:00"
GENERAL_NORMALIZE_TITLES = True

View File

@@ -24,6 +24,7 @@ GENERAL_IMAGE_RENDERER = (
)
GENERAL_MANGA_VIEWER = "The external application to use for viewing manga pages."
GENERAL_CHECK_FOR_UPDATES = "Automatically check for new versions of Viu on startup."
GENERAL_UPDATE_CHECK_INTERVAL = "The interval in hours to check for updates"
GENERAL_CACHE_REQUESTS = (
"Enable caching of network requests to speed up subsequent operations."
)

View File

@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, PrivateAttr, computed_field
from pydantic import BaseModel, Field
from ...libs.media_api.types import MediaSort, UserMediaListSort
from ...libs.provider.anime.types import ProviderName, ProviderServer
@@ -190,6 +190,10 @@ class GeneralConfig(BaseModel):
default=defaults.GENERAL_CHECK_FOR_UPDATES,
description=desc.GENERAL_CHECK_FOR_UPDATES,
)
update_check_interval: float = Field(
default=defaults.GENERAL_UPDATE_CHECK_INTERVAL,
description=desc.GENERAL_UPDATE_CHECK_INTERVAL,
)
cache_requests: bool = Field(
default=defaults.GENERAL_CACHE_REQUESTS,
description=desc.GENERAL_CACHE_REQUESTS,
@@ -319,14 +323,16 @@ class SessionsConfig(OtherConfig):
class FzfConfig(OtherConfig):
"""Configuration specific to the FZF selector."""
_opts: str = PrivateAttr(
default_factory=lambda: defaults.FZF_OPTS.read_text(encoding="utf-8")
opts: str = Field(
default_factory=lambda: defaults.FZF_OPTS.read_text(encoding="utf-8"),
description=desc.FZF_OPTS,
)
header_color: str = Field(
default=defaults.FZF_HEADER_COLOR, description=desc.FZF_HEADER_COLOR
)
_header_ascii_art: str = PrivateAttr(
default_factory=lambda: APP_ASCII_ART.read_text(encoding="utf-8")
header_ascii_art: str = Field(
default_factory=lambda: APP_ASCII_ART.read_text(encoding="utf-8"),
description=desc.FZF_HEADER_ASCII_ART,
)
preview_header_color: str = Field(
default=defaults.FZF_PREVIEW_HEADER_COLOR,
@@ -337,28 +343,6 @@ class FzfConfig(OtherConfig):
description=desc.FZF_PREVIEW_SEPARATOR_COLOR,
)
def __init__(self, **kwargs):
opts = kwargs.pop("opts", None)
header_ascii_art = kwargs.pop("header_ascii_art", None)
super().__init__(**kwargs)
if opts:
self._opts = opts
if header_ascii_art:
self._header_ascii_art = header_ascii_art
@computed_field(description=desc.FZF_OPTS)
@property
def opts(self) -> str:
return "\n" + "\n".join([f"\t{line}" for line in self._opts.split()])
@computed_field(description=desc.FZF_HEADER_ASCII_ART)
@property
def header_ascii_art(self) -> str:
return "\n" + "\n".join(
[f"\t{line}" for line in self._header_ascii_art.split()]
)
class RofiConfig(OtherConfig):
"""Configuration specific to the Rofi selector."""

View File

@@ -4,15 +4,16 @@ from importlib import metadata, resources
from pathlib import Path
PLATFORM = sys.platform
PROJECT_NAME = "VIU"
PROJECT_NAME_LOWER = "viu"
APP_NAME = os.environ.get(f"{PROJECT_NAME}_APP_NAME", PROJECT_NAME_LOWER)
CLI_NAME = "VIU"
CLI_NAME_LOWER = "viu"
PROJECT_NAME = "viu-media"
APP_NAME = os.environ.get(f"{CLI_NAME}_APP_NAME", CLI_NAME_LOWER)
USER_NAME = os.environ.get("USERNAME", "User")
__version__ = metadata.version("viu_media")
AUTHOR = "Benexl"
AUTHOR = "viu-media"
GIT_REPO = "github.com"
GIT_PROTOCOL = "https://"
REPO_HOME = f"https://{GIT_REPO}/{AUTHOR}/Viu"
@@ -24,7 +25,7 @@ ANILIST_AUTH = (
)
try:
APP_DIR = Path(str(resources.files(PROJECT_NAME.lower())))
APP_DIR = Path(str(resources.files(CLI_NAME.lower())))
except ModuleNotFoundError:
from pathlib import Path
@@ -81,6 +82,6 @@ APP_CACHE_DIR.mkdir(parents=True, exist_ok=True)
LOG_FOLDER.mkdir(parents=True, exist_ok=True)
USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
USER_CONFIG = APP_DATA_DIR / "config.ini"
USER_CONFIG = APP_DATA_DIR / "config.toml"
LOG_FILE = LOG_FOLDER / "app.log"

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from ._media_tags import MediaTag
# ENUMS
@@ -285,472 +286,6 @@ class MediaReview(BaseMediaApiModel):
user: Reviewer
# ENUMS
class MediaTag(Enum):
# Cast
POLYAMOROUS = "Polyamorous"
# Cast Main Cast
ANTI_HERO = "Anti-Hero"
ELDERLY_PROTAGONIST = "Elderly Protagonist"
ENSEMBLE_CAST = "Ensemble Cast"
ESTRANGED_FAMILY = "Estranged Family"
FEMALE_PROTAGONIST = "Female Protagonist"
MALE_PROTAGONIST = "Male Protagonist"
PRIMARILY_ADULT_CAST = "Primarily Adult Cast"
PRIMARILY_ANIMAL_CAST = "Primarily Animal Cast"
PRIMARILY_CHILD_CAST = "Primarily Child Cast"
PRIMARILY_FEMALE_CAST = "Primarily Female Cast"
PRIMARILY_MALE_CAST = "Primarily Male Cast"
PRIMARILY_TEEN_CAST = "Primarily Teen Cast"
# Cast Traits
AGE_REGRESSION = "Age Regression"
AGENDER = "Agender"
ALIENS = "Aliens"
AMNESIA = "Amnesia"
ANGELS = "Angels"
ANTHROPOMORPHISM = "Anthropomorphism"
AROMANTIC = "Aromantic"
ARRANGED_MARRIAGE = "Arranged Marriage"
ARTIFICIAL_INTELLIGENCE = "Artificial Intelligence"
ASEXUAL = "Asexual"
BISEXUAL = "Bisexual"
BUTLER = "Butler"
CENTAUR = "Centaur"
CHIMERA = "Chimera"
CHUUNIBYOU = "Chuunibyou"
CLONE = "Clone"
COSPLAY = "Cosplay"
COWBOYS = "Cowboys"
CROSSDRESSING = "Crossdressing"
CYBORG = "Cyborg"
DELINQUENTS = "Delinquents"
DEMONS = "Demons"
DETECTIVE = "Detective"
DINOSAURS = "Dinosaurs"
DISABILITY = "Disability"
DISSOCIATIVE_IDENTITIES = "Dissociative Identities"
DRAGONS = "Dragons"
DULLAHAN = "Dullahan"
ELF = "Elf"
FAIRY = "Fairy"
FEMBOY = "Femboy"
GHOST = "Ghost"
GOBLIN = "Goblin"
GODS = "Gods"
GYARU = "Gyaru"
HIKIKOMORI = "Hikikomori"
HOMELESS = "Homeless"
IDOL = "Idol"
KEMONOMIMI = "Kemonomimi"
KUUDERE = "Kuudere"
MAIDS = "Maids"
MERMAID = "Mermaid"
MONSTER_BOY = "Monster Boy"
MONSTER_GIRL = "Monster Girl"
NEKOMIMI = "Nekomimi"
NINJA = "Ninja"
NUDITY = "Nudity"
NUN = "Nun"
OFFICE_LADY = "Office Lady"
OIRAN = "Oiran"
OJOU_SAMA = "Ojou-sama"
ORPHAN = "Orphan"
PIRATES = "Pirates"
ROBOTS = "Robots"
SAMURAI = "Samurai"
SHRINE_MAIDEN = "Shrine Maiden"
SKELETON = "Skeleton"
SUCCUBUS = "Succubus"
TANNED_SKIN = "Tanned Skin"
TEACHER = "Teacher"
TOMBOY = "Tomboy"
TRANSGENDER = "Transgender"
TSUNDERE = "Tsundere"
TWINS = "Twins"
VAMPIRE = "Vampire"
VETERINARIAN = "Veterinarian"
VIKINGS = "Vikings"
VILLAINESS = "Villainess"
VTUBER = "VTuber"
WEREWOLF = "Werewolf"
WITCH = "Witch"
YANDERE = "Yandere"
YOUKAI = "Youkai"
ZOMBIE = "Zombie"
# Demographic
JOSEI = "Josei"
KIDS = "Kids"
SEINEN = "Seinen"
SHOUJO = "Shoujo"
SHOUNEN = "Shounen"
# Setting
MATRIARCHY = "Matriarchy"
# Setting Scene
BAR = "Bar"
BOARDING_SCHOOL = "Boarding School"
CAMPING = "Camping"
CIRCUS = "Circus"
COASTAL = "Coastal"
COLLEGE = "College"
DESERT = "Desert"
DUNGEON = "Dungeon"
FOREIGN = "Foreign"
INN = "Inn"
KONBINI = "Konbini"
NATURAL_DISASTER = "Natural Disaster"
OFFICE = "Office"
OUTDOOR_ACTIVITIES = "Outdoor Activities"
PRISON = "Prison"
RESTAURANT = "Restaurant"
RURAL = "Rural"
SCHOOL = "School"
SCHOOL_CLUB = "School Club"
SNOWSCAPE = "Snowscape"
URBAN = "Urban"
WILDERNESS = "Wilderness"
WORK = "Work"
# Setting Time
ACHRONOLOGICAL_ORDER = "Achronological Order"
ANACHRONISM = "Anachronism"
ANCIENT_CHINA = "Ancient China"
DYSTOPIAN = "Dystopian"
HISTORICAL = "Historical"
MEDIEVAL = "Medieval"
TIME_SKIP = "Time Skip"
# Setting Universe
AFTERLIFE = "Afterlife"
ALTERNATE_UNIVERSE = "Alternate Universe"
AUGMENTED_REALITY = "Augmented Reality"
OMEGAVERSE = "Omegaverse"
POST_APOCALYPTIC = "Post-Apocalyptic"
SPACE = "Space"
URBAN_FANTASY = "Urban Fantasy"
VIRTUAL_WORLD = "Virtual World"
# Sexual Content
AHEGAO = "Ahegao"
AMPUTATION = "Amputation"
ANAL_SEX = "Anal Sex"
ARMPITS = "Armpits"
ASHIKOKI = "Ashikoki"
ASPHYXIATION = "Asphyxiation"
BONDAGE = "Bondage"
BOOBJOB = "Boobjob"
CERVIX_PENETRATION = "Cervix Penetration"
CHEATING = "Cheating"
CUMFLATION = "Cumflation"
CUNNILINGUS = "Cunnilingus"
DEEPTHROAT = "Deepthroat"
DEFLORATION = "Defloration"
DILF = "DILF"
DOUBLE_PENETRATION = "Double Penetration"
EROTIC_PIERCINGS = "Erotic Piercings"
EXHIBITIONISM = "Exhibitionism"
FACIAL = "Facial"
FEET = "Feet"
FELLATIO = "Fellatio"
FEMDOM = "Femdom"
FISTING = "Fisting"
FLAT_CHEST = "Flat Chest"
FUTANARI = "Futanari"
GROUP_SEX = "Group Sex"
HAIR_PULLING = "Hair Pulling"
HANDJOB = "Handjob"
HUMAN_PET = "Human Pet"
HYPERSEXUALITY = "Hypersexuality"
INCEST = "Incest"
INSEKI = "Inseki"
IRRUMATIO = "Irrumatio"
LACTATION = "Lactation"
LARGE_BREASTS = "Large Breasts"
MALE_PREGNANCY = "Male Pregnancy"
MASOCHISM = "Masochism"
MASTURBATION = "Masturbation"
MATING_PRESS = "Mating Press"
MILF = "MILF"
NAKADASHI = "Nakadashi"
NETORARE = "Netorare"
NETORASE = "Netorase"
NETORI = "Netori"
PET_PLAY = "Pet Play"
PROSTITUTION = "Prostitution"
PUBLIC_SEX = "Public Sex"
RAPE = "Rape"
RIMJOB = "Rimjob"
SADISM = "Sadism"
SCAT = "Scat"
SCISSORING = "Scissoring"
SEX_TOYS = "Sex Toys"
SHIMAIDON = "Shimaidon"
SQUIRTING = "Squirting"
SUMATA = "Sumata"
SWEAT = "Sweat"
TENTACLES = "Tentacles"
THREESOME = "Threesome"
VIRGINITY = "Virginity"
VORE = "Vore"
VOYEUR = "Voyeur"
WATERSPORTS = "Watersports"
ZOOPHILIA = "Zoophilia"
# Technical
_4_KOMA = "4-koma"
ACHROMATIC = "Achromatic"
ADVERTISEMENT = "Advertisement"
ANTHOLOGY = "Anthology"
CGI = "CGI"
EPISODIC = "Episodic"
FLASH = "Flash"
FULL_CGI = "Full CGI"
FULL_COLOR = "Full Color"
LONG_STRIP = "Long Strip"
MIXED_MEDIA = "Mixed Media"
NO_DIALOGUE = "No Dialogue"
NON_FICTION = "Non-fiction"
POV = "POV"
PUPPETRY = "Puppetry"
ROTOSCOPING = "Rotoscoping"
STOP_MOTION = "Stop Motion"
VERTICAL_VIDEO = "Vertical Video"
# Theme Action
ARCHERY = "Archery"
BATTLE_ROYALE = "Battle Royale"
ESPIONAGE = "Espionage"
FUGITIVE = "Fugitive"
GUNS = "Guns"
MARTIAL_ARTS = "Martial Arts"
SPEARPLAY = "Spearplay"
SWORDPLAY = "Swordplay"
# Theme Arts
ACTING = "Acting"
CALLIGRAPHY = "Calligraphy"
CLASSIC_LITERATURE = "Classic Literature"
DRAWING = "Drawing"
FASHION = "Fashion"
FOOD = "Food"
MAKEUP = "Makeup"
PHOTOGRAPHY = "Photography"
RAKUGO = "Rakugo"
WRITING = "Writing"
# Theme Arts-Music
BAND = "Band"
CLASSICAL_MUSIC = "Classical Music"
DANCING = "Dancing"
HIP_HOP_MUSIC = "Hip-hop Music"
JAZZ_MUSIC = "Jazz Music"
METAL_MUSIC = "Metal Music"
MUSICAL_THEATER = "Musical Theater"
ROCK_MUSIC = "Rock Music"
# Theme Comedy
PARODY = "Parody"
SATIRE = "Satire"
SLAPSTICK = "Slapstick"
SURREAL_COMEDY = "Surreal Comedy"
# Theme Drama
BULLYING = "Bullying"
CLASS_STRUGGLE = "Class Struggle"
COMING_OF_AGE = "Coming of Age"
CONSPIRACY = "Conspiracy"
ECO_HORROR = "Eco-Horror"
FAKE_RELATIONSHIP = "Fake Relationship"
KINGDOM_MANAGEMENT = "Kingdom Management"
REHABILITATION = "Rehabilitation"
REVENGE = "Revenge"
SUICIDE = "Suicide"
TRAGEDY = "Tragedy"
# Theme Fantasy
ALCHEMY = "Alchemy"
BODY_SWAPPING = "Body Swapping"
CULTIVATION = "Cultivation"
CURSES = "Curses"
EXORCISM = "Exorcism"
FAIRY_TALE = "Fairy Tale"
HENSHIN = "Henshin"
ISEKAI = "Isekai"
KAIJU = "Kaiju"
MAGIC = "Magic"
MYTHOLOGY = "Mythology"
NECROMANCY = "Necromancy"
SHAPESHIFTING = "Shapeshifting"
STEAMPUNK = "Steampunk"
SUPER_POWER = "Super Power"
SUPERHERO = "Superhero"
WUXIA = "Wuxia"
# Theme Game
BOARD_GAME = "Board Game"
E_SPORTS = "E-Sports"
VIDEO_GAMES = "Video Games"
# Theme Game-Card & Board Game
CARD_BATTLE = "Card Battle"
GO = "Go"
KARUTA = "Karuta"
MAHJONG = "Mahjong"
POKER = "Poker"
SHOGI = "Shogi"
# Theme Game-Sport
ACROBATICS = "Acrobatics"
AIRSOFT = "Airsoft"
AMERICAN_FOOTBALL = "American Football"
ATHLETICS = "Athletics"
BADMINTON = "Badminton"
BASEBALL = "Baseball"
BASKETBALL = "Basketball"
BOWLING = "Bowling"
BOXING = "Boxing"
CHEERLEADING = "Cheerleading"
CYCLING = "Cycling"
FENCING = "Fencing"
FISHING = "Fishing"
FITNESS = "Fitness"
FOOTBALL = "Football"
GOLF = "Golf"
HANDBALL = "Handball"
ICE_SKATING = "Ice Skating"
JUDO = "Judo"
LACROSSE = "Lacrosse"
PARKOUR = "Parkour"
RUGBY = "Rugby"
SCUBA_DIVING = "Scuba Diving"
SKATEBOARDING = "Skateboarding"
SUMO = "Sumo"
SURFING = "Surfing"
SWIMMING = "Swimming"
TABLE_TENNIS = "Table Tennis"
TENNIS = "Tennis"
VOLLEYBALL = "Volleyball"
WRESTLING = "Wrestling"
# Theme Other
ADOPTION = "Adoption"
ANIMALS = "Animals"
ASTRONOMY = "Astronomy"
AUTOBIOGRAPHICAL = "Autobiographical"
BIOGRAPHICAL = "Biographical"
BLACKMAIL = "Blackmail"
BODY_HORROR = "Body Horror"
BODY_IMAGE = "Body Image"
CANNIBALISM = "Cannibalism"
CHIBI = "Chibi"
COSMIC_HORROR = "Cosmic Horror"
CREATURE_TAMING = "Creature Taming"
CRIME = "Crime"
CROSSOVER = "Crossover"
DEATH_GAME = "Death Game"
DENPA = "Denpa"
DRUGS = "Drugs"
ECONOMICS = "Economics"
EDUCATIONAL = "Educational"
ENVIRONMENTAL = "Environmental"
ERO_GURO = "Ero Guro"
FILMMAKING = "Filmmaking"
FOUND_FAMILY = "Found Family"
GAMBLING = "Gambling"
GENDER_BENDING = "Gender Bending"
GORE = "Gore"
INDIGENOUS_CULTURES = "Indigenous Cultures"
LANGUAGE_BARRIER = "Language Barrier"
LGBTQ_PLUS_THEMES = "LGBTQ+ Themes"
LOST_CIVILIZATION = "Lost Civilization"
MARRIAGE = "Marriage"
MEDICINE = "Medicine"
MEMORY_MANIPULATION = "Memory Manipulation"
META = "Meta"
MOUNTAINEERING = "Mountaineering"
NOIR = "Noir"
OTAKU_CULTURE = "Otaku Culture"
PANDEMIC = "Pandemic"
PHILOSOPHY = "Philosophy"
POLITICS = "Politics"
PREGNANCY = "Pregnancy"
PROXY_BATTLE = "Proxy Battle"
PSYCHOSEXUAL = "Psychosexual"
REINCARNATION = "Reincarnation"
RELIGION = "Religion"
RESCUE = "Rescue"
ROYAL_AFFAIRS = "Royal Affairs"
SLAVERY = "Slavery"
SOFTWARE_DEVELOPMENT = "Software Development"
SURVIVAL = "Survival"
TERRORISM = "Terrorism"
TORTURE = "Torture"
TRAVEL = "Travel"
VOCAL_SYNTH = "Vocal Synth"
WAR = "War"
# Theme Other-Organisations
ASSASSINS = "Assassins"
CRIMINAL_ORGANIZATION = "Criminal Organization"
CULT = "Cult"
FIREFIGHTERS = "Firefighters"
GANGS = "Gangs"
MAFIA = "Mafia"
MILITARY = "Military"
POLICE = "Police"
TRIADS = "Triads"
YAKUZA = "Yakuza"
# Theme Other-Vehicle
AVIATION = "Aviation"
CARS = "Cars"
MOPEDS = "Mopeds"
MOTORCYCLES = "Motorcycles"
SHIPS = "Ships"
TANKS = "Tanks"
TRAINS = "Trains"
# Theme Romance
AGE_GAP = "Age Gap"
BOYS_LOVE = "Boys' Love"
COHABITATION = "Cohabitation"
FEMALE_HAREM = "Female Harem"
HETEROSEXUAL = "Heterosexual"
LOVE_TRIANGLE = "Love Triangle"
MALE_HAREM = "Male Harem"
MATCHMAKING = "Matchmaking"
MIXED_GENDER_HAREM = "Mixed Gender Harem"
TEENS_LOVE = "Teens' Love"
UNREQUITED_LOVE = "Unrequited Love"
YURI = "Yuri"
# Theme Sci-Fi
CYBERPUNK = "Cyberpunk"
SPACE_OPERA = "Space Opera"
TIME_LOOP = "Time Loop"
TIME_MANIPULATION = "Time Manipulation"
TOKUSATSU = "Tokusatsu"
# Theme Sci-Fi-Mecha
REAL_ROBOT = "Real Robot"
SUPER_ROBOT = "Super Robot"
# Theme Slice of Life
AGRICULTURE = "Agriculture"
CUTE_BOYS_DOING_CUTE_THINGS = "Cute Boys Doing Cute Things"
CUTE_GIRLS_DOING_CUTE_THINGS = "Cute Girls Doing Cute Things"
FAMILY_LIFE = "Family Life"
HORTICULTURE = "Horticulture"
IYASHIKEI = "Iyashikei"
PARENTHOOD = "Parenthood"
class MediaSort(Enum):
ID = "ID"
ID_DESC = "ID_DESC"

View File

@@ -1,6 +1,6 @@
import re
ANIMEPAHE = "animepahe.ru"
ANIMEPAHE = "animepahe.si"
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api"
@@ -19,13 +19,13 @@ REQUEST_HEADERS = {
"TE": "trailers",
}
SERVER_HEADERS = {
"Host": "kwik.si",
"Host": "kwik.cx",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "Utf-8",
"DNT": "1",
"Connection": "keep-alive",
"Referer": "https://animepahe.ru/",
"Referer": "https://animepahe.si/",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "iframe",
"Sec-Fetch-Mode": "navigate",

View File

@@ -0,0 +1,14 @@
import re
ANIMEUNITY = "animeunity.so"
ANIMEUNITY_BASE = f"https://www.{ANIMEUNITY}"
MAX_TIMEOUT = 10
TOKEN_REGEX = re.compile(r'<meta.*?name="csrf-token".*?content="([^"]*)".*?>')
REPLACEMENT_WORDS = {"Season ": "", "Cour": "Part"}
# Server Specific
AVAILABLE_VIDEO_QUALITY = ["1080", "720", "480"]
VIDEO_INFO_REGEX = re.compile(r"window.video\s*=\s*(\{[^\}]*\})")
DOWNLOAD_URL_REGEX = re.compile(r"window.downloadUrl\s*=\s*'([^']*)'")

View File

@@ -0,0 +1,129 @@
from typing import Literal
from ..types import (
Anime,
AnimeEpisodeInfo,
AnimeEpisodes,
EpisodeStream,
MediaTranslationType,
PageInfo,
SearchResult,
SearchResults,
Server,
)
from .constants import AVAILABLE_VIDEO_QUALITY
def map_to_search_results(
data: dict, translation_type: Literal["sub", "dub"]
) -> SearchResults:
results = []
for result in data:
mapped_result = map_to_search_result(result, translation_type)
if mapped_result:
results.append(mapped_result)
return SearchResults(
page_info=PageInfo(),
results=results,
)
def map_to_search_result(
data: dict, translation_type: Literal["sub", "dub"] | None
) -> SearchResult | None:
if translation_type and data["dub"] != 1 if translation_type == "dub" else 0:
return None
return SearchResult(
id=str(data["id"]),
title=get_titles(data)[0] if get_titles(data) else "Unknown",
episodes=AnimeEpisodes(
sub=(
list(map(str, range(1, get_episodes_count(data) + 1)))
if data["dub"] == 0
else []
),
dub=(
list(map(str, range(1, get_episodes_count(data) + 1)))
if data["dub"] == 1
else []
),
),
other_titles=get_titles(data),
score=data["score"],
poster=data["imageurl"],
year=data["date"],
)
def map_to_anime_result(data: list, search_result: SearchResult) -> Anime:
return Anime(
id=search_result.id,
title=search_result.title,
episodes=AnimeEpisodes(
sub=[
episode["number"]
for episode in data
if len(search_result.episodes.sub) > 0
],
dub=[
episode["number"]
for episode in data
if len(search_result.episodes.dub) > 0
],
),
episodes_info=[
AnimeEpisodeInfo(
id=str(episode["id"]),
episode=episode["number"],
title=f"{search_result.title} - Ep {episode['number']}",
)
for episode in data
],
type=search_result.media_type,
poster=search_result.poster,
year=search_result.year,
)
def map_to_server(
episode: AnimeEpisodeInfo, info: dict, translation_type: Literal["sub", "dub"]
) -> Server:
return Server(
name="vixcloud",
links=[
EpisodeStream(
link=info["link"].replace(str(info["quality"]), quality),
title=info["name"],
quality=quality, # type: ignore
translation_type=MediaTranslationType(translation_type),
mp4=True,
)
for quality in AVAILABLE_VIDEO_QUALITY
if int(quality) <= info["quality"]
],
episode_title=episode.title,
)
def get_titles(data: dict) -> list[str]:
"""
Return the most appropriate title from the record.
"""
titles = []
if data.get("title_eng"):
titles.append(data["title_eng"])
if data.get("title"):
titles.append(data["title"])
if data.get("title_it"):
titles.append(data["title_it"])
return titles
def get_episodes_count(record: dict) -> int:
"""
Return the number of episodes from the record.
"""
if (count := record.get("real_episodes_count", 0)) > 0:
return count
return record.get("episodes_count", 0)

View File

@@ -0,0 +1,175 @@
import logging
from functools import lru_cache
from ...scraping.user_agents import UserAgentGenerator
from ..base import BaseAnimeProvider
from ..params import AnimeParams, EpisodeStreamsParams, SearchParams
from ..types import Anime, AnimeEpisodeInfo, SearchResult, SearchResults
from ..utils.debug import debug_provider
from .constants import (
ANIMEUNITY_BASE,
DOWNLOAD_URL_REGEX,
MAX_TIMEOUT,
REPLACEMENT_WORDS,
TOKEN_REGEX,
VIDEO_INFO_REGEX,
)
from .mappers import (
map_to_anime_result,
map_to_search_result,
map_to_search_results,
map_to_server,
)
logger = logging.getLogger(__name__)
class AnimeUnity(BaseAnimeProvider):
HEADERS = {
"User-Agent": UserAgentGenerator().random(),
}
_cache = dict[str, SearchResult]()
@lru_cache
def _get_token(self) -> None:
response = self.client.get(
ANIMEUNITY_BASE,
headers=self.HEADERS,
timeout=MAX_TIMEOUT,
follow_redirects=True,
)
response.raise_for_status()
token_match = TOKEN_REGEX.search(response.text)
if token_match:
self.HEADERS["x-csrf-token"] = token_match.group(1)
self.client.cookies = {
"animeunity_session": response.cookies.get("animeunity_session") or ""
}
self.client.headers = self.HEADERS
@debug_provider
def search(self, params: SearchParams) -> SearchResults | None:
if not (res := self._search(params)):
return None
for result in res.results:
self._cache[result.id] = result
return res
@lru_cache
def _search(self, params: SearchParams) -> SearchResults | None:
self._get_token()
# Replace words in query to
query = params.query
for old, new in REPLACEMENT_WORDS.items():
query = query.replace(old, new)
response = self.client.post(
url=f"{ANIMEUNITY_BASE}/livesearch",
data={"title": query},
timeout=MAX_TIMEOUT,
)
response.raise_for_status()
return map_to_search_results(
response.json().get("records", []), params.translation_type
)
@debug_provider
def get(self, params: AnimeParams) -> Anime | None:
return self._get_anime(params)
@lru_cache()
def _get_search_result(self, params: AnimeParams) -> SearchResult | None:
if cached := self._cache.get(params.id):
return cached
response = self.client.get(
url=f"{ANIMEUNITY_BASE}/info_api/{params.id}/",
timeout=MAX_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if res := map_to_search_result(data, None):
self._cache[params.id] = res
return res
@lru_cache
def _get_anime(self, params: AnimeParams) -> Anime | None:
if (search_result := self._get_search_result(params)) is None:
logger.error(f"No search result found for ID {params.id}")
return None
# Fetch episodes in chunks
data = []
start_range = 1
episode_count = max(
len(search_result.episodes.sub), len(search_result.episodes.dub)
)
while start_range <= episode_count:
end_range = min(start_range + 119, episode_count)
response = self.client.get(
url=f"{ANIMEUNITY_BASE}/info_api/{params.id}/1",
params={
"start_range": start_range,
"end_range": end_range,
},
timeout=MAX_TIMEOUT,
)
response.raise_for_status()
data.extend(response.json().get("episodes", []))
start_range = end_range + 1
return map_to_anime_result(data, search_result)
@lru_cache()
def _get_episode_info(
self, params: EpisodeStreamsParams
) -> AnimeEpisodeInfo | None:
anime_info = self._get_anime(
AnimeParams(id=params.anime_id, query=params.query)
)
if not anime_info:
logger.error(f"No anime info for {params.anime_id}")
return
if not anime_info.episodes_info:
logger.error(f"No episodes info for {params.anime_id}")
return
for episode in anime_info.episodes_info:
if episode.episode == params.episode:
return episode
@debug_provider
def episode_streams(self, params: EpisodeStreamsParams):
if not (episode := self._get_episode_info(params)):
logger.error(
f"Episode {params.episode} doesn't exist for anime {params.anime_id}"
)
return
# Get the Server url
response = self.client.get(
url=f"{ANIMEUNITY_BASE}/embed-url/{episode.id}", timeout=MAX_TIMEOUT
)
response.raise_for_status()
# Fetch the Server page
video_response = self.client.get(url=response.text.strip(), timeout=MAX_TIMEOUT)
video_response.raise_for_status()
video_info = VIDEO_INFO_REGEX.search(video_response.text)
download_url_match = DOWNLOAD_URL_REGEX.search(video_response.text)
if not (download_url_match and video_info):
logger.error(f"Failed to extract video info for episode {episode.id}")
return None
info = eval(video_info.group(1).replace("null", "None"))
info["link"] = download_url_match.group(1)
yield map_to_server(episode, info, params.translation_type)
if __name__ == "__main__":
from ..utils.debug import test_anime_provider
test_anime_provider(AnimeUnity)

View File

@@ -14,6 +14,7 @@ PROVIDERS_AVAILABLE = {
"hianime": "provider.HiAnime",
"nyaa": "provider.Nyaa",
"yugen": "provider.Yugen",
"animeunity": "provider.AnimeUnity",
}

View File

@@ -11,6 +11,7 @@ from pydantic import BaseModel, ConfigDict
class ProviderName(Enum):
ALLANIME = "allanime"
ANIMEPAHE = "animepahe"
ANIMEUNITY = "animeunity"
class ProviderServer(Enum):
@@ -28,6 +29,9 @@ class ProviderServer(Enum):
# AnimePaheServer values
KWIK = "kwik"
# AnimeUnityServer values
VIXCLOUD = "vixcloud"
class MediaTranslationType(Enum):
SUB = "sub"

View File

@@ -48,6 +48,7 @@ class FzfSelector(BaseSelector):
input=fzf_input,
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
)
if result.returncode != 0:
return None
@@ -74,6 +75,7 @@ class FzfSelector(BaseSelector):
input=fzf_input,
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
)
if result.returncode != 0:
return []
@@ -137,6 +139,7 @@ class FzfSelector(BaseSelector):
input="",
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
)
if result.returncode != 0:
return None

View File

@@ -54,15 +54,15 @@ class RofiSelector(BaseSelector):
from plyer import notification
from ....core.constants import (
CLI_NAME,
CLI_NAME_LOWER,
ICON_PATH,
PROJECT_NAME,
PROJECT_NAME_LOWER,
)
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
message=f"Nothing was selected {PROJECT_NAME_LOWER} is shutting down",
app_name=PROJECT_NAME,
title=f"{CLI_NAME} notification".title(),
message=f"Nothing was selected {CLI_NAME_LOWER} is shutting down",
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=2 * 60,
)
@@ -120,15 +120,15 @@ class RofiSelector(BaseSelector):
from plyer import notification
from ....core.constants import (
CLI_NAME,
CLI_NAME_LOWER,
ICON_PATH,
PROJECT_NAME,
PROJECT_NAME_LOWER,
)
notification.notify( # type: ignore
title=f"{PROJECT_NAME} notification".title(),
message=f"Nothing was selected {PROJECT_NAME_LOWER} is shutting down",
app_name=PROJECT_NAME,
title=f"{CLI_NAME} notification".title(),
message=f"Nothing was selected {CLI_NAME_LOWER} is shutting down",
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=2 * 60,
)