mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
Compare commits
55 Commits
feature/pl
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d7f1702c | ||
|
|
1fea1335c6 | ||
|
|
8b664fae36 | ||
|
|
19a85511b4 | ||
|
|
205299108b | ||
|
|
7670bdd2f3 | ||
|
|
cd3f7f7fb8 | ||
|
|
5be03ed5b8 | ||
|
|
6581179336 | ||
|
|
2bb674f4a0 | ||
|
|
642e77f601 | ||
|
|
a5e99122f5 | ||
|
|
39bd7bed61 | ||
|
|
869072633b | ||
|
|
cbd788a573 | ||
|
|
11fe54b146 | ||
|
|
a13bdb1aa0 | ||
|
|
627b09a723 | ||
|
|
aecec5c75b | ||
|
|
49b298ed52 | ||
|
|
9a90fa196b | ||
|
|
4ac059e873 | ||
|
|
8b39a28e32 | ||
|
|
066cc89b74 | ||
|
|
db16758d9f | ||
|
|
78e17b2ba0 | ||
|
|
c5326eb8d9 | ||
|
|
4a2d95e75e | ||
|
|
3a92ba69df | ||
|
|
cf59f4822e | ||
|
|
1cea6d0179 | ||
|
|
4bc1edcc4e | ||
|
|
0c546af99c | ||
|
|
1b49e186c8 | ||
|
|
fe831f9658 | ||
|
|
72f0e2e5b9 | ||
|
|
8530da23ef | ||
|
|
1e01b6e54a | ||
|
|
aa6ba9018d | ||
|
|
354ba6256a | ||
|
|
eae31420f9 | ||
|
|
01432a0fec | ||
|
|
c158d3fb99 | ||
|
|
877bc043a0 | ||
|
|
4968f8030a | ||
|
|
c5c7644d0d | ||
|
|
ff2a5d635a | ||
|
|
8626d1991c | ||
|
|
75d15a100d | ||
|
|
25d9895c52 | ||
|
|
f1b796d72b | ||
|
|
3f63198563 | ||
|
|
8d61463156 | ||
|
|
2daa51d384 | ||
|
|
43a0d77e1b |
2
.envrc
2
.envrc
@@ -1,3 +1,5 @@
|
||||
VIU_APP_NAME="viu-dev"
|
||||
export VIU_APP_NAME
|
||||
if command -v nix >/dev/null;then
|
||||
use flake
|
||||
fi
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -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.
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -10,10 +10,10 @@
|
||||
|
||||
[](https://pypi.org/project/viu-media/)
|
||||
[](https://pypi.org/project/viu-media/)
|
||||
[](https://github.com/Benexl/Viu/actions)
|
||||
[](https://github.com/viu-media/Viu/actions)
|
||||
[](https://discord.gg/HBEmAwvbHV)
|
||||
[](https://github.com/Benexl/Viu/issues)
|
||||
[](https://github.com/Benexl/Viu/blob/master/LICENSE)
|
||||
[](https://github.com/viu-media/Viu/issues)
|
||||
[](https://github.com/viu-media/Viu/blob/master/LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,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
|
||||
|
||||
@@ -72,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)
|
||||
@@ -102,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
|
||||
|
||||
64
dev/generate_anilist_media_tags.py
Executable file
64
dev/generate_anilist_media_tags.py
Executable 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
8
dev/graphql/anilist/media_tags.gql
Normal file
8
dev/graphql/anilist/media_tags.gql
Normal file
@@ -0,0 +1,8 @@
|
||||
query {
|
||||
MediaTagCollection {
|
||||
name
|
||||
description
|
||||
category
|
||||
isAdult
|
||||
}
|
||||
}
|
||||
0
dev/make_release
Normal file → Executable file
0
dev/make_release
Normal file → Executable file
8
flake.lock
generated
8
flake.lock
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
15
flake.nix
15
flake.nix
@@ -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 ];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[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"
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.11"
|
||||
"pythonVersion": "3.12"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ def _create_tar_backup(
|
||||
api: str,
|
||||
):
|
||||
"""Create a tar-based backup."""
|
||||
# TODO: Add support for bz2/xz compression if needed
|
||||
mode = "w:gz" if compress else "w"
|
||||
|
||||
with tarfile.open(output_path, mode) as tar:
|
||||
|
||||
@@ -5,6 +5,7 @@ Registry restore command - restore registry from backup files
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -25,6 +26,11 @@ from ....service.registry.service import MediaRegistryService
|
||||
is_flag=True,
|
||||
help="Create backup of current registry before restoring",
|
||||
)
|
||||
@click.option(
|
||||
"--backup-current-tar-compression-fmt",
|
||||
type=click.Choice(["gz", "bz2", "xz"], case_sensitive=False),
|
||||
help="The compression format to use for the current registry backup (if enabled)",
|
||||
)
|
||||
@click.option("--verify", is_flag=True, help="Verify backup integrity before restoring")
|
||||
@click.option(
|
||||
"--api",
|
||||
@@ -38,6 +44,7 @@ def restore(
|
||||
backup_file: Path,
|
||||
force: bool,
|
||||
backup_current: bool,
|
||||
backup_current_compression_fmt: str,
|
||||
verify: bool,
|
||||
api: str,
|
||||
):
|
||||
@@ -61,7 +68,7 @@ def restore(
|
||||
"Verification Failed",
|
||||
"Backup file appears to be corrupted or invalid",
|
||||
)
|
||||
raise click.Abort()
|
||||
return
|
||||
feedback.success("Verification", "Backup file integrity verified")
|
||||
|
||||
# Check if current registry exists
|
||||
@@ -77,7 +84,13 @@ def restore(
|
||||
|
||||
# Create backup of current registry if requested
|
||||
if backup_current and registry_exists:
|
||||
_backup_current_registry(registry_service, api, feedback)
|
||||
_backup_current_registry(
|
||||
registry_service,
|
||||
api,
|
||||
feedback,
|
||||
backup_format=backup_format,
|
||||
compression_fmt=backup_current_compression_fmt,
|
||||
)
|
||||
|
||||
# Show restore summary
|
||||
_show_restore_summary(backup_file, backup_format, feedback)
|
||||
@@ -110,7 +123,13 @@ def restore(
|
||||
def _detect_backup_format(backup_file: Path) -> str:
|
||||
"""Detect backup file format."""
|
||||
suffixes = "".join(backup_file.suffixes).lower()
|
||||
if ".tar" in suffixes or ".gz" in suffixes or ".tgz" in suffixes:
|
||||
if (
|
||||
".tar" in suffixes
|
||||
or ".gz" in suffixes
|
||||
or ".tgz" in suffixes
|
||||
or ".bz2" in suffixes
|
||||
or ".xz" in suffixes
|
||||
):
|
||||
return "tar"
|
||||
elif ".zip" in suffixes:
|
||||
return "zip"
|
||||
@@ -122,25 +141,38 @@ def _verify_backup(
|
||||
) -> bool:
|
||||
"""Verify backup file integrity."""
|
||||
try:
|
||||
metadata = {}
|
||||
has_registry = has_index = has_metadata = False
|
||||
if format_type == "tar":
|
||||
with tarfile.open(backup_file, "r:*") as tar:
|
||||
names = tar.getnames()
|
||||
has_registry = any("registry/" in name for name in names)
|
||||
has_index = any("index/" in name for name in names)
|
||||
has_metadata = "backup_metadata.json" in names
|
||||
for name in names:
|
||||
if name == "registry/":
|
||||
has_registry = True
|
||||
continue
|
||||
if name == "index/":
|
||||
has_index = True
|
||||
continue
|
||||
if name == "backup_metadata.json":
|
||||
has_metadata = True
|
||||
continue
|
||||
if has_metadata:
|
||||
metadata_member = tar.getmember("backup_metadata.json")
|
||||
if metadata_file := tar.extractfile(metadata_member):
|
||||
metadata = json.load(metadata_file)
|
||||
else: # zip
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(backup_file, "r") as zip_file:
|
||||
names = zip_file.namelist()
|
||||
has_registry = any("registry/" in name for name in names)
|
||||
has_index = any("index/" in name for name in names)
|
||||
has_metadata = "backup_metadata.json" in names
|
||||
for name in names:
|
||||
if name == "registry/":
|
||||
has_registry = True
|
||||
continue
|
||||
if name == "index/":
|
||||
has_index = True
|
||||
continue
|
||||
if name == "backup_metadata.json":
|
||||
has_metadata = True
|
||||
continue
|
||||
if has_metadata:
|
||||
with zip_file.open("backup_metadata.json") as metadata_file:
|
||||
metadata = json.load(metadata_file)
|
||||
@@ -163,27 +195,42 @@ def _verify_backup(
|
||||
|
||||
def _check_registry_exists(registry_service: MediaRegistryService) -> bool:
|
||||
"""Check if a registry already exists."""
|
||||
try:
|
||||
stats = registry_service.get_registry_stats()
|
||||
return stats.get("total_media", 0) > 0
|
||||
except Exception:
|
||||
return False
|
||||
# TODO: Improve this check to be more robust
|
||||
return registry_service.media_registry_dir.exists() and any(
|
||||
registry_service.media_registry_dir.iterdir()
|
||||
)
|
||||
|
||||
|
||||
def _backup_current_registry(
|
||||
registry_service: MediaRegistryService, api: str, feedback: FeedbackService
|
||||
registry_service: MediaRegistryService,
|
||||
api: str,
|
||||
feedback: FeedbackService,
|
||||
backup_format: str,
|
||||
compression_fmt: str,
|
||||
):
|
||||
"""Create backup of current registry before restoring."""
|
||||
from .backup import _create_tar_backup
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup_path = Path(f"viu_registry_pre_restore_{api}_{timestamp}.tar.gz")
|
||||
if backup_format == "tar":
|
||||
from .backup import _create_tar_backup
|
||||
|
||||
try:
|
||||
_create_tar_backup(registry_service, backup_path, True, False, feedback, api)
|
||||
feedback.success("Current Registry Backed Up", f"Saved to {backup_path}")
|
||||
except Exception as e:
|
||||
feedback.warning("Backup Warning", f"Failed to backup current registry: {e}")
|
||||
backup_path = Path(f"viu_registry_pre_restore_{api}_{timestamp}.tar.gz")
|
||||
|
||||
try:
|
||||
_create_tar_backup(
|
||||
registry_service, backup_path, True, False, feedback, api
|
||||
)
|
||||
feedback.success("Current Registry Backed Up", f"Saved to {backup_path}")
|
||||
except Exception as e:
|
||||
feedback.warning(
|
||||
"Backup Warning", f"Failed to backup current registry: {e}"
|
||||
)
|
||||
else:
|
||||
from .backup import _create_zip_backup
|
||||
|
||||
backup_path = Path(f"viu_registry_pre_restore_{api}_{timestamp}.zip")
|
||||
|
||||
_create_zip_backup(registry_service, backup_path, True, feedback, api)
|
||||
|
||||
|
||||
def _show_restore_summary(
|
||||
|
||||
@@ -2,6 +2,7 @@ import textwrap
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, get_args, get_origin
|
||||
|
||||
# TODO: should we maintain a separate dependency for InquirerPy or write our own simple prompt system?
|
||||
from InquirerPy import inquirer
|
||||
from InquirerPy.validator import NumberValidator
|
||||
from pydantic import BaseModel
|
||||
@@ -28,7 +29,7 @@ class InteractiveConfigEditor:
|
||||
if not isinstance(section_model, BaseModel):
|
||||
continue
|
||||
|
||||
if not inquirer.confirm(
|
||||
if not inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=f"Configure '{section_name.title()}' settings?",
|
||||
default=True,
|
||||
).execute():
|
||||
@@ -83,14 +84,14 @@ class InteractiveConfigEditor:
|
||||
|
||||
# Boolean fields
|
||||
if field_type is bool:
|
||||
return inquirer.confirm(
|
||||
return inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message, default=current_value, long_instruction=help_text
|
||||
)
|
||||
|
||||
# Literal (Choice) fields
|
||||
if hasattr(field_type, "__origin__") and get_origin(field_type) is Literal:
|
||||
choices = list(get_args(field_type))
|
||||
return inquirer.select(
|
||||
return inquirer.select( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message,
|
||||
choices=choices,
|
||||
default=current_value,
|
||||
@@ -99,7 +100,7 @@ class InteractiveConfigEditor:
|
||||
|
||||
# Numeric fields
|
||||
if field_type is int:
|
||||
return inquirer.number(
|
||||
return inquirer.number( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message,
|
||||
default=int(current_value),
|
||||
long_instruction=help_text,
|
||||
@@ -110,7 +111,7 @@ class InteractiveConfigEditor:
|
||||
validate=NumberValidator(),
|
||||
)
|
||||
if field_type is float:
|
||||
return inquirer.number(
|
||||
return inquirer.number( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message,
|
||||
default=float(current_value),
|
||||
float_allowed=True,
|
||||
@@ -120,7 +121,7 @@ class InteractiveConfigEditor:
|
||||
# Path fields
|
||||
if field_type is Path:
|
||||
# Use text prompt for paths to allow '~' expansion, as FilePathPrompt can be tricky
|
||||
return inquirer.text(
|
||||
return inquirer.text( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
@@ -128,13 +129,13 @@ class InteractiveConfigEditor:
|
||||
if field_type is str:
|
||||
# Check for 'examples' to provide choices
|
||||
if hasattr(field_info, "examples") and field_info.examples:
|
||||
return inquirer.fuzzy(
|
||||
return inquirer.fuzzy( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message,
|
||||
choices=field_info.examples,
|
||||
default=str(current_value),
|
||||
long_instruction=help_text,
|
||||
)
|
||||
return inquirer.text(
|
||||
return inquirer.text( # pyright: ignore[reportPrivateImportUsage]
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,8 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from viu_media.core.exceptions import DependencyNotFoundError
|
||||
import importlib.util
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -43,67 +45,74 @@ def resize_image_from_url(
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
if importlib.util.find_spec("PIL"):
|
||||
from PIL import Image # pyright: ignore[reportMissingImports]
|
||||
|
||||
if not return_bytes and output_path is None:
|
||||
raise ValueError("output_path must be provided if return_bytes is False.")
|
||||
if not return_bytes and output_path is None:
|
||||
raise ValueError("output_path must be provided if return_bytes is False.")
|
||||
|
||||
try:
|
||||
# Use the provided synchronous client
|
||||
response = client.get(url)
|
||||
response.raise_for_status() # Raise an exception for bad status codes
|
||||
try:
|
||||
# Use the provided synchronous client
|
||||
response = client.get(url)
|
||||
response.raise_for_status() # Raise an exception for bad status codes
|
||||
|
||||
image_bytes = response.content
|
||||
image_stream = BytesIO(image_bytes)
|
||||
img = Image.open(image_stream)
|
||||
image_bytes = response.content
|
||||
image_stream = BytesIO(image_bytes)
|
||||
img = Image.open(image_stream)
|
||||
|
||||
if maintain_aspect_ratio:
|
||||
img_copy = img.copy()
|
||||
img_copy.thumbnail((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
resized_img = img_copy
|
||||
else:
|
||||
resized_img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
if return_bytes:
|
||||
# Determine the output format. Default to JPEG if original is unknown or problematic.
|
||||
# Handle RGBA to RGB conversion for JPEG output.
|
||||
output_format = (
|
||||
img.format if img.format in ["JPEG", "PNG", "WEBP"] else "JPEG"
|
||||
)
|
||||
if output_format == "JPEG":
|
||||
if resized_img.mode in ("RGBA", "P"):
|
||||
resized_img = resized_img.convert("RGB")
|
||||
|
||||
byte_arr = BytesIO()
|
||||
resized_img.save(byte_arr, format=output_format)
|
||||
logger.info(
|
||||
f"Image from {url} resized to {resized_img.width}x{resized_img.height} and returned as bytes ({output_format} format)."
|
||||
)
|
||||
return byte_arr.getvalue()
|
||||
else:
|
||||
# Ensure the directory exists before saving
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resized_img.save(output_path)
|
||||
logger.info(
|
||||
f"Image from {url} resized to {resized_img.width}x{resized_img.height} and saved as '{output_path}'"
|
||||
if maintain_aspect_ratio:
|
||||
img_copy = img.copy()
|
||||
img_copy.thumbnail((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
resized_img = img_copy
|
||||
else:
|
||||
resized_img = img.resize(
|
||||
(new_width, new_height), Image.Resampling.LANCZOS
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"An error occurred while requesting {url}: {e}")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error occurred: {e.response.status_code} - {e.response.text}"
|
||||
if return_bytes:
|
||||
# Determine the output format. Default to JPEG if original is unknown or problematic.
|
||||
# Handle RGBA to RGB conversion for JPEG output.
|
||||
output_format = (
|
||||
img.format if img.format in ["JPEG", "PNG", "WEBP"] else "JPEG"
|
||||
)
|
||||
if output_format == "JPEG":
|
||||
if resized_img.mode in ("RGBA", "P"):
|
||||
resized_img = resized_img.convert("RGB")
|
||||
|
||||
byte_arr = BytesIO()
|
||||
resized_img.save(byte_arr, format=output_format)
|
||||
logger.info(
|
||||
f"Image from {url} resized to {resized_img.width}x{resized_img.height} and returned as bytes ({output_format} format)."
|
||||
)
|
||||
return byte_arr.getvalue()
|
||||
else:
|
||||
# Ensure the directory exists before saving
|
||||
if output_path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
resized_img.save(output_path)
|
||||
logger.info(
|
||||
f"Image from {url} resized to {resized_img.width}x{resized_img.height} and saved as '{output_path}'"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"An error occurred while requesting {url}: {e}")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(
|
||||
f"HTTP error occurred: {e.response.status_code} - {e.response.text}"
|
||||
)
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred: {e}")
|
||||
return None
|
||||
else:
|
||||
raise DependencyNotFoundError(
|
||||
"Pillow library is required for image processing. Please install it via 'uv pip install Pillow'."
|
||||
)
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str]:
|
||||
@@ -123,17 +132,12 @@ def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str
|
||||
If capture is False, prints directly to the terminal and returns None.
|
||||
Returns None on any failure.
|
||||
"""
|
||||
# --- Common subprocess arguments ---
|
||||
subprocess_kwargs = {
|
||||
"check": False, # We will handle errors manually
|
||||
"capture_output": capture,
|
||||
"text": capture, # Decode stdout/stderr as text if capturing
|
||||
}
|
||||
|
||||
# --- Try icat (Kitty terminal) first ---
|
||||
if icat_executable := shutil.which("icat"):
|
||||
process = subprocess.run(
|
||||
[icat_executable, "--align", "left", url], **subprocess_kwargs
|
||||
[icat_executable, "--align", "left", url],
|
||||
capture_output=capture,
|
||||
text=capture,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
return process.stdout if capture else None
|
||||
@@ -148,11 +152,11 @@ def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str
|
||||
response.raise_for_status()
|
||||
img_bytes = response.content
|
||||
|
||||
# Add stdin input to the subprocess arguments
|
||||
subprocess_kwargs["input"] = img_bytes
|
||||
|
||||
process = subprocess.run(
|
||||
[chafa_executable, f"--size={size}", "-"], **subprocess_kwargs
|
||||
[chafa_executable, f"--size={size}", "-"],
|
||||
capture_output=capture,
|
||||
text=capture,
|
||||
input=img_bytes,
|
||||
)
|
||||
if process.returncode == 0:
|
||||
return process.stdout if capture else None
|
||||
|
||||
@@ -13,7 +13,7 @@ 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"
|
||||
|
||||
@@ -130,10 +130,11 @@ class YtDLPDownloader(BaseDownloader):
|
||||
}
|
||||
)
|
||||
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
# TODO: Confirm this type issues
|
||||
with yt_dlp.YoutubeDL(opts) as ydl: # type: ignore
|
||||
info = ydl.extract_info(params.url, download=True)
|
||||
if info:
|
||||
_video_path = info["requested_downloads"][0]["filepath"]
|
||||
_video_path = info["requested_downloads"][0]["filepath"] # type: ignore
|
||||
if _video_path.endswith(".unknown_video"):
|
||||
print("Normalizing path...")
|
||||
_vid_path = _video_path.replace(".unknown_video", ".mp4")
|
||||
|
||||
@@ -219,7 +219,7 @@ class BackgroundWorker(ABC):
|
||||
else:
|
||||
# Wait for tasks to complete with timeout
|
||||
try:
|
||||
self._executor.shutdown(wait=True, timeout=timeout)
|
||||
self._executor.shutdown(wait=True)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Worker {self.name} shutdown timed out, forcing cancellation"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
from httpx import get
|
||||
|
||||
ANISKIP_ENDPOINT = "https://api.aniskip.com/v1/skip-times"
|
||||
|
||||
|
||||
# TODO: Finish own implementation of aniskip script
|
||||
class AniSkip:
|
||||
@classmethod
|
||||
def get_skip_times(
|
||||
cls, mal_id: int, episode_number: float | int, types=["op", "ed"]
|
||||
):
|
||||
url = f"{ANISKIP_ENDPOINT}/{mal_id}/{episode_number}?types=op&types=ed"
|
||||
response = get(url)
|
||||
print(response.text)
|
||||
return response.json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mal_id = input("Mal id: ")
|
||||
episode_number = input("episode_number: ")
|
||||
skip_times = AniSkip.get_skip_times(int(mal_id), float(episode_number))
|
||||
print(skip_times)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .api import connect
|
||||
|
||||
__all__ = ["connect"]
|
||||
@@ -1,13 +0,0 @@
|
||||
import time
|
||||
|
||||
from pypresence import Presence
|
||||
|
||||
|
||||
def connect(show, episode, switch):
|
||||
presence = Presence(client_id="1292070065583165512")
|
||||
presence.connect()
|
||||
if not switch.is_set():
|
||||
presence.update(details=show, state="Watching episode " + episode)
|
||||
time.sleep(10)
|
||||
else:
|
||||
presence.close()
|
||||
1359
viu_media/libs/media_api/_media_tags.py
Normal file
1359
viu_media/libs/media_api/_media_tags.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@ from ..types import (
|
||||
Studio,
|
||||
UserListItem,
|
||||
UserMediaListStatus,
|
||||
MediaType,
|
||||
UserProfile,
|
||||
)
|
||||
from .types import (
|
||||
@@ -539,7 +540,7 @@ def _to_generic_media_item_from_notification_partial(
|
||||
title=_to_generic_media_title(data["title"]),
|
||||
cover_image=_to_generic_media_image(data["coverImage"]),
|
||||
# Provide default/empty values for fields not in notification payload
|
||||
type="ANIME",
|
||||
type=MediaType.ANIME,
|
||||
status=MediaStatus.RELEASING, # Assume releasing for airing notifications
|
||||
format=None,
|
||||
description=None,
|
||||
|
||||
@@ -6,6 +6,7 @@ from ..types import (
|
||||
MediaImage,
|
||||
MediaItem,
|
||||
MediaSearchResult,
|
||||
MediaStatus,
|
||||
MediaTitle,
|
||||
PageInfo,
|
||||
Studio,
|
||||
@@ -17,9 +18,9 @@ if TYPE_CHECKING:
|
||||
|
||||
# Jikan uses specific strings for status, we can map them to our generic enum.
|
||||
JIKAN_STATUS_MAP = {
|
||||
"Finished Airing": "FINISHED",
|
||||
"Currently Airing": "RELEASING",
|
||||
"Not yet aired": "NOT_YET_RELEASED",
|
||||
"Finished Airing": MediaStatus.FINISHED,
|
||||
"Currently Airing": MediaStatus.RELEASING,
|
||||
"Not yet aired": MediaStatus.NOT_YET_RELEASED,
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +43,11 @@ def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
|
||||
elif type_ == "Japanese":
|
||||
native = title_
|
||||
|
||||
return MediaTitle(romaji=romaji, english=english, native=native)
|
||||
return MediaTitle(
|
||||
romaji=romaji,
|
||||
english=english or romaji or native or "NOT AVAILABLE",
|
||||
native=native,
|
||||
)
|
||||
|
||||
|
||||
def _to_generic_image(jikan_images: dict) -> MediaImage:
|
||||
@@ -69,7 +74,7 @@ def _to_generic_media_item(data: dict) -> MediaItem:
|
||||
id_mal=data["mal_id"],
|
||||
title=_to_generic_title(data.get("titles", [])),
|
||||
cover_image=_to_generic_image(data.get("images", {})),
|
||||
status=JIKAN_STATUS_MAP.get(data.get("status", ""), None),
|
||||
status=JIKAN_STATUS_MAP.get(data.get("status", ""), MediaStatus.UNKNOWN),
|
||||
episodes=data.get("episodes"),
|
||||
duration=data.get("duration"),
|
||||
average_score=score,
|
||||
@@ -81,7 +86,7 @@ def _to_generic_media_item(data: dict) -> MediaItem:
|
||||
Studio(id=s["mal_id"], name=s["name"]) for s in data.get("studios", [])
|
||||
],
|
||||
# Jikan doesn't provide streaming episodes
|
||||
streaming_episodes=[],
|
||||
streaming_episodes={},
|
||||
# Jikan doesn't provide user list status in its search results.
|
||||
user_status=None,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -14,6 +15,7 @@ class MediaStatus(Enum):
|
||||
NOT_YET_RELEASED = "NOT_YET_RELEASED"
|
||||
CANCELLED = "CANCELLED"
|
||||
HIATUS = "HIATUS"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class MediaType(Enum):
|
||||
@@ -285,472 +287,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"
|
||||
|
||||
@@ -125,47 +125,10 @@ def test_media_api(api_client: BaseApiClient):
|
||||
print()
|
||||
|
||||
# Test 5: Get Characters
|
||||
print("5. Testing Character Information...")
|
||||
try:
|
||||
characters = api_client.get_characters_of(
|
||||
MediaCharactersParams(id=selected_anime.id)
|
||||
)
|
||||
if characters and characters.get("data"):
|
||||
char_data = characters["data"]["Page"]["media"][0]["characters"]["nodes"]
|
||||
if char_data:
|
||||
print(f" Found {len(char_data)} characters:")
|
||||
for char in char_data[:3]: # Show first 3
|
||||
name = char["name"]["full"] or char["name"]["first"]
|
||||
print(f" - {name}")
|
||||
else:
|
||||
print(" No character data found")
|
||||
else:
|
||||
print(" No characters found")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
print()
|
||||
# TODO: Recreate this test
|
||||
|
||||
# Test 6: Get Airing Schedule
|
||||
print("6. Testing Airing Schedule...")
|
||||
try:
|
||||
schedule = api_client.get_airing_schedule_for(
|
||||
MediaAiringScheduleParams(id=selected_anime.id)
|
||||
)
|
||||
if schedule and schedule.get("data"):
|
||||
schedule_data = schedule["data"]["Page"]["media"][0]["airingSchedule"][
|
||||
"nodes"
|
||||
]
|
||||
if schedule_data:
|
||||
print(f" Found {len(schedule_data)} upcoming episodes:")
|
||||
for ep in schedule_data[:3]: # Show first 3
|
||||
print(f" - Episode {ep['episode']}")
|
||||
else:
|
||||
print(" No upcoming episodes")
|
||||
else:
|
||||
print(" No airing schedule found")
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
print()
|
||||
# TODO: Recreate this test
|
||||
|
||||
# Test 7: User Media List (if authenticated)
|
||||
if api_client.is_authenticated():
|
||||
|
||||
@@ -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",
|
||||
|
||||
14
viu_media/libs/provider/anime/animeunity/constants.py
Normal file
14
viu_media/libs/provider/anime/animeunity/constants.py
Normal 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*'([^']*)'")
|
||||
129
viu_media/libs/provider/anime/animeunity/mappers.py
Normal file
129
viu_media/libs/provider/anime/animeunity/mappers.py
Normal 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)
|
||||
175
viu_media/libs/provider/anime/animeunity/provider.py
Normal file
175
viu_media/libs/provider/anime/animeunity/provider.py
Normal 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)
|
||||
@@ -14,6 +14,7 @@ PROVIDERS_AVAILABLE = {
|
||||
"hianime": "provider.HiAnime",
|
||||
"nyaa": "provider.Nyaa",
|
||||
"yugen": "provider.Yugen",
|
||||
"animeunity": "provider.AnimeUnity",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user