Compare commits

...

16 Commits

Author SHA1 Message Date
benexl
20ce2f6ca3 fix(cli): update command name based on availability of viu-media 2025-12-16 17:57:18 +03:00
benexl
dbbfe0331b chore: bump version to 3.3.3 2025-12-16 17:54:48 +03:00
benexl
04ae196d5f fix(cli): remove stdout and stderr reconfiguration for UTF-8 encoding on Windows 2025-12-16 17:50:04 +03:00
benexl
fe92ff8716 fix(preview): update cache directory paths and improve script execution formatting 2025-12-16 17:24:49 +03:00
benexl
c047377289 fix(preview): pass posix paths 2025-12-16 16:47:44 +03:00
benexl
fcbaa7fb0d fix(cli): ensure UTF-8 encoding on Windows platforms 2025-12-16 16:17:14 +03:00
benexl
87c87ebca7 fix(preview): update path handling for cache directories in preview scripts to pass as posix paths 2025-12-16 16:16:05 +03:00
Benedict Xavier
e1272ddf35 Merge pull request #171 from axtrat/provider/animeunity 2025-12-14 09:26:07 +03:00
axtrat
5fe59e1ddb fix: fixed pyright error 2025-12-13 21:25:12 +01:00
axtrat
83ad67a4a8 refactor(animeunity): reorganize extraction logic and update mapper 2025-12-11 13:19:39 +01:00
axtrat
94866b68f3 fix(animeunity): patch missing video info due to VixCloud changes
VixCloud's window.video object no longer provides 'quality' and 'filename' fields, causing a KeyError.
This fix updates the extraction logic.
2025-12-11 13:02:40 +01:00
Benedict Xavier
5f7e10a510 Update README with Termux installation instructions
Added installation instructions for Termux and clarified Python installation requirements.
2025-12-03 11:41:23 +03:00
Benexl
95586eb36f chore: bump version 2025-12-03 10:04:07 +03:00
Benexl
c01c08c03b feat: show welcome screen once a month 2025-12-03 10:03:52 +03:00
Benexl
14e1f44696 chore: bump version 2025-12-02 19:04:14 +03:00
Benexl
36b71c0751 feat: update welcome message 2025-12-02 18:58:15 +03:00
11 changed files with 151 additions and 42 deletions

View File

@@ -49,7 +49,7 @@
## Installation
Viu runs on any platform with Python 3.10+, including Windows, macOS, Linux, and Android (via Termux).
Viu runs on any platform with Python 3.10+, including Windows, macOS, Linux, and Android (via Termux, see other installation methods).
### Prerequisites
@@ -112,6 +112,40 @@ uv tool install "viu-media[notifications]" # For desktop notifications
# Git version (latest commit)
yay -S viu-media-git
```
#### Termux
You may have to have rust installed see this issue: https://github.com/pydantic/pydantic-core/issues/1012#issuecomment-2511269688.
```bash
pkg install python # though uv will probably install python for you, but doesn't hurt to have it :)
pkg install rust # maybe required cause of pydantic
# Recommended (with pip due to more control)
pip install viu-media
# you may need to install pydantic manually
python -m pip install pydantic --extra-index-url https://termux-user-repository.github.io/pypi/ # may also be necessary incase the above fails
# add yt-dlp by
pip install yt-dlp[default,curl-cffi]
# prefer without standard and manually install the things you need lxml, yt-dlp and
pip install viu-media[standard]
# you may need to manually install lxml and plyer manually eg
python -m pip install lxml --extra-index-url https://termux-user-repository.github.io/pypi/ # may also be necessary incase the above fails
# Alternative With Uv may work, no promises
pkg install uv
uv tool install viu-media
# and to add yt-dlp only you can do
uv tool install viu-media --with yt-dlp[default,curl-cffi]
# or though may fail, cause of lxml and plyer, in that case try to install manually
uv tool install viu-media[standard]
```
#### Using pipx (for isolated environments)
```bash

View File

@@ -1,6 +1,6 @@
[project]
name = "viu-media"
version = "3.3.0"
version = "3.3.3"
description = "A browser anime site experience from the terminal"
license = "UNLICENSE"
readme = "README.md"

2
uv.lock generated
View File

@@ -3743,7 +3743,7 @@ wheels = [
[[package]]
name = "viu-media"
version = "3.3.0"
version = "3.3.3"
source = { editable = "." }
dependencies = [
{ name = "click" },

View File

@@ -1,3 +1,9 @@
from .cli import cli as run_cli
import sys
import os
if sys.platform.startswith("win"):
os.environ.setdefault("PYTHONUTF8", "1")
__all__ = ["run_cli"]

View File

@@ -1,4 +1,5 @@
import logging
import shutil
import sys
from typing import TYPE_CHECKING
@@ -121,8 +122,8 @@ def cli(ctx: click.Context, **options: "Unpack[Options]"):
last_welcomed_at = float(
last_welcomed_at_file.read_text(encoding="utf-8")
)
# runs once a day
if (time.time() - last_welcomed_at) > 24 * 3600:
# runs once a month
if (time.time() - last_welcomed_at) > 30 * 24 * 3600:
should_welcome = True
except Exception as e:
@@ -136,10 +137,10 @@ def cli(ctx: click.Context, **options: "Unpack[Options]"):
from rich.prompt import Confirm
if Confirm.ask(f"""\
[green]How are you {USER_NAME} 🙂?
If you like the project and are able to support it please consider buying me a coffee at {SUPPORT_PROJECT_URL}.
If you would like to proceed to {SUPPORT_PROJECT_URL} select yes, otherwise enjoy your browser anime experience 😁.[/]
This message can be disabled by switching off the welcome_screen option in the config and is only shown once every 24hrs.
[green]How are you, {USER_NAME} 🙂?
If you enjoy the project and would like to support it, you can buy me a coffee at {SUPPORT_PROJECT_URL}.
Would you like to open the support page? Select yes to continue — otherwise, enjoy your terminal-anime browsing experience 😁.[/]
You can disable this message by turning off the welcome_screen option in the config. It only appears once a month.
"""):
from webbrowser import open
@@ -188,7 +189,8 @@ This message can be disabled by switching off the welcome_screen option in the c
):
import subprocess
cmd = ["viu", "config", "--update"]
_cli_cmd_name="viu" if not shutil.which("viu-media") else "viu-media"
cmd = [_cli_cmd_name, "config", "--update"]
print(f"running '{' '.join(cmd)}'...")
subprocess.run(cmd)

View File

@@ -1,6 +1,7 @@
import json
import logging
import sys
from pathlib import Path
from .....core.constants import APP_CACHE_DIR, SCRIPTS_DIR
from .....libs.media_api.params import MediaSearchParams
@@ -9,7 +10,7 @@ from ...state import InternalDirective, MediaApiState, MenuName, State
logger = logging.getLogger(__name__)
SEARCH_CACHE_DIR = APP_CACHE_DIR / "search"
SEARCH_CACHE_DIR = APP_CACHE_DIR / "previews" / "dynamic-search"
SEARCH_RESULTS_FILE = SEARCH_CACHE_DIR / "current_search_results.json"
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
SEARCH_TEMPLATE_SCRIPT = (FZF_SCRIPTS_DIR / "search.py").read_text(encoding="utf-8")
@@ -42,7 +43,7 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective:
replacements = {
"GRAPHQL_ENDPOINT": "https://graphql.anilist.co",
"GRAPHQL_QUERY": search_query_json,
"SEARCH_RESULTS_FILE": str(SEARCH_RESULTS_FILE),
"SEARCH_RESULTS_FILE": SEARCH_RESULTS_FILE.as_posix(),
"AUTH_HEADER": auth_header,
}
@@ -50,12 +51,14 @@ def dynamic_search(ctx: Context, state: State) -> State | InternalDirective:
search_command = search_command.replace(f"{{{key}}}", str(value))
# Write the filled template to a cache file
search_script_file = SEARCH_CACHE_DIR / "search-script.py"
search_script_file = SEARCH_CACHE_DIR / "search.py"
search_script_file.write_text(search_command, encoding="utf-8")
# Make the search script executable by calling it with python3
# fzf will pass the query as {q} which becomes the first argument
search_command_final = f"{sys.executable} {search_script_file} {{q}}"
search_command_final = (
f"{Path(sys.executable).as_posix()} {search_script_file.as_posix()} {{q}}"
)
try:
# Prepare preview functionality

View File

@@ -1,4 +1,5 @@
import logging
from pathlib import Path
import re
from hashlib import sha256
import sys
@@ -308,8 +309,8 @@ def get_anime_preview(
# 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_DIR": IMAGES_CACHE_DIR.as_posix(),
"INFO_CACHE_DIR": INFO_CACHE_DIR.as_posix(),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -325,7 +326,9 @@ def get_anime_preview(
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} {{}}"
preview_script_final = (
f"{Path(sys.executable).as_posix()} {preview_file.as_posix()} {{}}"
)
return preview_script_final
@@ -366,8 +369,8 @@ def get_episode_preview(
# 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_DIR": IMAGES_CACHE_DIR.as_posix(),
"INFO_CACHE_DIR": INFO_CACHE_DIR.as_posix(),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -383,7 +386,9 @@ def get_episode_preview(
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} {{}}"
preview_script_final = (
f"{Path(sys.executable).as_posix()} {preview_file.as_posix()} {{}}"
)
return preview_script_final
@@ -412,8 +417,8 @@ def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
"IMAGE_CACHE_DIR": IMAGES_CACHE_DIR.as_posix(),
"INFO_CACHE_DIR": INFO_CACHE_DIR.as_posix(),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -429,7 +434,9 @@ def get_character_preview(choice_map: Dict[str, Character], config: AppConfig) -
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} {{}}"
preview_script_final = (
f"{Path(sys.executable).as_posix()} {preview_file.as_posix()} {{}}"
)
return preview_script_final
@@ -458,8 +465,8 @@ def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) ->
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
"IMAGE_CACHE_DIR": IMAGES_CACHE_DIR.as_posix(),
"INFO_CACHE_DIR": INFO_CACHE_DIR.as_posix(),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -475,7 +482,9 @@ def get_review_preview(choice_map: Dict[str, MediaReview], config: AppConfig) ->
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} {{}}"
preview_script_final = (
f"{Path(sys.executable).as_posix()} {preview_file.as_posix()} {{}}"
)
return preview_script_final
@@ -506,8 +515,8 @@ def get_airing_schedule_preview(
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
"IMAGE_CACHE_DIR": IMAGES_CACHE_DIR.as_posix(),
"INFO_CACHE_DIR": INFO_CACHE_DIR.as_posix(),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -546,8 +555,10 @@ 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)
search_cache_dir = APP_CACHE_DIR / "previews" / "dynamic-search"
search_cache_dir.mkdir(parents=True, exist_ok=True)
source = FZF_SCRIPTS_DIR / "_ansi_utils.py"
dest = PREVIEWS_CACHE_DIR / "_ansi_utils.py"
dest = search_cache_dir / "_ansi_utils.py"
if source.exists() and (
not dest.exists() or source.stat().st_mtime > dest.stat().st_mtime
@@ -566,13 +577,12 @@ def get_dynamic_anime_preview(config: AppConfig) -> str:
# Use the dynamic preview script template
preview_script = DYNAMIC_PREVIEW_SCRIPT
search_cache_dir = APP_CACHE_DIR / "search"
search_results_file = search_cache_dir / "current_search_results.json"
# Prepare replacements for the template
replacements = {
"SEARCH_RESULTS_FILE": str(search_results_file),
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
"SEARCH_RESULTS_FILE": search_results_file.as_posix(),
"IMAGE_CACHE_DIR": IMAGES_CACHE_DIR.as_posix(),
"PREVIEW_MODE": config.general.preview,
"IMAGE_RENDERER": config.general.image_renderer,
"HEADER_COLOR": ",".join(HEADER_COLOR),
@@ -584,11 +594,13 @@ def get_dynamic_anime_preview(config: AppConfig) -> str:
preview_script = preview_script.replace(f"{{{key}}}", value)
# Write the preview script to cache
preview_file = PREVIEWS_CACHE_DIR / "dynamic-search-preview-script.py"
preview_file = search_cache_dir / "dynamic-search-preview-script.py"
preview_file.write_text(preview_script, encoding="utf-8")
# Return the command to execute the preview script
preview_script_final = f"{sys.executable} {preview_file} {{}}"
preview_script_final = (
f"{Path(sys.executable).as_posix()} {preview_file.as_posix()} {{}}"
)
return preview_script_final

View File

@@ -11,4 +11,7 @@ REPLACEMENT_WORDS = {"Season ": "", "Cour": "Part"}
# Server Specific
AVAILABLE_VIDEO_QUALITY = ["1080", "720", "480"]
VIDEO_INFO_REGEX = re.compile(r"window.video\s*=\s*(\{[^\}]*\})")
VIDEO_INFO_CLEAN_REGEX = re.compile(r'(?<!["\'])(\b\w+\b)(?=\s*:)')
DOWNLOAD_FILENAME_REGEX = re.compile(r"[?&]filename=([^&]+)")
QUALITY_REGEX = re.compile(r"/(\d{3,4}p)")
DOWNLOAD_URL_REGEX = re.compile(r"window.downloadUrl\s*=\s*'([^']*)'")

View File

@@ -0,0 +1,50 @@
import logging
from .constants import (
DOWNLOAD_FILENAME_REGEX,
DOWNLOAD_URL_REGEX,
QUALITY_REGEX,
VIDEO_INFO_CLEAN_REGEX,
VIDEO_INFO_REGEX,
)
logger = logging.getLogger(__name__)
def extract_server_info(html_content: str, episode_title: str | None) -> dict | None:
"""
Extracts server information from the VixCloud/AnimeUnity embed page.
Handles extraction from both window.video object and download URL.
"""
video_info = VIDEO_INFO_REGEX.search(html_content)
download_url_match = DOWNLOAD_URL_REGEX.search(html_content)
if not (download_url_match and video_info):
return None
info_str = VIDEO_INFO_CLEAN_REGEX.sub(r'"\1"', video_info.group(1))
# Use eval context for JS constants
ctx = {"null": None, "true": True, "false": False}
try:
info = eval(info_str, ctx)
except Exception as e:
logger.error(f"Failed to parse JS object: {e}")
return None
download_url = download_url_match.group(1)
info["link"] = download_url
# Extract metadata from download URL if missing in window.video
if filename_match := DOWNLOAD_FILENAME_REGEX.search(download_url):
info["name"] = filename_match.group(1)
else:
info["name"] = f"{episode_title or 'Unknown'}"
if quality_match := QUALITY_REGEX.search(download_url):
# "720p" -> 720
info["quality"] = int(quality_match.group(1)[:-1])
else:
info["quality"] = 0 # Fallback
return info

View File

@@ -99,7 +99,11 @@ def map_to_server(
translation_type=MediaTranslationType(translation_type),
mp4=True,
)
for quality in AVAILABLE_VIDEO_QUALITY
for quality in sorted(
list(set(AVAILABLE_VIDEO_QUALITY + [str(info["quality"])])),
key=lambda x: int(x),
reverse=True,
)
if int(quality) <= info["quality"]
],
episode_title=episode.title,

View File

@@ -8,12 +8,11 @@ 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 .extractor import extract_server_info
from .mappers import (
map_to_anime_result,
map_to_search_result,
@@ -158,14 +157,10 @@ class AnimeUnity(BaseAnimeProvider):
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):
if not (info := extract_server_info(video_response.text, episode.title)):
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)