mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-25 20:34:26 -08:00
Compare commits
2 Commits
c8c4e1b2c0
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f99ff546d5 | ||
|
|
88b707e060 |
3
.envrc
3
.envrc
@@ -1,6 +1,3 @@
|
||||
VIU_APP_NAME="viu-dev"
|
||||
PATH="./.venv/bin/:$PATH"
|
||||
export PATH VIU_APP_NAME
|
||||
if command -v nix >/dev/null;then
|
||||
use flake
|
||||
fi
|
||||
|
||||
57
.github/workflows/stale.yml
vendored
57
.github/workflows/stale.yml
vendored
@@ -1,57 +0,0 @@
|
||||
name: Mark Stale Issues and Pull Requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Runs every day at 6:30 UTC
|
||||
- cron: "30 6 * * *"
|
||||
# Allows you to run this workflow manually from the Actions tab for testing
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
stale-issue-message: |
|
||||
Greetings @{{author}},
|
||||
|
||||
This bug report is like an ancient scroll detailing a legendary beast. Our small guild of developers is often on many quests at once, so our response times can be slower than a tortoise in a time-stop spell. We deeply appreciate your patience!
|
||||
|
||||
**Seeking Immediate Help or Discussion?**
|
||||
Our **[Discord Tavern](https://discord.gg/HBEmAwvbHV)** is the best place to get a quick response from the community for general questions or setup help!
|
||||
|
||||
**Want to Be the Hero?**
|
||||
You could try to tame this beast yourself! With modern grimoires (like AI coding assistants) and our **[Contribution Guide](https://github.com/viu-media/Viu/blob/master/CONTRIBUTIONS.md)**, you might just be the hero we're waiting for. We would be thrilled to review your solution!
|
||||
|
||||
---
|
||||
To keep our quest board tidy, we need to know if this creature is still roaming the lands in the latest version of `viu`. If we don't get an update within **7 days**, we'll assume it has vanished and archive the scroll.
|
||||
|
||||
Thanks for being our trusted scout!
|
||||
|
||||
stale-pr-message: |
|
||||
Hello @{{author}}, it looks like this powerful contribution has been left in the middle of its training arc! 💪
|
||||
|
||||
Our review dojo is managed by just a few senseis who are sometimes away on long missions, so thank you for your patience as we work through the queue.
|
||||
|
||||
We were excited to see this new technique being developed. Are you still planning to complete its training, or have you embarked on a different quest? If you need a sparring partner (reviewer) or some guidance from a senpai, just let us know!
|
||||
|
||||
To keep our dojo tidy, we'll be archiving unfinished techniques. If we don't hear back within **7 days**, we'll assume it's time to close this PR for now. You can always resume your training and reopen it when you're ready.
|
||||
|
||||
Thank you for your incredible effort!
|
||||
|
||||
# --- Labels and Timing ---
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
|
||||
# How many days of inactivity before an issue/PR is marked as stale.
|
||||
days-before-stale: 14
|
||||
|
||||
# How many days of inactivity to wait before closing a stale issue/PR.
|
||||
days-before-close: 7
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -41,7 +41,5 @@ jobs:
|
||||
- name: Run type checking
|
||||
run: uv run pyright
|
||||
|
||||
# TODO: write tests
|
||||
|
||||
# - name: Run tests
|
||||
# run: uv run pytest tests
|
||||
- name: Run tests
|
||||
run: uv run pytest tests
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
default_language_version:
|
||||
python: python3.12
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.14.2
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff-check
|
||||
args: [--fix]
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
args: ["--profile", "black"]
|
||||
|
||||
- repo: https://github.com/PyCQA/autoflake
|
||||
rev: v2.2.1
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args:
|
||||
[
|
||||
"--in-place",
|
||||
"--remove-unused-variables",
|
||||
"--remove-all-unused-imports",
|
||||
]
|
||||
# - repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# rev: v0.4.10
|
||||
# hooks:
|
||||
# - id: ruff
|
||||
# args: [--fix]
|
||||
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
name: black
|
||||
#language_version: python3.10
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
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/viu-media/Viu/issues).
|
||||
* **Reporting Bugs:** If you find a bug, please create an issue in our [issue tracker](https://github.com/Benexl/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/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.
|
||||
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.
|
||||
|
||||
2. **Fork the Repository:** Create your own fork of the Viu repository.
|
||||
|
||||
|
||||
319
PLUGINS.md
Normal file
319
PLUGINS.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# Viu Plugin Development Guide
|
||||
|
||||
This guide explains how to create plugins for viu, the terminal-based anime streaming tool.
|
||||
|
||||
## Overview
|
||||
|
||||
Viu supports four types of plugins:
|
||||
|
||||
- **Providers**: Add support for new anime streaming websites
|
||||
- **Players**: Add support for new media players
|
||||
- **Selectors**: Add support for new interactive selection tools
|
||||
- **Commands**: Add new CLI commands to viu
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
Every plugin must be a Git repository with the following structure:
|
||||
|
||||
```
|
||||
your-plugin-repo/
|
||||
├── plugin.info.toml # Plugin metadata (required)
|
||||
├── your_module.py # Your plugin implementation
|
||||
├── config.toml # Default configuration (optional)
|
||||
├── requirements.txt # Dependencies (optional)
|
||||
├── utils.py # Additional modules (optional)
|
||||
├── helpers/ # Subdirectories supported (optional)
|
||||
│ ├── __init__.py
|
||||
│ └── parser.py
|
||||
└── README.md # Documentation (recommended)
|
||||
```
|
||||
|
||||
### Multi-File Plugins
|
||||
|
||||
Viu supports plugins with multiple Python files. You can organize your plugin code across multiple modules and import between them normally:
|
||||
|
||||
```python
|
||||
# In your main plugin file
|
||||
from utils import helper_function
|
||||
from helpers.parser import ResponseParser
|
||||
|
||||
class MyProvider(BaseAnimeProvider):
|
||||
def __init__(self, client, **config):
|
||||
self.parser = ResponseParser()
|
||||
# ... rest of implementation
|
||||
```
|
||||
|
||||
The plugin system automatically adds your plugin directory to Python's import path during loading, so relative imports work as expected.
|
||||
|
||||
### Plugin Manifest (`plugin.info.toml`)
|
||||
|
||||
Every plugin repository must contain a `plugin.info.toml` file at its root:
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
name = "My Awesome Plugin"
|
||||
version = "1.0.0"
|
||||
description = "Adds support for Example Anime Site"
|
||||
author = "Your Name"
|
||||
homepage = "https://github.com/yourname/viu-example-plugin"
|
||||
requires_python = ">=3.11"
|
||||
|
||||
[components]
|
||||
# Specify which components your plugin provides
|
||||
provider = "example_provider:ExampleProvider" # format: module:class
|
||||
# player = "my_player:MyPlayer" # (if providing a player)
|
||||
# selector = "my_selector:MySelector" # (if providing a selector)
|
||||
# command = "my_command:my_command_func" # (if providing a command)
|
||||
```
|
||||
|
||||
## Provider Plugins
|
||||
|
||||
Provider plugins add support for new anime streaming websites.
|
||||
|
||||
### Requirements
|
||||
|
||||
Your provider class must inherit from `BaseAnimeProvider` and implement:
|
||||
|
||||
- `search(query: str) -> SearchResults`
|
||||
- `get(anime_id: str) -> Anime`
|
||||
- `episode_streams(anime_id: str, episode: str) -> List[Server]`
|
||||
|
||||
### Example Provider Plugin
|
||||
|
||||
**plugin.info.toml:**
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
name = "Example Anime Provider"
|
||||
version = "1.0.0"
|
||||
description = "Adds support for example.anime.site"
|
||||
|
||||
[components]
|
||||
provider = "example_provider:ExampleProvider"
|
||||
```
|
||||
|
||||
**example_provider.py:**
|
||||
|
||||
```python
|
||||
from typing import List
|
||||
from httpx import Client
|
||||
|
||||
# These imports work because viu adds the plugin path to sys.path
|
||||
from viu_media.libs.provider.anime.base import BaseAnimeProvider
|
||||
from viu_media.libs.provider.anime.types import SearchResults, Anime, Server
|
||||
|
||||
class ExampleProvider(BaseAnimeProvider):
|
||||
HEADERS = {
|
||||
"Referer": "https://example.anime.site/",
|
||||
}
|
||||
|
||||
def __init__(self, client: Client, **config):
|
||||
self.client = client
|
||||
# Access plugin configuration
|
||||
self.timeout = config.get("timeout", 30)
|
||||
self.preferred_quality = config.get("preferred_quality", "720p")
|
||||
|
||||
def search(self, query: str) -> SearchResults:
|
||||
# Implement search logic
|
||||
response = self.client.get(f"https://example.anime.site/search?q={query}")
|
||||
# Parse response and return SearchResults
|
||||
return SearchResults(...)
|
||||
|
||||
def get(self, anime_id: str) -> Anime:
|
||||
# Implement anime details fetching
|
||||
response = self.client.get(f"https://example.anime.site/anime/{anime_id}")
|
||||
# Parse response and return Anime
|
||||
return Anime(...)
|
||||
|
||||
def episode_streams(self, anime_id: str, episode: str) -> List[Server]:
|
||||
# Implement stream URL extraction
|
||||
response = self.client.get(f"https://example.anime.site/watch/{anime_id}/{episode}")
|
||||
# Parse response and return list of Server objects
|
||||
return [Server(...)]
|
||||
```
|
||||
|
||||
## Player Plugins
|
||||
|
||||
Player plugins add support for new media players.
|
||||
|
||||
### Requirements
|
||||
|
||||
Your player class must inherit from `BasePlayer` and implement:
|
||||
|
||||
- `play(media_url: str, **kwargs) -> None`
|
||||
|
||||
### Example Player Plugin
|
||||
|
||||
**plugin.info.toml:**
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
name = "Custom Player"
|
||||
version = "1.0.0"
|
||||
description = "Adds support for my custom media player"
|
||||
|
||||
[components]
|
||||
player = "custom_player:CustomPlayer"
|
||||
```
|
||||
|
||||
**custom_player.py:**
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
from viu_media.libs.player.base import BasePlayer
|
||||
|
||||
class CustomPlayer(BasePlayer):
|
||||
def __init__(self, **config):
|
||||
self.executable = config.get("executable", "my-player")
|
||||
self.extra_args = config.get("extra_args", [])
|
||||
|
||||
def play(self, media_url: str, **kwargs) -> None:
|
||||
cmd = [self.executable] + self.extra_args + [media_url]
|
||||
subprocess.run(cmd)
|
||||
```
|
||||
|
||||
## Selector Plugins
|
||||
|
||||
Selector plugins add support for new interactive selection tools.
|
||||
|
||||
### Requirements
|
||||
|
||||
Your selector class must inherit from `BaseSelector` and implement:
|
||||
|
||||
- `choose(choices: List[str], **kwargs) -> str`
|
||||
- `confirm(message: str, **kwargs) -> bool`
|
||||
- `ask(message: str, **kwargs) -> str`
|
||||
|
||||
## Command Plugins
|
||||
|
||||
Command plugins add new CLI commands to viu.
|
||||
|
||||
### Example Command Plugin
|
||||
|
||||
**plugin.info.toml:**
|
||||
|
||||
```toml
|
||||
[plugin]
|
||||
name = "My Command"
|
||||
version = "1.0.0"
|
||||
description = "Adds a custom command to viu"
|
||||
|
||||
[components]
|
||||
command = "my_command:my_command"
|
||||
```
|
||||
|
||||
**my_command.py:**
|
||||
|
||||
```python
|
||||
import click
|
||||
|
||||
@click.command()
|
||||
@click.argument("arg1")
|
||||
def my_command(arg1: str):
|
||||
"""My custom command description."""
|
||||
click.echo(f"Hello from plugin command with arg: {arg1}")
|
||||
```
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
Plugins can include a default configuration file (`config.toml`) in their repository root. When a plugin is installed, this default configuration is automatically copied to the user's `~/.config/viu/plugins.config.toml` file.
|
||||
|
||||
**Example `config.toml` in plugin repository:**
|
||||
|
||||
```toml
|
||||
# Default configuration for My Plugin
|
||||
[my-plugin-name]
|
||||
timeout = 30
|
||||
preferred_quality = "720p"
|
||||
custom_option = "default_value"
|
||||
```
|
||||
|
||||
**After installation, users can customize by editing `~/.config/viu/plugins.config.toml`:**
|
||||
|
||||
```toml
|
||||
[my-plugin-name]
|
||||
timeout = 60 # Customized value
|
||||
preferred_quality = "1080p" # Customized value
|
||||
custom_option = "my_value" # Customized value
|
||||
```
|
||||
|
||||
Access this configuration in your plugin constructor via the `**config` parameter.
|
||||
|
||||
## Installation and Usage
|
||||
|
||||
### For Plugin Developers
|
||||
|
||||
1. Create your plugin repository following the structure above
|
||||
2. Test your plugin locally
|
||||
3. Publish your repository on GitHub/GitLab
|
||||
4. Share the installation command with users
|
||||
|
||||
### For Users
|
||||
|
||||
Install a plugin:
|
||||
|
||||
```bash
|
||||
viu plugin add --type provider myplugin github:user/viu-myplugin
|
||||
```
|
||||
|
||||
Configure the plugin by editing `~/.config/viu/plugins.config.toml`:
|
||||
|
||||
```toml
|
||||
[myplugin]
|
||||
option1 = "value1"
|
||||
option2 = "value2"
|
||||
```
|
||||
|
||||
Use the plugin:
|
||||
|
||||
```bash
|
||||
viu --provider myplugin search "anime name"
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
If your plugin requires additional Python packages, include a `requirements.txt` file in your repository root. Users will need to install these manually:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Error Handling**: Implement proper error handling and logging
|
||||
2. **Configuration**: Make your plugin configurable through the config system
|
||||
3. **Documentation**: Include a README.md with usage instructions
|
||||
4. **Testing**: Test your plugin thoroughly before publishing
|
||||
5. **Versioning**: Use semantic versioning for your plugin releases
|
||||
6. **Compatibility**: Specify minimum Python version requirements
|
||||
|
||||
## Plugin Management Commands
|
||||
|
||||
```bash
|
||||
# Install a plugin
|
||||
viu plugin add --type provider myplugin github:user/viu-myplugin
|
||||
|
||||
# List installed plugins
|
||||
viu plugin list
|
||||
viu plugin list --type provider
|
||||
|
||||
# Update a plugin
|
||||
viu plugin update --type provider myplugin
|
||||
|
||||
# Remove a plugin
|
||||
viu plugin remove --type provider myplugin
|
||||
```
|
||||
|
||||
## Example Plugins
|
||||
|
||||
Check out these example plugin repositories:
|
||||
|
||||
- [Example Provider Plugin](https://github.com/example/viu-example-provider)
|
||||
- [Example Player Plugin](https://github.com/example/viu-example-player)
|
||||
|
||||
## Support
|
||||
|
||||
For plugin development support:
|
||||
|
||||
- Open an issue in the main viu repository
|
||||
- Join the Discord server: https://discord.gg/C4rhMA4mmK
|
||||
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/viu-media/Viu/actions)
|
||||
[](https://github.com/Benexl/Viu/actions)
|
||||
[](https://discord.gg/HBEmAwvbHV)
|
||||
[](https://github.com/viu-media/Viu/issues)
|
||||
[](https://github.com/viu-media/Viu/blob/master/LICENSE)
|
||||
[](https://github.com/Benexl/Viu/issues)
|
||||
[](https://github.com/Benexl/Viu/blob/master/LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -23,14 +23,6 @@
|
||||
</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
|
||||
|
||||
@@ -80,32 +72,18 @@ 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 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
|
||||
nix profile install github:Benexl/viu
|
||||
```
|
||||
|
||||
#### Arch Linux (AUR)
|
||||
Use an AUR helper like `yay` or `paru`.
|
||||
```bash
|
||||
# Stable version (recommended)
|
||||
yay -S viu-media
|
||||
yay -S viu
|
||||
|
||||
# Git version (latest commit)
|
||||
yay -S viu-media-git
|
||||
yay -S viu-git
|
||||
```
|
||||
|
||||
#### Using pipx (for isolated environments)
|
||||
@@ -124,7 +102,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/viu-media/Viu.git --depth 1
|
||||
git clone https://github.com/Benexl/Viu.git --depth 1
|
||||
cd Viu
|
||||
uv tool install .
|
||||
viu --version
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from viu_media.core.utils.graphql import execute_graphql
|
||||
|
||||
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
@@ -1,8 +0,0 @@
|
||||
query {
|
||||
MediaTagCollection {
|
||||
name
|
||||
description
|
||||
category
|
||||
isAdult
|
||||
}
|
||||
}
|
||||
0
dev/make_release
Executable file → Normal file
0
dev/make_release
Executable file → Normal file
15
examples/plugins/player/config.toml
Normal file
15
examples/plugins/player/config.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[multi-file-provider]
|
||||
# Base URL for the anime site
|
||||
base_url = "https://multifile.example.site"
|
||||
|
||||
# Request timeout in seconds
|
||||
timeout = 30
|
||||
|
||||
# Preferred video quality
|
||||
preferred_quality = "720p"
|
||||
|
||||
# Maximum number of search results
|
||||
max_results = 25
|
||||
|
||||
# Enable debug logging
|
||||
debug = false
|
||||
169
examples/plugins/player/player.py
Normal file
169
examples/plugins/player/player.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
VLC player integration for Viu.
|
||||
|
||||
This module provides the VlcPlayer class, which implements the BasePlayer interface for the VLC media player.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from viu_media.core.config import VlcConfig
|
||||
from viu_media.core.exceptions import ViuError
|
||||
from viu_media.core.patterns import TORRENT_REGEX, YOUTUBE_REGEX
|
||||
from viu_media.core.utils import detect
|
||||
from viu_media.libs.player.base import BasePlayer
|
||||
from viu_media.libs.player.params import PlayerParams
|
||||
from viu_media.libs.player.types import PlayerResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VlcPlayer(BasePlayer):
|
||||
"""
|
||||
VLC player implementation for Viu.
|
||||
|
||||
Provides playback functionality using the VLC media player, supporting desktop, mobile, and torrent scenarios.
|
||||
"""
|
||||
|
||||
def __init__(self, config: VlcConfig):
|
||||
"""
|
||||
Initialize the VlcPlayer with the given VLC configuration.
|
||||
|
||||
Args:
|
||||
config: VlcConfig object containing VLC-specific settings.
|
||||
"""
|
||||
self.config = config
|
||||
self.executable = shutil.which("vlc")
|
||||
|
||||
def play(self, params: PlayerParams) -> PlayerResult:
|
||||
"""
|
||||
Play the given media using VLC, handling desktop, mobile, and torrent scenarios.
|
||||
|
||||
Args:
|
||||
params: PlayerParams object containing playback parameters.
|
||||
|
||||
Returns:
|
||||
PlayerResult: Information about the playback session.
|
||||
"""
|
||||
if not self.executable:
|
||||
raise ViuError("VLC executable not found in PATH.")
|
||||
|
||||
if TORRENT_REGEX.match(params.url) and detect.is_running_in_termux():
|
||||
return self._play_on_mobile(params)
|
||||
else:
|
||||
return self._play_on_desktop(params)
|
||||
|
||||
def play_with_ipc(self, params: PlayerParams, socket_path: str) -> subprocess.Popen:
|
||||
"""
|
||||
Not implemented for VLC player.
|
||||
"""
|
||||
raise NotImplementedError("play_with_ipc is not implemented for VLC player.")
|
||||
|
||||
def _play_on_mobile(self, params: PlayerParams) -> PlayerResult:
|
||||
"""
|
||||
Play media on a mobile device using Android intents.
|
||||
|
||||
Args:
|
||||
params: PlayerParams object containing playback parameters.
|
||||
|
||||
Returns:
|
||||
PlayerResult: Information about the playback session.
|
||||
"""
|
||||
if YOUTUBE_REGEX.match(params.url):
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
params.url,
|
||||
"-n",
|
||||
"com.google.android.youtube/.UrlActivity",
|
||||
]
|
||||
else:
|
||||
args = [
|
||||
"nohup",
|
||||
"am",
|
||||
"start",
|
||||
"--user",
|
||||
"0",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
params.url,
|
||||
"-n",
|
||||
"org.videolan.vlc/org.videolan.vlc.gui.video.VideoPlayerActivity",
|
||||
"-e",
|
||||
"title",
|
||||
params.title,
|
||||
]
|
||||
|
||||
subprocess.run(args)
|
||||
|
||||
return PlayerResult(episode=params.episode)
|
||||
|
||||
def _play_on_desktop(self, params: PlayerParams) -> PlayerResult:
|
||||
"""
|
||||
Play media on a desktop environment using VLC.
|
||||
|
||||
Args:
|
||||
params: PlayerParams object containing playback parameters.
|
||||
|
||||
Returns:
|
||||
PlayerResult: Information about the playback session.
|
||||
"""
|
||||
if TORRENT_REGEX.search(params.url):
|
||||
return self._stream_on_desktop_with_webtorrent_cli(params)
|
||||
|
||||
args = [self.executable, params.url]
|
||||
if params.subtitles:
|
||||
for sub in params.subtitles:
|
||||
args.extend(["--sub-file", sub])
|
||||
break
|
||||
if params.title:
|
||||
args.extend(["--video-title", params.title])
|
||||
|
||||
if self.config.args:
|
||||
args.extend(self.config.args.split(","))
|
||||
|
||||
subprocess.run(args, encoding="utf-8")
|
||||
return PlayerResult(episode=params.episode)
|
||||
|
||||
def _stream_on_desktop_with_webtorrent_cli(
|
||||
self, params: PlayerParams
|
||||
) -> PlayerResult:
|
||||
"""
|
||||
Stream torrent media using the webtorrent CLI and VLC.
|
||||
|
||||
Args:
|
||||
params: PlayerParams object containing playback parameters.
|
||||
|
||||
Returns:
|
||||
PlayerResult: Information about the playback session.
|
||||
"""
|
||||
WEBTORRENT_CLI = shutil.which("webtorrent")
|
||||
if not WEBTORRENT_CLI:
|
||||
raise ViuError("Please Install webtorrent cli inorder to stream torrents")
|
||||
|
||||
args = [WEBTORRENT_CLI, params.url, "--vlc"]
|
||||
|
||||
if self.config.args:
|
||||
args.append("--player-args")
|
||||
args.extend(self.config.args.split(","))
|
||||
|
||||
subprocess.run(args)
|
||||
return PlayerResult(episode=params.episode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from viu_media.core.constants import APP_ASCII_ART
|
||||
|
||||
print(APP_ASCII_ART)
|
||||
url = input("Enter the url you would like to stream: ")
|
||||
vlc = VlcPlayer(VlcConfig())
|
||||
player_result = vlc.play(PlayerParams(url=url, title="", query="", episode=""))
|
||||
print(player_result)
|
||||
9
examples/plugins/player/plugin.info.toml
Normal file
9
examples/plugins/player/plugin.info.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[plugin]
|
||||
name = "Multi-File Provider Plugin"
|
||||
version = "1.0.0"
|
||||
description = "A demo plugin with multiple Python files"
|
||||
author = "Viu Developer"
|
||||
requires_python = ">=3.11"
|
||||
|
||||
[components]
|
||||
player = "player:VlcPlayer"
|
||||
18
examples/plugins/provider/config.toml
Normal file
18
examples/plugins/provider/config.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
# Default configuration for Example Provider Plugin
|
||||
# This file is automatically copied to ~/.config/viu/plugins.config.toml during installation
|
||||
|
||||
[example-provider]
|
||||
# Request timeout in seconds
|
||||
timeout = 30
|
||||
|
||||
# Preferred video quality
|
||||
preferred_quality = "720p"
|
||||
|
||||
# Maximum number of search results to return
|
||||
max_results = 20
|
||||
|
||||
# Custom headers (optional)
|
||||
# custom_header = "value"
|
||||
|
||||
# Enable debug logging for this plugin
|
||||
# debug = false
|
||||
37
examples/plugins/provider/constants.py
Normal file
37
examples/plugins/provider/constants.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
|
||||
ANIMEPAHE = "animepahe.ru"
|
||||
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
|
||||
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api"
|
||||
|
||||
SERVERS_AVAILABLE = ["kwik"]
|
||||
REQUEST_HEADERS = {
|
||||
"Cookie": "__ddgid_=VvX0ebHrH2DsFZo4; __ddgmark_=3savRpSVFhvZcn5x; __ddg2_=buBJ3c4pNBYKFZNp; __ddg1_=rbVADKr9URtt55zoIGFa; SERVERID=janna; XSRF-TOKEN=eyJpdiI6IjV5bFNtd0phUHgvWGJxc25wL0VJSUE9PSIsInZhbHVlIjoicEJTZktlR2hxR2JZTWhnL0JzazlvZU5TQTR2bjBWZ2dDb0RwUXVUUWNSclhQWUhLRStYSmJmWmUxWkpiYkFRYU12RjFWejlSWHorME1wZG5qQ1U0TnFlNnBFR2laQjN1MjdyNjc5TjVPdXdJb2o5VkU1bEduRW9pRHNDTHh6Sy8iLCJtYWMiOiI0OTc0ZmNjY2UwMGJkOWY2MWNkM2NlMjk2ZGMyZGJmMWE0NTdjZTdkNGI2Y2IwNTIzZmFiZWU5ZTE2OTk0YmU4IiwidGFnIjoiIn0%3D; laravel_session=eyJpdiI6ImxvdlpqREFnTjdaeFJubUlXQWlJVWc9PSIsInZhbHVlIjoiQnE4R3VHdjZ4M1NDdEVWM1ZqMUxtNnVERnJCcmtCUHZKNzRPR2RFbzNFcStTL29xdnVTbWhsNVRBUXEybVZWNU1UYVlTazFqYlN5UjJva1k4czNGaXBTbkJJK01oTUd3VHRYVHBoc3dGUWxHYnFlS2NJVVNFbTFqMVBWdFpuVUgiLCJtYWMiOiI1NDdjZTVkYmNhNjUwZTMxZmRlZmVmMmRlMGNiYjAwYjlmYjFjY2U0MDc1YTQzZThiMTIxMjJlYTg1NTA4YjBmIiwidGFnIjoiIn0%3D; latest=5592",
|
||||
"Host": ANIMEPAHE,
|
||||
"Accept": "application, text/javascript, */*; q=0.01",
|
||||
"Accept-Encoding": "Utf-8",
|
||||
"Referer": ANIMEPAHE_BASE,
|
||||
"DNT": "1",
|
||||
"Connection": "keep-alive",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"TE": "trailers",
|
||||
}
|
||||
SERVER_HEADERS = {
|
||||
"Host": "kwik.si",
|
||||
"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/",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "iframe",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Priority": "u=4",
|
||||
"TE": "trailers",
|
||||
}
|
||||
JUICY_STREAM_REGEX = re.compile(r"source='(.*)';")
|
||||
KWIK_RE = re.compile(r"Player\|(.+?)'")
|
||||
77
examples/plugins/provider/extractor.py
Normal file
77
examples/plugins/provider/extractor.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import re
|
||||
|
||||
|
||||
def animepahe_key_creator(c: int, a: int):
|
||||
from ...scraping.utils import encode_base_n
|
||||
|
||||
if c < a:
|
||||
val_a = ""
|
||||
else:
|
||||
val_a = animepahe_key_creator(int(c / a), a)
|
||||
c = c % a
|
||||
if c > 35:
|
||||
val_b = chr(c + 29)
|
||||
else:
|
||||
val_b = encode_base_n(c, 36)
|
||||
return val_a + val_b
|
||||
|
||||
|
||||
def animepahe_embed_decoder(
|
||||
encoded_js_p: str,
|
||||
base_a: int,
|
||||
no_of_keys_c: int,
|
||||
values_to_replace_with_k: list,
|
||||
):
|
||||
decode_mapper_d: dict = {}
|
||||
for i in range(no_of_keys_c):
|
||||
key = animepahe_key_creator(i, base_a)
|
||||
val = values_to_replace_with_k[i] or key
|
||||
decode_mapper_d[key] = val
|
||||
return re.sub(
|
||||
r"\b\w+\b", lambda match: decode_mapper_d[match.group(0)], encoded_js_p
|
||||
)
|
||||
|
||||
|
||||
PARAMETERS_REGEX = re.compile(r"eval\(function\(p,a,c,k,e,d\)\{.*\}\((.*?)\)\)$")
|
||||
ENCODE_JS_REGEX = re.compile(r"'(.*?);',(\d+),(\d+),'(.*)'\.split")
|
||||
|
||||
|
||||
def process_animepahe_embed_page(embed_page: str):
|
||||
from ...scraping.html_parser import get_element_text_and_html_by_tag
|
||||
|
||||
encoded_js_string = ""
|
||||
embed_page_content = embed_page
|
||||
for _ in range(8):
|
||||
text, html = get_element_text_and_html_by_tag("script", embed_page_content)
|
||||
if not text and html:
|
||||
embed_page_content = re.sub(html, "", embed_page_content)
|
||||
continue
|
||||
if text:
|
||||
encoded_js_string = text.strip()
|
||||
break
|
||||
if not encoded_js_string:
|
||||
return
|
||||
obsfucated_js_parameter_match = PARAMETERS_REGEX.search(encoded_js_string)
|
||||
if not obsfucated_js_parameter_match:
|
||||
return
|
||||
parameter_string = obsfucated_js_parameter_match.group(1)
|
||||
encoded_js_parameter_string = ENCODE_JS_REGEX.search(parameter_string)
|
||||
if not encoded_js_parameter_string:
|
||||
return
|
||||
p: str = encoded_js_parameter_string.group(1)
|
||||
a: int = int(encoded_js_parameter_string.group(2))
|
||||
c: int = int(encoded_js_parameter_string.group(3))
|
||||
k: list = encoded_js_parameter_string.group(4).split("|")
|
||||
return animepahe_embed_decoder(p, a, c, k).replace("\\", "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Testing time
|
||||
filepath = input("Enter file name: ")
|
||||
if filepath:
|
||||
with open(filepath) as file:
|
||||
data = file.read()
|
||||
else:
|
||||
data = """<script>eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('f $7={H:a(2){4 B(9.7.h(y z("(?:(?:^|.*;)\\\\s*"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=\\\\s*([^;]*).*$)|^.*$"),"$1"))||G},E:a(2,q,3,6,5,t){k(!2||/^(?:8|r\\-v|o|m|p)$/i.D(2)){4 w}f b="";k(3){F(3.J){j K:b=3===P?"; 8=O, I N Q M:u:u A":"; r-v="+3;n;j L:b="; 8="+3;n;j S:b="; 8="+3.Z();n}}9.7=d(2)+"="+d(q)+b+(5?"; m="+5:"")+(6?"; o="+6:"")+(t?"; p":"");4 x},Y:a(2,6,5){k(!2||!11.C(2)){4 w}9.7=d(2)+"=; 8=12, R 10 W l:l:l A"+(5?"; m="+5:"")+(6?"; o="+6:"");4 x},C:a(2){4(y z("(?:^|;\\\\s*)"+d(2).h(/[\\-\\.\\+\\*]/g,"\\\\$&")+"\\\\s*\\\\=")).D(9.7)},X:a(){f c=9.7.h(/((?:^|\\s*;)[^\\=]+)(?=;|$)|^\\s*|\\s*(?:\\=[^;]*)?(?:\\1|$)/g,"").T(/\\s*(?:\\=[^;]*)?;\\s*/);U(f e=0;e<c.V;e++){c[e]=B(c[e])}4 c}};',62,65,'||sKey|vEnd|return|sDomain|sPath|cookie|expires|document|function|sExpires|aKeys|encodeURIComponent|nIdx|var||replace||case|if|00|domain|break|path|secure|sValue|max||bSecure|59|age|false|true|new|RegExp|GMT|decodeURIComponent|hasItem|test|setItem|switch|null|getItem|31|constructor|Number|String|23|Dec|Fri|Infinity|9999|01|Date|split|for|length|1970|keys|removeItem|toUTCString|Jan|this|Thu'.split('|'),0,{}));eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--){d[e(c)]=k[c]||e(c)}k=[function(e){return d[e]}];e=function(){return'\\w+'};c=1};while(c--){if(k[c]){p=p.replace(new RegExp('\\b'+e(c)+'\\b','g'),k[c])}}return p}('h o=\'1D://1C-E.1B.1A.1z/1y/E/1x/1w/1v.1u\';h d=s.r(\'d\');h 0=B 1t(d,{\'1s\':{\'1r\':i},\'1q\':\'16:9\',\'D\':1,\'1p\':5,\'1o\':{\'1n\':\'1m\'},1l:[\'7-1k\',\'7\',\'1j\',\'1i-1h\',\'1g\',\'1f-1e\',\'1d\',\'D\',\'1c\',\'1b\',\'1a\',\'19\',\'C\',\'18\'],\'C\':{\'17\':i}});8(!A.15()){d.14=o}x{j z={13:12,11:10,Z:Y,X:i,W:i};h c=B A(z);c.V(o);c.U(d);g.c=c}0.3("T",6=>{g.S.R.Q("P")});0.O=1;k v(b,n,m){8(b.y){b.y(n,m,N)}x 8(b.w){b.w(\'3\'+n,m)}}j 4=k(l){g.M.L(l,\'*\')};v(g,\'l\',k(e){j a=e.a;8(a===\'7\')0.7();8(a===\'f\')0.f();8(a===\'u\')0.u()});0.3(\'t\',6=>{4(\'t\')});0.3(\'7\',6=>{4(\'7\')});0.3(\'f\',6=>{4(\'f\')});0.3(\'K\',6=>{4(0.q);s.r(\'.J-I\').H=G(0.q.F(2))});0.3(\'p\',6=>{4(\'p\')});',62,102,'player|||on|sendMessage||event|play|if||data|element|hls|video||pause|window|const|true|var|function|message|eventHandler|eventName|source|ended|currentTime|querySelector|document|ready|stop|bindEvent|attachEvent|else|addEventListener|config|Hls|new|fullscreen|volume|01|toFixed|String|innerHTML|timestamp|ss|timeupdate|postMessage|parent|false|speed|landscape|lock|orientation|screen|enterfullscreen|attachMedia|loadSource|lowLatencyMode|enableWorker|Infinity|backBufferLength|600|maxMaxBufferLength|180|maxBufferLength|src|isSupported||iosNative|capture|airplay|pip|settings|captions|mute|time|current|progress|forward|fast|rewind|large|controls|kwik|key|storage|seekTime|ratio|global|keyboard|Plyr|m3u8|uwu|b92a392054c041a3f9c6eecabeb0e127183f44e547828447b10bca8d77523e6f|03|stream|org|nextcdn|files|eu|https'.split('|'),0,{}))</script>"""
|
||||
|
||||
print(process_animepahe_embed_page(data))
|
||||
100
examples/plugins/provider/mappers.py
Normal file
100
examples/plugins/provider/mappers.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from typing import Any
|
||||
|
||||
from viu_media.libs.provider.anime.types import (
|
||||
Anime,
|
||||
AnimeEpisodeInfo,
|
||||
AnimeEpisodes,
|
||||
EpisodeStream,
|
||||
MediaTranslationType,
|
||||
PageInfo,
|
||||
SearchResult,
|
||||
SearchResults,
|
||||
Server,
|
||||
)
|
||||
|
||||
from .types import (
|
||||
AnimePaheAnimePage,
|
||||
AnimePaheSearchPage,
|
||||
)
|
||||
|
||||
translation_type_map = {
|
||||
"sub": MediaTranslationType.SUB,
|
||||
"dub": MediaTranslationType.DUB,
|
||||
"raw": MediaTranslationType.RAW,
|
||||
}
|
||||
|
||||
|
||||
def map_to_search_results(data: AnimePaheSearchPage) -> SearchResults:
|
||||
results = []
|
||||
for result in data["data"]:
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=result["session"],
|
||||
title=result["title"],
|
||||
episodes=AnimeEpisodes(
|
||||
sub=list(map(str, range(1, result["episodes"] + 1))),
|
||||
dub=list(map(str, range(1, result["episodes"] + 1))),
|
||||
raw=list(map(str, range(1, result["episodes"] + 1))),
|
||||
),
|
||||
media_type=result["type"],
|
||||
score=result["score"],
|
||||
status=result["status"],
|
||||
season=result["season"],
|
||||
poster=result["poster"],
|
||||
year=str(result["year"]),
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResults(
|
||||
page_info=PageInfo(
|
||||
total=data["total"],
|
||||
per_page=data["per_page"],
|
||||
current_page=data["current_page"],
|
||||
),
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
def map_to_anime_result(
|
||||
search_result: SearchResult, anime: AnimePaheAnimePage
|
||||
) -> Anime:
|
||||
episodes_info = []
|
||||
episodes = []
|
||||
anime["data"] = sorted(anime["data"], key=lambda k: float(k["episode"]))
|
||||
for ep_info in anime["data"]:
|
||||
episodes.append(str(ep_info["episode"]))
|
||||
episodes_info.append(
|
||||
AnimeEpisodeInfo(
|
||||
id=str(ep_info["id"]),
|
||||
session_id=ep_info["session"],
|
||||
episode=str(ep_info["episode"]),
|
||||
title=ep_info["title"],
|
||||
poster=ep_info["snapshot"],
|
||||
duration=str(ep_info["duration"]),
|
||||
)
|
||||
)
|
||||
|
||||
return Anime(
|
||||
id=search_result.id,
|
||||
title=search_result.title,
|
||||
episodes=AnimeEpisodes(
|
||||
sub=episodes,
|
||||
dub=episodes,
|
||||
),
|
||||
year=str(search_result.year),
|
||||
poster=search_result.poster,
|
||||
episodes_info=episodes_info,
|
||||
)
|
||||
|
||||
|
||||
def map_to_server(
|
||||
episode: AnimeEpisodeInfo, translation_type: Any, quality: Any, stream_link: Any
|
||||
) -> Server:
|
||||
links = [
|
||||
EpisodeStream(
|
||||
link=stream_link,
|
||||
quality=quality,
|
||||
translation_type=translation_type_map[translation_type],
|
||||
)
|
||||
]
|
||||
return Server(name="kwik", links=links, episode_title=episode.title)
|
||||
10
examples/plugins/provider/plugin.info.toml
Normal file
10
examples/plugins/provider/plugin.info.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[plugin]
|
||||
name = "Example Provider Plugin"
|
||||
version = "1.0.0"
|
||||
description = "A demo provider plugin for testing the viu plugin system"
|
||||
author = "Viu Developer"
|
||||
homepage = "https://github.com/example/viu-example-plugin"
|
||||
requires_python = ">=3.11"
|
||||
|
||||
[components]
|
||||
provider = "example_provider:ExampleProvider"
|
||||
207
examples/plugins/provider/provider.py
Normal file
207
examples/plugins/provider/provider.py
Normal file
@@ -0,0 +1,207 @@
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Iterator, Optional
|
||||
|
||||
from viu_media.libs.provider.anime.base import BaseAnimeProvider
|
||||
from viu_media.libs.provider.anime.params import (
|
||||
AnimeParams,
|
||||
EpisodeStreamsParams,
|
||||
SearchParams,
|
||||
)
|
||||
from viu_media.libs.provider.anime.types import (
|
||||
Anime,
|
||||
AnimeEpisodeInfo,
|
||||
SearchResult,
|
||||
SearchResults,
|
||||
Server,
|
||||
)
|
||||
from viu_media.libs.provider.anime.utils.debug import debug_provider
|
||||
|
||||
from .constants import (
|
||||
ANIMEPAHE_BASE,
|
||||
ANIMEPAHE_ENDPOINT,
|
||||
JUICY_STREAM_REGEX,
|
||||
REQUEST_HEADERS,
|
||||
SERVER_HEADERS,
|
||||
)
|
||||
from .extractor import process_animepahe_embed_page
|
||||
from .mappers import map_to_anime_result, map_to_search_results, map_to_server
|
||||
from .types import AnimePaheAnimePage, AnimePaheSearchPage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnimePahe(BaseAnimeProvider):
|
||||
HEADERS = REQUEST_HEADERS
|
||||
|
||||
@debug_provider
|
||||
def search(self, params: SearchParams) -> SearchResults | None:
|
||||
return self._search(params)
|
||||
|
||||
@lru_cache()
|
||||
def _search(self, params: SearchParams) -> SearchResults | None:
|
||||
url_params = {"m": "search", "q": params.query}
|
||||
response = self.client.get(ANIMEPAHE_ENDPOINT, params=url_params)
|
||||
response.raise_for_status()
|
||||
data: AnimePaheSearchPage = response.json()
|
||||
if not data.get("data"):
|
||||
return
|
||||
return map_to_search_results(data)
|
||||
|
||||
@debug_provider
|
||||
def get(self, params: AnimeParams) -> Anime | None:
|
||||
return self._get_anime(params)
|
||||
|
||||
@lru_cache()
|
||||
def _get_anime(self, params: AnimeParams) -> Anime | None:
|
||||
page = 1
|
||||
standardized_episode_number = 0
|
||||
|
||||
search_result = self._get_search_result(params)
|
||||
if not search_result:
|
||||
logger.error(f"No search result found for ID {params.id}")
|
||||
return None
|
||||
|
||||
anime: Optional[AnimePaheAnimePage] = None
|
||||
|
||||
has_next_page = True
|
||||
while has_next_page:
|
||||
logger.debug(f"Loading page: {page}")
|
||||
_anime_page = self._anime_page_loader(
|
||||
m="release",
|
||||
id=params.id,
|
||||
sort="episode_asc",
|
||||
page=page,
|
||||
)
|
||||
|
||||
has_next_page = True if _anime_page["next_page_url"] else False
|
||||
page += 1
|
||||
if not anime:
|
||||
anime = _anime_page
|
||||
else:
|
||||
anime["data"].extend(_anime_page["data"])
|
||||
|
||||
if anime:
|
||||
for episode in anime.get("data", []):
|
||||
if episode["episode"] % 1 == 0:
|
||||
standardized_episode_number += 1
|
||||
episode.update({"episode": standardized_episode_number})
|
||||
else:
|
||||
standardized_episode_number += episode["episode"] % 1
|
||||
episode.update({"episode": standardized_episode_number})
|
||||
standardized_episode_number = int(standardized_episode_number)
|
||||
|
||||
return map_to_anime_result(search_result, anime)
|
||||
|
||||
@lru_cache()
|
||||
def _get_search_result(self, params: AnimeParams) -> Optional[SearchResult]:
|
||||
search_results = self._search(SearchParams(query=params.query))
|
||||
if not search_results or not search_results.results:
|
||||
logger.error(f"No search results found for ID {params.id}")
|
||||
return None
|
||||
for search_result in search_results.results:
|
||||
if search_result.id == params.id:
|
||||
return search_result
|
||||
|
||||
@lru_cache()
|
||||
def _anime_page_loader(self, m, id, sort, page) -> AnimePaheAnimePage:
|
||||
url_params = {
|
||||
"m": m,
|
||||
"id": id,
|
||||
"sort": sort,
|
||||
"page": page,
|
||||
}
|
||||
response = self.client.get(ANIMEPAHE_ENDPOINT, params=url_params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@debug_provider
|
||||
def episode_streams(self, params: EpisodeStreamsParams) -> Iterator[Server] | None:
|
||||
from viu_media.libs.provider.scraping.html_parser import (
|
||||
extract_attributes,
|
||||
get_element_by_id,
|
||||
get_elements_html_by_class,
|
||||
)
|
||||
|
||||
episode = self._get_episode_info(params)
|
||||
if not episode:
|
||||
logger.error(
|
||||
f"Episode {params.episode} doesn't exist for anime {params.anime_id}"
|
||||
)
|
||||
return
|
||||
|
||||
url = f"{ANIMEPAHE_BASE}/play/{params.anime_id}/{episode.session_id}"
|
||||
response = self.client.get(url, follow_redirects=True)
|
||||
response.raise_for_status()
|
||||
|
||||
c = get_element_by_id("resolutionMenu", response.text)
|
||||
if not c:
|
||||
logger.error("Resolution menu not found in the response")
|
||||
return
|
||||
resolutionMenuItems = get_elements_html_by_class("dropdown-item", c)
|
||||
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
|
||||
quality = None
|
||||
translation_type = None
|
||||
stream_link = None
|
||||
|
||||
# TODO: better document the scraping process
|
||||
for res_dict in res_dicts:
|
||||
# the actual attributes are data attributes in the original html 'prefixed with data-'
|
||||
embed_url = res_dict["src"]
|
||||
data_audio = "dub" if res_dict["audio"] == "eng" else "sub"
|
||||
|
||||
if data_audio != params.translation_type:
|
||||
continue
|
||||
|
||||
if not embed_url:
|
||||
logger.warning("embed url not found please report to the developers")
|
||||
continue
|
||||
|
||||
embed_response = self.client.get(
|
||||
embed_url,
|
||||
headers={
|
||||
"User-Agent": self.client.headers["User-Agent"],
|
||||
**SERVER_HEADERS,
|
||||
},
|
||||
)
|
||||
embed_response.raise_for_status()
|
||||
embed_page = embed_response.text
|
||||
|
||||
decoded_js = process_animepahe_embed_page(embed_page)
|
||||
if not decoded_js:
|
||||
logger.error("failed to decode embed page")
|
||||
continue
|
||||
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
|
||||
if not juicy_stream:
|
||||
logger.error("failed to find juicy stream")
|
||||
continue
|
||||
juicy_stream = juicy_stream.group(1)
|
||||
quality = res_dict["resolution"]
|
||||
translation_type = data_audio
|
||||
stream_link = juicy_stream
|
||||
|
||||
if translation_type and quality and stream_link:
|
||||
yield map_to_server(episode, translation_type, quality, stream_link)
|
||||
|
||||
@lru_cache()
|
||||
def _get_episode_info(
|
||||
self, params: EpisodeStreamsParams
|
||||
) -> Optional[AnimeEpisodeInfo]:
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from viu_media.libs.provider.anime.utils.debug import test_anime_provider
|
||||
|
||||
test_anime_provider(AnimePahe)
|
||||
108
examples/plugins/provider/types.py
Normal file
108
examples/plugins/provider/types.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from enum import Enum
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
|
||||
class Server(Enum):
|
||||
KWIK = "Kwik"
|
||||
|
||||
|
||||
class AnimePaheSearchResult(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
type: str
|
||||
episodes: int
|
||||
status: str
|
||||
season: str
|
||||
year: int
|
||||
score: int
|
||||
poster: str
|
||||
session: str
|
||||
|
||||
|
||||
class AnimePaheSearchPage(TypedDict):
|
||||
total: int
|
||||
per_page: int
|
||||
current_page: int
|
||||
last_page: int
|
||||
_from: int
|
||||
to: int
|
||||
data: list[AnimePaheSearchResult]
|
||||
|
||||
|
||||
class Episode(TypedDict):
|
||||
id: str
|
||||
anime_id: int
|
||||
episode: float
|
||||
episode2: int
|
||||
edition: str
|
||||
title: str
|
||||
snapshot: str # episode image
|
||||
disc: str
|
||||
audio: Literal["eng", "jpn"]
|
||||
duration: str # time 00:00:00
|
||||
session: str
|
||||
filler: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class AnimePaheAnimePage(TypedDict):
|
||||
total: int
|
||||
per_page: int
|
||||
current_page: int
|
||||
last_page: int
|
||||
next_page_url: str | None
|
||||
prev_page_url: str | None
|
||||
_from: int
|
||||
to: int
|
||||
data: list[Episode]
|
||||
|
||||
|
||||
class AnimePaheEpisodeInfo(TypedDict):
|
||||
title: str
|
||||
episode: float
|
||||
id: str
|
||||
translation_type: Literal["eng", "jpn"]
|
||||
duration: str
|
||||
poster: str
|
||||
|
||||
|
||||
class AvailableEpisodesDetail(TypedDict):
|
||||
sub: list[str]
|
||||
dub: list[str]
|
||||
raw: list[str]
|
||||
|
||||
|
||||
class AnimePaheAnime(TypedDict):
|
||||
id: str
|
||||
title: str
|
||||
year: int
|
||||
season: str
|
||||
poster: str
|
||||
score: int
|
||||
availableEpisodesDetail: AvailableEpisodesDetail
|
||||
episodesInfo: list[AnimePaheEpisodeInfo]
|
||||
|
||||
|
||||
class PageInfo(TypedDict):
|
||||
total: int
|
||||
perPage: int
|
||||
currentPage: int
|
||||
|
||||
|
||||
class AnimePaheSearchResults(TypedDict):
|
||||
pageInfo: PageInfo
|
||||
results: list[AnimePaheSearchResult]
|
||||
|
||||
|
||||
class AnimePaheStreamLink(TypedDict):
|
||||
quality: str
|
||||
translation_type: Literal["sub", "dub"]
|
||||
link: str
|
||||
|
||||
|
||||
class AnimePaheServer(TypedDict):
|
||||
server: Literal["kwik"]
|
||||
links: list[AnimePaheStreamLink]
|
||||
episode_title: str
|
||||
subtitles: list
|
||||
headers: dict
|
||||
8
flake.lock
generated
8
flake.lock
generated
@@ -20,17 +20,17 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1756386758,
|
||||
"narHash": "sha256-1wxxznpW2CKvI9VdniaUnTT2Os6rdRJcRUf65ZK9OtE=",
|
||||
"lastModified": 1753345091,
|
||||
"narHash": "sha256-CdX2Rtvp5I8HGu9swBmYuq+ILwRxpXdJwlpg8jvN4tU=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "dfb2f12e899db4876308eba6d93455ab7da304cd",
|
||||
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3ff0e34b1383648053bba8ed03f201d3466f90c9",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
|
||||
15
flake.nix
15
flake.nix
@@ -2,7 +2,8 @@
|
||||
description = "Viu Project Flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
# The nixpkgs unstable latest commit breaks the plyer python package
|
||||
nixpkgs.url = "github:nixos/nixpkgs/3ff0e34b1383648053bba8ed03f201d3466f90c9";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
@@ -16,21 +17,21 @@
|
||||
system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
inherit (pkgs) lib python312Packages;
|
||||
inherit (pkgs) lib python3Packages;
|
||||
|
||||
version = "3.1.0";
|
||||
in
|
||||
{
|
||||
packages.default = python312Packages.buildPythonApplication {
|
||||
packages.default = python3Packages.buildPythonApplication {
|
||||
pname = "viu";
|
||||
inherit version;
|
||||
pyproject = true;
|
||||
|
||||
src = self;
|
||||
|
||||
build-system = with python312Packages; [ hatchling ];
|
||||
build-system = with python3Packages; [ hatchling ];
|
||||
|
||||
dependencies = with python312Packages; [
|
||||
dependencies = with python3Packages; [
|
||||
click
|
||||
inquirerpy
|
||||
requests
|
||||
@@ -68,8 +69,8 @@
|
||||
|
||||
meta = {
|
||||
description = "Your browser anime experience from the terminal";
|
||||
homepage = "https://github.com/viu-media/Viu";
|
||||
changelog = "https://github.com/viu-media/Viu/releases/tag/v${version}";
|
||||
homepage = "https://github.com/Benexl/Viu";
|
||||
changelog = "https://github.com/Benexl/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.8"
|
||||
version = "3.2.7"
|
||||
description = "A browser anime site experience from the terminal"
|
||||
license = "UNLICENSE"
|
||||
readme = "README.md"
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"inquirerpy>=0.3.4",
|
||||
"pydantic>=2.11.7",
|
||||
"rich>=13.9.2",
|
||||
"tomli-w>=1.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -49,8 +50,8 @@ torrents = [
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pre-commit>=4.0.1",
|
||||
"pyinstaller>=6.11.1",
|
||||
"pyright>=1.1.384",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"venvPath": ".",
|
||||
"venv": ".venv",
|
||||
"pythonVersion": "3.12"
|
||||
"pythonVersion": "3.11"
|
||||
}
|
||||
|
||||
@@ -13,12 +13,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
ANSI utilities for FZF preview scripts.
|
||||
|
||||
Lightweight stdlib-only utilities to replace Rich dependency in preview scripts.
|
||||
Provides RGB color formatting, table rendering, and markdown stripping.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import textwrap
|
||||
import unicodedata
|
||||
|
||||
|
||||
def get_terminal_width() -> int:
|
||||
"""
|
||||
Get terminal width, prioritizing FZF preview environment variables.
|
||||
|
||||
Returns:
|
||||
Terminal width in columns
|
||||
"""
|
||||
fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS")
|
||||
if fzf_cols:
|
||||
return int(fzf_cols)
|
||||
return shutil.get_terminal_size((80, 24)).columns
|
||||
|
||||
|
||||
def display_width(text: str) -> int:
|
||||
"""
|
||||
Calculate the actual display width of text, accounting for wide characters.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
|
||||
Returns:
|
||||
Display width in terminal columns
|
||||
"""
|
||||
width = 0
|
||||
for char in text:
|
||||
# East Asian Width property: 'F' (Fullwidth) and 'W' (Wide) take 2 columns
|
||||
if unicodedata.east_asian_width(char) in ("F", "W"):
|
||||
width += 2
|
||||
else:
|
||||
width += 1
|
||||
return width
|
||||
|
||||
|
||||
def rgb_color(r: int, g: int, b: int, text: str, bold: bool = False) -> str:
|
||||
"""
|
||||
Format text with RGB color using ANSI escape codes.
|
||||
|
||||
Args:
|
||||
r: Red component (0-255)
|
||||
g: Green component (0-255)
|
||||
b: Blue component (0-255)
|
||||
text: Text to colorize
|
||||
bold: Whether to make text bold
|
||||
|
||||
Returns:
|
||||
ANSI-escaped colored text
|
||||
"""
|
||||
color_code = f"\x1b[38;2;{r};{g};{b}m"
|
||||
bold_code = "\x1b[1m" if bold else ""
|
||||
reset = "\x1b[0m"
|
||||
return f"{color_code}{bold_code}{text}{reset}"
|
||||
|
||||
|
||||
def parse_color(color_csv: str) -> tuple[int, int, int]:
|
||||
"""
|
||||
Parse RGB color from comma-separated string.
|
||||
|
||||
Args:
|
||||
color_csv: Color as 'R,G,B' string
|
||||
|
||||
Returns:
|
||||
Tuple of (r, g, b) integers
|
||||
"""
|
||||
parts = color_csv.split(",")
|
||||
return int(parts[0]), int(parts[1]), int(parts[2])
|
||||
|
||||
|
||||
def print_rule(sep_color: str) -> None:
|
||||
"""
|
||||
Print a horizontal rule line.
|
||||
|
||||
Args:
|
||||
sep_color: Color as 'R,G,B' string
|
||||
"""
|
||||
width = get_terminal_width()
|
||||
r, g, b = parse_color(sep_color)
|
||||
print(rgb_color(r, g, b, "─" * width))
|
||||
|
||||
|
||||
def print_table_row(
|
||||
key: str, value: str, header_color: str, key_width: int, value_width: int
|
||||
) -> None:
|
||||
"""
|
||||
Print a two-column table row with left-aligned key and right-aligned value.
|
||||
|
||||
Args:
|
||||
key: Left column text (header/key)
|
||||
value: Right column text (value)
|
||||
header_color: Color for key as 'R,G,B' string
|
||||
key_width: Width for key column
|
||||
value_width: Width for value column
|
||||
"""
|
||||
r, g, b = parse_color(header_color)
|
||||
key_styled = rgb_color(r, g, b, key, bold=True)
|
||||
|
||||
# Get actual terminal width
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Calculate display widths accounting for wide characters
|
||||
key_display_width = display_width(key)
|
||||
|
||||
# Calculate actual value width based on terminal and key display width
|
||||
actual_value_width = max(20, term_width - key_display_width - 2)
|
||||
|
||||
# Wrap value if it's too long (use character count, not display width for wrapping)
|
||||
value_lines = textwrap.wrap(str(value), width=actual_value_width) if value else [""]
|
||||
|
||||
if not value_lines:
|
||||
value_lines = [""]
|
||||
|
||||
# Print first line with properly aligned value
|
||||
first_line = value_lines[0]
|
||||
first_line_display_width = display_width(first_line)
|
||||
|
||||
# Use manual spacing to right-align based on display width
|
||||
spacing = term_width - key_display_width - first_line_display_width - 2
|
||||
if spacing > 0:
|
||||
print(f"{key_styled} {' ' * spacing}{first_line}")
|
||||
else:
|
||||
print(f"{key_styled} {first_line}")
|
||||
|
||||
# Print remaining wrapped lines (left-aligned, indented)
|
||||
for line in value_lines[1:]:
|
||||
print(f"{' ' * (key_display_width + 2)}{line}")
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""
|
||||
Strip markdown formatting from text.
|
||||
|
||||
Removes:
|
||||
- Headers (# ## ###)
|
||||
- Bold (**text** or __text__)
|
||||
- Italic (*text* or _text_)
|
||||
- Links ([text](url))
|
||||
- Code blocks (```code```)
|
||||
- Inline code (`code`)
|
||||
|
||||
Args:
|
||||
text: Markdown-formatted text
|
||||
|
||||
Returns:
|
||||
Plain text with markdown removed
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove code blocks first
|
||||
text = re.sub(r"```[\s\S]*?```", "", text)
|
||||
|
||||
# Remove inline code
|
||||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||||
|
||||
# Remove headers
|
||||
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove bold (** or __)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
|
||||
# Remove italic (* or _)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||
|
||||
# Remove links, keep text
|
||||
text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
|
||||
|
||||
# Remove images
|
||||
text = re.sub(r"!\[.*?\]\(.+?\)", "", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
|
||||
def wrap_text(text: str, width: int | None = None) -> str:
|
||||
"""
|
||||
Wrap text to terminal width.
|
||||
|
||||
Args:
|
||||
text: Text to wrap
|
||||
width: Width to wrap to (defaults to terminal width)
|
||||
|
||||
Returns:
|
||||
Wrapped text
|
||||
"""
|
||||
if width is None:
|
||||
width = get_terminal_width()
|
||||
|
||||
return textwrap.fill(text, width=width)
|
||||
@@ -1,36 +0,0 @@
|
||||
import sys
|
||||
from _ansi_utils import (
|
||||
print_rule,
|
||||
print_table_row,
|
||||
strip_markdown,
|
||||
wrap_text,
|
||||
get_terminal_width,
|
||||
)
|
||||
|
||||
HEADER_COLOR = sys.argv[1]
|
||||
SEPARATOR_COLOR = sys.argv[2]
|
||||
|
||||
# Get terminal dimensions
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Print title centered
|
||||
print("{ANIME_TITLE}".center(term_width))
|
||||
|
||||
rows = [
|
||||
("Total Episodes", "{TOTAL_EPISODES}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Upcoming Episodes", "{UPCOMING_EPISODES}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
print(wrap_text(strip_markdown("""{SCHEDULE_TABLE}"""), term_width))
|
||||
@@ -1,47 +0,0 @@
|
||||
import sys
|
||||
from _ansi_utils import (
|
||||
print_rule,
|
||||
print_table_row,
|
||||
strip_markdown,
|
||||
wrap_text,
|
||||
get_terminal_width,
|
||||
)
|
||||
|
||||
HEADER_COLOR = sys.argv[1]
|
||||
SEPARATOR_COLOR = sys.argv[2]
|
||||
|
||||
# Get terminal dimensions
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Print title centered
|
||||
print("{CHARACTER_NAME}".center(term_width))
|
||||
|
||||
rows = [
|
||||
("Native Name", "{CHARACTER_NATIVE_NAME}"),
|
||||
("Gender", "{CHARACTER_GENDER}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Age", "{CHARACTER_AGE}"),
|
||||
("Blood Type", "{CHARACTER_BLOOD_TYPE}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Birthday", "{CHARACTER_BIRTHDAY}"),
|
||||
("Favourites", "{CHARACTER_FAVOURITES}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
print(wrap_text(strip_markdown("""{CHARACTER_DESCRIPTION}"""), term_width))
|
||||
@@ -1,49 +0,0 @@
|
||||
import sys
|
||||
from _ansi_utils import print_rule, print_table_row, get_terminal_width
|
||||
|
||||
HEADER_COLOR = sys.argv[1]
|
||||
SEPARATOR_COLOR = sys.argv[2]
|
||||
|
||||
# Get terminal dimensions
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Print title centered
|
||||
print("{TITLE}".center(term_width))
|
||||
|
||||
rows = [
|
||||
("Duration", "{DURATION}"),
|
||||
("Status", "{STATUS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Total Episodes", "{EPISODES}"),
|
||||
("Next Episode", "{NEXT_EPISODE}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Progress", "{USER_PROGRESS}"),
|
||||
("List Status", "{USER_STATUS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Start Date", "{START_DATE}"),
|
||||
("End Date", "{END_DATE}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
@@ -1,93 +0,0 @@
|
||||
import sys
|
||||
from _ansi_utils import (
|
||||
print_rule,
|
||||
print_table_row,
|
||||
strip_markdown,
|
||||
wrap_text,
|
||||
get_terminal_width,
|
||||
)
|
||||
|
||||
HEADER_COLOR = sys.argv[1]
|
||||
SEPARATOR_COLOR = sys.argv[2]
|
||||
|
||||
# Get terminal dimensions
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Print title centered
|
||||
print("{TITLE}".center(term_width))
|
||||
|
||||
# Define table data
|
||||
rows = [
|
||||
("Score", "{SCORE}"),
|
||||
("Favorites", "{FAVOURITES}"),
|
||||
("Popularity", "{POPULARITY}"),
|
||||
("Status", "{STATUS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Episodes", "{EPISODES}"),
|
||||
("Duration", "{DURATION}"),
|
||||
("Next Episode", "{NEXT_EPISODE}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Genres", "{GENRES}"),
|
||||
("Format", "{FORMAT}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("List Status", "{USER_STATUS}"),
|
||||
("Progress", "{USER_PROGRESS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Start Date", "{START_DATE}"),
|
||||
("End Date", "{END_DATE}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Studios", "{STUDIOS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Synonymns", "{SYNONYMNS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
rows = [
|
||||
("Tags", "{TAGS}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
print(wrap_text(strip_markdown("""{SYNOPSIS}"""), term_width))
|
||||
@@ -1,288 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# FZF Preview Script Template
|
||||
#
|
||||
# This script is a template. The placeholders in curly braces, like {NAME}
|
||||
# are dynamically filled by python using .replace() during runtime.
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
# --- Template Variables (Injected by Python) ---
|
||||
PREVIEW_MODE = "{PREVIEW_MODE}"
|
||||
IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}")
|
||||
INFO_CACHE_DIR = Path("{INFO_CACHE_DIR}")
|
||||
IMAGE_RENDERER = "{IMAGE_RENDERER}"
|
||||
HEADER_COLOR = "{HEADER_COLOR}"
|
||||
SEPARATOR_COLOR = "{SEPARATOR_COLOR}"
|
||||
PREFIX = "{PREFIX}"
|
||||
SCALE_UP = "{SCALE_UP}" == "True"
|
||||
|
||||
# --- Arguments ---
|
||||
# sys.argv[1] is usually the raw line from FZF (the anime title/key)
|
||||
TITLE = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||
KEY = """{KEY}"""
|
||||
KEY = KEY + "-" if KEY else KEY
|
||||
|
||||
# Generate the hash to find the cached files
|
||||
hash_id = f"{PREFIX}-{sha256((KEY + TITLE).encode('utf-8')).hexdigest()}"
|
||||
|
||||
|
||||
def get_terminal_dimensions():
|
||||
"""
|
||||
Determine the available dimensions (cols x lines) for the preview window.
|
||||
Prioritizes FZF environment variables.
|
||||
"""
|
||||
fzf_cols = os.environ.get("FZF_PREVIEW_COLUMNS")
|
||||
fzf_lines = os.environ.get("FZF_PREVIEW_LINES")
|
||||
|
||||
if fzf_cols and fzf_lines:
|
||||
return int(fzf_cols), int(fzf_lines)
|
||||
|
||||
# Fallback to stty if FZF vars aren't set (unlikely in preview)
|
||||
try:
|
||||
rows, cols = (
|
||||
subprocess.check_output(
|
||||
["stty", "size"], text=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
.strip()
|
||||
.split()
|
||||
)
|
||||
return int(cols), int(rows)
|
||||
except Exception:
|
||||
return 80, 24
|
||||
|
||||
|
||||
def which(cmd):
|
||||
"""Alias for shutil.which"""
|
||||
return shutil.which(cmd)
|
||||
|
||||
|
||||
def render_kitty(file_path, width, height, scale_up):
|
||||
"""Render using the Kitty Graphics Protocol (kitten/icat)."""
|
||||
# 1. Try 'kitten icat' (Modern)
|
||||
# 2. Try 'icat' (Legacy/Alias)
|
||||
# 3. Try 'kitty +kitten icat' (Fallback)
|
||||
|
||||
cmd = []
|
||||
if which("kitten"):
|
||||
cmd = ["kitten", "icat"]
|
||||
elif which("icat"):
|
||||
cmd = ["icat"]
|
||||
elif which("kitty"):
|
||||
cmd = ["kitty", "+kitten", "icat"]
|
||||
|
||||
if not cmd:
|
||||
return False
|
||||
|
||||
# Build Arguments
|
||||
args = [
|
||||
"--clear",
|
||||
"--transfer-mode=memory",
|
||||
"--unicode-placeholder",
|
||||
"--stdin=no",
|
||||
f"--place={width}x{height}@0x0",
|
||||
]
|
||||
|
||||
if scale_up:
|
||||
args.append("--scale-up")
|
||||
|
||||
args.append(file_path)
|
||||
|
||||
subprocess.run(cmd + args, stdout=sys.stdout, stderr=sys.stderr)
|
||||
return True
|
||||
|
||||
|
||||
def render_sixel(file_path, width, height):
|
||||
"""
|
||||
Render using Sixel.
|
||||
Prioritizes 'chafa' for Sixel as it handles text-cell sizing better than img2sixel.
|
||||
"""
|
||||
|
||||
# Option A: Chafa (Best for Sixel sizing)
|
||||
if which("chafa"):
|
||||
# Chafa automatically detects Sixel support if terminal reports it,
|
||||
# but we force it here if specifically requested via logic flow.
|
||||
subprocess.run(
|
||||
["chafa", "-f", "sixel", "-s", f"{width}x{height}", file_path],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
# Option B: img2sixel (Libsixel)
|
||||
# Note: img2sixel uses pixels, not cells. We estimate 1 cell ~= 10px width, 20px height
|
||||
if which("img2sixel"):
|
||||
pixel_width = width * 10
|
||||
pixel_height = height * 20
|
||||
subprocess.run(
|
||||
[
|
||||
"img2sixel",
|
||||
f"--width={pixel_width}",
|
||||
f"--height={pixel_height}",
|
||||
file_path,
|
||||
],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def render_iterm(file_path, width, height):
|
||||
"""Render using iTerm2 Inline Image Protocol."""
|
||||
if which("imgcat"):
|
||||
subprocess.run(
|
||||
["imgcat", "-W", str(width), "-H", str(height), file_path],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
|
||||
# Chafa also supports iTerm
|
||||
if which("chafa"):
|
||||
subprocess.run(
|
||||
["chafa", "-f", "iterm", "-s", f"{width}x{height}", file_path],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def render_timg(file_path, width, height):
|
||||
"""Render using timg (supports half-blocks, quarter-blocks, sixel, kitty, etc)."""
|
||||
if which("timg"):
|
||||
subprocess.run(
|
||||
["timg", f"-g{width}x{height}", "--upscale", file_path],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def render_chafa_auto(file_path, width, height):
|
||||
"""
|
||||
Render using Chafa in auto mode.
|
||||
It supports Sixel, Kitty, iTerm, and various unicode block modes.
|
||||
"""
|
||||
if which("chafa"):
|
||||
subprocess.run(
|
||||
["chafa", "-s", f"{width}x{height}", file_path],
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def fzf_image_preview(file_path: str):
|
||||
"""
|
||||
Main dispatch function to choose the best renderer.
|
||||
"""
|
||||
cols, lines = get_terminal_dimensions()
|
||||
|
||||
# Heuristic: Reserve 1 line for prompt/status if needed, though FZF handles this.
|
||||
# Some renderers behave better with a tiny bit of padding.
|
||||
width = cols
|
||||
height = lines
|
||||
|
||||
# --- 1. Check Explicit Configuration ---
|
||||
|
||||
if IMAGE_RENDERER == "icat" or IMAGE_RENDERER == "system-kitty":
|
||||
if render_kitty(file_path, width, height, SCALE_UP):
|
||||
return
|
||||
|
||||
elif IMAGE_RENDERER == "sixel" or IMAGE_RENDERER == "system-sixels":
|
||||
if render_sixel(file_path, width, height):
|
||||
return
|
||||
|
||||
elif IMAGE_RENDERER == "imgcat":
|
||||
if render_iterm(file_path, width, height):
|
||||
return
|
||||
|
||||
elif IMAGE_RENDERER == "timg":
|
||||
if render_timg(file_path, width, height):
|
||||
return
|
||||
|
||||
elif IMAGE_RENDERER == "chafa":
|
||||
if render_chafa_auto(file_path, width, height):
|
||||
return
|
||||
|
||||
# --- 2. Auto-Detection / Fallback Strategy ---
|
||||
|
||||
# If explicit failed or set to 'auto'/'system-default', try detecting environment
|
||||
|
||||
# Ghostty / Kitty Environment
|
||||
if os.environ.get("KITTY_WINDOW_ID") or os.environ.get("GHOSTTY_BIN_DIR"):
|
||||
if render_kitty(file_path, width, height, SCALE_UP):
|
||||
return
|
||||
|
||||
# iTerm Environment
|
||||
if os.environ.get("TERM_PROGRAM") == "iTerm.app":
|
||||
if render_iterm(file_path, width, height):
|
||||
return
|
||||
|
||||
# Try standard tools in order of quality/preference
|
||||
if render_kitty(file_path, width, height, SCALE_UP):
|
||||
return # Try kitty just in case
|
||||
if render_sixel(file_path, width, height):
|
||||
return
|
||||
if render_timg(file_path, width, height):
|
||||
return
|
||||
if render_chafa_auto(file_path, width, height):
|
||||
return
|
||||
|
||||
print("⚠️ No suitable image renderer found (icat, chafa, timg, img2sixel).")
|
||||
|
||||
|
||||
def fzf_text_info_render():
|
||||
"""Renders the text-based info via the cached python script."""
|
||||
# Get terminal dimensions from FZF environment or fallback
|
||||
cols, lines = get_terminal_dimensions()
|
||||
|
||||
# Print simple separator line with proper width
|
||||
r, g, b = map(int, SEPARATOR_COLOR.split(","))
|
||||
separator = f"\x1b[38;2;{r};{g};{b}m" + ("─" * cols) + "\x1b[0m"
|
||||
print(separator, flush=True)
|
||||
|
||||
if PREVIEW_MODE == "text" or PREVIEW_MODE == "full":
|
||||
preview_info_path = INFO_CACHE_DIR / f"{hash_id}.py"
|
||||
if preview_info_path.exists():
|
||||
subprocess.run(
|
||||
[sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR]
|
||||
)
|
||||
else:
|
||||
# Print dim text
|
||||
print("\x1b[2m📝 Loading details...\x1b[0m")
|
||||
|
||||
|
||||
def main():
|
||||
# 1. Image Preview
|
||||
if (PREVIEW_MODE == "image" or PREVIEW_MODE == "full") and (
|
||||
PREFIX not in ("character", "review", "airing-schedule")
|
||||
):
|
||||
preview_image_path = IMAGE_CACHE_DIR / f"{hash_id}.png"
|
||||
if preview_image_path.exists():
|
||||
fzf_image_preview(str(preview_image_path))
|
||||
print() # Spacer
|
||||
else:
|
||||
print("🖼️ Loading image...")
|
||||
|
||||
# 2. Text Info Preview
|
||||
fzf_text_info_render()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"Preview Error: {e}")
|
||||
@@ -1,28 +0,0 @@
|
||||
import sys
|
||||
from _ansi_utils import (
|
||||
print_rule,
|
||||
print_table_row,
|
||||
strip_markdown,
|
||||
wrap_text,
|
||||
get_terminal_width,
|
||||
)
|
||||
|
||||
HEADER_COLOR = sys.argv[1]
|
||||
SEPARATOR_COLOR = sys.argv[2]
|
||||
|
||||
# Get terminal dimensions
|
||||
term_width = get_terminal_width()
|
||||
|
||||
# Print title centered
|
||||
print("{REVIEWER_NAME}".center(term_width))
|
||||
|
||||
rows = [
|
||||
("Summary", "{REVIEW_SUMMARY}"),
|
||||
]
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
for key, value in rows:
|
||||
print_table_row(key, value, HEADER_COLOR, 15, term_width - 20)
|
||||
|
||||
print_rule(SEPARATOR_COLOR)
|
||||
print(wrap_text(strip_markdown("""{REVIEW_BODY}"""), term_width))
|
||||
@@ -39,6 +39,7 @@ commands = {
|
||||
"worker": "worker.worker",
|
||||
"queue": "queue.queue",
|
||||
"completions": "completions.completions",
|
||||
"plugin": "plugin.plugin",
|
||||
}
|
||||
|
||||
|
||||
|
||||
5
viu_media/cli/commands/plugin/__init__.py
Normal file
5
viu_media/cli/commands/plugin/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Plugin management commands for viu."""
|
||||
|
||||
from .cmd import plugin
|
||||
|
||||
__all__ = ["plugin"]
|
||||
24
viu_media/cli/commands/plugin/cmd.py
Normal file
24
viu_media/cli/commands/plugin/cmd.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Main plugin command group."""
|
||||
|
||||
import click
|
||||
|
||||
from ...utils.lazyloader import LazyGroup
|
||||
|
||||
lazy_subcommands = {
|
||||
"add": "add.add",
|
||||
"remove": "remove.remove",
|
||||
"list": "list_plugins.list_plugins",
|
||||
"update": "update.update",
|
||||
}
|
||||
|
||||
|
||||
@click.group(
|
||||
name="plugin",
|
||||
cls=LazyGroup,
|
||||
root="viu_media.cli.commands.plugin.commands",
|
||||
lazy_subcommands=lazy_subcommands,
|
||||
help="Manage viu plugins (providers, players, selectors, commands)"
|
||||
)
|
||||
def plugin() -> None:
|
||||
"""Manage viu plugins."""
|
||||
pass
|
||||
1
viu_media/cli/commands/plugin/commands/__init__.py
Normal file
1
viu_media/cli/commands/plugin/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Plugin command implementations."""
|
||||
54
viu_media/cli/commands/plugin/commands/add.py
Normal file
54
viu_media/cli/commands/plugin/commands/add.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Add plugin command."""
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from typing import cast
|
||||
|
||||
from viu_media.core.plugins.manager import PluginError, plugin_manager, ComponentType
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--type",
|
||||
"plugin_type",
|
||||
type=click.Choice(["provider", "player", "selector", "command"]),
|
||||
required=True,
|
||||
help="Type of plugin to install"
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
is_flag=True,
|
||||
help="Force installation, overwriting existing plugin"
|
||||
)
|
||||
@click.argument("name")
|
||||
@click.argument("source")
|
||||
def add(plugin_type: str, name: str, source: str, force: bool) -> None:
|
||||
"""Install a plugin from a Git repository.
|
||||
|
||||
NAME: Local name for the plugin
|
||||
SOURCE: Git source (e.g., 'github:user/repo' or full URL)
|
||||
|
||||
Examples:
|
||||
viu plugin add --type provider gogoanime github:user/viu-gogoanime
|
||||
viu plugin add --type player custom-mpv https://github.com/user/viu-mpv-plugin
|
||||
"""
|
||||
try:
|
||||
console.print(f"Installing {plugin_type} plugin '{name}' from {source}...")
|
||||
plugin_manager.add_plugin(cast(ComponentType, plugin_type), name, source, force=force)
|
||||
console.print(f"✅ Successfully installed plugin '{name}'", style="green")
|
||||
|
||||
# Show configuration hint
|
||||
from viu_media.core.constants import PLUGINS_CONFIG
|
||||
console.print(
|
||||
f"\n💡 Configure the plugin by editing: {PLUGINS_CONFIG}",
|
||||
style="blue"
|
||||
)
|
||||
|
||||
except PluginError as e:
|
||||
console.print(f"❌ Failed to install plugin: {e}", style="red")
|
||||
raise click.ClickException(str(e))
|
||||
except Exception as e:
|
||||
console.print(f"❌ Unexpected error: {e}", style="red")
|
||||
raise click.ClickException(f"Unexpected error: {e}")
|
||||
74
viu_media/cli/commands/plugin/commands/list_plugins.py
Normal file
74
viu_media/cli/commands/plugin/commands/list_plugins.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""List plugins command."""
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from typing import cast
|
||||
|
||||
from viu_media.core.plugins.manager import plugin_manager, ComponentType
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.command(name="list")
|
||||
@click.option(
|
||||
"--type",
|
||||
"plugin_type",
|
||||
type=click.Choice(["provider", "player", "selector", "command"]),
|
||||
help="Filter by plugin type"
|
||||
)
|
||||
def list_plugins(plugin_type: str) -> None:
|
||||
"""List installed plugins.
|
||||
|
||||
Examples:
|
||||
viu plugin list
|
||||
viu plugin list --type provider
|
||||
"""
|
||||
all_plugins = plugin_manager.list_plugins()
|
||||
|
||||
# Filter by type if specified
|
||||
if plugin_type:
|
||||
plugins_to_show = {cast(ComponentType, plugin_type): all_plugins[cast(ComponentType, plugin_type)]}
|
||||
else:
|
||||
plugins_to_show = all_plugins
|
||||
|
||||
# Count total plugins
|
||||
total_count = sum(len(plugins) for plugins in plugins_to_show.values())
|
||||
|
||||
if total_count == 0:
|
||||
if plugin_type:
|
||||
console.print(f"No {plugin_type} plugins installed.", style="yellow")
|
||||
else:
|
||||
console.print("No plugins installed.", style="yellow")
|
||||
console.print("Install plugins with: viu plugin add --type <type> <name> <source>")
|
||||
return
|
||||
|
||||
# Create table
|
||||
table = Table(title="Installed Plugins")
|
||||
table.add_column("Type", style="cyan")
|
||||
table.add_column("Name", style="green")
|
||||
table.add_column("Version", style="yellow")
|
||||
table.add_column("Source", style="blue")
|
||||
table.add_column("Path", style="magenta")
|
||||
|
||||
# Add rows
|
||||
for component_type, plugins in plugins_to_show.items():
|
||||
for name, plugin_info in plugins.items():
|
||||
table.add_row(
|
||||
component_type,
|
||||
name,
|
||||
plugin_info.version or "unknown",
|
||||
plugin_info.source,
|
||||
str(plugin_info.path)
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
console.print(f"\nTotal: {total_count} plugin(s)")
|
||||
|
||||
# Show configuration hint if plugins exist
|
||||
if total_count > 0:
|
||||
from viu_media.core.constants import PLUGINS_CONFIG
|
||||
console.print(
|
||||
f"\n💡 Configure plugins by editing: {PLUGINS_CONFIG}",
|
||||
style="blue"
|
||||
)
|
||||
43
viu_media/cli/commands/plugin/commands/remove.py
Normal file
43
viu_media/cli/commands/plugin/commands/remove.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Remove plugin command."""
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from typing import cast
|
||||
|
||||
from viu_media.core.plugins.manager import PluginError, PluginNotFoundError, plugin_manager, ComponentType
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--type",
|
||||
"plugin_type",
|
||||
type=click.Choice(["provider", "player", "selector", "command"]),
|
||||
required=True,
|
||||
help="Type of plugin to remove"
|
||||
)
|
||||
@click.argument("name")
|
||||
def remove(plugin_type: str, name: str) -> None:
|
||||
"""Remove an installed plugin.
|
||||
|
||||
NAME: Name of the plugin to remove
|
||||
|
||||
Examples:
|
||||
viu plugin remove --type provider gogoanime
|
||||
viu plugin remove --type player custom-mpv
|
||||
"""
|
||||
try:
|
||||
console.print(f"Removing {plugin_type} plugin '{name}'...")
|
||||
plugin_manager.remove_plugin(cast(ComponentType, plugin_type), name)
|
||||
console.print(f"✅ Successfully removed plugin '{name}'", style="green")
|
||||
|
||||
except PluginNotFoundError as e:
|
||||
console.print(f"❌ Plugin not found: {e}", style="red")
|
||||
raise click.ClickException(str(e))
|
||||
except PluginError as e:
|
||||
console.print(f"❌ Failed to remove plugin: {e}", style="red")
|
||||
raise click.ClickException(str(e))
|
||||
except Exception as e:
|
||||
console.print(f"❌ Unexpected error: {e}", style="red")
|
||||
raise click.ClickException(f"Unexpected error: {e}")
|
||||
43
viu_media/cli/commands/plugin/commands/update.py
Normal file
43
viu_media/cli/commands/plugin/commands/update.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Update plugin command."""
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from typing import cast
|
||||
|
||||
from viu_media.core.plugins.manager import PluginError, PluginNotFoundError, plugin_manager, ComponentType
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--type",
|
||||
"plugin_type",
|
||||
type=click.Choice(["provider", "player", "selector", "command"]),
|
||||
required=True,
|
||||
help="Type of plugin to update"
|
||||
)
|
||||
@click.argument("name")
|
||||
def update(plugin_type: str, name: str) -> None:
|
||||
"""Update an installed plugin by pulling from Git.
|
||||
|
||||
NAME: Name of the plugin to update
|
||||
|
||||
Examples:
|
||||
viu plugin update --type provider gogoanime
|
||||
viu plugin update --type player custom-mpv
|
||||
"""
|
||||
try:
|
||||
console.print(f"Updating {plugin_type} plugin '{name}'...")
|
||||
plugin_manager.update_plugin(cast(ComponentType, plugin_type), name)
|
||||
console.print(f"✅ Successfully updated plugin '{name}'", style="green")
|
||||
|
||||
except PluginNotFoundError as e:
|
||||
console.print(f"❌ Plugin not found: {e}", style="red")
|
||||
raise click.ClickException(str(e))
|
||||
except PluginError as e:
|
||||
console.print(f"❌ Failed to update plugin: {e}", style="red")
|
||||
raise click.ClickException(str(e))
|
||||
except Exception as e:
|
||||
console.print(f"❌ Unexpected error: {e}", style="red")
|
||||
raise click.ClickException(f"Unexpected error: {e}")
|
||||
@@ -113,7 +113,6 @@ 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,7 +5,6 @@ Registry restore command - restore registry from backup files
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,11 +25,6 @@ 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",
|
||||
@@ -44,7 +38,6 @@ def restore(
|
||||
backup_file: Path,
|
||||
force: bool,
|
||||
backup_current: bool,
|
||||
backup_current_compression_fmt: str,
|
||||
verify: bool,
|
||||
api: str,
|
||||
):
|
||||
@@ -68,7 +61,7 @@ def restore(
|
||||
"Verification Failed",
|
||||
"Backup file appears to be corrupted or invalid",
|
||||
)
|
||||
return
|
||||
raise click.Abort()
|
||||
feedback.success("Verification", "Backup file integrity verified")
|
||||
|
||||
# Check if current registry exists
|
||||
@@ -84,13 +77,7 @@ def restore(
|
||||
|
||||
# Create backup of current registry if requested
|
||||
if backup_current and registry_exists:
|
||||
_backup_current_registry(
|
||||
registry_service,
|
||||
api,
|
||||
feedback,
|
||||
backup_format=backup_format,
|
||||
compression_fmt=backup_current_compression_fmt,
|
||||
)
|
||||
_backup_current_registry(registry_service, api, feedback)
|
||||
|
||||
# Show restore summary
|
||||
_show_restore_summary(backup_file, backup_format, feedback)
|
||||
@@ -123,13 +110,7 @@ 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
|
||||
or ".bz2" in suffixes
|
||||
or ".xz" in suffixes
|
||||
):
|
||||
if ".tar" in suffixes or ".gz" in suffixes or ".tgz" in suffixes:
|
||||
return "tar"
|
||||
elif ".zip" in suffixes:
|
||||
return "zip"
|
||||
@@ -141,38 +122,25 @@ 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()
|
||||
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
|
||||
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
|
||||
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()
|
||||
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
|
||||
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
|
||||
if has_metadata:
|
||||
with zip_file.open("backup_metadata.json") as metadata_file:
|
||||
metadata = json.load(metadata_file)
|
||||
@@ -195,42 +163,27 @@ def _verify_backup(
|
||||
|
||||
def _check_registry_exists(registry_service: MediaRegistryService) -> bool:
|
||||
"""Check if a registry already exists."""
|
||||
# TODO: Improve this check to be more robust
|
||||
return registry_service.media_registry_dir.exists() and any(
|
||||
registry_service.media_registry_dir.iterdir()
|
||||
)
|
||||
try:
|
||||
stats = registry_service.get_registry_stats()
|
||||
return stats.get("total_media", 0) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _backup_current_registry(
|
||||
registry_service: MediaRegistryService,
|
||||
api: str,
|
||||
feedback: FeedbackService,
|
||||
backup_format: str,
|
||||
compression_fmt: str,
|
||||
registry_service: MediaRegistryService, api: str, feedback: FeedbackService
|
||||
):
|
||||
"""Create backup of current registry before restoring."""
|
||||
from .backup import _create_tar_backup
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
if backup_format == "tar":
|
||||
from .backup import _create_tar_backup
|
||||
backup_path = Path(f"viu_registry_pre_restore_{api}_{timestamp}.tar.gz")
|
||||
|
||||
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)
|
||||
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}")
|
||||
|
||||
|
||||
def _show_restore_summary(
|
||||
|
||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
||||
@click.option(
|
||||
"--anime-title",
|
||||
"-t",
|
||||
required=True,
|
||||
shell_complete=anime_titles_shell_complete,
|
||||
multiple=True,
|
||||
help="Specify which anime to download",
|
||||
@@ -51,10 +52,6 @@ def search(config: AppConfig, **options: "Unpack[Options]"):
|
||||
from ...libs.provider.anime.provider import create_provider
|
||||
from ...libs.selectors.selector import create_selector
|
||||
|
||||
if not options["anime_title"]:
|
||||
raw = click.prompt("What are you in the mood for? (comma-separated)")
|
||||
options["anime_title"] = [a.strip() for a in raw.split(",") if a.strip()]
|
||||
|
||||
feedback = FeedbackService(config)
|
||||
provider = create_provider(config.general.provider)
|
||||
selector = create_selector(config)
|
||||
@@ -176,22 +173,6 @@ def stream_anime(
|
||||
if not server_name:
|
||||
raise ViuError("Server not selected")
|
||||
server = servers[server_name]
|
||||
quality = [
|
||||
ep_stream.link
|
||||
for ep_stream in server.links
|
||||
if ep_stream.quality == config.stream.quality
|
||||
]
|
||||
if not quality:
|
||||
feedback.warning("Preferred quality not found, selecting quality...")
|
||||
stream_link = selector.choose(
|
||||
"Select Quality", [link.quality for link in server.links]
|
||||
)
|
||||
if not stream_link:
|
||||
raise ViuError("Quality not selected")
|
||||
stream_link = next(
|
||||
(link.link for link in server.links if link.quality == stream_link), None
|
||||
)
|
||||
|
||||
stream_link = server.links[0].link
|
||||
if not stream_link:
|
||||
raise ViuError(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Update command for Viu CLI."""
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -29,7 +28,7 @@ class InteractiveConfigEditor:
|
||||
if not isinstance(section_model, BaseModel):
|
||||
continue
|
||||
|
||||
if not inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
|
||||
if not inquirer.confirm(
|
||||
message=f"Configure '{section_name.title()}' settings?",
|
||||
default=True,
|
||||
).execute():
|
||||
@@ -84,14 +83,14 @@ class InteractiveConfigEditor:
|
||||
|
||||
# Boolean fields
|
||||
if field_type is bool:
|
||||
return inquirer.confirm( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.confirm(
|
||||
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( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.select(
|
||||
message=message,
|
||||
choices=choices,
|
||||
default=current_value,
|
||||
@@ -100,7 +99,7 @@ class InteractiveConfigEditor:
|
||||
|
||||
# Numeric fields
|
||||
if field_type is int:
|
||||
return inquirer.number( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.number(
|
||||
message=message,
|
||||
default=int(current_value),
|
||||
long_instruction=help_text,
|
||||
@@ -111,7 +110,7 @@ class InteractiveConfigEditor:
|
||||
validate=NumberValidator(),
|
||||
)
|
||||
if field_type is float:
|
||||
return inquirer.number( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.number(
|
||||
message=message,
|
||||
default=float(current_value),
|
||||
float_allowed=True,
|
||||
@@ -121,7 +120,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( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.text(
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
@@ -129,13 +128,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( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.fuzzy(
|
||||
message=message,
|
||||
choices=field_info.examples,
|
||||
default=str(current_value),
|
||||
long_instruction=help_text,
|
||||
)
|
||||
return inquirer.text( # pyright: ignore[reportPrivateImportUsage]
|
||||
return inquirer.text(
|
||||
message=message, default=str(current_value), long_instruction=help_text
|
||||
)
|
||||
|
||||
|
||||
@@ -308,8 +308,6 @@ 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,8 +249,7 @@ def _change_quality(ctx: Context, state: State) -> MenuAction:
|
||||
return InternalDirective.BACK
|
||||
|
||||
new_quality = selector.choose(
|
||||
"Select a different quality:",
|
||||
[link.quality for link in state.provider.server.links],
|
||||
"Select a different server:", list(["360", "480", "720", "1080"])
|
||||
)
|
||||
if new_quality:
|
||||
ctx.config.stream.quality = new_quality # type:ignore
|
||||
|
||||
@@ -296,7 +296,8 @@ class DownloadService:
|
||||
message=message,
|
||||
app_name="Viu",
|
||||
app_icon=app_icon,
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration
|
||||
* 60,
|
||||
)
|
||||
except: # noqa: E722
|
||||
pass
|
||||
@@ -317,7 +318,7 @@ class DownloadService:
|
||||
message=message,
|
||||
app_name="Viu",
|
||||
app_icon=app_icon,
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
except: # noqa: E722
|
||||
pass
|
||||
|
||||
@@ -41,7 +41,7 @@ class FeedbackService:
|
||||
message=message,
|
||||
app_name=CLI_NAME,
|
||||
app_icon=str(ICON_PATH),
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
return
|
||||
except: # noqa: E722
|
||||
@@ -67,7 +67,7 @@ class FeedbackService:
|
||||
message=message,
|
||||
app_name=CLI_NAME,
|
||||
app_icon=str(ICON_PATH),
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
return
|
||||
except: # noqa: E722
|
||||
@@ -94,7 +94,7 @@ class FeedbackService:
|
||||
message=message,
|
||||
app_name=CLI_NAME,
|
||||
app_icon=str(ICON_PATH),
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
return
|
||||
except: # noqa: E722
|
||||
@@ -120,7 +120,7 @@ class FeedbackService:
|
||||
message=message,
|
||||
app_name=CLI_NAME,
|
||||
app_icon=str(ICON_PATH),
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
return
|
||||
except: # noqa: E722
|
||||
@@ -176,7 +176,7 @@ class FeedbackService:
|
||||
message="No current way to display info in rofi, use fzf and the terminal instead",
|
||||
app_name=CLI_NAME,
|
||||
app_icon=str(ICON_PATH),
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
return
|
||||
except: # noqa: E722
|
||||
|
||||
@@ -101,7 +101,7 @@ class NotificationService:
|
||||
message=message,
|
||||
app_name="Viu",
|
||||
app_icon=app_icon, # plyer supports file paths or URLs depending on platform
|
||||
timeout=self.app_config.general.desktop_notification_duration,
|
||||
timeout=self.app_config.general.desktop_notification_duration * 60,
|
||||
)
|
||||
logger.info(f"Displayed notification: {message}")
|
||||
self._mark_seen(
|
||||
|
||||
@@ -3,8 +3,6 @@ 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
|
||||
@@ -45,74 +43,67 @@ def resize_image_from_url(
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
if importlib.util.find_spec("PIL"):
|
||||
from PIL import Image # pyright: ignore[reportMissingImports]
|
||||
from PIL import Image
|
||||
|
||||
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 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}'"
|
||||
)
|
||||
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"
|
||||
)
|
||||
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'."
|
||||
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
|
||||
|
||||
|
||||
def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str]:
|
||||
@@ -132,12 +123,17 @@ 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],
|
||||
capture_output=capture,
|
||||
text=capture,
|
||||
[icat_executable, "--align", "left", url], **subprocess_kwargs
|
||||
)
|
||||
if process.returncode == 0:
|
||||
return process.stdout if capture else None
|
||||
@@ -152,11 +148,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}", "-"],
|
||||
capture_output=capture,
|
||||
text=capture,
|
||||
input=img_bytes,
|
||||
[chafa_executable, f"--size={size}", "-"], **subprocess_kwargs
|
||||
)
|
||||
if process.returncode == 0:
|
||||
return process.stdout if capture else None
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from hashlib import sha256
|
||||
import sys
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from viu_media.core.utils import formatter
|
||||
|
||||
from ...core.config import AppConfig
|
||||
from ...core.constants import APP_CACHE_DIR, PLATFORM, SCRIPTS_DIR
|
||||
from ...core.utils.file import AtomicWriter
|
||||
@@ -119,15 +117,31 @@ def _get_episode_image(episode: str, media_item: MediaItem) -> str:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# os.environ["SHELL"] = sys.executable
|
||||
os.environ["SHELL"] = "bash"
|
||||
|
||||
PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews"
|
||||
IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images"
|
||||
INFO_CACHE_DIR = PREVIEWS_CACHE_DIR / "info"
|
||||
REVIEWS_CACHE_DIR = PREVIEWS_CACHE_DIR / "reviews"
|
||||
CHARACTERS_CACHE_DIR = PREVIEWS_CACHE_DIR / "characters"
|
||||
AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule"
|
||||
|
||||
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
|
||||
TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8")
|
||||
DYNAMIC_PREVIEW_SCRIPT = ""
|
||||
TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
TEMPLATE_REVIEW_PREVIEW_SCRIPT = (
|
||||
FZF_SCRIPTS_DIR / "review-preview.template.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
TEMPLATE_CHARACTER_PREVIEW_SCRIPT = (
|
||||
FZF_SCRIPTS_DIR / "character-preview.template.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = (
|
||||
FZF_SCRIPTS_DIR / "airing-schedule-preview.template.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
DYNAMIC_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "dynamic-preview.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*")
|
||||
|
||||
@@ -135,23 +149,6 @@ EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*")
|
||||
_preview_manager: Optional[PreviewWorkerManager] = None
|
||||
|
||||
|
||||
def _ensure_ansi_utils_in_cache():
|
||||
"""Copy _ansi_utils.py to the info cache directory so cached scripts can import it."""
|
||||
source = FZF_SCRIPTS_DIR / "_ansi_utils.py"
|
||||
dest = INFO_CACHE_DIR / "_ansi_utils.py"
|
||||
|
||||
if source.exists() and (
|
||||
not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime
|
||||
):
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.copy2(source, dest)
|
||||
logger.debug(f"Copied _ansi_utils.py to {INFO_CACHE_DIR}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to copy _ansi_utils.py to cache: {e}")
|
||||
|
||||
|
||||
def create_preview_context():
|
||||
"""
|
||||
Create a context manager for preview operations.
|
||||
@@ -287,7 +284,6 @@ def get_anime_preview(
|
||||
# Ensure cache directories exist on startup
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_ensure_ansi_utils_in_cache()
|
||||
|
||||
HEADER_COLOR = config.fzf.preview_header_color.split(",")
|
||||
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
|
||||
@@ -304,28 +300,30 @@ def get_anime_preview(
|
||||
logger.error(f"Failed to start background caching: {e}")
|
||||
# Continue with script generation even if caching fails
|
||||
|
||||
# Prepare values to inject into the template
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Format the template with the dynamic values
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_PATH": str(INFO_CACHE_DIR),
|
||||
"PATH_SEP": path_sep,
|
||||
"IMAGE_RENDERER": config.general.image_renderer,
|
||||
# Color codes
|
||||
"HEADER_COLOR": ",".join(HEADER_COLOR),
|
||||
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
|
||||
"PREFIX": "search-result",
|
||||
"KEY": "",
|
||||
"SCALE_UP": str(config.general.preview_scale_up),
|
||||
"C_TITLE": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_KEY": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_VALUE": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_RULE": ansi.get_true_fg(SEPARATOR_COLOR, bold=True),
|
||||
"RESET": ansi.RESET,
|
||||
"PREFIX": "",
|
||||
"SCALE_UP": " --scale-up" if config.general.preview_scale_up else "",
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
preview_file = PREVIEWS_CACHE_DIR / "search-result-preview-script.py"
|
||||
preview_file.write_text(preview_script, encoding="utf-8")
|
||||
|
||||
preview_script_final = f"{sys.executable} {preview_file} {{}}"
|
||||
return preview_script_final
|
||||
return preview_script
|
||||
|
||||
|
||||
def get_episode_preview(
|
||||
@@ -362,169 +360,30 @@ def get_episode_preview(
|
||||
logger.error(f"Failed to start episode background caching: {e}")
|
||||
# Continue with script generation even if caching fails
|
||||
|
||||
# Prepare values to inject into the template
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Format the template with the dynamic values
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_PATH": str(INFO_CACHE_DIR),
|
||||
"PATH_SEP": path_sep,
|
||||
"IMAGE_RENDERER": config.general.image_renderer,
|
||||
# Color codes
|
||||
"HEADER_COLOR": ",".join(HEADER_COLOR),
|
||||
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
|
||||
"PREFIX": "episode",
|
||||
"KEY": f"{media_item.title.english.replace(formatter.DOUBLE_QUOTE, formatter.SINGLE_QUOTE)}",
|
||||
"SCALE_UP": str(config.general.preview_scale_up),
|
||||
"C_TITLE": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_KEY": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_VALUE": ansi.get_true_fg(HEADER_COLOR, bold=True),
|
||||
"C_RULE": ansi.get_true_fg(SEPARATOR_COLOR, bold=True),
|
||||
"RESET": ansi.RESET,
|
||||
"PREFIX": f"{media_item.title.english}_Episode_",
|
||||
"SCALE_UP": " --scale-up" if config.general.preview_scale_up else "",
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
preview_file = PREVIEWS_CACHE_DIR / "episode-preview-script.py"
|
||||
preview_file.write_text(preview_script, encoding="utf-8")
|
||||
|
||||
preview_script_final = f"{sys.executable} {preview_file} {{}}"
|
||||
return preview_script_final
|
||||
|
||||
|
||||
def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -> str:
|
||||
"""
|
||||
Generate the generic loader script for character previews and start background caching.
|
||||
"""
|
||||
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
HEADER_COLOR = config.fzf.preview_header_color.split(",")
|
||||
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
|
||||
|
||||
# Start managed background caching for episodes
|
||||
try:
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_character_worker()
|
||||
worker.cache_character_previews(choice_map, config)
|
||||
logger.debug("Started background caching for character previews")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start episode background caching: {e}")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_PREVIEW_SCRIPT
|
||||
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_RENDERER": config.general.image_renderer,
|
||||
# Color codes
|
||||
"HEADER_COLOR": ",".join(HEADER_COLOR),
|
||||
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
|
||||
"PREFIX": "character",
|
||||
"KEY": "",
|
||||
"SCALE_UP": str(config.general.preview_scale_up),
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
preview_file = PREVIEWS_CACHE_DIR / "character-preview-script.py"
|
||||
preview_file.write_text(preview_script, encoding="utf-8")
|
||||
|
||||
preview_script_final = f"{sys.executable} {preview_file} {{}}"
|
||||
return preview_script_final
|
||||
|
||||
|
||||
def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> str:
|
||||
"""
|
||||
Generate the generic loader script for review previews and start background caching.
|
||||
"""
|
||||
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
HEADER_COLOR = config.fzf.preview_header_color.split(",")
|
||||
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
|
||||
|
||||
# Start managed background caching for episodes
|
||||
try:
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_review_worker()
|
||||
worker.cache_review_previews(choice_map, config)
|
||||
logger.debug("Started background caching for review previews")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start episode background caching: {e}")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_PREVIEW_SCRIPT
|
||||
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_RENDERER": config.general.image_renderer,
|
||||
# Color codes
|
||||
"HEADER_COLOR": ",".join(HEADER_COLOR),
|
||||
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
|
||||
"PREFIX": "review",
|
||||
"KEY": "",
|
||||
"SCALE_UP": str(config.general.preview_scale_up),
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
preview_file = PREVIEWS_CACHE_DIR / "review-preview-script.py"
|
||||
preview_file.write_text(preview_script, encoding="utf-8")
|
||||
|
||||
preview_script_final = f"{sys.executable} {preview_file} {{}}"
|
||||
return preview_script_final
|
||||
|
||||
|
||||
def get_airing_schedule_preview(
|
||||
schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime"
|
||||
) -> str:
|
||||
"""
|
||||
Generate the generic loader script for airing schedule previews and start background caching.
|
||||
"""
|
||||
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
HEADER_COLOR = config.fzf.preview_header_color.split(",")
|
||||
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
|
||||
|
||||
# Start managed background caching for episodes
|
||||
try:
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_airing_schedule_worker()
|
||||
worker.cache_airing_schedule_preview(anime_title, schedule_result, config)
|
||||
logger.debug("Started background caching for airing schedule previews")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start episode background caching: {e}")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_PREVIEW_SCRIPT
|
||||
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_RENDERER": config.general.image_renderer,
|
||||
# Color codes
|
||||
"HEADER_COLOR": ",".join(HEADER_COLOR),
|
||||
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
|
||||
"PREFIX": "airing-schedule",
|
||||
"KEY": "",
|
||||
"SCALE_UP": str(config.general.preview_scale_up),
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
preview_file = PREVIEWS_CACHE_DIR / "airing-schedule-preview-script.py"
|
||||
preview_file.write_text(preview_script, encoding="utf-8")
|
||||
|
||||
preview_script_final = f"{sys.executable} {preview_file} {{}}"
|
||||
# NOTE: disabled cause not very useful
|
||||
return ""
|
||||
return preview_script
|
||||
|
||||
|
||||
def get_dynamic_anime_preview(config: AppConfig) -> str:
|
||||
@@ -545,7 +404,6 @@ def get_dynamic_anime_preview(config: AppConfig) -> str:
|
||||
# Ensure cache directories exist
|
||||
IMAGES_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_ensure_ansi_utils_in_cache()
|
||||
|
||||
HEADER_COLOR = config.fzf.preview_header_color.split(",")
|
||||
SEPARATOR_COLOR = config.fzf.preview_separator_color.split(",")
|
||||
@@ -586,7 +444,9 @@ def _get_preview_manager() -> PreviewWorkerManager:
|
||||
"""Get or create the global preview worker manager."""
|
||||
global _preview_manager
|
||||
if _preview_manager is None:
|
||||
_preview_manager = PreviewWorkerManager(IMAGES_CACHE_DIR, INFO_CACHE_DIR)
|
||||
_preview_manager = PreviewWorkerManager(
|
||||
IMAGES_CACHE_DIR, INFO_CACHE_DIR, REVIEWS_CACHE_DIR
|
||||
)
|
||||
return _preview_manager
|
||||
|
||||
|
||||
@@ -610,3 +470,111 @@ def get_preview_worker_status() -> dict:
|
||||
if _preview_manager:
|
||||
return _preview_manager.get_status()
|
||||
return {"preview_worker": None, "episode_worker": None}
|
||||
|
||||
|
||||
def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) -> str:
|
||||
"""
|
||||
Generate the generic loader script for review previews and start background caching.
|
||||
"""
|
||||
|
||||
REVIEWS_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_review_worker()
|
||||
worker.cache_review_previews(choice_map, config)
|
||||
logger.debug("Started background caching for review previews")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_REVIEW_PREVIEW_SCRIPT
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Inject the correct cache path and color codes
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"INFO_CACHE_DIR": str(REVIEWS_CACHE_DIR),
|
||||
"PATH_SEP": path_sep,
|
||||
"C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_RULE": ansi.get_true_fg(
|
||||
config.fzf.preview_separator_color.split(","), bold=True
|
||||
),
|
||||
"RESET": ansi.RESET,
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
return preview_script
|
||||
|
||||
|
||||
def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -> str:
|
||||
"""
|
||||
Generate the generic loader script for character previews and start background caching.
|
||||
"""
|
||||
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_character_worker()
|
||||
worker.cache_character_previews(choice_map, config)
|
||||
logger.debug("Started background caching for character previews")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_CHARACTER_PREVIEW_SCRIPT
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Inject the correct cache path and color codes
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
|
||||
"PATH_SEP": path_sep,
|
||||
"C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_RULE": ansi.get_true_fg(
|
||||
config.fzf.preview_separator_color.split(","), bold=True
|
||||
),
|
||||
"RESET": ansi.RESET,
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
return preview_script
|
||||
|
||||
|
||||
def get_airing_schedule_preview(
|
||||
schedule_result: AiringScheduleResult, config: AppConfig, anime_title: str = "Anime"
|
||||
) -> str:
|
||||
"""
|
||||
Generate the generic loader script for airing schedule previews and start background caching.
|
||||
"""
|
||||
|
||||
INFO_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
preview_manager = _get_preview_manager()
|
||||
worker = preview_manager.get_airing_schedule_worker()
|
||||
worker.cache_airing_schedule_preview(anime_title, schedule_result, config)
|
||||
logger.debug("Started background caching for airing schedule previews")
|
||||
|
||||
# Use the generic loader script
|
||||
preview_script = TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT
|
||||
path_sep = "\\" if PLATFORM == "win32" else "/"
|
||||
|
||||
# Inject the correct cache path and color codes
|
||||
replacements = {
|
||||
"PREVIEW_MODE": config.general.preview,
|
||||
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
|
||||
"PATH_SEP": path_sep,
|
||||
"C_TITLE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_KEY": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_VALUE": ansi.get_true_fg(config.fzf.header_color.split(","), bold=True),
|
||||
"C_RULE": ansi.get_true_fg(
|
||||
config.fzf.preview_separator_color.split(","), bold=True
|
||||
),
|
||||
"RESET": ansi.RESET,
|
||||
}
|
||||
|
||||
for key, value in replacements.items():
|
||||
preview_script = preview_script.replace(f"{{{key}}}", value)
|
||||
|
||||
return preview_script
|
||||
|
||||
@@ -6,7 +6,6 @@ including image downloads and info text generation with proper lifecycle managem
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
@@ -32,20 +31,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
|
||||
TEMPLATE_MEDIA_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "media_info.py").read_text(
|
||||
TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode_info.py").read_text(
|
||||
TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode-info.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review_info.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
TEMPLATE_CHARACTER_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "character_info.py").read_text(
|
||||
TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review-info.template.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
TEMPLATE_CHARACTER_INFO_SCRIPT = (
|
||||
FZF_SCRIPTS_DIR / "character-info.template.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
TEMPLATE_AIRING_SCHEDULE_INFO_SCRIPT = (
|
||||
FZF_SCRIPTS_DIR / "airing_schedule_info.py"
|
||||
FZF_SCRIPTS_DIR / "airing-schedule-info.template.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@@ -104,29 +103,29 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
raise RuntimeError("PreviewCacheWorker is not running")
|
||||
|
||||
for media_item, title_str in zip(media_items, titles):
|
||||
selection_title = self._get_selection_title(title_str)
|
||||
hash_id = self._get_cache_hash(title_str)
|
||||
|
||||
# Submit image download task if needed
|
||||
if config.general.preview in ("full", "image") and media_item.cover_image:
|
||||
image_path = self.images_cache_dir / f"{selection_title}.png"
|
||||
image_path = self.images_cache_dir / f"{hash_id}.png"
|
||||
if not image_path.exists():
|
||||
self.submit_function(
|
||||
self._download_and_save_image,
|
||||
media_item.cover_image.large,
|
||||
selection_title,
|
||||
hash_id,
|
||||
)
|
||||
|
||||
# Submit info generation task if needed
|
||||
if config.general.preview in ("full", "text"):
|
||||
info_text = self._generate_info_text(media_item, config)
|
||||
self.submit_function(self._save_info_text, info_text, selection_title)
|
||||
self.submit_function(self._save_info_text, info_text, hash_id)
|
||||
|
||||
def _download_and_save_image(self, url: str, selection_title: str) -> None:
|
||||
def _download_and_save_image(self, url: str, hash_id: str) -> None:
|
||||
"""Download an image and save it to cache."""
|
||||
if not self._http_client:
|
||||
raise RuntimeError("HTTP client not initialized")
|
||||
|
||||
image_path = self.images_cache_dir / f"{selection_title}.png"
|
||||
image_path = self.images_cache_dir / f"{hash_id}.png"
|
||||
|
||||
try:
|
||||
with self._http_client.stream("GET", url) as response:
|
||||
@@ -136,7 +135,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
for chunk in response.iter_bytes():
|
||||
f.write(chunk)
|
||||
|
||||
logger.debug(f"Successfully cached image: {selection_title}")
|
||||
logger.debug(f"Successfully cached image: {hash_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download image {url}: {e}")
|
||||
@@ -145,7 +144,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
def _generate_info_text(self, media_item: MediaItem, config: AppConfig) -> str:
|
||||
"""Generate formatted info text for a media item."""
|
||||
# Import here to avoid circular imports
|
||||
info_script = TEMPLATE_MEDIA_INFO_SCRIPT
|
||||
info_script = TEMPLATE_INFO_SCRIPT
|
||||
description = formatter.clean_html(
|
||||
media_item.description or "No description available."
|
||||
)
|
||||
@@ -160,13 +159,11 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
media_item.format.value if media_item.format else "UNKNOWN"
|
||||
),
|
||||
"NEXT_EPISODE": formatter.shell_safe(
|
||||
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X')}"
|
||||
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
|
||||
if media_item.next_airing
|
||||
else "N/A"
|
||||
),
|
||||
"EPISODES": formatter.shell_safe(
|
||||
str(media_item.episodes) if media_item.episodes else "??"
|
||||
),
|
||||
"EPISODES": formatter.shell_safe(str(media_item.episodes)),
|
||||
"DURATION": formatter.shell_safe(
|
||||
formatter.format_media_duration(media_item.duration)
|
||||
),
|
||||
@@ -193,12 +190,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
)
|
||||
),
|
||||
"SYNONYMNS": formatter.shell_safe(
|
||||
formatter.format_list_with_commas(
|
||||
[media_item.title.romaji] + media_item.synonymns
|
||||
if media_item.title.romaji
|
||||
and media_item.title.romaji not in media_item.synonymns
|
||||
else media_item.synonymns
|
||||
)
|
||||
formatter.format_list_with_commas(media_item.synonymns)
|
||||
),
|
||||
"USER_STATUS": formatter.shell_safe(
|
||||
media_item.user_status.status.value
|
||||
@@ -224,22 +216,22 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
|
||||
|
||||
return info_script
|
||||
|
||||
def _save_info_text(self, info_text: str, selection_title: str) -> None:
|
||||
def _save_info_text(self, info_text: str, hash_id: str) -> None:
|
||||
"""Save info text to cache."""
|
||||
try:
|
||||
info_path = self.info_cache_dir / f"{selection_title}.py"
|
||||
info_path = self.info_cache_dir / hash_id
|
||||
with AtomicWriter(info_path) as f:
|
||||
f.write(info_text)
|
||||
logger.debug(f"Successfully cached info: {selection_title}")
|
||||
logger.debug(f"Successfully cached info: {hash_id}")
|
||||
except IOError as e:
|
||||
logger.error(f"Failed to write info cache for {selection_title}: {e}")
|
||||
logger.error(f"Failed to write info cache for {hash_id}: {e}")
|
||||
raise
|
||||
|
||||
def _get_selection_title(self, text: str) -> str:
|
||||
def _get_cache_hash(self, text: str) -> str:
|
||||
"""Generate a cache hash for the given text."""
|
||||
from hashlib import sha256
|
||||
|
||||
return f"search-result-{sha256(text.encode('utf-8')).hexdigest()}"
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _on_task_completed(self, task: WorkerTask, future) -> None:
|
||||
"""Handle task completion with enhanced logging."""
|
||||
@@ -309,7 +301,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker):
|
||||
|
||||
for episode_str in episodes:
|
||||
hash_id = self._get_cache_hash(
|
||||
f"{media_item.title.english.replace(formatter.DOUBLE_QUOTE, formatter.SINGLE_QUOTE)}-{episode_str}"
|
||||
f"{media_item.title.english}_Episode_{episode_str}"
|
||||
)
|
||||
|
||||
# Find episode data
|
||||
@@ -360,7 +352,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker):
|
||||
replacements = {
|
||||
"TITLE": formatter.shell_safe(title),
|
||||
"NEXT_EPISODE": formatter.shell_safe(
|
||||
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X')}"
|
||||
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
|
||||
if media_item.next_airing
|
||||
else "N/A"
|
||||
),
|
||||
@@ -393,7 +385,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker):
|
||||
def _save_info_text(self, info_text: str, hash_id: str) -> None:
|
||||
"""Save episode info text to cache."""
|
||||
try:
|
||||
info_path = self.info_cache_dir / (hash_id + ".py")
|
||||
info_path = self.info_cache_dir / hash_id
|
||||
with AtomicWriter(info_path) as f:
|
||||
f.write(info_text)
|
||||
logger.debug(f"Successfully cached episode info: {hash_id}")
|
||||
@@ -405,7 +397,7 @@ class EpisodeCacheWorker(ManagedBackgroundWorker):
|
||||
"""Generate a cache hash for the given text."""
|
||||
from hashlib import sha256
|
||||
|
||||
return "episode-" + sha256(text.encode("utf-8")).hexdigest()
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _on_task_completed(self, task: WorkerTask, future) -> None:
|
||||
"""Handle task completion with enhanced logging."""
|
||||
@@ -422,12 +414,9 @@ class ReviewCacheWorker(ManagedBackgroundWorker):
|
||||
Specialized background worker for caching fully-rendered media review previews.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, images_cache_dir: Path, info_cache_dir: Path, max_workers: int = 10
|
||||
):
|
||||
def __init__(self, reviews_cache_dir, max_workers: int = 10):
|
||||
super().__init__(max_workers=max_workers, name="ReviewCacheWorker")
|
||||
self.images_cache_dir = images_cache_dir
|
||||
self.info_cache_dir = info_cache_dir
|
||||
self.reviews_cache_dir = reviews_cache_dir
|
||||
|
||||
def cache_review_previews(
|
||||
self, choice_map: Dict[str, MediaReview], config: AppConfig
|
||||
@@ -475,7 +464,7 @@ class ReviewCacheWorker(ManagedBackgroundWorker):
|
||||
def _save_preview_content(self, content: str, hash_id: str) -> None:
|
||||
"""Saves the final preview content to the cache."""
|
||||
try:
|
||||
info_path = self.info_cache_dir / hash_id
|
||||
info_path = self.reviews_cache_dir / hash_id
|
||||
with AtomicWriter(info_path) as f:
|
||||
f.write(content)
|
||||
logger.debug(f"Successfully cached review preview: {hash_id}")
|
||||
@@ -486,7 +475,7 @@ class ReviewCacheWorker(ManagedBackgroundWorker):
|
||||
def _get_cache_hash(self, text: str) -> str:
|
||||
from hashlib import sha256
|
||||
|
||||
return "review-" + sha256(text.encode("utf-8")).hexdigest() + ".py"
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _on_task_completed(self, task: WorkerTask, future) -> None:
|
||||
super()._on_task_completed(task, future)
|
||||
@@ -621,7 +610,7 @@ class CharacterCacheWorker(ManagedBackgroundWorker):
|
||||
def _get_cache_hash(self, text: str) -> str:
|
||||
from hashlib import sha256
|
||||
|
||||
return "character-" + sha256(text.encode("utf-8")).hexdigest() + ".py"
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _on_task_completed(self, task: WorkerTask, future) -> None:
|
||||
super()._on_task_completed(task, future)
|
||||
@@ -745,7 +734,7 @@ class AiringScheduleCacheWorker(ManagedBackgroundWorker):
|
||||
def _get_cache_hash(self, text: str) -> str:
|
||||
from hashlib import sha256
|
||||
|
||||
return "airing-schedule-" + sha256(text.encode("utf-8")).hexdigest() + ".py"
|
||||
return sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
def _on_task_completed(self, task: WorkerTask, future) -> None:
|
||||
super()._on_task_completed(task, future)
|
||||
@@ -761,7 +750,7 @@ class PreviewWorkerManager:
|
||||
caching workers with automatic lifecycle management.
|
||||
"""
|
||||
|
||||
def __init__(self, images_cache_dir, info_cache_dir):
|
||||
def __init__(self, images_cache_dir, info_cache_dir, reviews_cache_dir):
|
||||
"""
|
||||
Initialize the preview worker manager.
|
||||
|
||||
@@ -772,6 +761,7 @@ class PreviewWorkerManager:
|
||||
"""
|
||||
self.images_cache_dir = images_cache_dir
|
||||
self.info_cache_dir = info_cache_dir
|
||||
self.reviews_cache_dir = reviews_cache_dir
|
||||
self._preview_worker: Optional[PreviewCacheWorker] = None
|
||||
self._episode_worker: Optional[EpisodeCacheWorker] = None
|
||||
self._review_worker: Optional[ReviewCacheWorker] = None
|
||||
@@ -815,9 +805,7 @@ class PreviewWorkerManager:
|
||||
# Clean up old worker
|
||||
thread_manager.shutdown_worker("review_cache_worker")
|
||||
|
||||
self._review_worker = ReviewCacheWorker(
|
||||
self.images_cache_dir, self.info_cache_dir
|
||||
)
|
||||
self._review_worker = ReviewCacheWorker(self.reviews_cache_dir)
|
||||
self._review_worker.start()
|
||||
thread_manager.register_worker("review_cache_worker", self._review_worker)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ GENERAL_PYGMENT_STYLE = "github-dark"
|
||||
GENERAL_PREFERRED_SPINNER = "smiley"
|
||||
GENERAL_API_CLIENT = "anilist"
|
||||
GENERAL_PREFERRED_TRACKER = "local"
|
||||
GENERAL_DESKTOP_NOTIFICATION_DURATION = 5 * 60
|
||||
GENERAL_DESKTOP_NOTIFICATION_DURATION = 5
|
||||
GENERAL_PROVIDER = "allanime"
|
||||
|
||||
|
||||
|
||||
@@ -178,9 +178,7 @@ class GeneralConfig(BaseModel):
|
||||
description=desc.GENERAL_SCALE_PREVIEW,
|
||||
)
|
||||
|
||||
image_renderer: Literal[
|
||||
"icat", "chafa", "imgcat", "system-sixels", "system-kitty", "system-default"
|
||||
] = Field(
|
||||
image_renderer: Literal["icat", "chafa", "imgcat"] = Field(
|
||||
default_factory=defaults.GENERAL_IMAGE_RENDERER,
|
||||
description=desc.GENERAL_IMAGE_RENDERER,
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ USER_NAME = os.environ.get("USERNAME", "User")
|
||||
|
||||
__version__ = metadata.version("viu_media")
|
||||
|
||||
AUTHOR = "viu-media"
|
||||
AUTHOR = "Benexl"
|
||||
GIT_REPO = "github.com"
|
||||
GIT_PROTOCOL = "https://"
|
||||
REPO_HOME = f"https://{GIT_REPO}/{AUTHOR}/Viu"
|
||||
@@ -25,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
|
||||
@@ -76,11 +76,17 @@ else:
|
||||
USER_APPLICATIONS = Path.home() / ".local" / "share" / "applications"
|
||||
LOG_FOLDER = APP_CACHE_DIR / "logs"
|
||||
|
||||
# Plugin system paths
|
||||
PLUGINS_DIR = APP_DATA_DIR / "plugins"
|
||||
PLUGINS_MANIFEST = APP_DATA_DIR / "plugins.toml"
|
||||
PLUGINS_CONFIG = APP_DATA_DIR / "plugins.config.toml"
|
||||
|
||||
# USER_APPLICATIONS.mkdir(parents=True,exist_ok=True)
|
||||
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
PLUGINS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
USER_CONFIG = APP_DATA_DIR / "config.toml"
|
||||
|
||||
|
||||
@@ -130,11 +130,10 @@ class YtDLPDownloader(BaseDownloader):
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: Confirm this type issues
|
||||
with yt_dlp.YoutubeDL(opts) as ydl: # type: ignore
|
||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||
info = ydl.extract_info(params.url, download=True)
|
||||
if info:
|
||||
_video_path = info["requested_downloads"][0]["filepath"] # type: ignore
|
||||
_video_path = info["requested_downloads"][0]["filepath"]
|
||||
if _video_path.endswith(".unknown_video"):
|
||||
print("Normalizing path...")
|
||||
_vid_path = _video_path.replace(".unknown_video", ".mp4")
|
||||
|
||||
6
viu_media/core/plugins/__init__.py
Normal file
6
viu_media/core/plugins/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Plugin system for viu."""
|
||||
|
||||
from .model import PluginComponents, PluginInfo
|
||||
from .manager import PluginManager
|
||||
|
||||
__all__ = ["PluginInfo", "PluginComponents", "PluginManager"]
|
||||
646
viu_media/core/plugins/manager.py
Normal file
646
viu_media/core/plugins/manager.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""Plugin manager for viu.
|
||||
|
||||
This module contains the PluginManager singleton that handles all plugin operations
|
||||
including loading, discovery, installation, and removal.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Literal, Optional, Set, Union
|
||||
|
||||
import tomli_w
|
||||
from pydantic import ValidationError
|
||||
from viu_media.core.exceptions import ViuError
|
||||
|
||||
from ..constants import PLUGINS_CONFIG, PLUGINS_DIR, PLUGINS_MANIFEST
|
||||
from .model import InstalledPlugin, PluginInfo, PluginManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ComponentType = Literal["provider", "player", "selector", "command"]
|
||||
|
||||
|
||||
class PluginError(ViuError):
|
||||
"""Base exception for plugin-related errors."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PluginNotFoundError(ViuError):
|
||||
"""Raised when a requested plugin is not found."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PluginLoadError(ViuError):
|
||||
"""Raised when a plugin fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""Manages the plugin system for viu.
|
||||
|
||||
This is a singleton class that handles:
|
||||
- Loading and caching plugins
|
||||
- Installing and removing plugins from Git repositories
|
||||
- Managing plugin configurations
|
||||
- Discovering available plugins
|
||||
"""
|
||||
|
||||
_instance: Optional["PluginManager"] = None
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> "PluginManager":
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._loaded_components: Dict[str, Any] = {}
|
||||
self._manifest: PluginManifest = PluginManifest()
|
||||
self._plugin_configs: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
self._load_manifest()
|
||||
self._load_plugin_configs()
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def load_component(self, component_type: ComponentType, name: str) -> Any:
|
||||
"""Lazy-load a plugin component by type and name.
|
||||
|
||||
Args:
|
||||
component_type: Type of component (provider, player, selector, command)
|
||||
name: Name of the component to load
|
||||
|
||||
Returns:
|
||||
The loaded component instance or function
|
||||
|
||||
Raises:
|
||||
PluginNotFoundError: If the plugin is not installed
|
||||
PluginLoadError: If the plugin fails to load
|
||||
"""
|
||||
cache_key = f"{component_type}:{name}"
|
||||
|
||||
# Return cached component if already loaded
|
||||
if cache_key in self._loaded_components:
|
||||
return self._loaded_components[cache_key]
|
||||
|
||||
# Find the plugin in the manifest
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
if name not in plugins_of_type:
|
||||
raise PluginNotFoundError(
|
||||
f"Plugin '{name}' of type '{component_type}' is not installed"
|
||||
)
|
||||
|
||||
plugin_entry = plugins_of_type[name]
|
||||
plugin_path = plugin_entry.path
|
||||
|
||||
if not plugin_path.exists():
|
||||
raise PluginLoadError(f"Plugin path does not exist: {plugin_path}")
|
||||
|
||||
# Load plugin info to get component definition
|
||||
try:
|
||||
plugin_info = self._get_plugin_info(plugin_path)
|
||||
except PluginError as e:
|
||||
raise PluginLoadError(f"Failed to load plugin info: {e}") from e
|
||||
|
||||
# Get the component definition
|
||||
component_def = getattr(plugin_info.components, component_type)
|
||||
if not component_def:
|
||||
raise PluginLoadError(
|
||||
f"Plugin '{name}' does not provide a {component_type} component"
|
||||
)
|
||||
|
||||
# Parse module:class format
|
||||
if ":" not in component_def:
|
||||
raise PluginLoadError(f"Invalid component definition: {component_def}")
|
||||
|
||||
module_name, class_name = component_def.split(":", 1)
|
||||
|
||||
# Load the module
|
||||
module_path = plugin_path / f"{module_name}.py"
|
||||
if not module_path.exists():
|
||||
raise PluginLoadError(f"Plugin module not found: {module_path}")
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"plugin_{name}_{module_name}", module_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise PluginLoadError(f"Could not create module spec for {module_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Add plugin path to sys.path temporarily for relative imports
|
||||
sys.path.insert(0, str(plugin_path))
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
sys.path.remove(str(plugin_path))
|
||||
|
||||
except Exception as e:
|
||||
raise PluginLoadError(f"Failed to load module {module_path}: {e}") from e
|
||||
|
||||
# Get the component class/function
|
||||
if not hasattr(module, class_name):
|
||||
raise PluginLoadError(f"Module {module_name} does not have {class_name}")
|
||||
|
||||
component_cls = getattr(module, class_name)
|
||||
|
||||
# For providers, players, and selectors, instantiate with config
|
||||
if component_type in ("provider", "player", "selector"):
|
||||
plugin_config = self._plugin_configs.get(name, {})
|
||||
|
||||
# For providers, also inject httpx client like the built-in system
|
||||
if component_type == "provider":
|
||||
from httpx import Client
|
||||
|
||||
from ...core.utils.networking import random_user_agent
|
||||
|
||||
headers = getattr(component_cls, "HEADERS", {})
|
||||
client = Client(headers={"User-Agent": random_user_agent(), **headers})
|
||||
|
||||
try:
|
||||
component = component_cls(client, **plugin_config)
|
||||
except TypeError:
|
||||
# Fallback if constructor doesn't accept config
|
||||
component = component_cls(client)
|
||||
else:
|
||||
try:
|
||||
component = component_cls(**plugin_config)
|
||||
except TypeError:
|
||||
# Fallback if constructor doesn't accept config
|
||||
component = component_cls()
|
||||
else:
|
||||
# For commands, just return the function
|
||||
component = component_cls
|
||||
|
||||
# Cache and return
|
||||
self._loaded_components[cache_key] = component
|
||||
logger.debug(f"Loaded plugin component: {cache_key}")
|
||||
return component
|
||||
|
||||
def add_plugin(
|
||||
self, component_type: ComponentType, name: str, source: str, force: bool = False
|
||||
) -> None:
|
||||
"""Install a plugin from a Git repository.
|
||||
|
||||
Args:
|
||||
component_type: Type of component the plugin provides
|
||||
name: Local name for the plugin
|
||||
source: Git source (e.g., "github:user/repo")
|
||||
force: Whether to overwrite existing plugin
|
||||
|
||||
Raises:
|
||||
PluginError: If installation fails
|
||||
"""
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
|
||||
# Check if plugin already exists
|
||||
if name in plugins_of_type and not force:
|
||||
raise PluginError(
|
||||
f"Plugin '{name}' already exists. Use --force to overwrite."
|
||||
)
|
||||
|
||||
# Determine installation path
|
||||
plugin_dir = PLUGINS_DIR / f"{component_type}s" / name
|
||||
|
||||
# Remove existing if force is True
|
||||
if plugin_dir.exists():
|
||||
if force:
|
||||
shutil.rmtree(plugin_dir)
|
||||
else:
|
||||
raise PluginError(f"Plugin directory already exists: {plugin_dir}")
|
||||
|
||||
# Create parent directory
|
||||
plugin_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Clone the repository
|
||||
self._clone_plugin(source, plugin_dir)
|
||||
|
||||
# Validate plugin structure
|
||||
try:
|
||||
plugin_info = self._get_plugin_info(plugin_dir)
|
||||
except PluginError:
|
||||
# Clean up on validation failure
|
||||
shutil.rmtree(plugin_dir)
|
||||
raise
|
||||
|
||||
# Ensure plugin provides the expected component type
|
||||
expected_component = getattr(plugin_info.components, component_type)
|
||||
if not expected_component:
|
||||
shutil.rmtree(plugin_dir)
|
||||
raise PluginError(f"Plugin does not provide a {component_type} component")
|
||||
|
||||
# Add to manifest
|
||||
plugins_of_type[name] = InstalledPlugin(
|
||||
source=source, path=plugin_dir, version=plugin_info.plugin.version
|
||||
)
|
||||
|
||||
# Save manifest
|
||||
self._save_manifest()
|
||||
|
||||
# Copy default config if it exists
|
||||
self._install_default_config(name, plugin_dir)
|
||||
|
||||
logger.info(f"Successfully installed {component_type} plugin '{name}'")
|
||||
|
||||
def remove_plugin(self, component_type: ComponentType, name: str) -> None:
|
||||
"""Remove an installed plugin.
|
||||
|
||||
Args:
|
||||
component_type: Type of component
|
||||
name: Name of the plugin to remove
|
||||
|
||||
Raises:
|
||||
PluginNotFoundError: If plugin is not installed
|
||||
PluginError: If removal fails
|
||||
"""
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
|
||||
if name not in plugins_of_type:
|
||||
raise PluginNotFoundError(
|
||||
f"Plugin '{name}' of type '{component_type}' is not installed"
|
||||
)
|
||||
|
||||
plugin_entry = plugins_of_type[name]
|
||||
plugin_path = plugin_entry.path
|
||||
|
||||
# Remove from filesystem
|
||||
if plugin_path.exists():
|
||||
try:
|
||||
shutil.rmtree(plugin_path)
|
||||
except OSError as e:
|
||||
raise PluginError(f"Failed to remove plugin directory: {e}") from e
|
||||
|
||||
# Remove from manifest
|
||||
del plugins_of_type[name]
|
||||
|
||||
# Remove from loaded components cache
|
||||
cache_key = f"{component_type}:{name}"
|
||||
self._loaded_components.pop(cache_key, None)
|
||||
|
||||
# Save manifest
|
||||
self._save_manifest()
|
||||
|
||||
logger.info(f"Successfully removed {component_type} plugin '{name}'")
|
||||
|
||||
def update_plugin(self, component_type: ComponentType, name: str) -> None:
|
||||
"""Update an installed plugin by pulling from Git.
|
||||
|
||||
Args:
|
||||
component_type: Type of component
|
||||
name: Name of the plugin to update
|
||||
|
||||
Raises:
|
||||
PluginNotFoundError: If plugin is not installed
|
||||
PluginError: If update fails
|
||||
"""
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
|
||||
if name not in plugins_of_type:
|
||||
raise PluginNotFoundError(
|
||||
f"Plugin '{name}' of type '{component_type}' is not installed"
|
||||
)
|
||||
|
||||
plugin_entry = plugins_of_type[name]
|
||||
plugin_path = plugin_entry.path
|
||||
|
||||
if not plugin_path.exists():
|
||||
raise PluginError(f"Plugin path does not exist: {plugin_path}")
|
||||
|
||||
# Pull latest changes
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "pull"],
|
||||
cwd=plugin_path,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
logger.debug(f"Git pull output: {result.stdout}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise PluginError(f"Failed to update plugin: {e.stderr}") from e
|
||||
except FileNotFoundError:
|
||||
raise PluginError("Git is not installed or not in PATH") from None
|
||||
|
||||
# Update version in manifest
|
||||
try:
|
||||
plugin_info = self._get_plugin_info(plugin_path)
|
||||
plugin_entry.version = plugin_info.plugin.version
|
||||
self._save_manifest()
|
||||
except PluginError as e:
|
||||
logger.warning(f"Could not update plugin version: {e}")
|
||||
|
||||
# Clear from cache to force reload
|
||||
cache_key = f"{component_type}:{name}"
|
||||
self._loaded_components.pop(cache_key, None)
|
||||
|
||||
logger.info(f"Successfully updated {component_type} plugin '{name}'")
|
||||
|
||||
def list_plugins(self) -> Dict[ComponentType, Dict[str, InstalledPlugin]]:
|
||||
"""List all installed plugins grouped by type.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping component types to their installed plugins
|
||||
"""
|
||||
return {
|
||||
"provider": dict(self._manifest.providers),
|
||||
"player": dict(self._manifest.players),
|
||||
"selector": dict(self._manifest.selectors),
|
||||
"command": dict(self._manifest.commands),
|
||||
}
|
||||
|
||||
def get_available_components(self, component_type: ComponentType) -> Set[str]:
|
||||
"""Get names of all available components of a given type.
|
||||
|
||||
This includes both built-in components and installed plugins.
|
||||
|
||||
Args:
|
||||
component_type: Type of component
|
||||
|
||||
Returns:
|
||||
Set of component names
|
||||
"""
|
||||
# Get plugin names
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
plugin_names = set(plugins_of_type.keys())
|
||||
|
||||
# Add built-in component names
|
||||
if component_type == "provider":
|
||||
from ...libs.provider.anime.provider import PROVIDERS_AVAILABLE
|
||||
|
||||
builtin_names = set(PROVIDERS_AVAILABLE.keys())
|
||||
elif component_type == "player":
|
||||
from ...libs.player.player import PLAYERS
|
||||
|
||||
builtin_names = set(PLAYERS)
|
||||
elif component_type == "selector":
|
||||
from ...libs.selectors.selector import SELECTORS
|
||||
|
||||
builtin_names = set(SELECTORS)
|
||||
elif component_type == "command":
|
||||
# Commands would need to be handled differently as they're registered in CLI
|
||||
builtin_names = set()
|
||||
else:
|
||||
builtin_names = set()
|
||||
|
||||
return plugin_names | builtin_names
|
||||
|
||||
def is_plugin(self, component_type: ComponentType, name: str) -> bool:
|
||||
"""Check if a component is provided by a plugin.
|
||||
|
||||
Args:
|
||||
component_type: Type of component
|
||||
name: Name of the component
|
||||
|
||||
Returns:
|
||||
True if it's a plugin, False if it's built-in
|
||||
"""
|
||||
plugins_of_type = getattr(self._manifest, f"{component_type}s")
|
||||
return name in plugins_of_type
|
||||
|
||||
def _load_manifest(self) -> None:
|
||||
"""Load the plugins.toml manifest file."""
|
||||
if not PLUGINS_MANIFEST.exists():
|
||||
logger.debug("No plugins manifest found, creating empty one")
|
||||
self._save_manifest()
|
||||
return
|
||||
|
||||
try:
|
||||
with open(PLUGINS_MANIFEST, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
self._manifest = PluginManifest.model_validate(data)
|
||||
logger.debug(
|
||||
f"Loaded plugins manifest with {len(self.list_plugins())} plugins"
|
||||
)
|
||||
except (OSError, ValidationError, tomllib.TOMLDecodeError) as e:
|
||||
logger.error(f"Failed to load plugins manifest: {e}")
|
||||
self._manifest = PluginManifest()
|
||||
|
||||
def _save_manifest(self) -> None:
|
||||
"""Save the current manifest to plugins.toml."""
|
||||
try:
|
||||
# Convert Path objects to strings for TOML serialization
|
||||
manifest_dict = self._manifest.model_dump()
|
||||
|
||||
# Convert all Path objects to strings
|
||||
def convert_paths(obj: Any) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
return {k: convert_paths(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_paths(item) for item in obj]
|
||||
elif isinstance(obj, Path):
|
||||
return str(obj)
|
||||
else:
|
||||
return obj
|
||||
|
||||
manifest_dict = convert_paths(manifest_dict)
|
||||
|
||||
with open(PLUGINS_MANIFEST, "wb") as f:
|
||||
tomli_w.dump(manifest_dict, f)
|
||||
logger.debug("Saved plugins manifest")
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to save plugins manifest: {e}")
|
||||
raise PluginError(f"Could not save plugins manifest: {e}") from e
|
||||
|
||||
def _load_plugin_configs(self) -> None:
|
||||
"""Load plugin configurations from plugins.config.toml."""
|
||||
if not PLUGINS_CONFIG.exists():
|
||||
logger.debug("No plugin configs found")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(PLUGINS_CONFIG, "rb") as f:
|
||||
self._plugin_configs = tomllib.load(f)
|
||||
logger.debug(f"Loaded configs for {len(self._plugin_configs)} plugins")
|
||||
except (OSError, tomllib.TOMLDecodeError) as e:
|
||||
logger.error(f"Failed to load plugin configs: {e}")
|
||||
self._plugin_configs = {}
|
||||
|
||||
def _get_plugin_info(self, plugin_path: Path) -> PluginInfo:
|
||||
"""Load and validate plugin.info.toml from a plugin directory."""
|
||||
info_file = plugin_path / "plugin.info.toml"
|
||||
if not info_file.exists():
|
||||
raise PluginError(f"Plugin info file not found: {info_file}")
|
||||
|
||||
try:
|
||||
with open(info_file, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return PluginInfo.model_validate(data)
|
||||
except (OSError, ValidationError, tomllib.TOMLDecodeError) as e:
|
||||
raise PluginError(f"Invalid plugin info file {info_file}: {e}") from e
|
||||
|
||||
def _parse_git_source(self, source: str) -> tuple[str, str]:
|
||||
"""Parse a git source string into platform and repo.
|
||||
|
||||
Examples:
|
||||
"github:user/repo" -> ("github.com", "user/repo")
|
||||
"gitlab:user/repo" -> ("gitlab.com", "user/repo")
|
||||
"https://github.com/user/repo" -> ("github.com", "user/repo")
|
||||
"/path/to/local/repo" -> ("local", "/path/to/local/repo")
|
||||
"file:///path/to/repo" -> ("local", "/path/to/repo")
|
||||
"""
|
||||
# Handle local file paths
|
||||
if source.startswith("file://"):
|
||||
return "local", source[7:] # Remove file:// prefix
|
||||
elif (
|
||||
source.startswith("/")
|
||||
or source.startswith("./")
|
||||
or source.startswith("../")
|
||||
):
|
||||
return "local", source
|
||||
|
||||
if source.startswith("http"):
|
||||
# Full URL provided
|
||||
if "github.com" in source:
|
||||
repo = source.split("github.com/")[-1].rstrip(".git")
|
||||
return "github.com", repo
|
||||
elif "gitlab.com" in source:
|
||||
repo = source.split("gitlab.com/")[-1].rstrip(".git")
|
||||
return "gitlab.com", repo
|
||||
else:
|
||||
raise PluginError(f"Unsupported git host in URL: {source}")
|
||||
|
||||
# Short format like "github:user/repo"
|
||||
if ":" not in source:
|
||||
raise PluginError(f"Invalid source format: {source}")
|
||||
|
||||
platform, repo = source.split(":", 1)
|
||||
platform_map = {
|
||||
"github": "github.com",
|
||||
"gitlab": "gitlab.com",
|
||||
}
|
||||
|
||||
if platform not in platform_map:
|
||||
raise PluginError(f"Unsupported platform: {platform}")
|
||||
|
||||
return platform_map[platform], repo
|
||||
|
||||
def _clone_plugin(self, source: str, dest_path: Path) -> None:
|
||||
"""Clone a plugin repository from Git."""
|
||||
platform, repo = self._parse_git_source(source)
|
||||
|
||||
if platform == "local":
|
||||
# Handle local repository - just copy the directory
|
||||
import shutil
|
||||
|
||||
src_path = Path(repo).resolve()
|
||||
|
||||
if not src_path.exists():
|
||||
raise PluginError(f"Local repository path does not exist: {src_path}")
|
||||
|
||||
if (src_path / ".git").exists():
|
||||
logger.info(f"Copying local Git repository from {src_path}")
|
||||
|
||||
try:
|
||||
# Use git clone to properly copy the repository
|
||||
subprocess.run(
|
||||
["git", "clone", str(src_path), str(dest_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise PluginError(
|
||||
f"Failed to clone local repository: {e.stderr}"
|
||||
) from e
|
||||
else:
|
||||
logger.warning("Copying non git repo, local plugin")
|
||||
shutil.copytree(src_path, dest_path)
|
||||
|
||||
else:
|
||||
# Handle remote repository
|
||||
git_url = f"https://{platform}/{repo}.git"
|
||||
|
||||
logger.info(f"Cloning plugin from {git_url}")
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "clone", git_url, str(dest_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise PluginError(f"Failed to clone plugin: {e.stderr}") from e
|
||||
|
||||
if not dest_path.exists():
|
||||
raise PluginError(
|
||||
"Plugin cloning failed - destination directory was not created"
|
||||
)
|
||||
|
||||
# Check for git command availability
|
||||
try:
|
||||
subprocess.run(["git", "--version"], check=True, capture_output=True)
|
||||
except FileNotFoundError:
|
||||
raise PluginError("Git is not installed or not in PATH") from None
|
||||
|
||||
def _install_default_config(self, plugin_name: str, plugin_dir: Path) -> None:
|
||||
"""Install default configuration from plugin's config.toml if it exists."""
|
||||
default_config_path = plugin_dir / "config.toml"
|
||||
|
||||
if not default_config_path.exists():
|
||||
logger.debug(f"No default config found for plugin '{plugin_name}'")
|
||||
return
|
||||
|
||||
# Load the default config
|
||||
try:
|
||||
with open(default_config_path, "rb") as f:
|
||||
default_config = tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError) as e:
|
||||
logger.warning(
|
||||
f"Failed to load default config for plugin '{plugin_name}': {e}"
|
||||
)
|
||||
return
|
||||
|
||||
# Load existing plugins config or create empty dict
|
||||
if PLUGINS_CONFIG.exists():
|
||||
try:
|
||||
with open(PLUGINS_CONFIG, "rb") as f:
|
||||
existing_config = tomllib.load(f)
|
||||
except (OSError, tomllib.TOMLDecodeError) as e:
|
||||
logger.warning(f"Failed to load existing plugins config: {e}")
|
||||
existing_config = {}
|
||||
else:
|
||||
existing_config = {}
|
||||
|
||||
# Check if plugin config already exists
|
||||
if plugin_name in existing_config:
|
||||
logger.debug(
|
||||
f"Plugin '{plugin_name}' config already exists, skipping default config installation"
|
||||
)
|
||||
return
|
||||
|
||||
# Merge the default config
|
||||
if plugin_name in default_config:
|
||||
existing_config[plugin_name] = default_config[plugin_name]
|
||||
|
||||
# Write the updated config
|
||||
try:
|
||||
with open(PLUGINS_CONFIG, "wb") as f:
|
||||
tomli_w.dump(existing_config, f)
|
||||
logger.info(
|
||||
f"Installed default configuration for plugin '{plugin_name}'"
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning(
|
||||
f"Failed to save default config for plugin '{plugin_name}': {e}"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"No config section found for plugin '{plugin_name}' in default config"
|
||||
)
|
||||
|
||||
|
||||
# Global instance
|
||||
plugin_manager = PluginManager()
|
||||
91
viu_media/core/plugins/model.py
Normal file
91
viu_media/core/plugins/model.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Plugin interface definitions for viu.
|
||||
|
||||
This module defines the Pydantic models that represent the structure
|
||||
of plugin.info.toml files and plugin configurations.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginComponents(BaseModel):
|
||||
"""Defines the components that a plugin provides.
|
||||
|
||||
Each component is defined as a string in the format:
|
||||
{module_name_in_repo}:{ClassName_or_factory_function}
|
||||
|
||||
For example:
|
||||
provider = "gogo_provider:GogoProvider"
|
||||
player = "my_player:MyPlayer"
|
||||
selector = "my_selector:MySelector"
|
||||
command = "my_command:my_command_func"
|
||||
"""
|
||||
|
||||
provider: Optional[str] = Field(
|
||||
None,
|
||||
description="Provider component in format 'module:class'"
|
||||
)
|
||||
player: Optional[str] = Field(
|
||||
None,
|
||||
description="Player component in format 'module:class'"
|
||||
)
|
||||
selector: Optional[str] = Field(
|
||||
None,
|
||||
description="Selector component in format 'module:class'"
|
||||
)
|
||||
command: Optional[str] = Field(
|
||||
None,
|
||||
description="Command component in format 'module:function'"
|
||||
)
|
||||
|
||||
|
||||
class PluginMetadata(BaseModel):
|
||||
"""Plugin metadata from the [plugin] section."""
|
||||
|
||||
name: str = Field(description="Human-readable plugin name")
|
||||
version: str = Field(description="Plugin version")
|
||||
description: str = Field(description="Plugin description")
|
||||
author: Optional[str] = Field(None, description="Plugin author")
|
||||
homepage: Optional[str] = Field(None, description="Plugin homepage URL")
|
||||
requires_python: Optional[str] = Field(
|
||||
None,
|
||||
description="Minimum Python version required"
|
||||
)
|
||||
|
||||
|
||||
class PluginInfo(BaseModel):
|
||||
"""Complete plugin information from plugin.info.toml."""
|
||||
|
||||
plugin: PluginMetadata = Field(description="Plugin metadata")
|
||||
components: PluginComponents = Field(description="Plugin components")
|
||||
|
||||
|
||||
class InstalledPlugin(BaseModel):
|
||||
"""Represents a plugin entry in plugins.toml."""
|
||||
|
||||
source: str = Field(description="Git source (e.g., 'github:user/repo')")
|
||||
path: Path = Field(description="Local filesystem path to the plugin")
|
||||
version: Optional[str] = Field(None, description="Installed version")
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
"""Complete plugins.toml manifest structure."""
|
||||
|
||||
providers: Dict[str, InstalledPlugin] = Field(
|
||||
default_factory=dict,
|
||||
description="Installed provider plugins"
|
||||
)
|
||||
players: Dict[str, InstalledPlugin] = Field(
|
||||
default_factory=dict,
|
||||
description="Installed player plugins"
|
||||
)
|
||||
selectors: Dict[str, InstalledPlugin] = Field(
|
||||
default_factory=dict,
|
||||
description="Installed selector plugins"
|
||||
)
|
||||
commands: Dict[str, InstalledPlugin] = Field(
|
||||
default_factory=dict,
|
||||
description="Installed command plugins"
|
||||
)
|
||||
@@ -219,7 +219,7 @@ class BackgroundWorker(ABC):
|
||||
else:
|
||||
# Wait for tasks to complete with timeout
|
||||
try:
|
||||
self._executor.shutdown(wait=True)
|
||||
self._executor.shutdown(wait=True, timeout=timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Worker {self.name} shutdown timed out, forcing cancellation"
|
||||
|
||||
@@ -5,8 +5,6 @@ from typing import Dict, List, Optional, Union
|
||||
from ...libs.media_api.types import AiringSchedule
|
||||
|
||||
COMMA_REGEX = re.compile(r"([0-9]{3})(?=\d)")
|
||||
SINGLE_QUOTE = "'"
|
||||
DOUBLE_QUOTE = '"'
|
||||
|
||||
|
||||
def format_media_duration(total_minutes: Optional[int]) -> str:
|
||||
|
||||
22
viu_media/libs/aniskip/api.py
Normal file
22
viu_media/libs/aniskip/api.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
3
viu_media/libs/discord/__init__.py
Normal file
3
viu_media/libs/discord/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .api import connect
|
||||
|
||||
__all__ = ["connect"]
|
||||
13
viu_media/libs/discord/api.py
Normal file
13
viu_media/libs/discord/api.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,6 @@ from ..types import (
|
||||
Studio,
|
||||
UserListItem,
|
||||
UserMediaListStatus,
|
||||
MediaType,
|
||||
UserProfile,
|
||||
)
|
||||
from .types import (
|
||||
@@ -540,7 +539,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=MediaType.ANIME,
|
||||
type="ANIME",
|
||||
status=MediaStatus.RELEASING, # Assume releasing for airing notifications
|
||||
format=None,
|
||||
description=None,
|
||||
|
||||
@@ -6,7 +6,6 @@ from ..types import (
|
||||
MediaImage,
|
||||
MediaItem,
|
||||
MediaSearchResult,
|
||||
MediaStatus,
|
||||
MediaTitle,
|
||||
PageInfo,
|
||||
Studio,
|
||||
@@ -18,9 +17,9 @@ if TYPE_CHECKING:
|
||||
|
||||
# Jikan uses specific strings for status, we can map them to our generic enum.
|
||||
JIKAN_STATUS_MAP = {
|
||||
"Finished Airing": MediaStatus.FINISHED,
|
||||
"Currently Airing": MediaStatus.RELEASING,
|
||||
"Not yet aired": MediaStatus.NOT_YET_RELEASED,
|
||||
"Finished Airing": "FINISHED",
|
||||
"Currently Airing": "RELEASING",
|
||||
"Not yet aired": "NOT_YET_RELEASED",
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +42,7 @@ def _to_generic_title(jikan_titles: list[dict]) -> MediaTitle:
|
||||
elif type_ == "Japanese":
|
||||
native = title_
|
||||
|
||||
return MediaTitle(
|
||||
romaji=romaji,
|
||||
english=english or romaji or native or "NOT AVAILABLE",
|
||||
native=native,
|
||||
)
|
||||
return MediaTitle(romaji=romaji, english=english, native=native)
|
||||
|
||||
|
||||
def _to_generic_image(jikan_images: dict) -> MediaImage:
|
||||
@@ -74,7 +69,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", ""), MediaStatus.UNKNOWN),
|
||||
status=JIKAN_STATUS_MAP.get(data.get("status", ""), None),
|
||||
episodes=data.get("episodes"),
|
||||
duration=data.get("duration"),
|
||||
average_score=score,
|
||||
@@ -86,7 +81,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,7 +5,6 @@ from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from ._media_tags import MediaTag
|
||||
|
||||
|
||||
# ENUMS
|
||||
@@ -15,7 +14,6 @@ class MediaStatus(Enum):
|
||||
NOT_YET_RELEASED = "NOT_YET_RELEASED"
|
||||
CANCELLED = "CANCELLED"
|
||||
HIATUS = "HIATUS"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class MediaType(Enum):
|
||||
@@ -287,6 +285,472 @@ 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"
|
||||
|
||||
@@ -30,6 +30,8 @@ def test_media_api(api_client: BaseApiClient):
|
||||
"""
|
||||
from ....core.constants import APP_ASCII_ART
|
||||
from ..params import (
|
||||
MediaAiringScheduleParams,
|
||||
MediaCharactersParams,
|
||||
MediaRecommendationParams,
|
||||
MediaRelationsParams,
|
||||
MediaSearchParams,
|
||||
@@ -123,10 +125,47 @@ def test_media_api(api_client: BaseApiClient):
|
||||
print()
|
||||
|
||||
# Test 5: Get Characters
|
||||
# TODO: Recreate this test
|
||||
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()
|
||||
|
||||
# Test 6: Get Airing Schedule
|
||||
# TODO: Recreate this test
|
||||
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()
|
||||
|
||||
# Test 7: User Media List (if authenticated)
|
||||
if api_client.is_authenticated():
|
||||
|
||||
@@ -30,8 +30,18 @@ class PlayerFactory:
|
||||
ValueError: If the player_name is not supported.
|
||||
NotImplementedError: If the player is recognized but not yet implemented.
|
||||
"""
|
||||
from ...core.plugins.manager import plugin_manager
|
||||
|
||||
player_name = config.stream.player
|
||||
|
||||
# Check if it's a plugin first
|
||||
if plugin_manager.is_plugin("player", player_name):
|
||||
try:
|
||||
return plugin_manager.load_component("player", player_name)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not load plugin player '{player_name}': {e}") from e
|
||||
|
||||
# Handle built-in players
|
||||
if player_name not in PLAYERS:
|
||||
raise ValueError(
|
||||
f"Unsupported player: '{player_name}'. Supported players are: {PLAYERS}"
|
||||
|
||||
0
viu_media/libs/player/syncplay/__init__.py
Normal file
0
viu_media/libs/player/syncplay/__init__.py
Normal file
65
viu_media/libs/player/syncplay/player.py
Normal file
65
viu_media/libs/player/syncplay/player.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Syncplay integration for Viu.
|
||||
|
||||
This module provides a procedural function to launch Syncplay with the given media and options.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from .tools import exit_app
|
||||
|
||||
|
||||
def SyncPlayer(url: str, anime_title=None, headers={}, subtitles=[], *args):
|
||||
"""
|
||||
Launch Syncplay for synchronized playback with friends.
|
||||
|
||||
Args:
|
||||
url: The media URL to play.
|
||||
anime_title: Optional title to display in the player.
|
||||
headers: Optional HTTP headers to pass to the player.
|
||||
subtitles: Optional list of subtitle dicts with 'url' keys.
|
||||
*args: Additional arguments (unused).
|
||||
|
||||
Returns:
|
||||
Tuple of ("0", "0") for compatibility.
|
||||
"""
|
||||
# TODO: handle m3u8 multi quality streams
|
||||
#
|
||||
# check for SyncPlay
|
||||
SYNCPLAY_EXECUTABLE = shutil.which("syncplay")
|
||||
if not SYNCPLAY_EXECUTABLE:
|
||||
print("Syncplay not found")
|
||||
exit_app(1)
|
||||
return "0", "0"
|
||||
# start SyncPlayer
|
||||
mpv_args = []
|
||||
if headers:
|
||||
mpv_headers = "--http-header-fields="
|
||||
for header_name, header_value in headers.items():
|
||||
mpv_headers += f"{header_name}:{header_value},"
|
||||
mpv_args.append(mpv_headers)
|
||||
for subtitle in subtitles:
|
||||
mpv_args.append(f"--sub-file={subtitle['url']}")
|
||||
if not anime_title:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
else:
|
||||
subprocess.run(
|
||||
[
|
||||
SYNCPLAY_EXECUTABLE,
|
||||
url,
|
||||
"--",
|
||||
f"--force-media-title={anime_title}",
|
||||
*mpv_args,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
# for compatability
|
||||
return "0", "0"
|
||||
@@ -88,5 +88,4 @@ def decode_hex_string(hex_string):
|
||||
# Decode each hex pair
|
||||
decoded_chars = [hex_to_char.get(pair.lower(), pair) for pair in hex_pairs]
|
||||
|
||||
# TODO: Better type handling
|
||||
return "".join(decoded_chars) # type: ignore
|
||||
return "".join(decoded_chars)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
|
||||
ANIMEPAHE = "animepahe.si"
|
||||
ANIMEPAHE = "animepahe.ru"
|
||||
ANIMEPAHE_BASE = f"https://{ANIMEPAHE}"
|
||||
ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api"
|
||||
|
||||
@@ -19,13 +19,13 @@ REQUEST_HEADERS = {
|
||||
"TE": "trailers",
|
||||
}
|
||||
SERVER_HEADERS = {
|
||||
"Host": "kwik.cx",
|
||||
"Host": "kwik.si",
|
||||
"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.si/",
|
||||
"Referer": "https://animepahe.ru/",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "iframe",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
from ..types import (
|
||||
Anime,
|
||||
AnimeEpisodeInfo,
|
||||
@@ -85,16 +87,13 @@ def map_to_anime_result(
|
||||
|
||||
|
||||
def map_to_server(
|
||||
episode: AnimeEpisodeInfo,
|
||||
translation_type: str,
|
||||
stream_links: list[tuple[str, str]],
|
||||
episode: AnimeEpisodeInfo, translation_type: Any, quality: Any, stream_link: Any
|
||||
) -> Server:
|
||||
links = [
|
||||
EpisodeStream(
|
||||
link=link[1],
|
||||
quality=link[0] if link[0] in ["360", "480", "720", "1080"] else "1080", # type:ignore
|
||||
link=stream_link,
|
||||
quality=quality,
|
||||
translation_type=translation_type_map[translation_type],
|
||||
)
|
||||
for link in stream_links
|
||||
]
|
||||
return Server(name="kwik", links=links, episode_title=episode.title)
|
||||
|
||||
@@ -131,17 +131,15 @@ class AnimePahe(BaseAnimeProvider):
|
||||
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
|
||||
quality = None
|
||||
translation_type = None
|
||||
stream_links = []
|
||||
stream_link = None
|
||||
|
||||
# TODO: better document the scraping process
|
||||
for res_dict in res_dicts:
|
||||
# the actual attributes are data attributes in the original html 'prefixed with data-'
|
||||
embed_url = res_dict["src"]
|
||||
logger.debug(f"Found embed url: {embed_url}")
|
||||
data_audio = "dub" if res_dict["audio"] == "eng" else "sub"
|
||||
|
||||
if data_audio != params.translation_type:
|
||||
logger.debug(f"Found {data_audio} but wanted {params.translation_type}")
|
||||
continue
|
||||
|
||||
if not embed_url:
|
||||
@@ -157,26 +155,22 @@ class AnimePahe(BaseAnimeProvider):
|
||||
)
|
||||
embed_response.raise_for_status()
|
||||
embed_page = embed_response.text
|
||||
logger.debug("Processing embed page for JS decoding")
|
||||
|
||||
decoded_js = process_animepahe_embed_page(embed_page)
|
||||
if not decoded_js:
|
||||
logger.error("failed to decode embed page")
|
||||
continue
|
||||
logger.debug(f"Decoded JS: {decoded_js[:100]}...")
|
||||
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
|
||||
if not juicy_stream:
|
||||
logger.error("failed to find juicy stream")
|
||||
continue
|
||||
logger.debug(f"Found juicy stream: {juicy_stream.group(1)}")
|
||||
juicy_stream = juicy_stream.group(1)
|
||||
quality = res_dict["resolution"]
|
||||
logger.debug(f"Found quality: {quality}")
|
||||
translation_type = data_audio
|
||||
stream_links.append((quality, juicy_stream))
|
||||
stream_link = juicy_stream
|
||||
|
||||
if translation_type and stream_links:
|
||||
yield map_to_server(episode, translation_type, stream_links)
|
||||
if translation_type and quality and stream_link:
|
||||
yield map_to_server(episode, translation_type, quality, stream_link)
|
||||
|
||||
@lru_cache()
|
||||
def _get_episode_info(
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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*'([^']*)'")
|
||||
@@ -1,129 +0,0 @@
|
||||
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)
|
||||
@@ -1,175 +0,0 @@
|
||||
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)
|
||||
@@ -1,5 +1,6 @@
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Union
|
||||
|
||||
from httpx import Client
|
||||
|
||||
@@ -14,7 +15,6 @@ PROVIDERS_AVAILABLE = {
|
||||
"hianime": "provider.HiAnime",
|
||||
"nyaa": "provider.Nyaa",
|
||||
"yugen": "provider.Yugen",
|
||||
"animeunity": "provider.AnimeUnity",
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ class AnimeProviderFactory:
|
||||
"""Factory for creating anime provider instances."""
|
||||
|
||||
@staticmethod
|
||||
def create(provider_name: ProviderName) -> BaseAnimeProvider:
|
||||
def create(provider_name: Union[ProviderName, str]) -> BaseAnimeProvider:
|
||||
"""
|
||||
Dynamically creates an instance of the specified anime provider.
|
||||
|
||||
This method imports the necessary provider module, instantiates its main class,
|
||||
and injects a pre-configured HTTP client.
|
||||
and injects a pre-configured HTTP client. It now also supports plugin providers.
|
||||
|
||||
Args:
|
||||
provider_name: The name of the provider to create (e.g., 'allanime').
|
||||
@@ -39,24 +39,43 @@ class AnimeProviderFactory:
|
||||
ValueError: If the provider_name is not supported.
|
||||
ImportError: If the provider module or class cannot be found.
|
||||
"""
|
||||
from ....core.plugins.manager import plugin_manager
|
||||
from ....core.utils.networking import random_user_agent
|
||||
|
||||
# Convert to string if it's an enum
|
||||
if isinstance(provider_name, ProviderName):
|
||||
provider_str = provider_name.value
|
||||
else:
|
||||
provider_str = str(provider_name)
|
||||
|
||||
# Check if it's a plugin first
|
||||
if plugin_manager.is_plugin("provider", provider_str):
|
||||
try:
|
||||
return plugin_manager.load_component("provider", provider_str)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load plugin provider '{provider_str}': {e}")
|
||||
raise ImportError(f"Could not load plugin provider '{provider_str}': {e}") from e
|
||||
|
||||
# Handle built-in providers
|
||||
if provider_str.lower() not in PROVIDERS_AVAILABLE:
|
||||
raise ValueError(f"Provider '{provider_str}' is not available")
|
||||
|
||||
# Correctly determine module and class name from the map
|
||||
import_path = PROVIDERS_AVAILABLE[provider_name.value.lower()]
|
||||
import_path = PROVIDERS_AVAILABLE[provider_str.lower()]
|
||||
module_name, class_name = import_path.split(".", 1)
|
||||
|
||||
# Construct the full package path for dynamic import
|
||||
package_path = f"viu_media.libs.provider.anime.{provider_name.value.lower()}"
|
||||
package_path = f"viu_media.libs.provider.anime.{provider_str.lower()}"
|
||||
|
||||
try:
|
||||
provider_module = importlib.import_module(f".{module_name}", package_path)
|
||||
provider_class = getattr(provider_module, class_name)
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.error(
|
||||
f"Failed to load provider '{provider_name.value.lower()}': {e}"
|
||||
f"Failed to load provider '{provider_str}': {e}"
|
||||
)
|
||||
raise ImportError(
|
||||
f"Could not load provider '{provider_name.value.lower()}'. "
|
||||
f"Could not load provider '{provider_str}'. "
|
||||
"Check the module path and class name in PROVIDERS_AVAILABLE."
|
||||
) from e
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from pydantic import BaseModel, ConfigDict
|
||||
class ProviderName(Enum):
|
||||
ALLANIME = "allanime"
|
||||
ANIMEPAHE = "animepahe"
|
||||
ANIMEUNITY = "animeunity"
|
||||
|
||||
|
||||
class ProviderServer(Enum):
|
||||
@@ -29,9 +28,6 @@ class ProviderServer(Enum):
|
||||
# AnimePaheServer values
|
||||
KWIK = "kwik"
|
||||
|
||||
# AnimeUnityServer values
|
||||
VIXCLOUD = "vixcloud"
|
||||
|
||||
|
||||
class MediaTranslationType(Enum):
|
||||
SUB = "sub"
|
||||
|
||||
@@ -69,9 +69,6 @@ def test_anime_provider(AnimeProvider: Type[BaseAnimeProvider]):
|
||||
for i, stream in enumerate(episode_streams):
|
||||
print(f"{i + 1}: {stream.name}")
|
||||
stream = episode_streams[int(input("Select your preferred server: ")) - 1]
|
||||
for i, link in enumerate(stream.links):
|
||||
print(f"{i + 1}: {link.quality}")
|
||||
link = stream.links[int(input("Select your preferred quality: ")) - 1]
|
||||
if executable := shutil.which("mpv"):
|
||||
cmd = executable
|
||||
elif executable := shutil.which("xdg-open"):
|
||||
@@ -87,4 +84,4 @@ def test_anime_provider(AnimeProvider: Type[BaseAnimeProvider]):
|
||||
"Episode: ",
|
||||
stream.episode_title if stream.episode_title else episode_number,
|
||||
)
|
||||
subprocess.run([cmd, link.link])
|
||||
subprocess.run([cmd, stream.links[0].link])
|
||||
|
||||
105
viu_media/libs/provider/manga/MangaProvider.py
Normal file
105
viu_media/libs/provider/manga/MangaProvider.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""An abstraction over all providers offering added features with a simple and well typed api
|
||||
|
||||
[TODO:description]
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .libs.manga_provider import manga_sources
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MangaProvider:
|
||||
"""Class that manages all anime sources adding some extra functionality to them.
|
||||
Attributes:
|
||||
PROVIDERS: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
provider: [TODO:attribute]
|
||||
dynamic: [TODO:attribute]
|
||||
retries: [TODO:attribute]
|
||||
manga_provider: [TODO:attribute]
|
||||
"""
|
||||
|
||||
PROVIDERS = list(manga_sources.keys())
|
||||
provider = PROVIDERS[0]
|
||||
|
||||
def __init__(self, provider="mangadex", dynamic=False, retries=0) -> None:
|
||||
self.provider = provider
|
||||
self.dynamic = dynamic
|
||||
self.retries = retries
|
||||
self.lazyload_provider(self.provider)
|
||||
|
||||
def lazyload_provider(self, provider):
|
||||
"""updates the current provider being used"""
|
||||
_, anime_provider_cls_name = manga_sources[provider].split(".", 1)
|
||||
package = f"viu_media.libs.manga_provider.{provider}"
|
||||
provider_api = importlib.import_module(".api", package)
|
||||
manga_provider = getattr(provider_api, anime_provider_cls_name)
|
||||
self.manga_provider = manga_provider()
|
||||
|
||||
def search_for_manga(
|
||||
self,
|
||||
user_query,
|
||||
nsfw=True,
|
||||
unknown=True,
|
||||
):
|
||||
"""core abstraction over all providers search functionality
|
||||
|
||||
Args:
|
||||
user_query ([TODO:parameter]): [TODO:description]
|
||||
translation_type ([TODO:parameter]): [TODO:description]
|
||||
nsfw ([TODO:parameter]): [TODO:description]
|
||||
manga_provider ([TODO:parameter]): [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.search_for_manga(user_query, nsfw, unknown)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results
|
||||
|
||||
def get_manga(
|
||||
self,
|
||||
anime_id: str,
|
||||
):
|
||||
"""core abstraction over getting info of an anime from all providers
|
||||
|
||||
Args:
|
||||
anime_id: [TODO:description]
|
||||
anilist_obj: [TODO:description]
|
||||
|
||||
Returns:
|
||||
[TODO:return]
|
||||
"""
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.get_manga(anime_id)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results
|
||||
|
||||
def get_chapter_thumbnails(
|
||||
self,
|
||||
manga_id: str,
|
||||
chapter: str,
|
||||
):
|
||||
manga_provider = self.manga_provider
|
||||
try:
|
||||
results = manga_provider.get_chapter_thumbnails(manga_id, chapter)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
results = None
|
||||
return results # pyright:ignore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user