mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-12 15:50:01 -08:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
eaedf3268d | ||
|
|
ade0465ea4 | ||
|
|
5e82db4ea8 | ||
|
|
a10e56cb6f | ||
|
|
fbd95e1966 | ||
|
|
d37a441ccf | ||
|
|
cbc1ceccbb | ||
|
|
249a207cad | ||
|
|
c8a42c4920 |
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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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
1
.repomixignore
Normal file
@@ -0,0 +1 @@
|
||||
**/generated/**/*
|
||||
@@ -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.
|
||||
|
||||
|
||||
48
README.md
48
README.md
@@ -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 @@
|
||||
|
||||
[](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>
|
||||
|
||||
@@ -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
|
||||
|
||||
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,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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.10"
|
||||
"pythonVersion": "3.11"
|
||||
}
|
||||
|
||||
2
tox.ini
2
tox.ini
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
██╗░░░██╗██╗██╗░░░██╗
|
||||
██║░░░██║██║██║░░░██║
|
||||
╚██╗░██╔╝██║██║░░░██║
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.[/]")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user