mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-18 18:22:33 -08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ce2f6ca3 | ||
|
|
dbbfe0331b | ||
|
|
04ae196d5f | ||
|
|
fe92ff8716 | ||
|
|
c047377289 | ||
|
|
fcbaa7fb0d | ||
|
|
87c87ebca7 | ||
|
|
e1272ddf35 | ||
|
|
5fe59e1ddb | ||
|
|
83ad67a4a8 | ||
|
|
94866b68f3 | ||
|
|
5f7e10a510 | ||
|
|
95586eb36f | ||
|
|
c01c08c03b | ||
|
|
14e1f44696 | ||
|
|
36b71c0751 |
36
README.md
36
README.md
@@ -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
|
||||
|
||||
@@ -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
2
uv.lock
generated
@@ -3743,7 +3743,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "viu-media"
|
||||
version = "3.3.0"
|
||||
version = "3.3.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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*'([^']*)'")
|
||||
|
||||
50
viu_media/libs/provider/anime/animeunity/extractor.py
Normal file
50
viu_media/libs/provider/anime/animeunity/extractor.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user