├── .python-version ├── viu_media ├── core │ ├── __init__.py │ ├── downloader │ │ ├── __init__.py │ │ ├── params.py │ │ ├── base.py │ │ ├── model.py │ │ └── downloader.py │ ├── utils │ │ ├── __init__.py │ │ ├── graphql.py │ │ ├── converter.py │ │ ├── detect.py │ │ └── networking.py │ ├── patterns.py │ ├── config │ │ ├── __init__.py │ │ └── defaults.py │ └── constants.py ├── libs │ ├── __init__.py │ ├── media_api │ │ ├── __init__.py │ │ ├── jikan │ │ │ └── __init__.py │ │ ├── anilist │ │ │ ├── __init__.py │ │ │ └── gql.py │ │ ├── api.py │ │ └── params.py │ ├── provider │ │ ├── __init__.py │ │ ├── anime │ │ │ ├── __init__.py │ │ │ ├── animeunity │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ └── extractor.py │ │ │ ├── allanime │ │ │ │ ├── __init__.py │ │ │ │ ├── extractors │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── base.py │ │ │ │ │ ├── yt_mp4.py │ │ │ │ │ ├── wixmp.py │ │ │ │ │ ├── ak.py │ │ │ │ │ ├── dropbox.py │ │ │ │ │ ├── we_transfer.py │ │ │ │ │ ├── streamsb.py │ │ │ │ │ ├── sharepoint.py │ │ │ │ │ ├── vid_mp4.py │ │ │ │ │ ├── gogoanime.py │ │ │ │ │ ├── mp4_upload.py │ │ │ │ │ ├── extractor.py │ │ │ │ │ └── filemoon.py │ │ │ │ ├── constants.py │ │ │ │ ├── mappers.py │ │ │ │ ├── utils.py │ │ │ │ ├── types.py │ │ │ │ └── provider.py │ │ │ ├── animepahe │ │ │ │ ├── __init__.py │ │ │ │ ├── constants.py │ │ │ │ ├── types.py │ │ │ │ └── mappers.py │ │ │ ├── base.py │ │ │ ├── params.py │ │ │ ├── provider.py │ │ │ ├── types.py │ │ │ └── utils │ │ │ │ └── debug.py │ │ └── scraping │ │ │ └── __init__.py │ ├── player │ │ ├── mpv │ │ │ └── __init__.py │ │ ├── vlc │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── types.py │ │ ├── params.py │ │ ├── player.py │ │ └── base.py │ └── selectors │ │ ├── fzf │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── rofi │ │ └── __init__.py │ │ ├── inquirer │ │ ├── __init__.py │ │ └── selector.py │ │ └── selector.py ├── cli │ ├── utils │ │ ├── __init__.py │ │ ├── feh.py │ │ ├── exception.py │ │ ├── ansi.py │ │ ├── logging.py │ │ ├── search.py │ │ ├── lazyloader.py │ │ ├── completion.py │ │ └── icat.py │ ├── commands │ │ ├── __init__.py │ │ ├── anilist │ │ │ ├── commands │ │ │ │ ├── __init__.py │ │ │ │ ├── notifications.py │ │ │ │ ├── auth.py │ │ │ │ └── stats.py │ │ │ ├── __init__.py │ │ │ └── cmd.py │ │ ├── queue │ │ │ ├── commands │ │ │ │ ├── __init__.py │ │ │ │ ├── resume.py │ │ │ │ ├── clear.py │ │ │ │ └── list.py │ │ │ ├── __init__.py │ │ │ └── cmd.py │ │ ├── registry │ │ │ ├── commands │ │ │ │ └── __init__.py │ │ │ ├── __init__.py │ │ │ ├── examples.py │ │ │ └── cmd.py │ │ ├── worker.py │ │ └── examples.py │ ├── service │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ └── service.py │ │ ├── player │ │ │ ├── __init__.py │ │ │ ├── ipc │ │ │ │ └── base.py │ │ │ └── service.py │ │ ├── download │ │ │ └── __init__.py │ │ ├── feedback │ │ │ └── __init__.py │ │ ├── session │ │ │ ├── __init__.py │ │ │ ├── model.py │ │ │ └── service.py │ │ ├── registry │ │ │ └── __init__.py │ │ └── watch_history │ │ │ └── __init__.py │ ├── config │ │ └── __init__.py │ ├── __init__.py │ └── interactive │ │ └── menu │ │ └── media │ │ ├── media_review.py │ │ └── episodes.py ├── assets │ ├── icons │ │ ├── logo.ico │ │ └── logo.png │ ├── graphql │ │ ├── anilist │ │ │ ├── mutations │ │ │ │ ├── mark-read.gql │ │ │ │ ├── delete-list-entry.gql │ │ │ │ └── media-list.gql │ │ │ └── queries │ │ │ │ ├── media-list-item.gql │ │ │ │ ├── logged-in-user.gql │ │ │ │ ├── reviews.gql │ │ │ │ ├── media-airing-schedule.gql │ │ │ │ ├── notifications.gql │ │ │ │ ├── media-characters.gql │ │ │ │ ├── user-info.gql │ │ │ │ ├── media-relations.gql │ │ │ │ ├── media-recommendations.gql │ │ │ │ ├── media-list.gql │ │ │ │ └── search.gql │ │ └── allanime │ │ │ └── queries │ │ │ ├── anime.gql │ │ │ ├── episodes.gql │ │ │ └── search.gql │ ├── defaults │ │ ├── ascii-art │ │ ├── viu-worker.template.service │ │ ├── fzf-opts │ │ └── rofi-themes │ │ │ ├── input.rasi │ │ │ ├── preview.rasi │ │ │ ├── main.rasi │ │ │ └── confirm.rasi │ ├── scripts │ │ └── fzf │ │ │ ├── review_info.py │ │ │ ├── airing_schedule_info.py │ │ │ ├── character_info.py │ │ │ ├── episode_info.py │ │ │ └── media_info.py │ └── normalizer.json ├── __init__.py ├── __main__.py └── viu.py ├── .repomixignore ├── .vscode └── settings.json ├── pyrightconfig.json ├── dev ├── graphql │ └── anilist │ │ └── media_tags.gql ├── generate_completions.sh ├── make_release └── generate_anilist_media_tags.py ├── .envrc ├── bundle ├── Dockerfile └── pyinstaller.spec ├── .pre-commit-config.yaml ├── viu ├── tox.ini ├── completions ├── viu.fish ├── viu.bash └── viu.zsh ├── .github ├── workflows │ ├── build.yml │ ├── test.yml │ ├── publish.yml │ └── stale.yml └── chatmodes │ ├── new-command.chatmode.md │ ├── new-component.chatmode.md │ └── new-provider.chatmode.md ├── LICENSE ├── pyproject.toml ├── flake.lock ├── DISCLAIMER.md └── flake.nix /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /viu_media/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/cli/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.repomixignore: -------------------------------------------------------------------------------- 1 | **/generated/**/* 2 | -------------------------------------------------------------------------------- /viu_media/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/provider/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/jikan/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/player/mpv/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /viu_media/libs/player/vlc/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/provider/scraping/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/cli/commands/queue/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/anilist/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animeunity/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animepahe/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.autoImportCompletions": true 3 | } 4 | -------------------------------------------------------------------------------- /viu_media/cli/commands/registry/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # Registry commands package 2 | -------------------------------------------------------------------------------- /viu_media/cli/commands/queue/__init__.py: -------------------------------------------------------------------------------- 1 | from .cmd import queue 2 | 3 | __all__ = ["queue"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/__init__.py: -------------------------------------------------------------------------------- 1 | from .cmd import anilist 2 | 3 | __all__ = ["anilist"] 4 | -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "venvPath": ".", 3 | "venv": ".venv", 4 | "pythonVersion": "3.12" 5 | } 6 | -------------------------------------------------------------------------------- /viu_media/cli/commands/registry/__init__.py: -------------------------------------------------------------------------------- 1 | from .cmd import registry 2 | 3 | __all__ = ["registry"] 4 | -------------------------------------------------------------------------------- /viu_media/assets/icons/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viu-media/viu/HEAD/viu_media/assets/icons/logo.ico -------------------------------------------------------------------------------- /viu_media/assets/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viu-media/viu/HEAD/viu_media/assets/icons/logo.png -------------------------------------------------------------------------------- /viu_media/cli/service/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import AuthService 2 | 3 | __all__ = ["AuthService"] 4 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/fzf/__init__.py: -------------------------------------------------------------------------------- 1 | from .selector import FzfSelector 2 | 3 | __all__ = ["FzfSelector"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/service/player/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import PlayerService 2 | 3 | __all__ = ["PlayerService"] 4 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/__init__.py: -------------------------------------------------------------------------------- 1 | from .selector import create_selector 2 | 3 | __all__ = ["create_selector"] 4 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/rofi/__init__.py: -------------------------------------------------------------------------------- 1 | from .selector import RofiSelector 2 | 3 | __all__ = ["RofiSelector"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/service/download/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import DownloadService 2 | 3 | __all__ = ["DownloadService"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/service/feedback/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import FeedbackService 2 | 3 | __all__ = ["FeedbackService"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/service/session/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import SessionsService 2 | 3 | __all__ = ["SessionsService"] 4 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/inquirer/__init__.py: -------------------------------------------------------------------------------- 1 | from .selector import InquirerSelector 2 | 3 | __all__ = ["InquirerSelector"] 4 | -------------------------------------------------------------------------------- /viu_media/cli/service/registry/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import MediaRegistryService 2 | 3 | __all__ = ["MediaRegistryService"] 4 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/mutations/mark-read.gql: -------------------------------------------------------------------------------- 1 | mutation { 2 | UpdateUser { 3 | unreadNotificationCount 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /viu_media/cli/service/watch_history/__init__.py: -------------------------------------------------------------------------------- 1 | from .service import WatchHistoryService 2 | 3 | __all__ = ["WatchHistoryService"] 4 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/__init__.py: -------------------------------------------------------------------------------- 1 | from .extractor import extract_server 2 | 3 | __all__ = ["extract_server"] 4 | -------------------------------------------------------------------------------- /dev/graphql/anilist/media_tags.gql: -------------------------------------------------------------------------------- 1 | query { 2 | MediaTagCollection { 3 | name 4 | description 5 | category 6 | isAdult 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-list-item.gql: -------------------------------------------------------------------------------- 1 | query ($mediaId: Int) { 2 | MediaList(mediaId: $mediaId) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | VIU_APP_NAME="viu-dev" 2 | PATH="$PWD/.venv/bin:$PATH" 3 | export PATH VIU_APP_NAME 4 | if command -v nix >/dev/null; then 5 | use flake 6 | fi 7 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/mutations/delete-list-entry.gql: -------------------------------------------------------------------------------- 1 | mutation ($id: Int) { 2 | DeleteMediaListEntry(id: $id) { 3 | deleted 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/allanime/queries/anime.gql: -------------------------------------------------------------------------------- 1 | query ($showId: String!) { 2 | show(_id: $showId) { 3 | _id 4 | name 5 | availableEpisodesDetail 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/ascii-art: -------------------------------------------------------------------------------- 1 | ██╗░░░██╗██╗██╗░░░██╗ 2 | ██║░░░██║██║██║░░░██║ 3 | ╚██╗░██╔╝██║██║░░░██║ 4 | ░╚████╔╝░██║██║░░░██║ 5 | ░░╚██╔╝░░██║╚██████╔╝ 6 | ░░░╚═╝░░░╚═╝░╚═════╝░ 7 | -------------------------------------------------------------------------------- /viu_media/cli/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .generate import generate_config_toml_from_app_model 2 | from .loader import ConfigLoader 3 | 4 | __all__ = ["ConfigLoader", "generate_config_toml_from_app_model"] 5 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/logged-in-user.gql: -------------------------------------------------------------------------------- 1 | query { 2 | Viewer { 3 | id 4 | name 5 | bannerImage 6 | avatar { 7 | large 8 | medium 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /bundle/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim-bookworm 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 3 | COPY . /viu 4 | ENV PATH=/root/.local/bin:$PATH 5 | WORKDIR /viu 6 | RUN uv tool install . 7 | CMD ["bash"] 8 | -------------------------------------------------------------------------------- /viu_media/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli as run_cli 2 | import sys 3 | import os 4 | 5 | if sys.platform.startswith("win"): 6 | os.environ.setdefault("PYTHONUTF8", "1") 7 | 8 | 9 | __all__ = ["run_cli"] 10 | -------------------------------------------------------------------------------- /viu_media/core/downloader/__init__.py: -------------------------------------------------------------------------------- 1 | from .downloader import create_downloader 2 | from .params import DownloadParams 3 | from .model import DownloadResult 4 | 5 | __all__ = ["create_downloader", "DownloadParams", "DownloadResult"] 6 | -------------------------------------------------------------------------------- /viu_media/core/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Core utilities for Viu application. 3 | 4 | This module provides various utility classes and functions used throughout 5 | the Viu application, including concurrency management, file operations, 6 | and other common functionality. 7 | """ 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.14.2 5 | hooks: 6 | # Run the linter. 7 | - id: ruff-check 8 | args: [--fix] 9 | # Run the formatter. 10 | - id: ruff-format 11 | -------------------------------------------------------------------------------- /viu: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | provider_type=$1 3 | provider_name=$2 4 | [ -z "$provider_type" ] && echo "Please specify provider type" && exit 5 | [ -z "$provider_name" ] && echo "Please specify provider type" && exit 6 | uv run python -m viu_media.libs.provider.${provider_type}.${provider_name}.provider 7 | -------------------------------------------------------------------------------- /viu_media/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if sys.version_info < (3, 11): 4 | raise ImportError( 5 | "You are using an unsupported version of Python. Only Python versions 3.10 and above are supported by Viu" 6 | ) # noqa: F541 7 | 8 | 9 | def Cli(): 10 | from .cli import run_cli 11 | 12 | run_cli() 13 | -------------------------------------------------------------------------------- /viu_media/core/patterns.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | YOUTUBE_REGEX = re.compile( 4 | r"(https?://)?(www\.)?(youtube|youtu|youtube-nocookie)\.(com|be)/.+", re.IGNORECASE 5 | ) 6 | TORRENT_REGEX = re.compile( 7 | r"^(?:(magnet:\?xt=urn:btih:(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{40}).*)|(https?://.*\.torrent))$", 8 | re.IGNORECASE, 9 | ) 10 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/reviews.gql: -------------------------------------------------------------------------------- 1 | query ($id: Int) { 2 | Page { 3 | pageInfo { 4 | total 5 | } 6 | reviews(mediaId: $id) { 7 | summary 8 | user { 9 | name 10 | avatar { 11 | large 12 | medium 13 | } 14 | } 15 | body 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-airing-schedule.gql: -------------------------------------------------------------------------------- 1 | query ($id: Int, $type: MediaType) { 2 | Page { 3 | media(id: $id, sort: POPULARITY_DESC, type: $type) { 4 | airingSchedule(notYetAired: true) { 5 | nodes { 6 | airingAt 7 | timeUntilAiring 8 | episode 9 | } 10 | } 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/allanime/queries/episodes.gql: -------------------------------------------------------------------------------- 1 | query ( 2 | $showId: String! 3 | $translationType: VaildTranslationTypeEnumType! 4 | $episodeString: String! 5 | ) { 6 | episode( 7 | showId: $showId 8 | translationType: $translationType 9 | episodeString: $episodeString 10 | ) { 11 | episodeString 12 | sourceUrls 13 | notes 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /viu_media/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | if __package__ is None and not getattr(sys, "frozen", False): 4 | # direct call of __main__.py 5 | import os.path 6 | 7 | path = os.path.realpath(os.path.abspath(__file__)) 8 | sys.path.insert(0, os.path.dirname(os.path.dirname(path))) 9 | 10 | 11 | if __name__ == "__main__": 12 | from . import Cli 13 | 14 | Cli() 15 | -------------------------------------------------------------------------------- /viu_media/libs/player/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The player package provides abstractions and implementations for media player integration in Viu. 3 | 4 | This package defines the base player interface, player parameter/result types, and concrete implementations for various media players (e.g., MPV, VLC, Syncplay). 5 | """ 6 | 7 | from .player import create_player 8 | 9 | __all__ = ["create_player"] 10 | -------------------------------------------------------------------------------- /viu_media/viu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | # Add the application root directory to Python path 6 | if getattr(sys, "frozen", False): 7 | application_path = os.path.dirname(sys.executable) 8 | sys.path.insert(0, application_path) 9 | 10 | # Import and run the main application 11 | from viu_media import Cli 12 | 13 | if __name__ == "__main__": 14 | Cli() 15 | -------------------------------------------------------------------------------- /dev/generate_completions.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | APP_DIR="$( 3 | cd -- "$(dirname "$0")" >/dev/null 2>&1 4 | pwd -P 5 | )" 6 | 7 | # fish shell completions 8 | _VIU_COMPLETE=fish_source viu >"$APP_DIR/completions/viu.fish" 9 | 10 | # zsh completions 11 | _VIU_COMPLETE=zsh_source viu >"$APP_DIR/completions/viu.zsh" 12 | 13 | # bash completions 14 | _VIU_COMPLETE=bash_source viu >"$APP_DIR/completions/viu.bash" 15 | -------------------------------------------------------------------------------- /viu_media/cli/utils/feh.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | from sys import exit 4 | 5 | 6 | def feh_manga_viewer(image_links: list[str], window_title: str): 7 | FEH_EXECUTABLE = shutil.which("feh") 8 | if not FEH_EXECUTABLE: 9 | print("feh not found") 10 | exit(1) 11 | commands = [FEH_EXECUTABLE, *image_links, "--title", window_title] 12 | subprocess.run(commands, check=False) 13 | -------------------------------------------------------------------------------- /viu_media/cli/service/auth/model.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from ....libs.media_api.types import UserProfile 6 | 7 | AUTH_VERSION = "1.0" 8 | 9 | 10 | class AuthProfile(BaseModel): 11 | user_profile: UserProfile 12 | token: str 13 | 14 | 15 | class AuthModel(BaseModel): 16 | version: str = Field(default=AUTH_VERSION) 17 | profiles: Dict[str, AuthProfile] = Field(default_factory=dict) 18 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/viu-worker.template.service: -------------------------------------------------------------------------------- 1 | # values in {NAME} syntax are provided by python using .replace() 2 | # 3 | [Unit] 4 | Description=Viu Background Worker 5 | After=network-online.target 6 | 7 | [Service] 8 | Type=simple 9 | # Ensure you have the full path to your viu executable 10 | # Use `which viu` to find it 11 | ExecStart={EXECUTABLE} worker --log 12 | Restart=always 13 | RestartSec=30 14 | 15 | [Install] 16 | WantedBy=default.target 17 | -------------------------------------------------------------------------------- /viu_media/core/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .model import ( 2 | AnilistConfig, 3 | AppConfig, 4 | DownloadsConfig, 5 | FzfConfig, 6 | GeneralConfig, 7 | MediaRegistryConfig, 8 | MpvConfig, 9 | RofiConfig, 10 | StreamConfig, 11 | VlcConfig, 12 | ) 13 | 14 | __all__ = [ 15 | "AppConfig", 16 | "FzfConfig", 17 | "RofiConfig", 18 | "VlcConfig", 19 | "MpvConfig", 20 | "AnilistConfig", 21 | "StreamConfig", 22 | "GeneralConfig", 23 | "DownloadsConfig", 24 | "MediaRegistryConfig", 25 | ] 26 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | from httpx import Client 4 | 5 | from ...types import Server 6 | from ..types import AllAnimeEpisode, AllAnimeSource 7 | 8 | 9 | class BaseExtractor(ABC): 10 | @classmethod 11 | @abstractmethod 12 | def extract( 13 | cls, 14 | url: str, 15 | client: Client, 16 | episode_number: str, 17 | episode: AllAnimeEpisode, 18 | source: AllAnimeSource, 19 | ) -> Server | None: 20 | pass 21 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/allanime/queries/search.gql: -------------------------------------------------------------------------------- 1 | query ( 2 | $search: SearchInput 3 | $limit: Int 4 | $page: Int 5 | $translationType: VaildTranslationTypeEnumType 6 | $countryOrigin: VaildCountryOriginEnumType 7 | ) { 8 | shows( 9 | search: $search 10 | limit: $limit 11 | page: $page 12 | translationType: $translationType 13 | countryOrigin: $countryOrigin 14 | ) { 15 | pageInfo { 16 | total 17 | } 18 | edges { 19 | _id 20 | name 21 | availableEpisodes 22 | __typename 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | env_list = lint, pyright, py{311,312} 5 | 6 | [testenv] 7 | description = run unit tests 8 | deps =uv 9 | commands = 10 | uv sync --dev --all-extras 11 | uv run pytest 12 | 13 | [testenv:lint] 14 | description = run linters 15 | skip_install = true 16 | deps =uv 17 | commands = 18 | uv sync --dev --all-extras 19 | uv run ruff format . 20 | 21 | [testenv:pyright] 22 | description = run type checking 23 | skip_install = true 24 | deps =uv 25 | commands = 26 | uv sync --dev --all-extras 27 | uv run pyright 28 | -------------------------------------------------------------------------------- /viu_media/libs/player/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the PlayerResult dataclass, which encapsulates the result of a player session. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | 7 | 8 | @dataclass(frozen=True) 9 | class PlayerResult: 10 | """ 11 | Result of a player session. 12 | 13 | Attributes: 14 | episode: The episode identifier or label. 15 | stop_time: The time at which playback stopped. 16 | total_time: The total duration of the media. 17 | """ 18 | 19 | episode: str 20 | stop_time: str | None = None 21 | total_time: str | None = None 22 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/mutations/media-list.gql: -------------------------------------------------------------------------------- 1 | mutation ( 2 | $mediaId: Int 3 | $scoreRaw: Int 4 | $repeat: Int 5 | $progress: Int 6 | $status: MediaListStatus 7 | ) { 8 | SaveMediaListEntry( 9 | mediaId: $mediaId 10 | scoreRaw: $scoreRaw 11 | progress: $progress 12 | repeat: $repeat 13 | status: $status 14 | ) { 15 | id 16 | status 17 | mediaId 18 | score 19 | progress 20 | repeat 21 | startedAt { 22 | year 23 | month 24 | day 25 | } 26 | completedAt { 27 | year 28 | month 29 | day 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/notifications.gql: -------------------------------------------------------------------------------- 1 | query { 2 | Page(perPage: 5) { 3 | pageInfo { 4 | total 5 | } 6 | notifications(resetNotificationCount: true, type: AIRING) { 7 | ... on AiringNotification { 8 | id 9 | type 10 | episode 11 | contexts 12 | createdAt 13 | media { 14 | id 15 | idMal 16 | title { 17 | romaji 18 | english 19 | } 20 | coverImage { 21 | medium 22 | large 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /completions/viu.fish: -------------------------------------------------------------------------------- 1 | function _viu_completion; 2 | set -l response (env _VIU_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) viu); 3 | 4 | for completion in $response; 5 | set -l metadata (string split "," $completion); 6 | 7 | if test $metadata[1] = "dir"; 8 | __fish_complete_directories $metadata[2]; 9 | else if test $metadata[1] = "file"; 10 | __fish_complete_path $metadata[2]; 11 | else if test $metadata[1] = "plain"; 12 | echo $metadata[2]; 13 | end; 14 | end; 15 | end; 16 | 17 | complete --no-files --command viu --arguments "(_viu_completion)"; 18 | 19 | -------------------------------------------------------------------------------- /viu_media/cli/service/session/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from pydantic import BaseModel, Field, computed_field 5 | 6 | from ...interactive.state import State 7 | 8 | 9 | class Session(BaseModel): 10 | history: List[State] 11 | 12 | created_at: datetime = Field(default_factory=datetime.now) 13 | name: str = Field( 14 | default_factory=lambda: "session_" + datetime.now().strftime("%Y%m%d_%H%M%S_%f") 15 | ) 16 | description: Optional[str] = None 17 | is_from_crash: bool = False 18 | 19 | @computed_field 20 | @property 21 | def state_count(self) -> int: 22 | return len(self.history) 23 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/fzf-opts: -------------------------------------------------------------------------------- 1 | --color=fg:#d0d0d0,fg+:#d0d0d0,bg:#121212,bg+:#262626 2 | --color=hl:#5f87af,hl+:#5fd7ff,info:#afaf87,marker:#87ff00 3 | --color=prompt:#d7005f,spinner:#af5fff,pointer:#af5fff,header:#87afaf 4 | --color=border:#262626,label:#aeaeae,query:#d9d9d9 5 | --border=rounded 6 | --border-label='' 7 | --prompt='>' 8 | --marker='>' 9 | --pointer='◆' 10 | --separator='─' 11 | --scrollbar='│' 12 | --layout=reverse 13 | --cycle 14 | --info=hidden 15 | --height=100% 16 | --bind=right:accept,ctrl-/:toggle-preview,ctrl-space:toggle-wrap+toggle-preview-wrap 17 | --no-margin 18 | +m 19 | -i 20 | --exact 21 | --tabstop=1 22 | --preview-window=border-rounded,left,35%,wrap 23 | --wrap 24 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-characters.gql: -------------------------------------------------------------------------------- 1 | query ($id: Int, $type: MediaType) { 2 | Page { 3 | media(id: $id, type: $type) { 4 | characters { 5 | nodes { 6 | name { 7 | first 8 | middle 9 | last 10 | full 11 | native 12 | } 13 | image { 14 | medium 15 | large 16 | } 17 | description 18 | gender 19 | dateOfBirth { 20 | year 21 | month 22 | day 23 | } 24 | age 25 | bloodType 26 | favourites 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /viu_media/cli/utils/exception.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from rich.traceback import install as rich_install 4 | 5 | 6 | def custom_exception_hook(exc_type, exc_value, exc_traceback): 7 | print(f"{exc_type.__name__}: {exc_value}") 8 | 9 | 10 | default_exception_hook = sys.excepthook 11 | 12 | 13 | def setup_exceptions_handler( 14 | trace: bool | None, 15 | dev: bool | None, 16 | rich_traceback: bool | None, 17 | rich_traceback_theme: str, 18 | ): 19 | if trace or dev: 20 | sys.excepthook = default_exception_hook 21 | if rich_traceback: 22 | rich_install(show_locals=True, theme=rich_traceback_theme) 23 | else: 24 | sys.excepthook = custom_exception_hook 25 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animeunity/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | ANIMEUNITY = "animeunity.so" 4 | ANIMEUNITY_BASE = f"https://www.{ANIMEUNITY}" 5 | 6 | MAX_TIMEOUT = 10 7 | TOKEN_REGEX = re.compile(r'') 8 | 9 | REPLACEMENT_WORDS = {"Season ": "", "Cour": "Part"} 10 | 11 | # Server Specific 12 | AVAILABLE_VIDEO_QUALITY = ["1080", "720", "480"] 13 | VIDEO_INFO_REGEX = re.compile(r"window.video\s*=\s*(\{[^\}]*\})") 14 | VIDEO_INFO_CLEAN_REGEX = re.compile(r'(? Server: 17 | return Server( 18 | name="Yt", 19 | links=[EpisodeStream(link=url, quality="1080")], 20 | episode_title=episode["notes"], 21 | headers={"Referer": f"https://{API_BASE_URL}/"}, 22 | ) 23 | -------------------------------------------------------------------------------- /viu_media/cli/commands/registry/examples.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example usage for the registry command 3 | """ 4 | 5 | main = """ 6 | 7 | Examples: 8 | # Sync with remote AniList 9 | viu registry sync --upload --download 10 | 11 | # Show detailed registry statistics 12 | viu registry stats --detailed 13 | 14 | # Search local registry 15 | viu registry search "attack on titan" 16 | 17 | # Export registry to JSON 18 | viu registry export --format json --output backup.json 19 | 20 | # Import from backup 21 | viu registry import backup.json 22 | 23 | # Clean up orphaned entries 24 | viu registry clean --dry-run 25 | 26 | # Create full backup 27 | viu registry backup --compress 28 | 29 | # Restore from backup 30 | viu registry restore backup.tar.gz 31 | """ 32 | -------------------------------------------------------------------------------- /viu_media/core/downloader/params.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Callable 3 | 4 | 5 | @dataclass(frozen=True) 6 | class DownloadParams: 7 | url: str 8 | anime_title: str 9 | episode_title: str 10 | silent: bool 11 | progress_hooks: list[Callable] = field(default_factory=list) 12 | vid_format: str = "best" 13 | force_unknown_ext: bool = False 14 | verbose: bool = False 15 | headers: dict[str, str] = field(default_factory=dict) 16 | subtitles: list[str] = field(default_factory=list) 17 | merge: bool = False 18 | clean: bool = False 19 | prompt: bool = True 20 | force_ffmpeg: bool = False 21 | hls_use_mpegts: bool = False 22 | hls_use_h264: bool = False 23 | no_check_certificate: bool = True 24 | -------------------------------------------------------------------------------- /completions/viu.bash: -------------------------------------------------------------------------------- 1 | _viu_completion() { 2 | local IFS=$'\n' 3 | local response 4 | 5 | response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD _VIU_COMPLETE=bash_complete $1) 6 | 7 | for completion in $response; do 8 | IFS=',' read type value <<< "$completion" 9 | 10 | if [[ $type == 'dir' ]]; then 11 | COMPREPLY=() 12 | compopt -o dirnames 13 | elif [[ $type == 'file' ]]; then 14 | COMPREPLY=() 15 | compopt -o default 16 | elif [[ $type == 'plain' ]]; then 17 | COMPREPLY+=($value) 18 | fi 19 | done 20 | 21 | return 0 22 | } 23 | 24 | _viu_completion_setup() { 25 | complete -o nosort -F _viu_completion viu 26 | } 27 | 28 | _viu_completion_setup; 29 | 30 | -------------------------------------------------------------------------------- /dev/make_release: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env sh 2 | CLI_DIR="$(dirname "$(realpath "$0")")" 3 | VERSION=$1 4 | [ -z "$VERSION" ] && echo no version provided && exit 1 5 | [ "$VERSION" = "current" ] && viu --version && exit 0 6 | sed -i "s/^version.*/version = \"$VERSION\"/" "$CLI_DIR/pyproject.toml" && 7 | sed -i "s/__version__.*/__version__ = \"v$VERSION\"/" "$CLI_DIR/viu/__init__.py" && 8 | sed -i "s/version = .*/version = \"$VERSION\";/" "$CLI_DIR/flake.nix" && 9 | git stage "$CLI_DIR/pyproject.toml" "$CLI_DIR/viu/__init__.py" "$CLI_DIR/flake.nix" && 10 | git commit -m "chore: bump version (v$VERSION)" && 11 | # nix flake lock && 12 | uv lock && 13 | git stage "$CLI_DIR/flake.lock" "$CLI_DIR/uv.lock" && 14 | git commit -m "chore: update lock files" && 15 | git push && 16 | gh release create "v$VERSION" 17 | -------------------------------------------------------------------------------- /viu_media/core/downloader/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | import httpx 4 | 5 | from ..config.model import DownloadsConfig 6 | from .model import DownloadResult 7 | from .params import DownloadParams 8 | 9 | 10 | class BaseDownloader(ABC): 11 | client: httpx.Client 12 | 13 | def __init__(self, config: DownloadsConfig): 14 | self.config = config 15 | 16 | # Increase timeouts and add retries for robustness 17 | transport = httpx.HTTPTransport(retries=3) 18 | self.client = httpx.Client( 19 | transport=transport, 20 | timeout=httpx.Timeout(15.0, connect=60.0), 21 | follow_redirects=True, 22 | ) 23 | 24 | @abstractmethod 25 | def download(self, params: DownloadParams) -> DownloadResult: 26 | pass 27 | -------------------------------------------------------------------------------- /viu_media/cli/utils/ansi.py: -------------------------------------------------------------------------------- 1 | # Define ANSI escape codes as constants 2 | RESET = "\033[0m" 3 | BOLD = "\033[1m" 4 | INVISIBLE_CURSOR = "\033[?25l" 5 | VISIBLE_CURSOR = "\033[?25h" 6 | UNDERLINE = "\033[4m" 7 | 8 | 9 | def get_true_fg(color: list[str], bold: bool = True) -> str: 10 | """Custom helper function that enables colored text in the terminal 11 | 12 | Args: 13 | bold: whether to bolden the text 14 | string: string to color 15 | r: red 16 | g: green 17 | b: blue 18 | 19 | Returns: 20 | colored string 21 | """ 22 | # NOTE: Currently only supports terminals that support true color 23 | r = color[0] 24 | g = color[1] 25 | b = color[2] 26 | if bold: 27 | return f"{BOLD}\033[38;2;{r};{g};{b};m" 28 | else: 29 | return f"\033[38;2;{r};{g};{b};m" 30 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .....core.constants import GRAPHQL_DIR 4 | 5 | SERVERS_AVAILABLE = [ 6 | "sharepoint", 7 | "dropbox", 8 | "gogoanime", 9 | "weTransfer", 10 | "wixmp", 11 | "Yt", 12 | "mp4-upload", 13 | ] 14 | API_BASE_URL = "allanime.day" 15 | API_GRAPHQL_REFERER = "https://allanime.to/" 16 | API_GRAPHQL_ENDPOINT = f"https://api.{API_BASE_URL}/api/" 17 | 18 | # search constants 19 | DEFAULT_COUNTRY_OF_ORIGIN = "all" 20 | DEFAULT_NSFW = True 21 | DEFAULT_UNKNOWN = True 22 | DEFAULT_PER_PAGE = 40 23 | DEFAULT_PAGE = 1 24 | 25 | # regex stuff 26 | MP4_SERVER_JUICY_STREAM_REGEX = re.compile( 27 | r"video/mp4\",src:\"(https?://.*/video\.mp4)\"" 28 | ) 29 | 30 | # graphql files 31 | _GQL_QUERIES = GRAPHQL_DIR / "allanime" / "queries" 32 | SEARCH_GQL = _GQL_QUERIES / "search.gql" 33 | ANIME_GQL = _GQL_QUERIES / "anime.gql" 34 | EPISODE_GQL = _GQL_QUERIES / "episodes.gql" 35 | -------------------------------------------------------------------------------- /viu_media/assets/scripts/fzf/airing_schedule_info.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from _ansi_utils import ( 3 | print_rule, 4 | print_table_row, 5 | strip_markdown, 6 | wrap_text, 7 | get_terminal_width, 8 | ) 9 | 10 | HEADER_COLOR = sys.argv[1] 11 | SEPARATOR_COLOR = sys.argv[2] 12 | 13 | # Get terminal dimensions 14 | term_width = get_terminal_width() 15 | 16 | # Print title centered 17 | print("{ANIME_TITLE}".center(term_width)) 18 | 19 | rows = [ 20 | ("Total Episodes", "{TOTAL_EPISODES}"), 21 | ] 22 | 23 | print_rule(SEPARATOR_COLOR) 24 | for key, value in rows: 25 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 26 | 27 | rows = [ 28 | ("Upcoming Episodes", "{UPCOMING_EPISODES}"), 29 | ] 30 | 31 | print_rule(SEPARATOR_COLOR) 32 | for key, value in rows: 33 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 34 | 35 | print_rule(SEPARATOR_COLOR) 36 | print(wrap_text(strip_markdown("""{SCHEDULE_TABLE}"""), term_width)) 37 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/wixmp.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisodeStreams 4 | from .base import BaseExtractor 5 | 6 | 7 | class DefaultExtractor(BaseExtractor): 8 | @classmethod 9 | def extract(cls, url, client, episode_number, episode, source): 10 | response = client.get( 11 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 12 | timeout=10, 13 | ) 14 | response.raise_for_status() 15 | streams: AllAnimeEpisodeStreams = response.json() 16 | return Server( 17 | name="wixmp", 18 | links=[ 19 | EpisodeStream( 20 | link=stream["link"], quality="1080", format=stream["resolutionStr"] 21 | ) 22 | for stream in streams["links"] 23 | ], 24 | episode_title=episode["notes"], 25 | headers={"Referer": f"https://{API_BASE_URL}/"}, 26 | ) 27 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/ak.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | class AkExtractor(BaseExtractor): 8 | @classmethod 9 | def extract( 10 | cls, 11 | url, 12 | client, 13 | episode_number: str, 14 | episode: AllAnimeEpisode, 15 | source: AllAnimeSource, 16 | ) -> Server: 17 | response = client.get( 18 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 19 | timeout=10, 20 | ) 21 | response.raise_for_status() 22 | streams = response.json() 23 | 24 | return Server( 25 | name="Ak", 26 | links=[ 27 | EpisodeStream(link=link, quality="1080") for link in streams["links"] 28 | ], 29 | episode_title=episode["notes"], 30 | headers={"Referer": f"https://{API_BASE_URL}/"}, 31 | ) 32 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/dropbox.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | class SakExtractor(BaseExtractor): 8 | @classmethod 9 | def extract( 10 | cls, 11 | url, 12 | client, 13 | episode_number: str, 14 | episode: AllAnimeEpisode, 15 | source: AllAnimeSource, 16 | ) -> Server: 17 | response = client.get( 18 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 19 | timeout=10, 20 | ) 21 | response.raise_for_status() 22 | streams = response.json() 23 | 24 | return Server( 25 | name="dropbox", 26 | links=[ 27 | EpisodeStream(link=link, quality="1080") for link in streams["links"] 28 | ], 29 | episode_title=episode["notes"], 30 | headers={"Referer": f"https://{API_BASE_URL}/"}, 31 | ) 32 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/we_transfer.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | class KirExtractor(BaseExtractor): 8 | @classmethod 9 | def extract( 10 | cls, 11 | url, 12 | client, 13 | episode_number: str, 14 | episode: AllAnimeEpisode, 15 | source: AllAnimeSource, 16 | ) -> Server: 17 | response = client.get( 18 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 19 | timeout=10, 20 | ) 21 | response.raise_for_status() 22 | streams = response.json() 23 | 24 | return Server( 25 | name="weTransfer", 26 | links=[ 27 | EpisodeStream(link=link, quality="1080") for link in streams["links"] 28 | ], 29 | episode_title=episode["notes"], 30 | headers={"Referer": f"https://{API_BASE_URL}/"}, 31 | ) 32 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/streamsb.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | class SsHlsExtractor(BaseExtractor): 8 | @classmethod 9 | def extract( 10 | cls, 11 | url, 12 | client, 13 | episode_number: str, 14 | episode: AllAnimeEpisode, 15 | source: AllAnimeSource, 16 | ) -> Server: 17 | # TODO: requires some serious work i think : ) 18 | response = client.get( 19 | url, 20 | timeout=10, 21 | ) 22 | response.raise_for_status() 23 | streams = response.json()["links"] 24 | 25 | return Server( 26 | name="StreamSb", 27 | links=[ 28 | EpisodeStream(link=link, quality="1080") for link in streams["links"] 29 | ], 30 | episode_title=episode["notes"], 31 | headers={"Referer": f"https://{API_BASE_URL}/"}, 32 | ) 33 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/sharepoint.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisodeStreams 4 | from .base import BaseExtractor 5 | 6 | 7 | class Smp4Extractor(BaseExtractor): 8 | @classmethod 9 | def extract(cls, url, client, episode_number, episode, source): 10 | response = client.get( 11 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 12 | timeout=10, 13 | ) 14 | response.raise_for_status() 15 | streams: AllAnimeEpisodeStreams = response.json() 16 | return Server( 17 | name="sharepoint", 18 | links=[ 19 | EpisodeStream( 20 | link=stream["link"], 21 | quality="1080", 22 | format=stream["resolutionStr"], 23 | ) 24 | for stream in streams["links"] 25 | ], 26 | episode_title=episode["notes"], 27 | headers={"Referer": f"https://{API_BASE_URL}/"}, 28 | ) 29 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/vid_mp4.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | # TODO: requires some serious work i think : ) 8 | class VidMp4Extractor(BaseExtractor): 9 | @classmethod 10 | def extract( 11 | cls, 12 | url, 13 | client, 14 | episode_number: str, 15 | episode: AllAnimeEpisode, 16 | source: AllAnimeSource, 17 | ) -> Server: 18 | response = client.get( 19 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 20 | timeout=10, 21 | ) 22 | response.raise_for_status() 23 | streams = response.json() 24 | 25 | return Server( 26 | name="Vid-mp4", 27 | links=[ 28 | EpisodeStream(link=link, quality="1080") for link in streams["links"] 29 | ], 30 | episode_title=episode["notes"], 31 | headers={"Referer": f"https://{API_BASE_URL}/"}, 32 | ) 33 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/anilist/gql.py: -------------------------------------------------------------------------------- 1 | from ....core.constants import GRAPHQL_DIR 2 | 3 | _ANILIST_PATH = GRAPHQL_DIR / "anilist" 4 | _QUERIES_PATH = _ANILIST_PATH / "queries" 5 | _MUTATIONS_PATH = _ANILIST_PATH / "mutations" 6 | 7 | 8 | SEARCH_MEDIA = _QUERIES_PATH / "search.gql" 9 | SEARCH_USER_MEDIA_LIST = _QUERIES_PATH / "media-list.gql" 10 | 11 | GET_AIRING_SCHEDULE = _QUERIES_PATH / "media-airing-schedule.gql" 12 | GET_MEDIA_CHARACTERS = _QUERIES_PATH / "media-characters.gql" 13 | GET_MEDIA_RECOMMENDATIONS = _QUERIES_PATH / "media-recommendations.gql" 14 | GET_MEDIA_RELATIONS = _QUERIES_PATH / "media-relations.gql" 15 | GET_MEDIA_LIST_ITEM = _QUERIES_PATH / "media-list-item.gql" 16 | 17 | GET_LOGGED_IN_USER = _QUERIES_PATH / "logged-in-user.gql" 18 | GET_NOTIFICATIONS = _QUERIES_PATH / "notifications.gql" 19 | GET_REVIEWS = _QUERIES_PATH / "reviews.gql" 20 | GET_USER_INFO = _QUERIES_PATH / "user-info.gql" 21 | 22 | 23 | DELETE_MEDIA_LIST_ENTRY = _MUTATIONS_PATH / "delete-list-entry.gql" 24 | MARK_NOTIFICATIONS_AS_READ = _MUTATIONS_PATH / "mark-read.gql" 25 | SAVE_MEDIA_LIST_ENTRY = _MUTATIONS_PATH / "media-list.gql" 26 | -------------------------------------------------------------------------------- /viu_media/core/downloader/model.py: -------------------------------------------------------------------------------- 1 | """Download result models for downloader implementations.""" 2 | 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | from pydantic import BaseModel, Field 7 | 8 | 9 | class DownloadResult(BaseModel): 10 | """Result of a download operation.""" 11 | 12 | success: bool = Field(description="Whether the download was successful") 13 | video_path: Optional[Path] = Field( 14 | default=None, description="Path to the downloaded video file" 15 | ) 16 | subtitle_paths: list[Path] = Field( 17 | default_factory=list, description="Paths to downloaded subtitle files" 18 | ) 19 | merged_path: Optional[Path] = Field( 20 | default=None, 21 | description="Path to the merged video+subtitles file if merge was performed", 22 | ) 23 | error_message: Optional[str] = Field( 24 | default=None, description="Error message if download failed" 25 | ) 26 | anime_title: str = Field(description="Title of the anime") 27 | episode_title: str = Field(description="Title of the episode") 28 | 29 | model_config = {"arbitrary_types_allowed": True} 30 | -------------------------------------------------------------------------------- /viu_media/cli/commands/queue/commands/resume.py: -------------------------------------------------------------------------------- 1 | import click 2 | from viu_media.core.config import AppConfig 3 | 4 | 5 | @click.command( 6 | name="resume", help="Submit any queued or in-progress downloads to the worker." 7 | ) 8 | @click.pass_obj 9 | def resume(config: AppConfig): 10 | from viu_media.cli.service.download.service import DownloadService 11 | from viu_media.cli.service.feedback import FeedbackService 12 | from viu_media.cli.service.registry import MediaRegistryService 13 | from viu_media.libs.media_api.api import create_api_client 14 | from viu_media.libs.provider.anime.provider import create_provider 15 | 16 | feedback = FeedbackService(config) 17 | media_api = create_api_client(config.general.media_api, config) 18 | provider = create_provider(config.general.provider) 19 | registry = MediaRegistryService(config.general.media_api, config.media_registry) 20 | download_service = DownloadService(config, registry, media_api, provider) 21 | 22 | download_service.start() 23 | download_service.resume_unfinished_downloads() 24 | feedback.success("Submitted queued downloads to background worker.") 25 | -------------------------------------------------------------------------------- /viu_media/libs/player/params.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the PlayerParams dataclass, which encapsulates all parameters required to launch a media player session. 3 | """ 4 | 5 | from dataclasses import dataclass 6 | from typing import TYPE_CHECKING 7 | 8 | if TYPE_CHECKING: 9 | pass 10 | 11 | 12 | @dataclass(frozen=True) 13 | class PlayerParams: 14 | """ 15 | Parameters for launching a media player session. 16 | 17 | Attributes: 18 | url: The media URL to play. 19 | title: The title to display in the player. 20 | query: The original search query or context. 21 | episode: The episode identifier or label. 22 | syncplay: Whether to enable syncplay (synchronized playback). 23 | subtitles: List of subtitle file paths or URLs. 24 | headers: HTTP headers to include in the request. 25 | start_time: The time offset to start playback from. 26 | """ 27 | 28 | url: str 29 | title: str 30 | query: str 31 | episode: str 32 | syncplay: bool = False 33 | subtitles: list[str] | None = None 34 | headers: dict[str, str] | None = None 35 | start_time: str | None = None 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | workflow_run: 4 | workflows: ["Test Workflow"] 5 | types: 6 | - completed 7 | jobs: 8 | build: 9 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: "Set up Python" 16 | uses: actions/setup-python@v5 17 | 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | with: 21 | enable-cache: true 22 | 23 | - name: Build viu 24 | run: uv build 25 | 26 | - name: Archive production artifacts 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: viu_debug_build 30 | path: | 31 | dist 32 | 33 | - name: Install nix 34 | uses: DeterminateSystems/nix-installer-action@main 35 | 36 | - name: Use GitHub Action built-in cache 37 | uses: DeterminateSystems/magic-nix-cache-action@main 38 | 39 | - name: Nix Flake check (evaluation + tests) 40 | run: nix flake check 41 | 42 | - name: Build the nix derivation 43 | run: nix build 44 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/user-info.gql: -------------------------------------------------------------------------------- 1 | query ($userId: Int) { 2 | User(id: $userId) { 3 | name 4 | about 5 | avatar { 6 | large 7 | medium 8 | } 9 | bannerImage 10 | statistics { 11 | anime { 12 | count 13 | minutesWatched 14 | episodesWatched 15 | genres { 16 | count 17 | meanScore 18 | genre 19 | } 20 | tags { 21 | tag { 22 | id 23 | } 24 | count 25 | meanScore 26 | } 27 | } 28 | manga { 29 | count 30 | meanScore 31 | chaptersRead 32 | volumesRead 33 | tags { 34 | count 35 | meanScore 36 | } 37 | genres { 38 | count 39 | meanScore 40 | } 41 | } 42 | } 43 | favourites { 44 | anime { 45 | nodes { 46 | title { 47 | romaji 48 | english 49 | } 50 | } 51 | } 52 | manga { 53 | nodes { 54 | title { 55 | romaji 56 | english 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/gogoanime.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL 3 | from ..types import AllAnimeEpisode, AllAnimeEpisodeStreams, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | class Lufmp4Extractor(BaseExtractor): 8 | @classmethod 9 | def extract( 10 | cls, 11 | url, 12 | client, 13 | episode_number: str, 14 | episode: AllAnimeEpisode, 15 | source: AllAnimeSource, 16 | ) -> Server: 17 | response = client.get( 18 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 19 | timeout=10, 20 | ) 21 | response.raise_for_status() 22 | streams: AllAnimeEpisodeStreams = response.json() 23 | 24 | return Server( 25 | name="gogoanime", 26 | links=[ 27 | EpisodeStream( 28 | link=stream["link"], quality="1080", format=stream["resolutionStr"] 29 | ) 30 | for stream in streams["links"] 31 | ], 32 | episode_title=episode["notes"], 33 | headers={"Referer": f"https://{API_BASE_URL}/"}, 34 | ) 35 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Workflow 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | python-version: ["3.11", "3.12"] 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Install Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: Install dbus-python build dependencies 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get -y install libdbus-1-dev libglib2.0-dev 29 | 30 | - name: Install uv 31 | uses: astral-sh/setup-uv@v3 32 | with: 33 | enable-cache: true 34 | 35 | - name: Install the project 36 | run: uv sync --all-extras --dev 37 | 38 | - name: Run linter and formater 39 | run: uv run ruff check --output-format=github 40 | 41 | - name: Run type checking 42 | run: uv run pyright 43 | 44 | # TODO: write tests 45 | 46 | # - name: Run tests 47 | # run: uv run pytest tests 48 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import TYPE_CHECKING, ClassVar, Dict 3 | 4 | from .params import AnimeParams, EpisodeStreamsParams, SearchParams 5 | 6 | if TYPE_CHECKING: 7 | from collections.abc import Iterator 8 | 9 | from httpx import Client 10 | 11 | from .types import Anime, SearchResults, Server 12 | 13 | 14 | class BaseAnimeProvider(ABC): 15 | HEADERS: ClassVar[Dict[str, str]] 16 | 17 | def __init_subclass__(cls, **kwargs): 18 | super().__init_subclass__(**kwargs) 19 | if not hasattr(cls, "HEADERS"): 20 | raise TypeError( 21 | "Subclasses of BaseAnimeProvider must define a 'HEADERS' class attribute." 22 | ) 23 | 24 | def __init__(self, client: "Client") -> None: 25 | self.client = client 26 | 27 | @abstractmethod 28 | def search(self, params: SearchParams) -> "SearchResults | None": 29 | pass 30 | 31 | @abstractmethod 32 | def get(self, params: AnimeParams) -> "Anime | None": 33 | pass 34 | 35 | @abstractmethod 36 | def episode_streams( 37 | self, params: EpisodeStreamsParams 38 | ) -> "Iterator[Server] | None": 39 | pass 40 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/selector.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: 4 | from ...core.config import AppConfig 5 | 6 | from .base import BaseSelector 7 | 8 | SELECTORS = ["fzf", "rofi", "default"] 9 | 10 | 11 | class SelectorFactory: 12 | @staticmethod 13 | def create(config: "AppConfig") -> BaseSelector: 14 | """ 15 | Factory to create a selector instance based on the configuration. 16 | """ 17 | selector_name = config.general.selector 18 | 19 | if selector_name not in SELECTORS: 20 | raise ValueError( 21 | f"Unsupported selector: '{selector_name}'.Available selectors are: {SELECTORS}" 22 | ) 23 | 24 | # Instantiate the class, passing the relevant config section 25 | if selector_name == "fzf": 26 | from .fzf import FzfSelector 27 | 28 | return FzfSelector(config.fzf) 29 | if selector_name == "rofi": 30 | from .rofi import RofiSelector 31 | 32 | return RofiSelector(config.rofi) 33 | 34 | from .inquirer import InquirerSelector 35 | 36 | return InquirerSelector() 37 | 38 | 39 | # Simple alias for ease of use 40 | create_selector = SelectorFactory.create 41 | -------------------------------------------------------------------------------- /viu_media/cli/service/player/ipc/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from .....core.config import StreamConfig 5 | from .....libs.media_api.types import MediaItem 6 | from .....libs.player.base import BasePlayer 7 | from .....libs.player.params import PlayerParams 8 | from .....libs.player.types import PlayerResult 9 | from .....libs.provider.anime.base import BaseAnimeProvider 10 | from .....libs.provider.anime.types import Anime 11 | from ....service.registry import MediaRegistryService 12 | 13 | 14 | class BaseIPCPlayer(ABC): 15 | """ 16 | Abstract Base Class defining the contract for all media players with ipc control. 17 | """ 18 | 19 | def __init__(self, stream_config: StreamConfig): 20 | self.stream_config = stream_config 21 | 22 | @abstractmethod 23 | def play( 24 | self, 25 | player: BasePlayer, 26 | player_params: PlayerParams, 27 | provider: Optional[BaseAnimeProvider] = None, 28 | anime: Optional[Anime] = None, 29 | registry: Optional[MediaRegistryService] = None, 30 | media_item: Optional[MediaItem] = None, 31 | ) -> PlayerResult: 32 | """ 33 | Plays the given media URL. 34 | """ 35 | pass 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /viu_media/assets/scripts/fzf/character_info.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from _ansi_utils import ( 3 | print_rule, 4 | print_table_row, 5 | strip_markdown, 6 | wrap_text, 7 | get_terminal_width, 8 | ) 9 | 10 | HEADER_COLOR = sys.argv[1] 11 | SEPARATOR_COLOR = sys.argv[2] 12 | 13 | # Get terminal dimensions 14 | term_width = get_terminal_width() 15 | 16 | # Print title centered 17 | print("{CHARACTER_NAME}".center(term_width)) 18 | 19 | rows = [ 20 | ("Native Name", "{CHARACTER_NATIVE_NAME}"), 21 | ("Gender", "{CHARACTER_GENDER}"), 22 | ] 23 | 24 | print_rule(SEPARATOR_COLOR) 25 | for key, value in rows: 26 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 27 | 28 | rows = [ 29 | ("Age", "{CHARACTER_AGE}"), 30 | ("Blood Type", "{CHARACTER_BLOOD_TYPE}"), 31 | ] 32 | 33 | print_rule(SEPARATOR_COLOR) 34 | for key, value in rows: 35 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 36 | 37 | rows = [ 38 | ("Birthday", "{CHARACTER_BIRTHDAY}"), 39 | ("Favourites", "{CHARACTER_FAVOURITES}"), 40 | ] 41 | 42 | print_rule(SEPARATOR_COLOR) 43 | for key, value in rows: 44 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 45 | 46 | print_rule(SEPARATOR_COLOR) 47 | print(wrap_text(strip_markdown("""{CHARACTER_DESCRIPTION}"""), term_width)) 48 | -------------------------------------------------------------------------------- /viu_media/cli/utils/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.handlers import RotatingFileHandler 3 | from pathlib import Path 4 | 5 | from ...core.constants import LOG_FILE 6 | 7 | root_logger = logging.getLogger() 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def setup_logging(log: bool | None) -> None: 12 | """Configures the application's logging based on CLI flags.""" 13 | 14 | _setup_default_logger() 15 | if log: 16 | from rich.logging import RichHandler 17 | 18 | root_logger.addHandler(RichHandler()) 19 | logger.info("Rich logging initialized.") 20 | 21 | 22 | def _setup_default_logger( 23 | log_file_path: Path = LOG_FILE, 24 | max_bytes=10 * 1024 * 1024, # 10mb 25 | backup_count=5, 26 | level=logging.DEBUG, 27 | ): 28 | root_logger.setLevel(level) 29 | 30 | formatter = logging.Formatter( 31 | "%(asctime)s - [%(process)d:%(thread)d] - %(levelname)-8s - %(name)s - %(filename)s:%(lineno)d - %(message)s" 32 | ) 33 | 34 | file_handler = RotatingFileHandler( 35 | log_file_path, 36 | maxBytes=max_bytes, 37 | backupCount=backup_count, 38 | encoding="utf-8", 39 | ) 40 | file_handler.setLevel(level) 41 | file_handler.setFormatter(formatter) 42 | root_logger.addHandler(file_handler) 43 | -------------------------------------------------------------------------------- /completions/viu.zsh: -------------------------------------------------------------------------------- 1 | #compdef viu 2 | 3 | _viu_completion() { 4 | local -a completions 5 | local -a completions_with_descriptions 6 | local -a response 7 | (( ! $+commands[viu] )) && return 1 8 | 9 | response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) _VIU_COMPLETE=zsh_complete viu)}") 10 | 11 | for type key descr in ${response}; do 12 | if [[ "$type" == "plain" ]]; then 13 | if [[ "$descr" == "_" ]]; then 14 | completions+=("$key") 15 | else 16 | completions_with_descriptions+=("$key":"$descr") 17 | fi 18 | elif [[ "$type" == "dir" ]]; then 19 | _path_files -/ 20 | elif [[ "$type" == "file" ]]; then 21 | _path_files -f 22 | fi 23 | done 24 | 25 | if [ -n "$completions_with_descriptions" ]; then 26 | _describe -V unsorted completions_with_descriptions -U 27 | fi 28 | 29 | if [ -n "$completions" ]; then 30 | compadd -U -V unsorted -a completions 31 | fi 32 | } 33 | 34 | if [[ $zsh_eval_context[-1] == loadautofunc ]]; then 35 | # autoload from fpath, call function directly 36 | _viu_completion "$@" 37 | else 38 | # eval/source/. command, register function for later 39 | compdef _viu_completion viu 40 | fi 41 | 42 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-relations.gql: -------------------------------------------------------------------------------- 1 | query ($id: Int, $format_not_in: [MediaFormat]) { 2 | Media(id: $id, format_not_in: $format_not_in) { 3 | relations { 4 | nodes { 5 | id 6 | idMal 7 | type 8 | format 9 | title { 10 | english 11 | romaji 12 | native 13 | } 14 | coverImage { 15 | medium 16 | large 17 | } 18 | mediaListEntry { 19 | status 20 | id 21 | progress 22 | } 23 | description 24 | episodes 25 | duration 26 | trailer { 27 | site 28 | id 29 | } 30 | genres 31 | synonyms 32 | averageScore 33 | popularity 34 | streamingEpisodes { 35 | title 36 | thumbnail 37 | } 38 | favourites 39 | tags { 40 | name 41 | } 42 | startDate { 43 | year 44 | month 45 | day 46 | } 47 | endDate { 48 | year 49 | month 50 | day 51 | } 52 | status 53 | nextAiringEpisode { 54 | timeUntilAiring 55 | airingAt 56 | episode 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/params.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal, Optional 3 | 4 | 5 | @dataclass(frozen=True) 6 | class SearchParams: 7 | """Parameters for searching anime.""" 8 | 9 | query: str 10 | 11 | # pagination and sorting 12 | current_page: int = 1 13 | page_limit: int = 20 14 | sort_by: str = "relevance" 15 | order: Literal["asc", "desc"] = "desc" 16 | 17 | # filters 18 | translation_type: Literal["sub", "dub"] = "sub" 19 | genre: Optional[str] = None 20 | year: Optional[int] = None 21 | status: Optional[str] = None 22 | allow_nsfw: bool = True 23 | allow_unknown: bool = True 24 | country_of_origin: Optional[str] = None 25 | 26 | 27 | @dataclass(frozen=True) 28 | class EpisodeStreamsParams: 29 | """Parameters for fetching episode streams.""" 30 | 31 | query: str 32 | anime_id: str 33 | episode: str 34 | translation_type: Literal["sub", "dub"] = "sub" 35 | server: Optional[str] = None 36 | quality: Literal["1080", "720", "480", "360"] = "720" 37 | subtitles: bool = True 38 | 39 | 40 | @dataclass(frozen=True) 41 | class AnimeParams: 42 | """Parameters for fetching anime details.""" 43 | 44 | id: str 45 | # HACK: for the sake of providers which require previous data 46 | query: str 47 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-recommendations.gql: -------------------------------------------------------------------------------- 1 | query ($id: Int, $page: Int, $per_page: Int) { 2 | Page(perPage: $per_page, page: $page) { 3 | recommendations(mediaRecommendationId: $id) { 4 | media { 5 | id 6 | idMal 7 | format 8 | mediaListEntry { 9 | status 10 | id 11 | progress 12 | } 13 | title { 14 | english 15 | romaji 16 | native 17 | } 18 | coverImage { 19 | medium 20 | large 21 | } 22 | description 23 | episodes 24 | duration 25 | trailer { 26 | site 27 | id 28 | } 29 | genres 30 | synonyms 31 | averageScore 32 | popularity 33 | streamingEpisodes { 34 | title 35 | thumbnail 36 | } 37 | favourites 38 | tags { 39 | name 40 | } 41 | startDate { 42 | year 43 | month 44 | day 45 | } 46 | endDate { 47 | year 48 | month 49 | day 50 | } 51 | status 52 | nextAiringEpisode { 53 | timeUntilAiring 54 | airingAt 55 | episode 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /viu_media/assets/scripts/fzf/episode_info.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from _ansi_utils import print_rule, print_table_row, get_terminal_width 3 | 4 | HEADER_COLOR = sys.argv[1] 5 | SEPARATOR_COLOR = sys.argv[2] 6 | 7 | # Get terminal dimensions 8 | term_width = get_terminal_width() 9 | 10 | # Print title centered 11 | print("{TITLE}".center(term_width)) 12 | 13 | rows = [ 14 | ("Duration", "{DURATION}"), 15 | ("Status", "{STATUS}"), 16 | ] 17 | 18 | print_rule(SEPARATOR_COLOR) 19 | for key, value in rows: 20 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 21 | 22 | rows = [ 23 | ("Total Episodes", "{EPISODES}"), 24 | ("Next Episode", "{NEXT_EPISODE}"), 25 | ] 26 | 27 | print_rule(SEPARATOR_COLOR) 28 | for key, value in rows: 29 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 30 | 31 | rows = [ 32 | ("Progress", "{USER_PROGRESS}"), 33 | ("List Status", "{USER_STATUS}"), 34 | ] 35 | 36 | print_rule(SEPARATOR_COLOR) 37 | for key, value in rows: 38 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 39 | 40 | rows = [ 41 | ("Start Date", "{START_DATE}"), 42 | ("End Date", "{END_DATE}"), 43 | ] 44 | 45 | print_rule(SEPARATOR_COLOR) 46 | for key, value in rows: 47 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 48 | 49 | print_rule(SEPARATOR_COLOR) 50 | -------------------------------------------------------------------------------- /viu_media/cli/commands/queue/commands/clear.py: -------------------------------------------------------------------------------- 1 | import click 2 | from viu_media.core.config import AppConfig 3 | 4 | 5 | @click.command( 6 | name="clear", 7 | help="Clear queued items from the registry (QUEUED -> NOT_DOWNLOADED).", 8 | ) 9 | @click.option("--force", is_flag=True, help="Do not prompt for confirmation.") 10 | @click.pass_obj 11 | def clear_cmd(config: AppConfig, force: bool): 12 | from viu_media.cli.service.feedback import FeedbackService 13 | from viu_media.cli.service.registry import MediaRegistryService 14 | from viu_media.cli.service.registry.models import DownloadStatus 15 | 16 | feedback = FeedbackService(config) 17 | registry = MediaRegistryService(config.general.media_api, config.media_registry) 18 | 19 | if not force and not click.confirm("This will clear all queued items. Continue?"): 20 | feedback.info("Aborted.") 21 | return 22 | 23 | cleared = 0 24 | queued = registry.get_episodes_by_download_status(DownloadStatus.QUEUED) 25 | for media_id, ep in queued: 26 | ok = registry.update_episode_download_status( 27 | media_id=media_id, 28 | episode_number=ep, 29 | status=DownloadStatus.NOT_DOWNLOADED, 30 | ) 31 | if ok: 32 | cleared += 1 33 | feedback.success(f"Cleared {cleared} queued episode(s).") 34 | -------------------------------------------------------------------------------- /viu_media/cli/utils/search.py: -------------------------------------------------------------------------------- 1 | """Search functionality.""" 2 | 3 | from viu_media.core.utils.fuzzy import fuzz 4 | from viu_media.core.utils.normalizer import normalize_title 5 | from viu_media.libs.provider.anime.types import SearchResult, ProviderName 6 | from viu_media.libs.media_api.types import MediaItem 7 | 8 | 9 | def find_best_match_title( 10 | provider_results_map: dict[str, SearchResult], 11 | provider: ProviderName, 12 | media_item: MediaItem, 13 | ) -> str: 14 | """Find the best match title using fuzzy matching for both the english AND romaji title. 15 | 16 | Parameters: 17 | provider_results_map (dict[str, SearchResult]): The map of provider results. 18 | provider (ProviderName): The provider name from the config. 19 | media_item (MediaItem): The media item to match. 20 | 21 | Returns: 22 | str: The best match title. 23 | """ 24 | return max( 25 | provider_results_map.keys(), 26 | key=lambda p_title: max( 27 | fuzz.ratio( 28 | normalize_title(p_title, provider.value).lower(), 29 | (media_item.title.romaji or "").lower(), 30 | ), 31 | fuzz.ratio( 32 | normalize_title(p_title, provider.value).lower(), 33 | (media_item.title.english or "").lower(), 34 | ), 35 | ), 36 | ) 37 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/cmd.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ...utils.lazyloader import LazyGroup 4 | from . import examples 5 | 6 | commands = { 7 | # "trending": "trending.trending", 8 | # "recent": "recent.recent", 9 | "search": "search.search", 10 | "download": "download.download", 11 | "downloads": "downloads.downloads", 12 | "auth": "auth.auth", 13 | "stats": "stats.stats", 14 | "notifications": "notifications.notifications", 15 | } 16 | 17 | 18 | @click.group( 19 | cls=LazyGroup, 20 | name="anilist", 21 | root="viu_media.cli.commands.anilist.commands", 22 | invoke_without_command=True, 23 | help="A beautiful interface that gives you access to a commplete streaming experience", 24 | short_help="Access all streaming options", 25 | lazy_subcommands=commands, 26 | epilog=examples.main, 27 | ) 28 | @click.option( 29 | "--resume", is_flag=True, help="Resume from the last session (Not yet implemented)." 30 | ) 31 | @click.pass_context 32 | def anilist(ctx: click.Context, resume: bool): 33 | """ 34 | The entry point for the 'anilist' command. If no subcommand is invoked, 35 | it launches the interactive TUI mode. 36 | """ 37 | from ...interactive.session import session 38 | 39 | config = ctx.obj 40 | 41 | if ctx.invoked_subcommand is None: 42 | session.load_menus_from_folder("media") 43 | session.run(config, resume=resume) 44 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/mp4_upload.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import MP4_SERVER_JUICY_STREAM_REGEX 3 | from ..utils import logger 4 | from .base import BaseExtractor 5 | 6 | 7 | class Mp4Extractor(BaseExtractor): 8 | @classmethod 9 | def extract(cls, url, client, episode_number, episode, source): 10 | response = client.get(url, timeout=10, follow_redirects=True) 11 | response.raise_for_status() 12 | 13 | embed_html = response.text.replace(" ", "").replace("\n", "") 14 | 15 | # NOTE: some of the video were deleted so the embed html will just be "Filewasdeleted" 16 | vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html) 17 | if not vid: 18 | if embed_html == "Filewasdeleted": 19 | logger.debug( 20 | "Failed to extract stream url from mp4-uploads. Reason: Filewasdeleted" 21 | ) 22 | return 23 | logger.debug( 24 | f"Failed to extract stream url from mp4-uploads. Reason: unknown. Embed html: {embed_html}" 25 | ) 26 | return 27 | return Server( 28 | name="mp4-upload", 29 | links=[EpisodeStream(link=vid.group(1), quality="1080")], 30 | episode_title=episode["notes"], 31 | headers={"Referer": "https://www.mp4upload.com/"}, 32 | ) 33 | -------------------------------------------------------------------------------- /viu_media/assets/normalizer.json: -------------------------------------------------------------------------------- 1 | { 2 | "allanime": { 3 | "1P": "one piece", 4 | "Magia Record: Mahou Shoujo Madoka☆Magica Gaiden (TV)": "Mahou Shoujo Madoka☆Magica", 5 | "Dungeon ni Deai o Motomeru no wa Machigatte Iru Darouka": "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka", 6 | "Hazurewaku no \"Joutai Ijou Skill\" de Saikyou ni Natta Ore ga Subete wo Juurin suru made": "Hazure Waku no [Joutai Ijou Skill] de Saikyou ni Natta Ore ga Subete wo Juurin Suru made", 7 | "Re:Zero kara Hajimeru Isekai Seikatsu Season 3": "Re:Zero kara Hajimeru Isekai Seikatsu 3rd Season", 8 | "Hanka×Hanka (2011)": "Hunter × Hunter (2011)" 9 | }, 10 | "hianime": { 11 | "My Star": "Oshi no Ko" 12 | }, 13 | "animepahe": { 14 | "Azumanga Daiou The Animation": "Azumanga Daioh", 15 | "Mairimashita! Iruma-kun 2nd Season": "Mairimashita! Iruma-kun 2", 16 | "Mairimashita! Iruma-kun 3rd Season": "Mairimashita! Iruma-kun 3" 17 | }, 18 | "animeunity": { 19 | "Kaiju No. 8": "Kaiju No.8", 20 | "Naruto Shippuden": "Naruto: Shippuden", 21 | "Psycho-Pass: Sinners of the System Case.1 - Crime and Punishment": "PSYCHO-PASS Sinners of the System: Case.1 Crime and Punishment", 22 | "Psycho-Pass: Sinners of the System Case.2 - First Guardian": "PSYCHO-PASS Sinners of the System: Case.2 First Guardian", 23 | "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" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /viu_media/core/utils/graphql.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from httpx import Client, Response 7 | 8 | from .networking import TIMEOUT 9 | 10 | if TYPE_CHECKING: 11 | from httpx import Client 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def load_graphql_from_file(file: Path) -> str: 17 | """ 18 | Reads and returns the content of a .gql file. 19 | 20 | Args: 21 | file: The Path object pointing to the .gql file. 22 | 23 | Returns: 24 | The string content of the file. 25 | """ 26 | try: 27 | return file.read_text(encoding="utf-8") 28 | except FileNotFoundError: 29 | logger.error(f"GraphQL file not found at: {file}") 30 | raise 31 | 32 | 33 | def execute_graphql_query_with_get_request( 34 | url: str, httpx_client: Client, graphql_file: Path, variables: dict 35 | ) -> Response: 36 | query = load_graphql_from_file(graphql_file) 37 | params = {"query": query, "variables": json.dumps(variables)} 38 | response = httpx_client.get(url, params=params, timeout=TIMEOUT) 39 | return response 40 | 41 | 42 | def execute_graphql( 43 | url: str, httpx_client: Client, graphql_file: Path, variables: dict 44 | ) -> Response: 45 | query = load_graphql_from_file(graphql_file) 46 | json_body = {"query": query, "variables": variables} 47 | response = httpx_client.post(url, json=json_body, timeout=TIMEOUT) 48 | return response 49 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | release-build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10" 20 | 21 | - name: Install uv 22 | uses: astral-sh/setup-uv@v3 23 | with: 24 | enable-cache: true 25 | 26 | - name: Build viu 27 | run: uv build 28 | 29 | - name: Upload distributions 30 | uses: actions/upload-artifact@v4 31 | with: 32 | name: release-dists 33 | path: dist/ 34 | 35 | pypi-publish: 36 | runs-on: ubuntu-latest 37 | 38 | needs: 39 | - release-build 40 | 41 | permissions: 42 | # IMPORTANT: this permission is mandatory for trusted publishing 43 | id-token: write 44 | 45 | # Dedicated environments with protections for publishing are strongly recommended. 46 | environment: 47 | name: pypi 48 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 49 | # url: https://pypi.org/p/YOURPROJECT 50 | 51 | steps: 52 | - name: Retrieve release distributions 53 | uses: actions/download-artifact@v4 54 | with: 55 | name: release-dists 56 | path: dist/ 57 | 58 | - name: Publish release distributions to PyPI 59 | uses: pypa/gh-action-pypi-publish@release/v1 60 | -------------------------------------------------------------------------------- /viu_media/core/downloader/downloader.py: -------------------------------------------------------------------------------- 1 | from ..config.model import DownloadsConfig 2 | from ..exceptions import ViuError 3 | from .base import BaseDownloader 4 | 5 | DOWNLOADERS = ["auto", "default", "yt-dlp"] 6 | 7 | 8 | class DownloadFactory: 9 | @staticmethod 10 | def create(config: DownloadsConfig) -> BaseDownloader: 11 | """ 12 | Factory to create a downloader instance based on the configuration. 13 | """ 14 | downloader_name = config.downloader 15 | if downloader_name not in DOWNLOADERS: 16 | raise ViuError( 17 | f"Unsupported selector: '{downloader_name}'.Available selectors are: {DOWNLOADERS}" 18 | ) 19 | 20 | if downloader_name == "yt-dlp": 21 | from .yt_dlp import YtDLPDownloader 22 | 23 | return YtDLPDownloader(config) 24 | elif downloader_name == "default": 25 | from .default import DefaultDownloader 26 | 27 | return DefaultDownloader(config) 28 | elif downloader_name == "auto": 29 | # Auto mode: prefer yt-dlp if available, fallback to default 30 | try: 31 | from .yt_dlp import YtDLPDownloader 32 | 33 | return YtDLPDownloader(config) 34 | except ImportError: 35 | from .default import DefaultDownloader 36 | 37 | return DefaultDownloader(config) 38 | else: 39 | raise ViuError("Downloader not implemented") 40 | 41 | 42 | # Simple alias for ease of use 43 | create_downloader = DownloadFactory.create 44 | -------------------------------------------------------------------------------- /viu_media/core/utils/converter.py: -------------------------------------------------------------------------------- 1 | def time_to_seconds(time_str: str) -> int: 2 | """Convert HH:MM:SS to seconds.""" 3 | try: 4 | parts = time_str.split(":") 5 | if len(parts) == 3: 6 | h, m, s = map(int, parts) 7 | return h * 3600 + m * 60 + s 8 | except (ValueError, AttributeError): 9 | pass 10 | return 0 11 | 12 | 13 | def calculate_completion_percentage(last_watch_time: str, total_duration: str) -> float: 14 | """ 15 | Calculates the percentage completion based on last watch time and total duration. 16 | 17 | Args: 18 | last_watch_time: A string representing the last watched time in 'HH:MM:SS' format. 19 | total_duration: A string representing the total duration in 'HH:MM:SS' format. 20 | 21 | Returns: 22 | A float representing the percentage completion (0.0 to 100.0). 23 | Returns 0.0 if total_duration is '00:00:00'. 24 | Caps the percentage at 100.0 if last_watch_time exceeds total_duration. 25 | 26 | Raises: 27 | ValueError: If the input time strings are not in the expected format. 28 | """ 29 | last_watch_seconds = time_to_seconds(last_watch_time) 30 | total_duration_seconds = time_to_seconds(total_duration) 31 | 32 | if total_duration_seconds == 0: 33 | return 0.0 # Avoid division by zero, return 0% for zero duration 34 | 35 | # Calculate raw percentage 36 | percentage = (last_watch_seconds / total_duration_seconds) * 100.0 37 | 38 | # Ensure percentage does not exceed 100% 39 | return min(percentage, 100.0) 40 | -------------------------------------------------------------------------------- /viu_media/core/utils/detect.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import shutil 4 | import sys 5 | 6 | 7 | def is_running_in_termux(): 8 | # Check environment variables 9 | if os.environ.get("TERMUX_VERSION") is not None: 10 | return True 11 | 12 | # Check Python installation path 13 | if sys.prefix.startswith("/data/data/com.termux/files/usr"): 14 | return True 15 | 16 | # Check for Termux-specific binary 17 | if os.path.exists("/data/data/com.termux/files/usr/bin/termux-info"): 18 | return True 19 | 20 | return False 21 | 22 | 23 | def is_bash_script(text: str) -> bool: 24 | # Normalize line endings 25 | text = text.strip() 26 | 27 | # Check for shebang at the top 28 | if text.startswith("#!/bin/bash") or text.startswith("#!/usr/bin/env bash"): 29 | return True 30 | 31 | # Look for common bash syntax/keywords 32 | bash_keywords = [ 33 | r"\becho\b", 34 | r"\bfi\b", 35 | r"\bthen\b", 36 | r"\bfunction\b", 37 | r"\bfor\b", 38 | r"\bwhile\b", 39 | r"\bdone\b", 40 | r"\bcase\b", 41 | r"\besac\b", 42 | r"\$\(", 43 | r"\[\[", 44 | r"\]\]", 45 | r";;", 46 | ] 47 | 48 | # Score based on matches 49 | matches = sum(bool(re.search(pattern, text)) for pattern in bash_keywords) 50 | return matches >= 2 51 | 52 | 53 | def is_running_kitty_terminal() -> bool: 54 | return True if os.environ.get("KITTY_WINDOW_ID") else False 55 | 56 | 57 | def has_fzf() -> bool: 58 | return True if shutil.which("fzf") else False 59 | -------------------------------------------------------------------------------- /bundle/pyinstaller.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | from PyInstaller.utils.hooks import collect_data_files, collect_submodules 3 | 4 | block_cipher = None 5 | 6 | # Collect all required data files 7 | datas = [ 8 | ('viu/assets/*', 'viu/assets'), 9 | ] 10 | 11 | # Collect all required hidden imports 12 | hiddenimports = [ 13 | 'click', 14 | 'rich', 15 | 'requests', 16 | 'yt_dlp', 17 | 'python_mpv', 18 | 'fuzzywuzzy', 19 | 'viu', 20 | ] + collect_submodules('viu') 21 | 22 | a = Analysis( 23 | ['./viu/viu.py'], # Changed entry point 24 | pathex=[], 25 | binaries=[], 26 | datas=datas, 27 | hiddenimports=hiddenimports, 28 | hookspath=[], 29 | hooksconfig={}, 30 | runtime_hooks=[], 31 | excludes=[], 32 | win_no_prefer_redirects=False, 33 | win_private_assemblies=False, 34 | cipher=block_cipher, 35 | strip=True, # Strip debug information 36 | optimize=2 # Optimize bytecode noarchive=False 37 | ) 38 | 39 | pyz = PYZ( 40 | a.pure, 41 | a.zipped_data, 42 | optimize=2 # Optimize bytecode cipher=block_cipher 43 | ) 44 | 45 | exe = EXE( 46 | pyz, 47 | a.scripts, 48 | a.binaries, 49 | a.zipfiles, 50 | a.datas, 51 | [], 52 | name='viu', 53 | debug=False, 54 | bootloader_ignore_signals=False, 55 | strip=True, 56 | upx=True, 57 | upx_exclude=[], 58 | runtime_tmpdir=None, 59 | console=True, 60 | disable_windowed_traceback=False, 61 | target_arch=None, 62 | codesign_identity=None, 63 | entitlements_file=None, 64 | icon='viu/assets/logo.ico' 65 | ) 66 | -------------------------------------------------------------------------------- /viu_media/libs/selectors/inquirer/selector.py: -------------------------------------------------------------------------------- 1 | from InquirerPy.prompts import FuzzyPrompt # pyright: ignore[reportPrivateImportUsage] 2 | from rich.prompt import Confirm, Prompt 3 | 4 | from ..base import BaseSelector 5 | 6 | 7 | class InquirerSelector(BaseSelector): 8 | def choose(self, prompt, choices, *, preview=None, header=None): 9 | if header: 10 | print(f"[bold cyan]{header}[/bold cyan]") 11 | return FuzzyPrompt( 12 | message=prompt, 13 | choices=choices, 14 | height="100%", 15 | border=True, 16 | validate=lambda result: result in choices, 17 | ).execute() 18 | 19 | def confirm(self, prompt, *, default=False): 20 | return Confirm.ask(prompt, default=default) 21 | 22 | def ask(self, prompt, *, default=None): 23 | return Prompt.ask(prompt=prompt, default=default or None) 24 | 25 | def choose_multiple( 26 | self, prompt: str, choices: list[str], preview: str | None = None 27 | ) -> list[str]: 28 | return FuzzyPrompt( 29 | message=prompt, 30 | choices=choices, 31 | height="100%", 32 | multiselect=True, 33 | border=True, 34 | ).execute() 35 | 36 | 37 | if __name__ == "__main__": 38 | selector = InquirerSelector() 39 | choice = selector.ask("Hello dev :)") 40 | print(choice) 41 | choice = selector.confirm("Hello dev :)") 42 | print(choice) 43 | choice = selector.choose_multiple("What comes first", ["a", "b"]) 44 | print(choice) 45 | choice = selector.choose("What comes first", ["a", "b"]) 46 | print(choice) 47 | -------------------------------------------------------------------------------- /viu_media/libs/player/player.py: -------------------------------------------------------------------------------- 1 | """ 2 | Player factory and registration logic for Viu media players. 3 | 4 | This module provides a factory for instantiating the correct player implementation based on configuration. 5 | """ 6 | 7 | from ...core.config import AppConfig 8 | from .base import BasePlayer 9 | 10 | PLAYERS = ["mpv", "vlc", "syncplay"] 11 | 12 | 13 | class PlayerFactory: 14 | """ 15 | Factory for creating player instances based on configuration. 16 | """ 17 | 18 | @staticmethod 19 | def create(config: AppConfig) -> BasePlayer: 20 | """ 21 | Create a player instance based on the configured player name. 22 | 23 | Args: 24 | config: The full application configuration object. 25 | 26 | Returns: 27 | BasePlayer: An instance of a class that inherits from BasePlayer. 28 | 29 | Raises: 30 | ValueError: If the player_name is not supported. 31 | NotImplementedError: If the player is recognized but not yet implemented. 32 | """ 33 | player_name = config.stream.player 34 | 35 | if player_name not in PLAYERS: 36 | raise ValueError( 37 | f"Unsupported player: '{player_name}'. Supported players are: {PLAYERS}" 38 | ) 39 | 40 | if player_name == "mpv": 41 | from .mpv.player import MpvPlayer 42 | 43 | return MpvPlayer(config.mpv) 44 | raise NotImplementedError( 45 | f"Configuration logic for player '{player_name}' not implemented in factory." 46 | ) 47 | 48 | 49 | # Alias for convenient player creation 50 | create_player = PlayerFactory.create 51 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "viu-media" 3 | version = "3.3.3" 4 | description = "A browser anime site experience from the terminal" 5 | license = "UNLICENSE" 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "click>=8.1.7", 10 | "httpx>=0.28.1", 11 | "inquirerpy>=0.3.4", 12 | "pydantic>=2.11.7", 13 | "rich>=13.9.2", 14 | ] 15 | 16 | [project.scripts] 17 | viu = 'viu_media:Cli' 18 | 19 | [project.optional-dependencies] 20 | standard = [ 21 | "thefuzz>=0.22.1", 22 | "yt-dlp>=2025.7.21", 23 | "pycryptodomex>=3.23.0", 24 | "pypiwin32; sys_platform == 'win32'", # For Windows-specific functionality 25 | "pyobjc; sys_platform == 'darwin'", # For macOS-specific functionality 26 | "dbus-python; sys_platform == 'linux'", # For Linux-specific functionality (e.g., notifications), 27 | "plyer>=2.1.0", 28 | "lxml>=6.0.0" 29 | ] 30 | notifications = [ 31 | "dbus-python>=1.4.0", 32 | "plyer>=2.1.0", 33 | ] 34 | mpv = [ 35 | "mpv>=1.0.7", 36 | ] 37 | torrent = ["libtorrent>=2.0.11"] 38 | lxml = ["lxml>=6.0.0"] 39 | discord = ["pypresence>=4.3.0"] 40 | download = [ 41 | "pycryptodomex>=3.23.0", 42 | "yt-dlp>=2025.7.21", 43 | ] 44 | torrents = [ 45 | "libtorrent>=2.0.11", 46 | ] 47 | 48 | [build-system] 49 | requires = ["hatchling"] 50 | build-backend = "hatchling.build" 51 | 52 | [dependency-groups] 53 | dev = [ 54 | "pre-commit>=4.0.1", 55 | "pyinstaller>=6.11.1", 56 | "pyright>=1.1.384", 57 | "pytest>=8.3.3", 58 | "pytest-httpx>=0.35.0", 59 | "ruff>=0.6.9", 60 | ] 61 | 62 | [tool.pytest.ini_options] 63 | markers = [ 64 | "integration: marks tests as integration tests that require a live network connection", 65 | ] 66 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animeunity/extractor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .constants import ( 4 | DOWNLOAD_FILENAME_REGEX, 5 | DOWNLOAD_URL_REGEX, 6 | QUALITY_REGEX, 7 | VIDEO_INFO_CLEAN_REGEX, 8 | VIDEO_INFO_REGEX, 9 | ) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def extract_server_info(html_content: str, episode_title: str | None) -> dict | None: 15 | """ 16 | Extracts server information from the VixCloud/AnimeUnity embed page. 17 | Handles extraction from both window.video object and download URL. 18 | """ 19 | video_info = VIDEO_INFO_REGEX.search(html_content) 20 | download_url_match = DOWNLOAD_URL_REGEX.search(html_content) 21 | 22 | if not (download_url_match and video_info): 23 | return None 24 | 25 | info_str = VIDEO_INFO_CLEAN_REGEX.sub(r'"\1"', video_info.group(1)) 26 | 27 | # Use eval context for JS constants 28 | ctx = {"null": None, "true": True, "false": False} 29 | try: 30 | info = eval(info_str, ctx) 31 | except Exception as e: 32 | logger.error(f"Failed to parse JS object: {e}") 33 | return None 34 | 35 | download_url = download_url_match.group(1) 36 | info["link"] = download_url 37 | 38 | # Extract metadata from download URL if missing in window.video 39 | if filename_match := DOWNLOAD_FILENAME_REGEX.search(download_url): 40 | info["name"] = filename_match.group(1) 41 | else: 42 | info["name"] = f"{episode_title or 'Unknown'}" 43 | 44 | if quality_match := QUALITY_REGEX.search(download_url): 45 | # "720p" -> 720 46 | info["quality"] = int(quality_match.group(1)[:-1]) 47 | else: 48 | info["quality"] = 0 # Fallback 49 | 50 | return info 51 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1756386758, 24 | "narHash": "sha256-1wxxznpW2CKvI9VdniaUnTT2Os6rdRJcRUf65ZK9OtE=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "dfb2f12e899db4876308eba6d93455ab7da304cd", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /viu_media/cli/utils/lazyloader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import click 4 | 5 | 6 | # TODO: since command structure is pretty obvious default to only requiring mapping of command names to their function name(cause some have special names like import) 7 | class LazyGroup(click.Group): 8 | def __init__(self, root: str, *args, lazy_subcommands=None, **kwargs): 9 | super().__init__(*args, **kwargs) 10 | # lazy_subcommands is a map of the form: 11 | # 12 | # {command-name} -> {module-name}.{command-object-name} 13 | # 14 | self.root = root 15 | self.lazy_subcommands = lazy_subcommands or {} 16 | 17 | def list_commands(self, ctx): 18 | base = super().list_commands(ctx) 19 | lazy = sorted(self.lazy_subcommands.keys()) 20 | return base + lazy 21 | 22 | def get_command(self, ctx, cmd_name): # pyright:ignore 23 | if cmd_name in self.lazy_subcommands: 24 | return self._lazy_load(cmd_name) 25 | return super().get_command(ctx, cmd_name) 26 | 27 | def _lazy_load(self, cmd_name: str): 28 | # lazily loading a command, first get the module name and attribute name 29 | import_path: str = self.lazy_subcommands[cmd_name] 30 | modname, cmd_object_name = import_path.rsplit(".", 1) 31 | # do the import 32 | mod = importlib.import_module(f".{modname}", package=self.root) 33 | # get the Command object from that module 34 | cmd_object = getattr(mod, cmd_object_name) 35 | # check the result to make debugging easier 36 | if not isinstance(cmd_object, click.Command): 37 | raise ValueError( 38 | f"Lazy loading of {import_path} failed by returning " 39 | "a non-command object" 40 | ) 41 | return cmd_object 42 | -------------------------------------------------------------------------------- /viu_media/libs/player/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defines the abstract base class for all media player integrations in Viu. 3 | 4 | All concrete player implementations must inherit from BasePlayer and implement its methods. 5 | """ 6 | 7 | import subprocess 8 | from abc import ABC, abstractmethod 9 | 10 | from ...core.config import StreamConfig 11 | from .params import PlayerParams 12 | from .types import PlayerResult 13 | 14 | 15 | class BasePlayer(ABC): 16 | """ 17 | Abstract base class for all media player integrations. 18 | 19 | Subclasses must implement the play and play_with_ipc methods to provide playback functionality. 20 | """ 21 | 22 | def __init__(self, config: StreamConfig): 23 | """ 24 | Initialize the player with the given stream configuration. 25 | 26 | Args: 27 | config: StreamConfig object containing player configuration. 28 | """ 29 | self.stream_config = config 30 | 31 | @abstractmethod 32 | def play(self, params: PlayerParams) -> PlayerResult: 33 | """ 34 | Play the given media URL using the player. 35 | 36 | Args: 37 | params: PlayerParams object containing playback parameters. 38 | 39 | Returns: 40 | PlayerResult: Information about the playback session. 41 | """ 42 | pass 43 | 44 | @abstractmethod 45 | def play_with_ipc(self, params: PlayerParams, socket_path: str) -> subprocess.Popen: 46 | """ 47 | Play media using IPC (Inter-Process Communication) for enhanced control. 48 | 49 | Args: 50 | params: PlayerParams object containing playback parameters. 51 | socket_path: Path to the IPC socket for player control. 52 | 53 | Returns: 54 | subprocess.Popen: The running player process. 55 | """ 56 | pass 57 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/api.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import logging 5 | from typing import TYPE_CHECKING 6 | 7 | from httpx import Client 8 | 9 | from ...core.utils.networking import random_user_agent 10 | 11 | if TYPE_CHECKING: 12 | from ...core.config import AppConfig 13 | from .base import BaseApiClient 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | # Map the client name to its import path AND the config section it needs. 18 | API_CLIENTS = { 19 | "anilist": ("viu_media.libs.media_api.anilist.api.AniListApi", "anilist"), 20 | "jikan": ("viu_media.libs.media_api.jikan.api.JikanApi", "jikan"), # For the future 21 | } 22 | 23 | 24 | def create_api_client(client_name: str, config: AppConfig) -> BaseApiClient: 25 | """ 26 | Factory to create an instance of a specific API client, injecting only 27 | the relevant section of the application configuration. 28 | """ 29 | if client_name not in API_CLIENTS: 30 | raise ValueError(f"Unsupported API client: '{client_name}'") 31 | 32 | import_path, config_section_name = API_CLIENTS[client_name] 33 | module_name, class_name = import_path.rsplit(".", 1) 34 | 35 | try: 36 | module = importlib.import_module(module_name) 37 | client_class = getattr(module, class_name) 38 | except (ImportError, AttributeError) as e: 39 | raise ImportError(f"Could not load API client '{client_name}': {e}") from e 40 | 41 | # Create a shared httpx client for the API 42 | http_client = Client(headers={"User-Agent": random_user_agent()}) 43 | 44 | # Retrieve the specific config section from the main AppConfig 45 | scoped_config = getattr(config, config_section_name) 46 | 47 | # Inject the scoped config into the client's constructor 48 | return client_class(scoped_config, http_client) 49 | -------------------------------------------------------------------------------- /DISCLAIMER.md: -------------------------------------------------------------------------------- 1 |

Disclaimer

2 | 3 |
4 | 5 |

This project: viu

6 | 7 |
8 | 9 | The core aim of this project is to co-relate automation and efficiency to extract what is provided to a user on the internet. All content available through the project is hosted by external non-affiliated sources. 10 | 11 |
12 | 13 | All content served through this project is publicly accessible. If your site is listed in this project, the code is pretty much public. Take necessary measures to counter the exploits used to extract content in your site. 14 | 15 | Think of this project as your normal browser, but a bit more straight-forward and specific. While an average browser makes hundreds of requests to get everything from a site, this project goes on to only make requests associated with getting the content served by the sites. 16 | 17 | 18 | 19 | This project is to be used at the user's own risk, based on their government and laws. 20 | 21 | This project has no control on the content it is serving, using copyrighted content from the providers is not going to be accounted for by the developer. It is the user's own risk. 22 | 23 | 24 | 25 |
26 | 27 |

DMCA and Copyright Infrigements

28 | 29 |
30 | 31 | 32 | 33 | A browser is a tool, and the maliciousness of the tool is directly based on the user. 34 | 35 | 36 | This project uses client-side content access mechanisms. Hence, the copyright infrigements or DMCA in this project's regards are to be forwarded to the associated site by the associated notifier of any such claims. This is one of the main reasons the sites are listed in this project. 37 | 38 | Do not harass the developer. Any personal information about the developer is intentionally not made public. Exploiting such information without consent in regards to this topic will lead to legal actions by the developer themselves. 39 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/media-list.gql: -------------------------------------------------------------------------------- 1 | query ( 2 | $userId: Int 3 | $status: MediaListStatus 4 | $type: MediaType 5 | $page: Int 6 | $perPage: Int 7 | $sort: [MediaListSort] 8 | ) { 9 | Page(perPage: $perPage, page: $page) { 10 | pageInfo { 11 | total 12 | currentPage 13 | hasNextPage 14 | } 15 | mediaList(userId: $userId, status: $status, type: $type, sort: $sort) { 16 | mediaId 17 | media { 18 | id 19 | idMal 20 | format 21 | title { 22 | romaji 23 | english 24 | } 25 | coverImage { 26 | medium 27 | large 28 | } 29 | trailer { 30 | site 31 | id 32 | } 33 | popularity 34 | streamingEpisodes { 35 | title 36 | thumbnail 37 | } 38 | favourites 39 | averageScore 40 | episodes 41 | genres 42 | synonyms 43 | studios { 44 | nodes { 45 | name 46 | favourites 47 | isAnimationStudio 48 | } 49 | } 50 | tags { 51 | name 52 | } 53 | startDate { 54 | year 55 | month 56 | day 57 | } 58 | endDate { 59 | year 60 | month 61 | day 62 | } 63 | status 64 | description 65 | mediaListEntry { 66 | status 67 | id 68 | progress 69 | } 70 | nextAiringEpisode { 71 | timeUntilAiring 72 | airingAt 73 | episode 74 | } 75 | } 76 | status 77 | progress 78 | score 79 | repeat 80 | notes 81 | startedAt { 82 | year 83 | month 84 | day 85 | } 86 | completedAt { 87 | year 88 | month 89 | day 90 | } 91 | createdAt 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /viu_media/cli/service/auth/service.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import Optional 4 | 5 | from ....core.constants import APP_DATA_DIR 6 | from ....core.utils.file import AtomicWriter, FileLock 7 | from ....libs.media_api.types import UserProfile 8 | from .model import AuthModel, AuthProfile 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | AUTH_FILE = APP_DATA_DIR / "auth.json" 13 | 14 | 15 | class AuthService: 16 | def __init__(self, media_api: str): 17 | self.path = AUTH_FILE 18 | self.media_api = media_api 19 | _lock_file = APP_DATA_DIR / "auth.lock" 20 | self._lock = FileLock(_lock_file) 21 | 22 | def get_auth(self) -> Optional[AuthProfile]: 23 | auth = self._load_auth() 24 | return auth.profiles.get(self.media_api) 25 | 26 | def save_user_profile(self, profile: UserProfile, token: str) -> None: 27 | auth = self._load_auth() 28 | auth.profiles[self.media_api] = AuthProfile(user_profile=profile, token=token) 29 | self._save_auth(auth) 30 | logger.info(f"Successfully saved user credentials to {self.path}") 31 | 32 | def clear_user_profile(self) -> None: 33 | """Deletes the user credentials file.""" 34 | if self.path.exists(): 35 | self.path.unlink() 36 | logger.info("Cleared user credentials.") 37 | 38 | def _load_auth(self) -> AuthModel: 39 | if not self.path.exists(): 40 | self._auth = AuthModel() 41 | self._save_auth(self._auth) 42 | return self._auth 43 | 44 | with self.path.open("r", encoding="utf-8") as f: 45 | data = json.load(f) 46 | self._auth = AuthModel.model_validate(data) 47 | return self._auth 48 | 49 | def _save_auth(self, auth: AuthModel): 50 | with self._lock: 51 | with AtomicWriter(self.path) as f: 52 | json.dump(auth.model_dump(), f, indent=2) 53 | logger.info(f"Successfully saved user credentials to {self.path}") 54 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/mappers.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from httpx import Response 4 | 5 | from ..types import ( 6 | Anime, 7 | AnimeEpisodes, 8 | MediaTranslationType, 9 | PageInfo, 10 | SearchResult, 11 | SearchResults, 12 | ) 13 | from .types import AllAnimeSearchResults, AllAnimeShow 14 | 15 | 16 | def generate_list(count: Union[int, str]) -> list[str]: 17 | return list(map(str, range(int(count)))) 18 | 19 | 20 | translation_type_map = { 21 | "sub": MediaTranslationType.SUB, 22 | "dub": MediaTranslationType.DUB, 23 | "raw": MediaTranslationType.RAW, 24 | } 25 | 26 | 27 | def map_to_search_results(response: Response) -> SearchResults: 28 | search_results: AllAnimeSearchResults = response.json()["data"] 29 | return SearchResults( 30 | page_info=PageInfo(total=search_results["shows"]["pageInfo"]["total"]), 31 | results=[ 32 | SearchResult( 33 | id=result["_id"], 34 | title=result["name"], 35 | media_type=result["__typename"], 36 | episodes=AnimeEpisodes( 37 | sub=generate_list(result["availableEpisodes"]["sub"]), 38 | dub=generate_list(result["availableEpisodes"]["dub"]), 39 | raw=generate_list(result["availableEpisodes"]["raw"]), 40 | ), 41 | ) 42 | for result in search_results["shows"]["edges"] 43 | ], 44 | ) 45 | 46 | 47 | def map_to_anime_result(response: Response) -> Anime: 48 | anime: AllAnimeShow = response.json()["data"]["show"] 49 | return Anime( 50 | id=anime["_id"], 51 | title=anime["name"], 52 | episodes=AnimeEpisodes( 53 | sub=sorted(anime["availableEpisodesDetail"]["sub"], key=float), 54 | dub=sorted(anime["availableEpisodesDetail"]["dub"], key=float), 55 | raw=sorted(anime["availableEpisodesDetail"]["raw"], key=float), 56 | ), 57 | type=anime.get("__typename"), 58 | ) 59 | -------------------------------------------------------------------------------- /.github/chatmodes/new-command.chatmode.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "Generate a new 'click' command following the project's lazy-loading pattern and service architecture." 3 | tools: ['codebase'] 4 | --- 5 | # viu: CLI Command Generation Mode 6 | 7 | You are an expert on the `viu` CLI structure, which uses `click` and a custom `LazyGroup` for performance. Your task is to generate the boilerplate for a new command. 8 | 9 | **First, ask the user if this is a top-level command (like `viu new-cmd`) or a subcommand (like `viu anilist new-sub-cmd`).** 10 | 11 | --- 12 | 13 | ### If Top-Level Command: 14 | 15 | 1. **File Location:** State that the new command file should be created at: `viu/cli/commands/{command_name}.py`. 16 | 2. **Boilerplate:** Generate the `click.command()` function. 17 | * It **must** accept `config: AppConfig` as the first argument using `@click.pass_obj`. 18 | * It **must not** contain business logic. Instead, show how to instantiate a service from `viu.cli.service` and call its methods. 19 | 3. **Registration:** Instruct the user to register the command by adding it to the `commands` dictionary in `viu/cli/cli.py`. Provide the exact line to add, like: `"new-cmd": "new_cmd.new_cmd_function"`. 20 | 21 | --- 22 | 23 | ### If Subcommand: 24 | 25 | 1. **Ask for Parent:** Ask for the parent command group (e.g., `anilist`, `registry`). 26 | 2. **File Location:** State that the new command file should be created at: `viu/cli/commands/{parent_name}/commands/{command_name}.py`. 27 | 3. **Boilerplate:** Generate the `click.command()` function, similar to the top-level command. 28 | 4. **Registration:** Instruct the user to register the subcommand in the parent's `cmd.py` file (e.g., `viu/cli/commands/anilist/cmd.py`) by adding it to the `lazy_subcommands` dictionary within the `@click.group` decorator. 29 | 30 | **Final Instruction:** Remind the user that if the command introduces new logic, it should be encapsulated in a new or existing **Service** class in the `viu/cli/service/` directory. The CLI command function should only handle argument parsing and calling the service. 31 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animepahe/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | ANIMEPAHE = "animepahe.si" 4 | ANIMEPAHE_BASE = f"https://{ANIMEPAHE}" 5 | ANIMEPAHE_ENDPOINT = f"{ANIMEPAHE_BASE}/api" 6 | 7 | SERVERS_AVAILABLE = ["kwik"] 8 | REQUEST_HEADERS = { 9 | "Cookie": "__ddgid_=VvX0ebHrH2DsFZo4; __ddgmark_=3savRpSVFhvZcn5x; __ddg2_=buBJ3c4pNBYKFZNp; __ddg1_=rbVADKr9URtt55zoIGFa; SERVERID=janna; XSRF-TOKEN=eyJpdiI6IjV5bFNtd0phUHgvWGJxc25wL0VJSUE9PSIsInZhbHVlIjoicEJTZktlR2hxR2JZTWhnL0JzazlvZU5TQTR2bjBWZ2dDb0RwUXVUUWNSclhQWUhLRStYSmJmWmUxWkpiYkFRYU12RjFWejlSWHorME1wZG5qQ1U0TnFlNnBFR2laQjN1MjdyNjc5TjVPdXdJb2o5VkU1bEduRW9pRHNDTHh6Sy8iLCJtYWMiOiI0OTc0ZmNjY2UwMGJkOWY2MWNkM2NlMjk2ZGMyZGJmMWE0NTdjZTdkNGI2Y2IwNTIzZmFiZWU5ZTE2OTk0YmU4IiwidGFnIjoiIn0%3D; laravel_session=eyJpdiI6ImxvdlpqREFnTjdaeFJubUlXQWlJVWc9PSIsInZhbHVlIjoiQnE4R3VHdjZ4M1NDdEVWM1ZqMUxtNnVERnJCcmtCUHZKNzRPR2RFbzNFcStTL29xdnVTbWhsNVRBUXEybVZWNU1UYVlTazFqYlN5UjJva1k4czNGaXBTbkJJK01oTUd3VHRYVHBoc3dGUWxHYnFlS2NJVVNFbTFqMVBWdFpuVUgiLCJtYWMiOiI1NDdjZTVkYmNhNjUwZTMxZmRlZmVmMmRlMGNiYjAwYjlmYjFjY2U0MDc1YTQzZThiMTIxMjJlYTg1NTA4YjBmIiwidGFnIjoiIn0%3D; latest=5592", 10 | "Host": ANIMEPAHE, 11 | "Accept": "application, text/javascript, */*; q=0.01", 12 | "Accept-Encoding": "Utf-8", 13 | "Referer": ANIMEPAHE_BASE, 14 | "DNT": "1", 15 | "Connection": "keep-alive", 16 | "Sec-Fetch-Dest": "empty", 17 | "Sec-Fetch-Site": "same-origin", 18 | "Sec-Fetch-Mode": "cors", 19 | "TE": "trailers", 20 | } 21 | SERVER_HEADERS = { 22 | "Host": "kwik.cx", 23 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8", 24 | "Accept-Language": "en-US,en;q=0.5", 25 | "Accept-Encoding": "Utf-8", 26 | "DNT": "1", 27 | "Connection": "keep-alive", 28 | "Referer": "https://animepahe.si/", 29 | "Upgrade-Insecure-Requests": "1", 30 | "Sec-Fetch-Dest": "iframe", 31 | "Sec-Fetch-Mode": "navigate", 32 | "Sec-Fetch-Site": "cross-site", 33 | "Priority": "u=4", 34 | "TE": "trailers", 35 | } 36 | JUICY_STREAM_REGEX = re.compile(r"source='(.*)';") 37 | KWIK_RE = re.compile(r"Player\|(.+?)'") 38 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/commands/notifications.py: -------------------------------------------------------------------------------- 1 | import click 2 | from viu_media.core.config import AppConfig 3 | from rich.console import Console 4 | from rich.table import Table 5 | 6 | 7 | @click.command(help="Check for new AniList notifications (e.g., for airing episodes).") 8 | @click.pass_obj 9 | def notifications(config: AppConfig): 10 | """ 11 | Displays unread notifications from AniList. 12 | Running this command will also mark the notifications as read on the AniList website. 13 | """ 14 | from viu_media.cli.service.feedback import FeedbackService 15 | from viu_media.libs.media_api.api import create_api_client 16 | 17 | from ....service.auth import AuthService 18 | 19 | feedback = FeedbackService(config) 20 | console = Console() 21 | auth = AuthService(config.general.media_api) 22 | api_client = create_api_client(config.general.media_api, config) 23 | if profile := auth.get_auth(): 24 | api_client.authenticate(profile.token) 25 | 26 | if not api_client.is_authenticated(): 27 | feedback.error( 28 | "Authentication Required", "Please log in with 'viu anilist auth'." 29 | ) 30 | return 31 | 32 | with feedback.progress("Fetching notifications..."): 33 | notifs = api_client.get_notifications() 34 | 35 | if not notifs: 36 | feedback.success("All caught up!", "You have no new notifications.") 37 | return 38 | 39 | table = Table( 40 | title="🔔 AniList Notifications", show_header=True, header_style="bold magenta" 41 | ) 42 | table.add_column("Date", style="dim", width=12) 43 | table.add_column("Anime Title", style="cyan") 44 | table.add_column("Details", style="green") 45 | 46 | for notif in sorted(notifs, key=lambda n: n.created_at, reverse=True): 47 | title = notif.media.title.english or notif.media.title.romaji or "Unknown" 48 | date_str = notif.created_at.strftime("%Y-%m-%d") 49 | details = f"Episode {notif.episode} has aired!" 50 | 51 | table.add_row(date_str, title, details) 52 | 53 | console.print(table) 54 | feedback.info( 55 | "Notifications have been marked as read on AniList.", 56 | ) 57 | -------------------------------------------------------------------------------- /.github/chatmodes/new-component.chatmode.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "Scaffold the necessary files and code for a new Player or Selector component, including configuration." 3 | tools: ['codebase', 'search'] 4 | --- 5 | # viu: New Component Generation Mode 6 | 7 | You are an expert on `viu`'s modular architecture. Your task is to help the developer add a new **Player** or **Selector** component. 8 | 9 | **First, ask the user whether they want to create a 'Player' or a 'Selector'.** Then, follow the appropriate path below. 10 | 11 | --- 12 | 13 | ### If the user chooses 'Player': 14 | 15 | 1. **Scaffold Directory:** Create a directory at `viu/libs/player/{player_name}/`. 16 | 2. **Implement `BasePlayer`:** Create a `player.py` file with a class `NewPlayer` that inherits from `viu.libs.player.base.BasePlayer`. Implement the `play` and `play_with_ipc` methods. The `play` method should use `subprocess` to call the player's executable. 17 | 3. **Add Configuration:** 18 | * Instruct to create a new Pydantic model `NewPlayerConfig(OtherConfig)` in `viu/core/config/model.py`. 19 | * Add the new config model to the main `AppConfig`. 20 | * Add defaults in `viu/core/config/defaults.py` and descriptions in `viu/core/config/descriptions.py`. 21 | 4. **Register Player:** Instruct to modify `viu/libs/player/player.py` by: 22 | * Adding the player name to the `PLAYERS` list. 23 | * Adding the instantiation logic to the `PlayerFactory.create` method. 24 | 25 | --- 26 | 27 | ### If the user chooses 'Selector': 28 | 29 | 1. **Scaffold Directory:** Create a directory at `viu/libs/selectors/{selector_name}/`. 30 | 2. **Implement `BaseSelector`:** Create a `selector.py` file with a class `NewSelector` that inherits from `viu.libs.selectors.base.BaseSelector`. Implement the `choose`, `confirm`, and `ask` methods. 31 | 3. **Add Configuration:** (Follow the same steps as for a Player). 32 | 4. **Register Selector:** 33 | * Instruct to modify `viu/libs/selectors/selector.py` by adding the selector name to the `SELECTORS` list and the factory logic to `SelectorFactory.create`. 34 | * Instruct to update the `Literal` type hint for the `selector` field in `GeneralConfig` (`viu/core/config/model.py`). 35 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/extractor.py: -------------------------------------------------------------------------------- 1 | from httpx import Client 2 | 3 | from ...types import Server 4 | from ..types import AllAnimeEpisode, AllAnimeSource 5 | from ..utils import debug_extractor, logger, one_digit_symmetric_xor 6 | from .ak import AkExtractor 7 | from .dropbox import SakExtractor 8 | from .filemoon import FmHlsExtractor, OkExtractor 9 | from .gogoanime import Lufmp4Extractor 10 | from .mp4_upload import Mp4Extractor 11 | from .sharepoint import Smp4Extractor 12 | from .streamsb import SsHlsExtractor 13 | from .vid_mp4 import VidMp4Extractor 14 | from .we_transfer import KirExtractor 15 | from .wixmp import DefaultExtractor 16 | from .yt_mp4 import YtExtractor 17 | 18 | AVAILABLE_SOURCES = { 19 | "Sak": SakExtractor, 20 | "S-mp4": Smp4Extractor, 21 | "Luf-Mp4": Lufmp4Extractor, 22 | "Default": DefaultExtractor, 23 | "Yt-mp4": YtExtractor, 24 | "Kir": KirExtractor, 25 | "Mp4": Mp4Extractor, 26 | } 27 | OTHER_SOURCES = { 28 | "Ak": AkExtractor, 29 | "Vid-mp4": VidMp4Extractor, 30 | "Ok": OkExtractor, 31 | "Ss-Hls": SsHlsExtractor, 32 | "Fm-Hls": FmHlsExtractor, 33 | } 34 | 35 | 36 | @debug_extractor 37 | def extract_server( 38 | client: Client, 39 | episode_number: str, 40 | episode: AllAnimeEpisode, 41 | source: AllAnimeSource, 42 | ) -> Server | None: 43 | url = source.get("sourceUrl") 44 | if not url: 45 | logger.debug(f"Url not found in source: {source}") 46 | return 47 | 48 | if url.startswith("--"): 49 | url = one_digit_symmetric_xor(56, url[2:]) 50 | 51 | logger.debug(f"Decrypting url for source: {source['sourceName']}") 52 | if source["sourceName"] in OTHER_SOURCES: 53 | logger.debug(f"Found {source['sourceName']} but ignoring") 54 | return 55 | 56 | if source["sourceName"] not in AVAILABLE_SOURCES: 57 | logger.debug( 58 | f"Found {source['sourceName']} but did not expect it, its time to scrape lol" 59 | ) 60 | return 61 | logger.debug(f"Found {source['sourceName']}") 62 | 63 | return AVAILABLE_SOURCES[source["sourceName"]].extract( 64 | url, client, episode_number, episode, source 65 | ) 66 | -------------------------------------------------------------------------------- /viu_media/cli/commands/worker.py: -------------------------------------------------------------------------------- 1 | import click 2 | from viu_media.core.config import AppConfig 3 | 4 | 5 | @click.command(help="Run the background worker for notifications and downloads.") 6 | @click.pass_obj 7 | def worker(config: AppConfig): 8 | """ 9 | Starts the long-running background worker process. 10 | This process will periodically check for AniList notifications and 11 | process any queued downloads. It's recommended to run this in the 12 | background (e.g., 'viu worker &') or as a system service. 13 | """ 14 | from viu_media.cli.service.auth import AuthService 15 | from viu_media.cli.service.download.service import DownloadService 16 | from viu_media.cli.service.feedback import FeedbackService 17 | from viu_media.cli.service.notification.service import NotificationService 18 | from viu_media.cli.service.registry.service import MediaRegistryService 19 | from viu_media.cli.service.worker.service import BackgroundWorkerService 20 | from viu_media.libs.media_api.api import create_api_client 21 | from viu_media.libs.provider.anime.provider import create_provider 22 | 23 | feedback = FeedbackService(config) 24 | if not config.worker.enabled: 25 | feedback.warning("Worker is disabled in the configuration. Exiting.") 26 | return 27 | 28 | # Instantiate services 29 | media_api = create_api_client(config.general.media_api, config) 30 | # Authenticate if credentials exist (enables notifications) 31 | auth = AuthService(config.general.media_api) 32 | if profile := auth.get_auth(): 33 | try: 34 | media_api.authenticate(profile.token) 35 | except Exception: 36 | pass 37 | provider = create_provider(config.general.provider) 38 | registry = MediaRegistryService(config.general.media_api, config.media_registry) 39 | 40 | notification_service = NotificationService(config, media_api, registry) 41 | download_service = DownloadService(config, registry, media_api, provider) 42 | worker_service = BackgroundWorkerService( 43 | config.worker, notification_service, download_service 44 | ) 45 | 46 | feedback.info("Starting background worker...", "Press Ctrl+C to stop.") 47 | worker_service.run() 48 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/extractors/filemoon.py: -------------------------------------------------------------------------------- 1 | from ...types import EpisodeStream, Server 2 | from ..constants import API_BASE_URL, MP4_SERVER_JUICY_STREAM_REGEX 3 | from ..types import AllAnimeEpisode, AllAnimeSource 4 | from .base import BaseExtractor 5 | 6 | 7 | # TODO: requires decoding obsfucated js (filemoon) 8 | class FmHlsExtractor(BaseExtractor): 9 | @classmethod 10 | def extract( 11 | cls, 12 | url, 13 | client, 14 | episode_number: str, 15 | episode: AllAnimeEpisode, 16 | source: AllAnimeSource, 17 | ) -> Server: 18 | response = client.get( 19 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 20 | timeout=10, 21 | ) 22 | response.raise_for_status() 23 | 24 | embed_html = response.text.replace(" ", "").replace("\n", "") 25 | vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html) 26 | if not vid: 27 | raise Exception("") 28 | return Server( 29 | name="dropbox", 30 | links=[EpisodeStream(link=vid.group(1), quality="1080")], 31 | episode_title=episode["notes"], 32 | headers={"Referer": "https://www.mp4upload.com/"}, 33 | ) 34 | 35 | 36 | # TODO: requires decoding obsfucated js (filemoon) 37 | class OkExtractor(BaseExtractor): 38 | @classmethod 39 | def extract( 40 | cls, 41 | url, 42 | client, 43 | episode_number: str, 44 | episode: AllAnimeEpisode, 45 | source: AllAnimeSource, 46 | ) -> Server: 47 | response = client.get( 48 | f"https://{API_BASE_URL}{url.replace('clock', 'clock.json')}", 49 | timeout=10, 50 | ) 51 | response.raise_for_status() 52 | 53 | embed_html = response.text.replace(" ", "").replace("\n", "") 54 | vid = MP4_SERVER_JUICY_STREAM_REGEX.search(embed_html) 55 | if not vid: 56 | raise Exception("") 57 | return Server( 58 | name="dropbox", 59 | links=[EpisodeStream(link=vid.group(1), quality="1080")], 60 | episode_title=episode["notes"], 61 | headers={"Referer": "https://www.mp4upload.com/"}, 62 | ) 63 | -------------------------------------------------------------------------------- /viu_media/cli/utils/completion.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | 5 | ANILIST_ENDPOINT = "https://graphql.anilist.co" 6 | 7 | 8 | anime_title_query = """ 9 | query ($query: String) { 10 | Page(perPage: 50) { 11 | pageInfo { 12 | total 13 | } 14 | media(search: $query, type: ANIME) { 15 | id 16 | idMal 17 | title { 18 | romaji 19 | english 20 | } 21 | } 22 | } 23 | } 24 | """ 25 | 26 | 27 | def get_anime_titles(query: str, variables: dict = {}): 28 | """the abstraction over all none authenticated requests and that returns data of a similar type 29 | 30 | Args: 31 | query: the anilist query 32 | variables: the anilist api variables 33 | 34 | Returns: 35 | a boolean indicating success and none or an anilist object depending on success 36 | """ 37 | from httpx import post 38 | 39 | try: 40 | response = post( 41 | ANILIST_ENDPOINT, 42 | json={"query": query, "variables": variables}, 43 | timeout=10, 44 | ) 45 | anilist_data = response.json() 46 | 47 | if response.status_code == 200: 48 | eng_titles = [ 49 | anime["title"]["english"] 50 | for anime in anilist_data["data"]["Page"]["media"] 51 | if anime["title"]["english"] 52 | ] 53 | romaji_titles = [ 54 | anime["title"]["romaji"] 55 | for anime in anilist_data["data"]["Page"]["media"] 56 | if anime["title"]["romaji"] 57 | ] 58 | return [*eng_titles, *romaji_titles] 59 | else: 60 | return [] 61 | except Exception as e: 62 | logger.error(f"Something unexpected occurred {e}") 63 | return [] 64 | 65 | 66 | def anime_titles_shell_complete(ctx, param, incomplete): 67 | incomplete = incomplete.strip() 68 | if not incomplete: 69 | incomplete = None 70 | variables = {} 71 | else: 72 | variables = {"query": incomplete} 73 | return get_anime_titles(anime_title_query, variables) 74 | 75 | 76 | if __name__ == "__main__": 77 | t = input("Enter title") 78 | results = get_anime_titles(anime_title_query, {"query": t}) 79 | print(results) 80 | -------------------------------------------------------------------------------- /.github/chatmodes/new-provider.chatmode.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: "Scaffold and implement a new anime provider, following all architectural patterns of the viu project." 3 | tools: ['codebase', 'search', 'fetch'] 4 | --- 5 | # viu: New Provider Generation Mode 6 | 7 | You are an expert on the `viu` codebase, specializing in its provider architecture. Your task is to guide the developer in creating a new anime provider. You must strictly adhere to the project's structure and coding conventions. 8 | 9 | **Your process is as follows:** 10 | 11 | 1. **Ask for the Provider's Name:** First, ask the user for the name of the new provider (e.g., `gogoanime`, `crunchyroll`). Use this name (in lowercase) for all subsequent file and directory naming. 12 | 13 | 2. **Scaffold the Directory Structure:** Based on the name, state the required directory structure that needs to be created: 14 | `viu/libs/provider/anime/{provider_name}/` 15 | 16 | 3. **Scaffold the Core Files:** Generate the initial code for the following files inside the new directory. Ensure all code is fully type-hinted. 17 | 18 | * **`__init__.py`**: Can be an empty file. 19 | * **`types.py`**: Create placeholder `TypedDict` models for the provider's specific API responses (e.g., `GogoAnimeSearchResult`, `GogoAnimeEpisode`). 20 | * **`mappers.py`**: Create empty mapping functions that will convert the provider-specific types into the generic types from `viu.libs.provider.anime.types`. For example: `map_to_search_results(data: GogoAnimeSearchPage) -> SearchResults:`. 21 | * **`provider.py`**: Generate the main provider class. It **MUST** inherit from `viu.libs.provider.anime.base.BaseAnimeProvider`. Include stubs for the required abstract methods: `search`, `get`, and `episode_streams`. Remind the user to use `httpx.Client` for requests and to call the mapper functions. 22 | 23 | 4. **Instruct on Registration:** Clearly state the two files that **must** be modified to register the new provider: 24 | * **`viu/libs/provider/anime/types.py`**: Add the new provider's name to the `ProviderName` enum. 25 | * **`viu/libs/provider/anime/provider.py`**: Add an entry to the `PROVIDERS_AVAILABLE` dictionary. 26 | 27 | 5. **Final Guidance:** Remind the developer to add any title normalization rules to `viu/assets/normalizer.json` if the provider uses different anime titles than AniList. 28 | -------------------------------------------------------------------------------- /dev/generate_anilist_media_tags.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S uv run --script 2 | import json 3 | from collections import defaultdict 4 | from pathlib import Path 5 | 6 | import httpx 7 | from viu_media.core.utils.graphql import execute_graphql 8 | 9 | DEV_DIR = Path(__file__).resolve().parent 10 | media_tags_type_py = ( 11 | DEV_DIR.parent / "viu_media" / "libs" / "media_api" / "_media_tags.py" 12 | ) 13 | media_tags_gql = DEV_DIR / "graphql" / "anilist" / "media_tags.gql" 14 | generated_tags_json = DEV_DIR / "generated" / "anilist" / "tags.json" 15 | 16 | media_tags_response = execute_graphql( 17 | "https://graphql.anilist.co", httpx.Client(), media_tags_gql, {} 18 | ) 19 | media_tags_response.raise_for_status() 20 | 21 | template = """\ 22 | # DO NOT EDIT THIS FILE !!! ( 。 •̀ ᴖ •́ 。) 23 | # ITS AUTOMATICALLY GENERATED BY RUNNING ./dev/generate_anilist_media_tags.py 24 | # FROM THE PROJECT ROOT 25 | # SO RUN THAT INSTEAD TO UPDATE THE FILE WITH THE LATEST MEDIA TAGS :) 26 | 27 | 28 | from enum import Enum 29 | 30 | 31 | class MediaTag(Enum):\ 32 | """ 33 | 34 | # 4 spaces 35 | tab = " " 36 | tags = defaultdict(list) 37 | for tag in media_tags_response.json()["data"]["MediaTagCollection"]: 38 | tags[tag["category"]].append( 39 | { 40 | "name": tag["name"], 41 | "description": tag["description"], 42 | "is_adult": tag["isAdult"], 43 | } 44 | ) 45 | # save copy of data used to generate the class 46 | json.dump(tags, generated_tags_json.open("w", encoding="utf-8"), indent=2) 47 | 48 | for key, value in tags.items(): 49 | template = f"{template}\n{tab}#\n{tab}# {key.upper()}\n{tab}#\n" 50 | for tag in value: 51 | name = tag["name"] 52 | _tag_name = name.replace("-", "_").replace(" ", "_").upper() 53 | if _tag_name.startswith(("0", "1", "2", "3", "4", "5", "6", "7", "8", "9")): 54 | _tag_name = f"_{_tag_name}" 55 | 56 | tag_name = "" 57 | # sanitize invalid characters for attribute names 58 | for char in _tag_name: 59 | if char.isidentifier() or char.isdigit(): 60 | tag_name += char 61 | 62 | desc = tag["description"].replace("\n", "") 63 | is_adult = tag["is_adult"] 64 | template = f'{template}\n{tab}# {desc} (is_adult: {is_adult})\n{tab}{tag_name} = "{name}"\n' 65 | 66 | media_tags_type_py.write_text(template, "utf-8") 67 | -------------------------------------------------------------------------------- /viu_media/assets/scripts/fzf/media_info.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from _ansi_utils import ( 3 | print_rule, 4 | print_table_row, 5 | strip_markdown, 6 | wrap_text, 7 | get_terminal_width, 8 | ) 9 | 10 | HEADER_COLOR = sys.argv[1] 11 | SEPARATOR_COLOR = sys.argv[2] 12 | 13 | # Get terminal dimensions 14 | term_width = get_terminal_width() 15 | 16 | # Print title centered 17 | print("{TITLE}".center(term_width)) 18 | 19 | # Define table data 20 | rows = [ 21 | ("Score", "{SCORE}"), 22 | ("Favorites", "{FAVOURITES}"), 23 | ("Popularity", "{POPULARITY}"), 24 | ("Status", "{STATUS}"), 25 | ] 26 | 27 | print_rule(SEPARATOR_COLOR) 28 | for key, value in rows: 29 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 30 | 31 | rows = [ 32 | ("Episodes", "{EPISODES}"), 33 | ("Duration", "{DURATION}"), 34 | ("Next Episode", "{NEXT_EPISODE}"), 35 | ] 36 | 37 | print_rule(SEPARATOR_COLOR) 38 | for key, value in rows: 39 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 40 | 41 | rows = [ 42 | ("Genres", "{GENRES}"), 43 | ("Format", "{FORMAT}"), 44 | ] 45 | 46 | print_rule(SEPARATOR_COLOR) 47 | for key, value in rows: 48 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 49 | 50 | rows = [ 51 | ("List Status", "{USER_STATUS}"), 52 | ("Progress", "{USER_PROGRESS}"), 53 | ] 54 | 55 | print_rule(SEPARATOR_COLOR) 56 | for key, value in rows: 57 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 58 | 59 | rows = [ 60 | ("Start Date", "{START_DATE}"), 61 | ("End Date", "{END_DATE}"), 62 | ] 63 | 64 | print_rule(SEPARATOR_COLOR) 65 | for key, value in rows: 66 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 67 | 68 | rows = [ 69 | ("Studios", "{STUDIOS}"), 70 | ] 71 | 72 | print_rule(SEPARATOR_COLOR) 73 | for key, value in rows: 74 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 75 | 76 | rows = [ 77 | ("Synonyms", "{SYNONYMNS}"), 78 | ] 79 | 80 | print_rule(SEPARATOR_COLOR) 81 | for key, value in rows: 82 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 83 | 84 | rows = [ 85 | ("Tags", "{TAGS}"), 86 | ] 87 | 88 | print_rule(SEPARATOR_COLOR) 89 | for key, value in rows: 90 | print_table_row(key, value, HEADER_COLOR, 15, term_width - 20) 91 | 92 | print_rule(SEPARATOR_COLOR) 93 | print(wrap_text(strip_markdown("""{SYNOPSIS}"""), term_width)) 94 | -------------------------------------------------------------------------------- /viu_media/cli/service/player/service.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Optional 3 | 4 | from ....core.config import AppConfig 5 | from ....core.exceptions import ViuError 6 | from ....libs.media_api.types import MediaItem 7 | from ....libs.player.base import BasePlayer 8 | from ....libs.player.params import PlayerParams 9 | from ....libs.player.player import create_player 10 | from ....libs.player.types import PlayerResult 11 | from ....libs.provider.anime.base import BaseAnimeProvider 12 | from ....libs.provider.anime.types import Anime 13 | from ..registry import MediaRegistryService 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class PlayerService: 19 | app_config: AppConfig 20 | provider: BaseAnimeProvider 21 | player: BasePlayer 22 | registry: Optional[MediaRegistryService] = None 23 | local: bool = False 24 | 25 | def __init__( 26 | self, 27 | app_config: AppConfig, 28 | provider: BaseAnimeProvider, 29 | registry: Optional[MediaRegistryService] = None, 30 | ): 31 | self.app_config = app_config 32 | self.provider = provider 33 | self.registry = registry 34 | self.player = create_player(app_config) 35 | 36 | def play( 37 | self, 38 | params: PlayerParams, 39 | anime: Optional[Anime] = None, 40 | media_item: Optional[MediaItem] = None, 41 | local: bool = False, 42 | ) -> PlayerResult: 43 | self.local = local 44 | if self.app_config.stream.use_ipc: 45 | if anime or self.registry: 46 | return self._play_with_ipc(params, anime, media_item) 47 | else: 48 | logger.warning( 49 | f"Ipc player won't be used since Anime Object has not been given for url={params.url}" 50 | ) 51 | return self.player.play(params) 52 | 53 | def _play_with_ipc( 54 | self, 55 | params: PlayerParams, 56 | anime: Optional[Anime] = None, 57 | media_item: Optional[MediaItem] = None, 58 | ) -> PlayerResult: 59 | if self.app_config.stream.player == "mpv": 60 | from .ipc.mpv import MpvIPCPlayer 61 | 62 | registry = self.registry if self.local else None 63 | return MpvIPCPlayer(self.app_config.stream).play( 64 | self.player, params, self.provider, anime, registry, media_item 65 | ) 66 | else: 67 | raise ViuError("Not implemented") 68 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/rofi-themes/input.rasi: -------------------------------------------------------------------------------- 1 | /** 2 | * Rofi Theme: Viu "Tokyo Night" Input 3 | * Author: Gemini ft Benexl 4 | * Description: A compact, modern modal dialog for text input that correctly displays the prompt. 5 | */ 6 | 7 | /*****----- Configuration -----*****/ 8 | configuration { 9 | font: "JetBrains Mono Nerd Font 14"; 10 | } 11 | 12 | /*****----- Global Properties -----*****/ 13 | * { 14 | /* Tokyo Night Color Palette */ 15 | bg-col: #1a1b26ff; 16 | bg-alt: #24283bff; 17 | fg-col: #c0caf5ff; 18 | fg-alt: #a9b1d6ff; 19 | accent: #bb9af7ff; 20 | blue: #7aa2f7ff; 21 | 22 | background-color: transparent; 23 | text-color: @fg-col; 24 | } 25 | 26 | /*****----- Main Window -----*****/ 27 | window { 28 | transparency: "real"; 29 | location: center; 30 | anchor: center; 31 | fullscreen: false; 32 | width: 500px; 33 | 34 | border: 2px; 35 | border-color: @blue; 36 | border-radius: 8px; 37 | padding: 20px; 38 | background-color: @bg-col; 39 | } 40 | 41 | /*****----- Main Box -----*****/ 42 | mainbox { 43 | children: [ message, inputbar ]; 44 | spacing: 20px; 45 | background-color: transparent; 46 | } 47 | 48 | /*****----- Message (The Main Question, uses -mesg) -----*****/ 49 | message { 50 | padding: 10px; 51 | border-radius: 8px; 52 | background-color: @bg-alt; 53 | text-color: @fg-col; 54 | } 55 | 56 | textbox { 57 | font: "JetBrains Mono Nerd Font Bold 14"; 58 | horizontal-align: 0.5; /* Center the prompt text */ 59 | background-color: transparent; 60 | text-color: inherit; 61 | } 62 | 63 | /*****----- Inputbar (Contains the title and entry field) -----*****/ 64 | inputbar { 65 | padding: 8px 12px; 66 | border: 1px; 67 | border-radius: 6px; 68 | border-color: @accent; 69 | background-color: @bg-alt; 70 | spacing: 10px; 71 | children: [ prompt, entry ]; 72 | } 73 | 74 | /* This is the title from the -p flag */ 75 | prompt { 76 | background-color: transparent; 77 | text-color: @accent; 78 | } 79 | 80 | /* This is where the user types */ 81 | entry { 82 | background-color: transparent; 83 | text-color: @fg-col; 84 | placeholder: "Type here..."; 85 | placeholder-color: @fg-alt; 86 | } 87 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/utils.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import os 4 | import re 5 | from itertools import cycle 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | # Dictionary to map hex values to characters 10 | hex_to_char = { 11 | "01": "9", 12 | "08": "0", 13 | "05": "=", 14 | "0a": "2", 15 | "0b": "3", 16 | "0c": "4", 17 | "07": "?", 18 | "00": "8", 19 | "5c": "d", 20 | "0f": "7", 21 | "5e": "f", 22 | "17": "/", 23 | "54": "l", 24 | "09": "1", 25 | "48": "p", 26 | "4f": "w", 27 | "0e": "6", 28 | "5b": "c", 29 | "5d": "e", 30 | "0d": "5", 31 | "53": "k", 32 | "1e": "&", 33 | "5a": "b", 34 | "59": "a", 35 | "4a": "r", 36 | "4c": "t", 37 | "4e": "v", 38 | "57": "o", 39 | "51": "i", 40 | } 41 | 42 | 43 | def debug_extractor(extractor_function): 44 | @functools.wraps(extractor_function) 45 | def _provider_function_wrapper(*args): 46 | if not os.environ.get("VIU_DEBUG"): 47 | try: 48 | return extractor_function(*args) 49 | except Exception as e: 50 | logger.error( 51 | f"[AllAnime@Server={args[3].get('sourceName', 'UNKNOWN')}]: {e}" 52 | ) 53 | else: 54 | return extractor_function(*args) 55 | 56 | return _provider_function_wrapper 57 | 58 | 59 | def give_random_quality(links): 60 | qualities = cycle(["1080", "720", "480", "360"]) 61 | 62 | return [ 63 | {**episode_stream, "quality": quality} 64 | for episode_stream, quality in zip(links, qualities, strict=False) 65 | ] 66 | 67 | 68 | def one_digit_symmetric_xor(password: int, target: str): 69 | def genexp(): 70 | for segment in bytearray.fromhex(target): 71 | yield segment ^ password 72 | 73 | return bytes(genexp()).decode("utf-8") 74 | 75 | 76 | def decode_hex_string(hex_string): 77 | """some of the sources encrypt the urls into hex codes this function decrypts the urls 78 | 79 | Args: 80 | hex_string ([TODO:parameter]): [TODO:description] 81 | 82 | Returns: 83 | [TODO:return] 84 | """ 85 | # Split the hex string into pairs of characters 86 | hex_pairs = re.findall("..", hex_string) 87 | 88 | # Decode each hex pair 89 | decoded_chars = [hex_to_char.get(pair.lower(), pair) for pair in hex_pairs] 90 | 91 | # TODO: Better type handling 92 | return "".join(decoded_chars) # type: ignore 93 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animepahe/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Literal, TypedDict 3 | 4 | 5 | class Server(Enum): 6 | KWIK = "Kwik" 7 | 8 | 9 | class AnimePaheSearchResult(TypedDict): 10 | id: str 11 | title: str 12 | type: str 13 | episodes: int 14 | status: str 15 | season: str 16 | year: int 17 | score: int 18 | poster: str 19 | session: str 20 | 21 | 22 | class AnimePaheSearchPage(TypedDict): 23 | total: int 24 | per_page: int 25 | current_page: int 26 | last_page: int 27 | _from: int 28 | to: int 29 | data: list[AnimePaheSearchResult] 30 | 31 | 32 | class Episode(TypedDict): 33 | id: str 34 | anime_id: int 35 | episode: float 36 | episode2: int 37 | edition: str 38 | title: str 39 | snapshot: str # episode image 40 | disc: str 41 | audio: Literal["eng", "jpn"] 42 | duration: str # time 00:00:00 43 | session: str 44 | filler: int 45 | created_at: str 46 | 47 | 48 | class AnimePaheAnimePage(TypedDict): 49 | total: int 50 | per_page: int 51 | current_page: int 52 | last_page: int 53 | next_page_url: str | None 54 | prev_page_url: str | None 55 | _from: int 56 | to: int 57 | data: list[Episode] 58 | 59 | 60 | class AnimePaheEpisodeInfo(TypedDict): 61 | title: str 62 | episode: float 63 | id: str 64 | translation_type: Literal["eng", "jpn"] 65 | duration: str 66 | poster: str 67 | 68 | 69 | class AvailableEpisodesDetail(TypedDict): 70 | sub: list[str] 71 | dub: list[str] 72 | raw: list[str] 73 | 74 | 75 | class AnimePaheAnime(TypedDict): 76 | id: str 77 | title: str 78 | year: int 79 | season: str 80 | poster: str 81 | score: int 82 | availableEpisodesDetail: AvailableEpisodesDetail 83 | episodesInfo: list[AnimePaheEpisodeInfo] 84 | 85 | 86 | class PageInfo(TypedDict): 87 | total: int 88 | perPage: int 89 | currentPage: int 90 | 91 | 92 | class AnimePaheSearchResults(TypedDict): 93 | pageInfo: PageInfo 94 | results: list[AnimePaheSearchResult] 95 | 96 | 97 | class AnimePaheStreamLink(TypedDict): 98 | quality: str 99 | translation_type: Literal["sub", "dub"] 100 | link: str 101 | 102 | 103 | class AnimePaheServer(TypedDict): 104 | server: Literal["kwik"] 105 | links: list[AnimePaheStreamLink] 106 | episode_title: str 107 | subtitles: list 108 | headers: dict 109 | -------------------------------------------------------------------------------- /viu_media/cli/commands/examples.py: -------------------------------------------------------------------------------- 1 | download = """ 2 | \b 3 | \b\bExamples: 4 | # Download all available episodes 5 | # multiple titles can be specified with -t option 6 | viu download -t -t 7 | # -- or -- 8 | viu download -t -t -r ':' 9 | \b 10 | # download latest episode for the two anime titles 11 | # the number can be any no of latest episodes but a minus sign 12 | # must be present 13 | viu download -t -t -r '-1' 14 | \b 15 | # latest 5 16 | viu download -t -t -r '-5' 17 | \b 18 | # Download specific episode range 19 | # be sure to observe the range Syntax 20 | viu download -t -r '::' 21 | \b 22 | viu download -t -r ':' 23 | \b 24 | viu download -t -r ':' 25 | \b 26 | viu download -t -r ':' 27 | \b 28 | # download specific episode 29 | # remember python indexing starts at 0 30 | viu download -t -r ':' 31 | \b 32 | # merge subtitles with ffmpeg to mkv format; hianime tends to give subs as separate files 33 | # and dont prompt for anything 34 | # eg existing file in destination instead remove 35 | # and clean 36 | # ie remove original files (sub file and vid file) 37 | # only keep merged files 38 | viu download -t --merge --clean --no-prompt 39 | \b 40 | # EOF is used since -t always expects a title 41 | # you can supply anime titles from file or -t at the same time 42 | # from stdin 43 | echo -e "\\n\\n" | viu download -t "EOF" -r -f - 44 | \b 45 | # from file 46 | viu download -t "EOF" -r -f 47 | """ 48 | search = """ 49 | \b 50 | \b\bExamples: 51 | # basic form where you will still be prompted for the episode number 52 | # multiple titles can be specified with the -t option 53 | viu search -t -t 54 | \b 55 | # binge all episodes with this command 56 | viu search -t -r ':' 57 | \b 58 | # watch latest episode 59 | viu search -t -r '-1' 60 | \b 61 | # binge a specific episode range with this command 62 | # be sure to observe the range Syntax 63 | viu search -t -r ':' 64 | \b 65 | viu search -t -r '::' 66 | \b 67 | viu search -t -r ':' 68 | \b 69 | viu search -t -r ':' 70 | """ 71 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Literal, TypedDict 3 | 4 | 5 | class Server(Enum): 6 | SHAREPOINT = "sharepoint" 7 | DROPBOX = "dropbox" 8 | GOGOANIME = "gogoanime" 9 | WETRANSFER = "weTransfer" 10 | WIXMP = "wixmp" 11 | YT = "Yt" 12 | MP4_UPLOAD = "mp4-upload" 13 | 14 | 15 | class AllAnimeEpisodesDetail(TypedDict): 16 | dub: list[str] 17 | sub: list[str] 18 | raw: list[str] 19 | 20 | 21 | class AllAnimeEpisodes(TypedDict): 22 | dub: int 23 | sub: int 24 | raw: int 25 | 26 | 27 | class AllAnimePageInfo(TypedDict): 28 | total: int 29 | 30 | 31 | class AllAnimeShow(TypedDict): 32 | _id: str 33 | name: str 34 | availableEpisodesDetail: AllAnimeEpisodesDetail 35 | __typename: str 36 | 37 | 38 | class AllAnimeSearchResult(TypedDict): 39 | _id: str 40 | name: str 41 | availableEpisodes: AllAnimeEpisodes 42 | __typename: str | None 43 | 44 | 45 | class AllAnimeShows(TypedDict): 46 | pageInfo: AllAnimePageInfo 47 | edges: list[AllAnimeSearchResult] 48 | 49 | 50 | class AllAnimeSearchResults(TypedDict): 51 | shows: AllAnimeShows 52 | 53 | 54 | class AllAnimeSourceDownload(TypedDict): 55 | sourceName: str 56 | dowloadUrl: str 57 | 58 | 59 | class AllAnimeSource(TypedDict): 60 | sourceName: Literal[ 61 | "Sak", 62 | "S-mp4", 63 | "Luf-mp4", 64 | "Default", 65 | "Yt-mp4", 66 | "Kir", 67 | "Mp4", 68 | "Ak", 69 | "Vid-mp4", 70 | "Ok", 71 | "Ss-Hls", 72 | "Fm-Hls", 73 | ] 74 | sourceUrl: str 75 | priority: float 76 | sandbox: str 77 | type: str 78 | className: str 79 | streamerId: str 80 | downloads: AllAnimeSourceDownload 81 | 82 | 83 | class AllAnimeEpisodeStream(TypedDict): 84 | link: str 85 | hls: bool 86 | resolutionStr: str 87 | fromCache: str 88 | 89 | 90 | class AllAnimeEpisodeStreams(TypedDict): 91 | links: list[AllAnimeEpisodeStream] 92 | 93 | 94 | class AllAnimeEpisode(TypedDict): 95 | episodeString: str 96 | sourceUrls: list[AllAnimeSource] 97 | notes: str | None 98 | 99 | 100 | class AllAnimeStream: 101 | link: str 102 | mp4: bool 103 | hls: bool | None 104 | resolutionStr: str 105 | fromCache: str 106 | priority: int 107 | headers: dict | None 108 | 109 | 110 | class AllAnimeStreams: 111 | links: list[AllAnimeStream] 112 | -------------------------------------------------------------------------------- /viu_media/cli/commands/registry/cmd.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ....core.config.model import AppConfig 4 | from ...utils.lazyloader import LazyGroup 5 | from . import examples 6 | 7 | commands = { 8 | "sync": "sync.sync", 9 | "stats": "stats.stats", 10 | "search": "search.search", 11 | "export": "export.export", 12 | "import": "import_.import_", 13 | "clean": "clean.clean", 14 | "backup": "backup.backup", 15 | "restore": "restore.restore", 16 | } 17 | 18 | 19 | @click.group( 20 | cls=LazyGroup, 21 | name="registry", 22 | root="viu_media.cli.commands.registry.commands", 23 | invoke_without_command=True, 24 | help="Manage your local media registry - sync, search, backup and maintain your anime database", 25 | short_help="Local media registry management", 26 | lazy_subcommands=commands, 27 | epilog=examples.main, 28 | ) 29 | @click.option( 30 | "--api", 31 | default="anilist", 32 | help="Media API to use (default: anilist)", 33 | type=click.Choice(["anilist"], case_sensitive=False), 34 | ) 35 | @click.pass_context 36 | def registry(ctx: click.Context, api: str): 37 | """ 38 | The entry point for the 'registry' command. If no subcommand is invoked, 39 | it shows registry information and statistics. 40 | """ 41 | from ...service.feedback import FeedbackService 42 | from ...service.registry import MediaRegistryService 43 | 44 | config: AppConfig = ctx.obj 45 | feedback = FeedbackService(config) 46 | 47 | if ctx.invoked_subcommand is None: 48 | # Show registry overview and statistics 49 | try: 50 | registry_service = MediaRegistryService(api, config.media_registry) 51 | stats = registry_service.get_registry_stats() 52 | 53 | feedback.info("Registry Overview", f"API: {api}") 54 | feedback.info("Total Media", f"{stats.get('total_media', 0)} entries") 55 | feedback.info( 56 | "Recently Updated", 57 | f"{stats.get('recently_updated', 0)} entries in last 7 days", 58 | ) 59 | feedback.info("Storage Path", str(config.media_registry.media_dir)) 60 | 61 | # Show status breakdown if available 62 | status_breakdown = stats.get("status_breakdown", {}) 63 | if status_breakdown: 64 | feedback.info("Status Breakdown:") 65 | for status, count in status_breakdown.items(): 66 | feedback.info(f" {status}", f"{count} entries") 67 | 68 | except Exception as e: 69 | feedback.error("Registry Error", f"Failed to load registry: {e}") 70 | -------------------------------------------------------------------------------- /viu_media/cli/commands/queue/commands/list.py: -------------------------------------------------------------------------------- 1 | import click 2 | from viu_media.core.config import AppConfig 3 | 4 | 5 | @click.command(name="list", help="List items in the download queue and their statuses.") 6 | @click.option( 7 | "--status", 8 | type=click.Choice(["queued", "downloading", "completed", "failed", "paused"]), 9 | ) 10 | @click.option("--detailed", is_flag=True) 11 | @click.pass_obj 12 | def list_cmd(config: AppConfig, status: str | None, detailed: bool | None): 13 | from viu_media.cli.service.feedback import FeedbackService 14 | from viu_media.cli.service.registry import MediaRegistryService 15 | from viu_media.cli.service.registry.models import DownloadStatus 16 | 17 | feedback = FeedbackService(config) 18 | registry = MediaRegistryService(config.general.media_api, config.media_registry) 19 | 20 | status_map = { 21 | "queued": DownloadStatus.QUEUED, 22 | "downloading": DownloadStatus.DOWNLOADING, 23 | "completed": DownloadStatus.COMPLETED, 24 | "failed": DownloadStatus.FAILED, 25 | "paused": DownloadStatus.PAUSED, 26 | } 27 | 28 | # TODO: improve this by modifying the download_status function or create new function 29 | if detailed and status: 30 | target = status_map[status] 31 | episodes = registry.get_episodes_by_download_status(target) 32 | feedback.info(f"{len(episodes)} episode(s) with status {status}.") 33 | for media_id, ep in episodes: 34 | record = registry.get_media_record(media_id) 35 | if record: 36 | feedback.info(f"{record.media_item.title.english} episode {ep}") 37 | return 38 | 39 | if status: 40 | target = status_map[status] 41 | episodes = registry.get_episodes_by_download_status(target) 42 | feedback.info(f"{len(episodes)} episode(s) with status {status}.") 43 | for media_id, ep in episodes: 44 | feedback.info(f"- media:{media_id} episode:{ep}") 45 | else: 46 | from rich.console import Console 47 | from rich.table import Table 48 | 49 | stats = registry.get_download_statistics() 50 | table = Table(title="Queue Status") 51 | table.add_column("Metric") 52 | table.add_column("Value") 53 | table.add_row("Queued", str(stats.get("queued", 0))) 54 | table.add_row("Downloading", str(stats.get("downloading", 0))) 55 | table.add_row("Completed", str(stats.get("downloaded", 0))) 56 | table.add_row("Failed", str(stats.get("failed", 0))) 57 | table.add_row("Paused", str(stats.get("paused", 0))) 58 | 59 | console = Console() 60 | console.print(table) 61 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/allanime/provider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from .....core.utils.graphql import execute_graphql_query_with_get_request 5 | from ..base import BaseAnimeProvider 6 | from ..utils.debug import debug_provider 7 | from .constants import ( 8 | ANIME_GQL, 9 | API_GRAPHQL_ENDPOINT, 10 | API_GRAPHQL_REFERER, 11 | EPISODE_GQL, 12 | SEARCH_GQL, 13 | ) 14 | from .mappers import ( 15 | map_to_anime_result, 16 | map_to_search_results, 17 | ) 18 | 19 | if TYPE_CHECKING: 20 | from .types import AllAnimeEpisode 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | class AllAnime(BaseAnimeProvider): 25 | HEADERS = {"Referer": API_GRAPHQL_REFERER} 26 | 27 | @debug_provider 28 | def search(self, params): 29 | response = execute_graphql_query_with_get_request( 30 | API_GRAPHQL_ENDPOINT, 31 | self.client, 32 | SEARCH_GQL, 33 | variables={ 34 | "search": { 35 | "allowAdult": params.allow_nsfw, 36 | "allowUnknown": params.allow_unknown, 37 | "query": params.query, 38 | }, 39 | "limit": params.page_limit, 40 | "page": params.current_page, 41 | "translationtype": params.translation_type, 42 | "countryorigin": params.country_of_origin, 43 | }, 44 | ) 45 | return map_to_search_results(response) 46 | 47 | @debug_provider 48 | def get(self, params): 49 | response = execute_graphql_query_with_get_request( 50 | API_GRAPHQL_ENDPOINT, 51 | self.client, 52 | ANIME_GQL, 53 | variables={"showId": params.id}, 54 | ) 55 | return map_to_anime_result(response) 56 | 57 | @debug_provider 58 | def episode_streams(self, params): 59 | from .extractors import extract_server 60 | 61 | episode_response = execute_graphql_query_with_get_request( 62 | API_GRAPHQL_ENDPOINT, 63 | self.client, 64 | EPISODE_GQL, 65 | variables={ 66 | "showId": params.anime_id, 67 | "translationType": params.translation_type, 68 | "episodeString": params.episode, 69 | }, 70 | ) 71 | episode: AllAnimeEpisode = episode_response.json()["data"]["episode"] 72 | for source in episode["sourceUrls"]: 73 | if server := extract_server(self.client, params.episode, episode, source): 74 | yield server 75 | 76 | 77 | if __name__ == "__main__": 78 | from ..utils.debug import test_anime_provider 79 | 80 | test_anime_provider(AllAnime) 81 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/provider.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | 4 | from httpx import Client 5 | 6 | from .base import BaseAnimeProvider 7 | from .types import ProviderName 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | PROVIDERS_AVAILABLE = { 12 | "allanime": "provider.AllAnime", 13 | "animepahe": "provider.AnimePahe", 14 | "hianime": "provider.HiAnime", 15 | "nyaa": "provider.Nyaa", 16 | "yugen": "provider.Yugen", 17 | "animeunity": "provider.AnimeUnity", 18 | } 19 | 20 | 21 | class AnimeProviderFactory: 22 | """Factory for creating anime provider instances.""" 23 | 24 | @staticmethod 25 | def create(provider_name: ProviderName) -> BaseAnimeProvider: 26 | """ 27 | Dynamically creates an instance of the specified anime provider. 28 | 29 | This method imports the necessary provider module, instantiates its main class, 30 | and injects a pre-configured HTTP client. 31 | 32 | Args: 33 | provider_name: The name of the provider to create (e.g., 'allanime'). 34 | 35 | Returns: 36 | An instance of a class that inherits from BaseProvider. 37 | 38 | Raises: 39 | ValueError: If the provider_name is not supported. 40 | ImportError: If the provider module or class cannot be found. 41 | """ 42 | from ....core.utils.networking import random_user_agent 43 | 44 | # Correctly determine module and class name from the map 45 | import_path = PROVIDERS_AVAILABLE[provider_name.value.lower()] 46 | module_name, class_name = import_path.split(".", 1) 47 | 48 | # Construct the full package path for dynamic import 49 | package_path = f"viu_media.libs.provider.anime.{provider_name.value.lower()}" 50 | 51 | try: 52 | provider_module = importlib.import_module(f".{module_name}", package_path) 53 | provider_class = getattr(provider_module, class_name) 54 | except (ImportError, AttributeError) as e: 55 | logger.error( 56 | f"Failed to load provider '{provider_name.value.lower()}': {e}" 57 | ) 58 | raise ImportError( 59 | f"Could not load provider '{provider_name.value.lower()}'. " 60 | "Check the module path and class name in PROVIDERS_AVAILABLE." 61 | ) from e 62 | 63 | # Each provider class requires an httpx.Client, which we set up here. 64 | client = Client( 65 | headers={"User-Agent": random_user_agent(), **provider_class.HEADERS} 66 | ) 67 | 68 | return provider_class(client) 69 | 70 | 71 | # Simple alias for ease of use, consistent with other factories in the codebase. 72 | create_provider = AnimeProviderFactory.create 73 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Viu Project Flake"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | flake-utils.url = "github:numtide/flake-utils"; 7 | }; 8 | 9 | outputs = 10 | { 11 | self, 12 | nixpkgs, 13 | flake-utils, 14 | }: 15 | flake-utils.lib.eachDefaultSystem ( 16 | system: 17 | let 18 | pkgs = nixpkgs.legacyPackages.${system}; 19 | inherit (pkgs) lib python312Packages; 20 | 21 | version = "3.1.0"; 22 | in 23 | { 24 | packages.default = python312Packages.buildPythonApplication { 25 | pname = "viu"; 26 | inherit version; 27 | pyproject = true; 28 | 29 | src = self; 30 | 31 | build-system = with python312Packages; [ hatchling ]; 32 | 33 | dependencies = with python312Packages; [ 34 | click 35 | inquirerpy 36 | requests 37 | rich 38 | thefuzz 39 | yt-dlp 40 | dbus-python 41 | hatchling 42 | plyer 43 | mpv 44 | fastapi 45 | pycryptodome 46 | pypresence 47 | httpx 48 | ]; 49 | 50 | postPatch = '' 51 | substituteInPlace pyproject.toml \ 52 | --replace-fail "pydantic>=2.11.7" "pydantic>=2.11.4" 53 | ''; 54 | 55 | makeWrapperArgs = [ 56 | "--prefix PATH : ${ 57 | lib.makeBinPath ( 58 | with pkgs; 59 | [ 60 | mpv 61 | ] 62 | ) 63 | }" 64 | ]; 65 | 66 | # Needs to be adapted for the nix derivation build 67 | doCheck = false; 68 | 69 | meta = { 70 | description = "Your browser anime experience from the terminal"; 71 | homepage = "https://github.com/viu-media/Viu"; 72 | changelog = "https://github.com/viu-media/Viu/releases/tag/v${version}"; 73 | mainProgram = "viu"; 74 | license = lib.licenses.unlicense; 75 | maintainers = with lib.maintainers; [ theobori ]; 76 | }; 77 | }; 78 | 79 | devShells.default = pkgs.mkShell { 80 | venvDir = ".venv"; 81 | 82 | env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.libxcrypt-legacy ]; 83 | 84 | packages = 85 | with pkgs; 86 | [ 87 | mpv 88 | fzf 89 | rofi 90 | uv 91 | pyright 92 | ] 93 | ++ (with python3Packages; [ 94 | venvShellHook 95 | hatchling 96 | ]) 97 | ++ self.packages.${system}.default.dependencies; 98 | }; 99 | } 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark Stale Issues and Pull Requests 2 | 3 | on: 4 | schedule: 5 | # Runs every day at 6:30 UTC 6 | - cron: "30 6 * * *" 7 | # Allows you to run this workflow manually from the Actions tab for testing 8 | workflow_dispatch: 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | issues: write 15 | pull-requests: write 16 | 17 | steps: 18 | - uses: actions/stale@v5 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | stale-issue-message: | 23 | Greetings @{{author}}, 24 | 25 | 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! 26 | 27 | **Seeking Immediate Help or Discussion?** 28 | 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! 29 | 30 | **Want to Be the Hero?** 31 | 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! 32 | 33 | --- 34 | 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. 35 | 36 | Thanks for being our trusted scout! 37 | 38 | stale-pr-message: | 39 | Hello @{{author}}, it looks like this powerful contribution has been left in the middle of its training arc! 💪 40 | 41 | 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. 42 | 43 | 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! 44 | 45 | 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. 46 | 47 | Thank you for your incredible effort! 48 | 49 | # --- Labels and Timing --- 50 | stale-issue-label: "stale" 51 | stale-pr-label: "stale" 52 | 53 | # How many days of inactivity before an issue/PR is marked as stale. 54 | days-before-stale: 14 55 | 56 | # How many days of inactivity to wait before closing a stale issue/PR. 57 | days-before-close: 7 58 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/commands/auth.py: -------------------------------------------------------------------------------- 1 | import click 2 | import webbrowser 3 | 4 | from .....core.config.model import AppConfig 5 | 6 | 7 | @click.command(help="Login to your AniList account to enable progress tracking.") 8 | @click.option("--status", "-s", is_flag=True, help="Check current login status.") 9 | @click.option("--logout", "-l", is_flag=True, help="Log out and erase credentials.") 10 | @click.pass_obj 11 | def auth(config: AppConfig, status: bool, logout: bool): 12 | """Handles user authentication and credential management.""" 13 | from .....core.constants import ANILIST_AUTH 14 | from .....libs.media_api.api import create_api_client 15 | from .....libs.selectors.selector import create_selector 16 | from ....service.auth import AuthService 17 | from ....service.feedback import FeedbackService 18 | 19 | auth_service = AuthService("anilist") 20 | feedback = FeedbackService(config) 21 | selector = create_selector(config) 22 | feedback.clear_console() 23 | 24 | if status: 25 | user_data = auth_service.get_auth() 26 | if user_data: 27 | feedback.info(f"Logged in as: {user_data.user_profile}") 28 | else: 29 | feedback.error("Not logged in.") 30 | return 31 | 32 | if logout: 33 | if selector.confirm("Are you sure you want to log out and erase your token?"): 34 | auth_service.clear_user_profile() 35 | feedback.info("You have been logged out.") 36 | return 37 | 38 | if auth_profile := auth_service.get_auth(): 39 | if not selector.confirm( 40 | f"You are already logged in as {auth_profile.user_profile.name}.Would you like to relogin" 41 | ): 42 | return 43 | api_client = create_api_client("anilist", config) 44 | 45 | open_success = webbrowser.open(ANILIST_AUTH, new=2) 46 | if open_success: 47 | feedback.info("Your browser has been opened to obtain an AniList token.") 48 | feedback.info( 49 | f"or you can visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." 50 | ) 51 | else: 52 | feedback.warning( 53 | f"Failed to open the browser. Please visit the site manually [magenta][link={ANILIST_AUTH}]here[/link][/magenta]." 54 | ) 55 | feedback.info( 56 | "After authorizing, copy the token from the address bar and paste it below." 57 | ) 58 | 59 | token = selector.ask("Enter your AniList Access Token") 60 | if not token: 61 | feedback.error("Login cancelled.") 62 | return 63 | 64 | # Use the API client to validate the token and get profile info 65 | profile = api_client.authenticate(token.strip()) 66 | 67 | if profile: 68 | # If successful, use the manager to save the credentials 69 | auth_service.save_user_profile(profile, token) 70 | feedback.info(f"Successfully logged in as {profile.name}! ✨") 71 | else: 72 | feedback.error("Login failed. The token may be invalid or expired.") 73 | -------------------------------------------------------------------------------- /viu_media/core/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from importlib import metadata, resources 4 | from pathlib import Path 5 | 6 | PLATFORM = sys.platform 7 | CLI_NAME = "VIU" 8 | CLI_NAME_LOWER = "viu" 9 | PROJECT_NAME = "viu-media" 10 | APP_NAME = os.environ.get(f"{CLI_NAME}_APP_NAME", CLI_NAME_LOWER) 11 | 12 | USER_NAME = os.environ.get("USERNAME", os.environ.get("USER", "User")) 13 | 14 | 15 | __version__ = metadata.version("viu_media") 16 | 17 | AUTHOR = "viu-media" 18 | GIT_REPO = "github.com" 19 | GIT_PROTOCOL = "https://" 20 | REPO_HOME = f"https://{GIT_REPO}/{AUTHOR}/Viu" 21 | 22 | DISCORD_INVITE = "https://discord.gg/C4rhMA4mmK" 23 | 24 | ANILIST_AUTH = ( 25 | "https://anilist.co/api/v2/oauth/authorize?client_id=20148&response_type=token" 26 | ) 27 | 28 | try: 29 | APP_DIR = Path(str(resources.files(PROJECT_NAME.lower()))) 30 | 31 | except ModuleNotFoundError: 32 | from pathlib import Path 33 | 34 | APP_DIR = Path(__file__).resolve().parent.parent 35 | 36 | ASSETS_DIR = APP_DIR / "assets" 37 | DEFAULTS_DIR = ASSETS_DIR / "defaults" 38 | SCRIPTS_DIR = ASSETS_DIR / "scripts" 39 | GRAPHQL_DIR = ASSETS_DIR / "graphql" 40 | ICONS_DIR = ASSETS_DIR / "icons" 41 | 42 | ICON_PATH = ICONS_DIR / ("logo.ico" if PLATFORM == "Win32" else "logo.png") 43 | APP_ASCII_ART = DEFAULTS_DIR / "ascii-art" 44 | 45 | try: 46 | import click 47 | 48 | APP_DATA_DIR = Path(click.get_app_dir(APP_NAME, roaming=False)) 49 | except ModuleNotFoundError: 50 | if PLATFORM == "win32": 51 | folder = os.environ.get("LOCALAPPDATA") 52 | if folder is None: 53 | folder = Path.home() 54 | APP_DATA_DIR = Path(folder) / APP_NAME 55 | if PLATFORM == "darwin": 56 | APP_DATA_DIR = Path(Path.home() / "Library" / "Application Support" / APP_NAME) 57 | 58 | APP_DATA_DIR = ( 59 | Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / APP_NAME 60 | ) 61 | 62 | if PLATFORM == "win32": 63 | APP_CACHE_DIR = APP_DATA_DIR / "cache" 64 | USER_VIDEOS_DIR = Path.home() / "Videos" / APP_NAME 65 | 66 | elif PLATFORM == "darwin": 67 | APP_CACHE_DIR = Path.home() / "Library" / "Caches" / APP_NAME 68 | USER_VIDEOS_DIR = Path.home() / "Movies" / APP_NAME 69 | 70 | else: 71 | xdg_cache_home = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) 72 | APP_CACHE_DIR = xdg_cache_home / APP_NAME 73 | 74 | xdg_videos_dir = Path(os.environ.get("XDG_VIDEOS_DIR", Path.home() / "Videos")) 75 | USER_VIDEOS_DIR = xdg_videos_dir / APP_NAME 76 | 77 | USER_APPLICATIONS = Path.home() / ".local" / "share" / "applications" 78 | LOG_FOLDER = APP_CACHE_DIR / "logs" 79 | 80 | # USER_APPLICATIONS.mkdir(parents=True,exist_ok=True) 81 | APP_DATA_DIR.mkdir(parents=True, exist_ok=True) 82 | APP_CACHE_DIR.mkdir(parents=True, exist_ok=True) 83 | LOG_FOLDER.mkdir(parents=True, exist_ok=True) 84 | USER_VIDEOS_DIR.mkdir(parents=True, exist_ok=True) 85 | 86 | USER_CONFIG = APP_DATA_DIR / "config.toml" 87 | 88 | LOG_FILE = LOG_FOLDER / "app.log" 89 | SUPPORT_PROJECT_URL = "https://buymeacoffee.com/benexl" 90 | -------------------------------------------------------------------------------- /viu_media/assets/graphql/anilist/queries/search.gql: -------------------------------------------------------------------------------- 1 | query ( 2 | $query: String 3 | $per_page: Int 4 | $page: Int 5 | $sort: [MediaSort] 6 | $id_in: [Int] 7 | $genre_in: [String] 8 | $genre_not_in: [String] 9 | $tag_in: [String] 10 | $tag_not_in: [String] 11 | $status_in: [MediaStatus] 12 | $status: MediaStatus 13 | $status_not_in: [MediaStatus] 14 | $popularity_greater: Int 15 | $popularity_lesser: Int 16 | $averageScore_greater: Int 17 | $averageScore_lesser: Int 18 | $seasonYear: Int 19 | $startDate_greater: FuzzyDateInt 20 | $startDate_lesser: FuzzyDateInt 21 | $startDate: FuzzyDateInt 22 | $endDate_greater: FuzzyDateInt 23 | $endDate_lesser: FuzzyDateInt 24 | $format_in: [MediaFormat] 25 | $type: MediaType 26 | $season: MediaSeason 27 | $on_list: Boolean 28 | ) { 29 | Page(perPage: $per_page, page: $page) { 30 | pageInfo { 31 | total 32 | currentPage 33 | hasNextPage 34 | } 35 | media( 36 | search: $query 37 | id_in: $id_in 38 | genre_in: $genre_in 39 | genre_not_in: $genre_not_in 40 | tag_in: $tag_in 41 | tag_not_in: $tag_not_in 42 | status_in: $status_in 43 | status: $status 44 | startDate: $startDate 45 | status_not_in: $status_not_in 46 | popularity_greater: $popularity_greater 47 | popularity_lesser: $popularity_lesser 48 | averageScore_greater: $averageScore_greater 49 | averageScore_lesser: $averageScore_lesser 50 | startDate_greater: $startDate_greater 51 | startDate_lesser: $startDate_lesser 52 | endDate_greater: $endDate_greater 53 | endDate_lesser: $endDate_lesser 54 | format_in: $format_in 55 | sort: $sort 56 | season: $season 57 | seasonYear: $seasonYear 58 | type: $type 59 | onList: $on_list 60 | ) { 61 | id 62 | idMal 63 | format 64 | title { 65 | romaji 66 | english 67 | } 68 | coverImage { 69 | medium 70 | large 71 | } 72 | trailer { 73 | site 74 | id 75 | } 76 | mediaListEntry { 77 | status 78 | id 79 | progress 80 | } 81 | popularity 82 | streamingEpisodes { 83 | title 84 | thumbnail 85 | } 86 | favourites 87 | averageScore 88 | duration 89 | episodes 90 | genres 91 | synonyms 92 | studios { 93 | nodes { 94 | name 95 | favourites 96 | isAnimationStudio 97 | } 98 | } 99 | tags { 100 | name 101 | } 102 | startDate { 103 | year 104 | month 105 | day 106 | } 107 | endDate { 108 | year 109 | month 110 | day 111 | } 112 | status 113 | description 114 | nextAiringEpisode { 115 | timeUntilAiring 116 | airingAt 117 | episode 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /viu_media/cli/interactive/menu/media/media_review.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional, Union 2 | 3 | from .....libs.media_api.types import MediaReview 4 | from ...session import Context, session 5 | from ...state import InternalDirective, State 6 | 7 | 8 | @session.menu 9 | def media_review(ctx: Context, state: State) -> Union[State, InternalDirective]: 10 | """ 11 | Fetches and displays a list of reviews for the user to select from. 12 | Shows the full review body upon selection or in the preview pane. 13 | """ 14 | from rich.console import Console 15 | from rich.markdown import Markdown 16 | from rich.panel import Panel 17 | 18 | feedback = ctx.feedback 19 | selector = ctx.selector 20 | console = Console() 21 | config = ctx.config 22 | media_item = state.media_api.media_item 23 | 24 | if not media_item: 25 | feedback.error("Media item is not in state.") 26 | return InternalDirective.BACK 27 | 28 | from .....libs.media_api.params import MediaReviewsParams 29 | 30 | loading_message = ( 31 | f"Fetching reviews for {media_item.title.english or media_item.title.romaji}..." 32 | ) 33 | reviews: Optional[List[MediaReview]] = None 34 | 35 | with feedback.progress(loading_message): 36 | reviews = ctx.media_api.get_reviews_for( 37 | MediaReviewsParams(id=media_item.id, per_page=15) 38 | ) 39 | 40 | if not reviews: 41 | feedback.error("No reviews found for this anime.") 42 | return InternalDirective.BACK 43 | 44 | choice_map: Dict[str, MediaReview] = { 45 | f"By {review.user.name}: {(review.summary or 'No summary')[:80]}": review 46 | for review in reviews 47 | } 48 | choices = list(choice_map.keys()) + ["Back"] 49 | 50 | preview_command = None 51 | if config.general.preview != "none": 52 | from ....utils.preview import create_preview_context 53 | 54 | with create_preview_context() as preview_ctx: 55 | preview_command = preview_ctx.get_review_preview(choice_map, ctx.config) 56 | 57 | while True: 58 | chosen_title = selector.choose( 59 | prompt="Select a review to read", 60 | choices=choices, 61 | preview=preview_command, 62 | ) 63 | 64 | if not chosen_title or chosen_title == "Back": 65 | return InternalDirective.BACK 66 | 67 | selected_review = choice_map[chosen_title] 68 | console.clear() 69 | 70 | reviewer_name = f"[bold magenta]{selected_review.user.name}[/bold magenta]" 71 | review_summary = ( 72 | f"[italic green]'{selected_review.summary}'[/italic green]" 73 | if selected_review.summary 74 | else "" 75 | ) 76 | panel_title = f"Review by {reviewer_name} - {review_summary}" 77 | review_body = Markdown(selected_review.body) 78 | 79 | console.print( 80 | Panel(review_body, title=panel_title, border_style="blue", expand=True) 81 | ) 82 | selector.ask("\nPress Enter to return to the review list...") 83 | -------------------------------------------------------------------------------- /viu_media/cli/interactive/menu/media/episodes.py: -------------------------------------------------------------------------------- 1 | from ...session import Context, session 2 | from ...state import InternalDirective, MenuName, State 3 | 4 | 5 | @session.menu 6 | def episodes(ctx: Context, state: State) -> State | InternalDirective: 7 | """ 8 | Displays available episodes for a selected provider anime and handles 9 | the logic for continuing from watch history or manual selection. 10 | """ 11 | config = ctx.config 12 | feedback = ctx.feedback 13 | feedback.clear_console() 14 | 15 | provider_anime = state.provider.anime 16 | media_item = state.media_api.media_item 17 | 18 | if not provider_anime or not media_item: 19 | feedback.error("Error: Anime details are missing.") 20 | return InternalDirective.BACK 21 | 22 | available_episodes = getattr( 23 | provider_anime.episodes, config.stream.translation_type, [] 24 | ) 25 | if not available_episodes: 26 | feedback.warning( 27 | f"No '{config.stream.translation_type}' episodes found for this anime." 28 | ) 29 | return InternalDirective.BACKX2 30 | 31 | chosen_episode: str | None = None 32 | start_time: str | None = None 33 | 34 | if config.stream.continue_from_watch_history: 35 | chosen_episode, start_time = ctx.watch_history.get_episode(media_item) 36 | 37 | if not chosen_episode or ctx.switch.show_episodes_menu: 38 | choices = [*available_episodes, "Back"] 39 | 40 | preview_command = None 41 | if ctx.config.general.preview != "none": 42 | from ....utils.preview import create_preview_context 43 | 44 | with create_preview_context() as preview_ctx: 45 | preview_command = preview_ctx.get_episode_preview( 46 | available_episodes, media_item, ctx.config 47 | ) 48 | 49 | chosen_episode_str = ctx.selector.choose( 50 | prompt="Select Episode", choices=choices, preview=preview_command 51 | ) 52 | 53 | if not chosen_episode_str or chosen_episode_str == "Back": 54 | # TODO: should improve the back logic for menus that can be pass through 55 | return InternalDirective.BACKX2 56 | 57 | chosen_episode = chosen_episode_str 58 | # Workers are automatically cleaned up when exiting the context 59 | else: 60 | # No preview mode 61 | chosen_episode_str = ctx.selector.choose( 62 | prompt="Select Episode", choices=choices, preview=None 63 | ) 64 | 65 | if not chosen_episode_str or chosen_episode_str == "Back": 66 | # TODO: should improve the back logic for menus that can be pass through 67 | return InternalDirective.BACKX2 68 | 69 | chosen_episode = chosen_episode_str 70 | 71 | return State( 72 | menu_name=MenuName.SERVERS, 73 | media_api=state.media_api, 74 | provider=state.provider.model_copy( 75 | update={"episode_": chosen_episode, "start_time_": start_time} 76 | ), 77 | ) 78 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/rofi-themes/preview.rasi: -------------------------------------------------------------------------------- 1 | /** 2 | * Rofi Theme: Viu "Tokyo Night" Horizontal Strip 3 | * Author: Gemini ft Benexl 4 | * Description: A fullscreen, horizontal, icon-centric theme for previews. 5 | */ 6 | 7 | /*****----- Configuration -----*****/ 8 | configuration { 9 | font: "JetBrains Mono Nerd Font 12"; 10 | show-icons: true; 11 | } 12 | 13 | /*****----- Global Properties -----*****/ 14 | * { 15 | /* Tokyo Night Color Palette */ 16 | bg-col: #1a1b26; 17 | bg-alt: #24283b; /* Slightly lighter for elements */ 18 | fg-col: #c0caf5; 19 | fg-alt: #a9b1d6; 20 | 21 | blue: #7aa2f7; 22 | cyan: #7dcfff; 23 | magenta: #bb9af7; 24 | 25 | background-color: transparent; 26 | text-color: @fg-col; 27 | } 28 | 29 | /*****----- Main Window -----*****/ 30 | window { 31 | transparency: "real"; 32 | background-color: @bg-col; 33 | fullscreen: true; 34 | padding: 2%; 35 | } 36 | 37 | /*****----- Main Box -----*****/ 38 | mainbox { 39 | children: [ inputbar, listview ]; 40 | spacing: 3%; 41 | background-color: transparent; 42 | } 43 | 44 | /*****----- Inputbar -----*****/ 45 | inputbar { 46 | spacing: 15px; 47 | padding: 12px 16px; 48 | border-radius: 10px; 49 | background-color: @bg-alt; 50 | text-color: @fg-col; 51 | margin: 0% 20%; /* Center the input bar */ 52 | children: [ prompt, entry ]; 53 | } 54 | 55 | prompt { 56 | text-color: @magenta; 57 | background-color: transparent; 58 | } 59 | 60 | entry { 61 | background-color: transparent; 62 | placeholder: "Select an option..."; 63 | placeholder-color: @fg-alt; 64 | } 65 | 66 | /*****----- List of items -----*****/ 67 | listview { 68 | layout: horizontal; 69 | columns: 5; 70 | spacing: 20px; 71 | fixed-height: true; 72 | background-color: transparent; 73 | } 74 | 75 | /*****----- Elements -----*****/ 76 | element { 77 | orientation: vertical; 78 | padding: 30px 20px; 79 | border-radius: 12px; 80 | spacing: 20px; 81 | background-color: @bg-alt; 82 | cursor: pointer; 83 | width: 200px; /* Width of each element */ 84 | height: 50px; /* Height of each element */ 85 | } 86 | 87 | element-icon { 88 | size: 33%; 89 | horizontal-align: 0.5; 90 | background-color: transparent; 91 | } 92 | 93 | element-text { 94 | horizontal-align: 0.5; 95 | background-color: transparent; 96 | text-color: inherit; 97 | } 98 | 99 | /* Default state of elements */ 100 | element normal.normal { 101 | background-color: @bg-alt; 102 | text-color: @fg-col; 103 | } 104 | 105 | /* Selected entry in the list */ 106 | element selected.normal { 107 | background-color: @blue; 108 | text-color: @bg-col; /* Invert text color for contrast */ 109 | } 110 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/rofi-themes/main.rasi: -------------------------------------------------------------------------------- 1 | /** 2 | * Rofi Theme: Viu "Tokyo Night" Main 3 | * Author: Gemini ft Benexl 4 | * Description: A sharp, modern, and ultra-compact theme with a Tokyo Night palette. 5 | */ 6 | 7 | /*****----- Configuration -----*****/ 8 | configuration { 9 | font: "JetBrains Mono Nerd Font 14"; 10 | show-icons: false; 11 | location: 0; /* 0 = center */ 12 | width: 50; 13 | yoffset: -50; 14 | lines: 3; 15 | } 16 | 17 | /*****----- Global Properties -----*****/ 18 | * { 19 | /* Tokyo Night Color Palette */ 20 | bg-col: #1a1b26ff; /* Main Background */ 21 | bg-alt: #24283bff; /* Lighter Background for elements */ 22 | fg-col: #c0caf5ff; /* Main Foreground */ 23 | fg-alt: #a9b1d6ff; /* Dimmer Foreground for placeholders */ 24 | accent: #bb9af7ff; /* Magenta/Purple for accents */ 25 | selected: #7aa2f7ff; /* Blue for selection highlight */ 26 | 27 | background-color: transparent; 28 | text-color: @fg-col; 29 | } 30 | 31 | /*****----- Main Window -----*****/ 32 | window { 33 | transparency: "real"; 34 | background-color: @bg-col; 35 | border: 2px; 36 | border-color: @selected; /* Using blue for the main border */ 37 | border-radius: 8px; 38 | padding: 12px; 39 | } 40 | 41 | /*****----- Main Box -----*****/ 42 | mainbox { 43 | children: [ inputbar, listview ]; 44 | spacing: 10px; 45 | background-color: transparent; 46 | } 47 | 48 | /*****----- Inputbar -----*****/ 49 | inputbar { 50 | background-color: @bg-alt; 51 | border: 1px; 52 | border-color: @accent; /* Using magenta for the input border */ 53 | border-radius: 6px; 54 | padding: 6px 12px; 55 | spacing: 12px; 56 | children: [ prompt, entry ]; 57 | } 58 | 59 | prompt { 60 | background-color: transparent; 61 | text-color: @accent; 62 | } 63 | 64 | entry { 65 | background-color: transparent; 66 | text-color: @fg-col; 67 | placeholder: "Search..."; 68 | placeholder-color: @fg-alt; 69 | } 70 | 71 | /*****----- List of items -----*****/ 72 | listview { 73 | scrollbar: false; 74 | spacing: 4px; 75 | padding: 4px 0px; 76 | layout: vertical; 77 | background-color: transparent; 78 | } 79 | 80 | /*****----- Elements -----*****/ 81 | element { 82 | padding: 6px 12px; 83 | border-radius: 6px; 84 | spacing: 15px; 85 | background-color: transparent; 86 | } 87 | 88 | element-text { 89 | vertical-align: 0.5; 90 | background-color: transparent; 91 | text-color: inherit; 92 | } 93 | 94 | /* Default state of elements */ 95 | element normal.normal { 96 | background-color: transparent; 97 | text-color: @fg-col; 98 | } 99 | 100 | /* Selected entry in the list */ 101 | element selected.normal { 102 | background-color: @selected; /* Blue highlight */ 103 | text-color: @bg-col; /* Dark text for high contrast */ 104 | } 105 | -------------------------------------------------------------------------------- /viu_media/assets/defaults/rofi-themes/confirm.rasi: -------------------------------------------------------------------------------- 1 | /** 2 | * Rofi Theme: Viu "Tokyo Night" Confirmation 3 | * Author: Gemini ft Benexl 4 | * Description: A compact and clear modal dialog for Yes/No confirmations that displays a prompt. 5 | */ 6 | 7 | /*****----- Configuration -----*****/ 8 | configuration { 9 | font: "JetBrains Mono Nerd Font 12"; 10 | } 11 | 12 | /*****----- Global Properties -----*****/ 13 | * { 14 | /* Tokyo Night Color Palette */ 15 | bg-col: #1a1b26; 16 | bg-alt: #24283b; 17 | fg-col: #c0caf5; 18 | 19 | blue: #7aa2f7; 20 | green: #9ece6a; /* For 'Yes' */ 21 | red: #f7768e; /* For 'No' */ 22 | 23 | background-color: transparent; 24 | text-color: @fg-col; 25 | } 26 | 27 | /*****----- Main Window -----*****/ 28 | window { 29 | transparency: "real"; 30 | location: center; 31 | anchor: center; 32 | fullscreen: false; 33 | width: 350px; 34 | 35 | border: 2px; 36 | border-color: @blue; 37 | border-radius: 12px; 38 | padding: 20px; 39 | background-color: @bg-col; 40 | } 41 | 42 | /*****----- Main Box -----*****/ 43 | mainbox { 44 | children: [ inputbar, message, listview ]; 45 | spacing: 15px; 46 | background-color: transparent; 47 | } 48 | 49 | /*****----- Inputbar (Displays the -p 'prompt') -----*****/ 50 | inputbar { 51 | background-color: transparent; 52 | text-color: @blue; 53 | children: [ prompt ]; 54 | } 55 | 56 | prompt { 57 | font: "JetBrains Mono Nerd Font Bold 14"; 58 | horizontal-align: 0.5; /* Center the title */ 59 | background-color: transparent; 60 | text-color: inherit; 61 | } 62 | 63 | 64 | /*****----- Message (Displays the -mesg 'Are you Sure?') -----*****/ 65 | message { 66 | padding: 10px; 67 | margin: 5px 0px; 68 | border-radius: 8px; 69 | background-color: @bg-alt; 70 | text-color: @fg-col; 71 | } 72 | 73 | textbox { 74 | font: "JetBrains Mono Nerd Font 12"; 75 | horizontal-align: 0.5; 76 | background-color: transparent; 77 | text-color: inherit; 78 | } 79 | 80 | /*****----- Listview (The Buttons) -----*****/ 81 | listview { 82 | columns: 2; 83 | lines: 1; 84 | spacing: 15px; 85 | layout: vertical; 86 | background-color: transparent; 87 | } 88 | 89 | /*****----- Elements (Yes/No Buttons) -----*****/ 90 | element { 91 | padding: 12px; 92 | border-radius: 8px; 93 | background-color: @bg-alt; 94 | text-color: @fg-col; 95 | cursor: pointer; 96 | } 97 | 98 | element-text { 99 | font: "JetBrains Mono Nerd Font Bold 12"; 100 | horizontal-align: 0.5; 101 | background-color: transparent; 102 | text-color: inherit; 103 | } 104 | 105 | element normal.normal { 106 | background-color: @bg-alt; 107 | text-color: @fg-col; 108 | } 109 | 110 | element selected.normal { 111 | background-color: @blue; 112 | text-color: @bg-col; 113 | } 114 | -------------------------------------------------------------------------------- /viu_media/core/utils/networking.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import re 4 | from urllib.parse import unquote, urlparse 5 | 6 | import httpx 7 | 8 | TIMEOUT = 10 9 | 10 | 11 | def random_user_agent(): 12 | _USER_AGENT_TPL = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%s Safari/537.36" 13 | _CHROME_VERSIONS = ( 14 | "90.0.4430.212", 15 | "90.0.4430.24", 16 | "90.0.4430.70", 17 | "90.0.4430.72", 18 | "90.0.4430.85", 19 | "90.0.4430.93", 20 | "91.0.4472.101", 21 | "91.0.4472.106", 22 | "91.0.4472.114", 23 | "91.0.4472.124", 24 | "91.0.4472.164", 25 | "91.0.4472.19", 26 | "91.0.4472.77", 27 | "92.0.4515.107", 28 | "92.0.4515.115", 29 | "92.0.4515.131", 30 | "92.0.4515.159", 31 | "92.0.4515.43", 32 | "93.0.4556.0", 33 | "93.0.4577.15", 34 | "93.0.4577.63", 35 | "93.0.4577.82", 36 | "94.0.4606.41", 37 | "94.0.4606.54", 38 | "94.0.4606.61", 39 | "94.0.4606.71", 40 | "94.0.4606.81", 41 | "94.0.4606.85", 42 | "95.0.4638.17", 43 | "95.0.4638.50", 44 | "95.0.4638.54", 45 | "95.0.4638.69", 46 | "95.0.4638.74", 47 | "96.0.4664.18", 48 | "96.0.4664.45", 49 | "96.0.4664.55", 50 | "96.0.4664.93", 51 | "97.0.4692.20", 52 | ) 53 | return _USER_AGENT_TPL % random.choice(_CHROME_VERSIONS) 54 | 55 | 56 | def get_remote_filename(response: httpx.Response) -> str | None: 57 | """ 58 | Extracts the filename from the Content-Disposition header or the URL. 59 | 60 | Args: 61 | response: The httpx.Response object. 62 | 63 | Returns: 64 | The extracted filename as a string, or None if not found. 65 | """ 66 | content_disposition = response.headers.get("Content-Disposition") 67 | if content_disposition: 68 | filename_match = re.search( 69 | r"filename\*=(.+)", content_disposition, re.IGNORECASE 70 | ) 71 | if filename_match: 72 | encoded_filename = filename_match.group(1).strip() 73 | try: 74 | if "''" in encoded_filename: 75 | parts = encoded_filename.split("''", 1) 76 | if len(parts) == 2: 77 | return unquote(parts[1]) 78 | return unquote( 79 | encoded_filename 80 | ) # Fallback for simple URL-encoded parts 81 | except Exception: 82 | pass # Fallback to filename or URL if decoding fails 83 | 84 | filename_match = re.search( 85 | r"filename=\"?([^\";]+)\"?", content_disposition, re.IGNORECASE 86 | ) 87 | if filename_match: 88 | return unquote(filename_match.group(1).strip()) 89 | 90 | parsed_url = urlparse(str(response.url)) # Convert httpx.URL to string for urlparse 91 | path = parsed_url.path 92 | if path: 93 | filename_from_url = os.path.basename(path) 94 | if filename_from_url: 95 | filename_from_url = filename_from_url.split("?")[0].split("#")[0] 96 | return unquote(filename_from_url) # Unquote URL-encoded characters 97 | 98 | return None 99 | -------------------------------------------------------------------------------- /viu_media/cli/service/session/service.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from datetime import datetime 4 | from typing import List, Optional 5 | 6 | from ....core.config.model import SessionsConfig 7 | from ....core.utils.file import AtomicWriter 8 | from ...interactive.state import State 9 | from .model import Session 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class SessionsService: 15 | def __init__(self, config: SessionsConfig): 16 | self.dir = config.dir 17 | self._ensure_sessions_directory() 18 | 19 | def save_session( 20 | self, history: List[State], name: Optional[str] = None, default=True 21 | ): 22 | if default: 23 | name = "default" 24 | session = Session(history=history, name=name) 25 | else: 26 | session = Session(history=history) 27 | self._save_session(session) 28 | 29 | def create_crash_backup(self, history: List[State], default=True): 30 | if default: 31 | self._save_session( 32 | Session(history=history, name="crash", is_from_crash=True) 33 | ) 34 | else: 35 | self._save_session(Session(history=history, is_from_crash=True)) 36 | 37 | def get_session_history(self, session_name: str) -> Optional[List[State]]: 38 | if session := self._load_session(session_name): 39 | return session.history 40 | 41 | def get_default_session_history(self) -> Optional[List[State]]: 42 | if history := self.get_session_history("default"): 43 | return history 44 | 45 | def get_most_recent_session_history(self) -> Optional[List[State]]: 46 | session_name: Optional[str] = None 47 | latest_timestamp: Optional[datetime] = None 48 | for session_file in self.dir.iterdir(): 49 | try: 50 | _session_timestamp = session_file.stem.split("_")[1] 51 | 52 | session_timestamp = datetime.strptime( 53 | _session_timestamp, "%Y%m%d_%H%M%S_%f" 54 | ) 55 | if latest_timestamp is None or session_timestamp > latest_timestamp: 56 | session_name = session_file.stem 57 | latest_timestamp = session_timestamp 58 | 59 | except Exception as e: 60 | logger.error(f"{self.dir} is impure which caused: {e}") 61 | 62 | if session_name: 63 | return self.get_session_history(session_name) 64 | 65 | def _save_session(self, session: Session): 66 | path = self.dir / f"{session.name}.json" 67 | with AtomicWriter(path) as f: 68 | json.dump(session.model_dump(mode="json", by_alias=True), f) 69 | 70 | def _load_session(self, session_name: str) -> Optional[Session]: 71 | path = self.dir / f"{session_name}.json" 72 | if not path.exists(): 73 | logger.warning(f"Session file not found: {path}") 74 | return None 75 | 76 | with path.open("r", encoding="utf-8") as f: 77 | data = json.load(f) 78 | session = Session.model_validate(data) 79 | 80 | logger.info(f"Session loaded from {path} with {session.state_count} states") 81 | return session 82 | 83 | def _ensure_sessions_directory(self): 84 | self.dir.mkdir(parents=True, exist_ok=True) 85 | -------------------------------------------------------------------------------- /viu_media/libs/media_api/params.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional, Union 3 | 4 | from .types import ( 5 | MediaFormat, 6 | MediaGenre, 7 | MediaSeason, 8 | MediaSort, 9 | MediaStatus, 10 | MediaTag, 11 | MediaType, 12 | UserMediaListSort, 13 | UserMediaListStatus, 14 | ) 15 | 16 | 17 | @dataclass(frozen=True) 18 | class MediaSearchParams: 19 | query: Optional[str] = None 20 | page: int = 1 21 | per_page: Optional[int] = None 22 | sort: Optional[Union[MediaSort, List[MediaSort]]] = None 23 | 24 | # IDs 25 | id_in: Optional[List[int]] = None 26 | 27 | # Genres 28 | genre_in: Optional[List[MediaGenre]] = None 29 | genre_not_in: Optional[List[MediaGenre]] = None 30 | 31 | # Tags 32 | tag_in: Optional[List[MediaTag]] = None 33 | tag_not_in: Optional[List[MediaTag]] = None 34 | 35 | # Status 36 | status_in: Optional[List[MediaStatus]] = None # Corresponds to [MediaStatus] 37 | status: Optional[MediaStatus] = None # Corresponds to MediaStatus 38 | status_not_in: Optional[List[MediaStatus]] = None # Corresponds to [MediaStatus] 39 | 40 | # Popularity 41 | popularity_greater: Optional[int] = None 42 | popularity_lesser: Optional[int] = None 43 | 44 | # Average Score 45 | averageScore_greater: Optional[int] = None 46 | averageScore_lesser: Optional[int] = None 47 | 48 | # Season and Year 49 | seasonYear: Optional[int] = None 50 | season: Optional[MediaSeason] = None 51 | 52 | # Start Date (FuzzyDateInt is often an integer representation like YYYYMMDD) 53 | startDate_greater: Optional[int] = None 54 | startDate_lesser: Optional[int] = None 55 | startDate: Optional[int] = None 56 | 57 | # End Date (FuzzyDateInt) 58 | endDate_greater: Optional[int] = None 59 | endDate_lesser: Optional[int] = None 60 | 61 | # Format and Type 62 | format_in: Optional[List[MediaFormat]] = None 63 | type: Optional[MediaType] = None 64 | 65 | # On List 66 | on_list: Optional[bool] = None 67 | 68 | 69 | @dataclass(frozen=True) 70 | class UserMediaListSearchParams: 71 | status: UserMediaListStatus 72 | page: int = 1 73 | type: Optional[MediaType] = None 74 | sort: Optional[UserMediaListSort] = None 75 | per_page: Optional[int] = None 76 | 77 | 78 | @dataclass(frozen=True) 79 | class UpdateUserMediaListEntryParams: 80 | media_id: int 81 | status: Optional[UserMediaListStatus] = None 82 | progress: Optional[str] = None 83 | score: Optional[float] = None 84 | 85 | 86 | @dataclass(frozen=True) 87 | class MediaRecommendationParams: 88 | id: int 89 | page: Optional[int] = 1 90 | per_page: Optional[int] = None 91 | 92 | 93 | @dataclass(frozen=True) 94 | class MediaCharactersParams: 95 | id: int 96 | 97 | 98 | @dataclass(frozen=True) 99 | class MediaRelationsParams: 100 | id: int 101 | # page: Optional[int] = 1 102 | # per_page: Optional[int] = None 103 | 104 | 105 | @dataclass(frozen=True) 106 | class MediaAiringScheduleParams: 107 | id: int 108 | 109 | 110 | @dataclass(frozen=True) 111 | class MediaReviewsParams: 112 | id: int 113 | page: int = 1 114 | per_page: Optional[int] = None 115 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import List, Literal, Optional 3 | 4 | from pydantic import BaseModel, ConfigDict 5 | 6 | # from .allanime.types import Server as AllAnimeServer 7 | # from .animepahe.types import Server as AnimePaheServer 8 | 9 | 10 | # ENUMS 11 | class ProviderName(Enum): 12 | ALLANIME = "allanime" 13 | ANIMEPAHE = "animepahe" 14 | ANIMEUNITY = "animeunity" 15 | 16 | 17 | class ProviderServer(Enum): 18 | TOP = "TOP" 19 | 20 | # AllAnimeServer values 21 | SHAREPOINT = "sharepoint" 22 | DROPBOX = "dropbox" 23 | GOGOANIME = "gogoanime" 24 | WETRANSFER = "weTransfer" 25 | WIXMP = "wixmp" 26 | YT = "Yt" 27 | MP4_UPLOAD = "mp4-upload" 28 | 29 | # AnimePaheServer values 30 | KWIK = "kwik" 31 | 32 | # AnimeUnityServer values 33 | VIXCLOUD = "vixcloud" 34 | 35 | 36 | class MediaTranslationType(Enum): 37 | SUB = "sub" 38 | DUB = "dub" 39 | RAW = "raw" 40 | 41 | 42 | # MODELS 43 | class BaseAnimeProviderModel(BaseModel): 44 | model_config = ConfigDict(frozen=True) 45 | 46 | 47 | class PageInfo(BaseAnimeProviderModel): 48 | total: Optional[int] = None 49 | per_page: Optional[int] = None 50 | current_page: Optional[int] = None 51 | 52 | 53 | class AnimeEpisodes(BaseAnimeProviderModel): 54 | sub: List[str] 55 | dub: List[str] = [] 56 | raw: List[str] = [] 57 | 58 | 59 | class SearchResult(BaseAnimeProviderModel): 60 | id: str 61 | title: str 62 | episodes: AnimeEpisodes 63 | other_titles: List[str] = [] 64 | media_type: Optional[str] = None 65 | score: Optional[float] = None 66 | status: Optional[str] = None 67 | season: Optional[str] = None 68 | poster: Optional[str] = None 69 | year: Optional[str] = None 70 | 71 | 72 | class SearchResults(BaseAnimeProviderModel): 73 | page_info: PageInfo 74 | results: List[SearchResult] 75 | 76 | 77 | class AnimeEpisodeInfo(BaseAnimeProviderModel): 78 | id: str 79 | episode: str 80 | session_id: Optional[str] = None 81 | title: Optional[str] = None 82 | poster: Optional[str] = None 83 | duration: Optional[str] = None 84 | 85 | 86 | class Anime(BaseAnimeProviderModel): 87 | id: str 88 | title: str 89 | episodes: AnimeEpisodes 90 | type: Optional[str] = None 91 | episodes_info: List[AnimeEpisodeInfo] | None = None 92 | poster: Optional[str] = None 93 | year: Optional[str] = None 94 | 95 | 96 | class EpisodeStream(BaseAnimeProviderModel): 97 | # episode: str 98 | link: str 99 | title: Optional[str] = None 100 | quality: Literal["360", "480", "720", "1080"] = "720" 101 | translation_type: MediaTranslationType = MediaTranslationType.SUB 102 | format: Optional[str] = None 103 | hls: Optional[bool] = None 104 | mp4: Optional[bool] = None 105 | priority: Optional[int] = None 106 | 107 | 108 | class Subtitle(BaseAnimeProviderModel): 109 | url: str 110 | language: Optional[str] = None 111 | 112 | 113 | class Server(BaseAnimeProviderModel): 114 | name: str 115 | links: List[EpisodeStream] 116 | episode_title: Optional[str] = None 117 | headers: dict[str, str] = dict() 118 | subtitles: List[Subtitle] = [] 119 | audio: List[str] = [] 120 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/animepahe/mappers.py: -------------------------------------------------------------------------------- 1 | from ..types import ( 2 | Anime, 3 | AnimeEpisodeInfo, 4 | AnimeEpisodes, 5 | EpisodeStream, 6 | MediaTranslationType, 7 | PageInfo, 8 | SearchResult, 9 | SearchResults, 10 | Server, 11 | ) 12 | from .types import ( 13 | AnimePaheAnimePage, 14 | AnimePaheSearchPage, 15 | ) 16 | 17 | translation_type_map = { 18 | "sub": MediaTranslationType.SUB, 19 | "dub": MediaTranslationType.DUB, 20 | "raw": MediaTranslationType.RAW, 21 | } 22 | 23 | 24 | def map_to_search_results(data: AnimePaheSearchPage) -> SearchResults: 25 | results = [] 26 | for result in data["data"]: 27 | results.append( 28 | SearchResult( 29 | id=result["session"], 30 | title=result["title"], 31 | episodes=AnimeEpisodes( 32 | sub=list(map(str, range(1, result["episodes"] + 1))), 33 | dub=list(map(str, range(1, result["episodes"] + 1))), 34 | raw=list(map(str, range(1, result["episodes"] + 1))), 35 | ), 36 | media_type=result["type"], 37 | score=result["score"], 38 | status=result["status"], 39 | season=result["season"], 40 | poster=result["poster"], 41 | year=str(result["year"]), 42 | ) 43 | ) 44 | 45 | return SearchResults( 46 | page_info=PageInfo( 47 | total=data["total"], 48 | per_page=data["per_page"], 49 | current_page=data["current_page"], 50 | ), 51 | results=results, 52 | ) 53 | 54 | 55 | def map_to_anime_result( 56 | search_result: SearchResult, anime: AnimePaheAnimePage 57 | ) -> Anime: 58 | episodes_info = [] 59 | episodes = [] 60 | anime["data"] = sorted(anime["data"], key=lambda k: float(k["episode"])) 61 | for ep_info in anime["data"]: 62 | episodes.append(str(ep_info["episode"])) 63 | episodes_info.append( 64 | AnimeEpisodeInfo( 65 | id=str(ep_info["id"]), 66 | session_id=ep_info["session"], 67 | episode=str(ep_info["episode"]), 68 | title=ep_info["title"], 69 | poster=ep_info["snapshot"], 70 | duration=str(ep_info["duration"]), 71 | ) 72 | ) 73 | 74 | return Anime( 75 | id=search_result.id, 76 | title=search_result.title, 77 | episodes=AnimeEpisodes( 78 | sub=episodes, 79 | dub=episodes, 80 | ), 81 | year=str(search_result.year), 82 | poster=search_result.poster, 83 | episodes_info=episodes_info, 84 | ) 85 | 86 | 87 | def map_to_server( 88 | episode: AnimeEpisodeInfo, 89 | translation_type: str, 90 | stream_links: list[tuple[str, str]], 91 | ) -> Server: 92 | links = [ 93 | EpisodeStream( 94 | link=link[1], 95 | quality=link[0] if link[0] in ["360", "480", "720", "1080"] else "1080", # type:ignore 96 | translation_type=translation_type_map[translation_type], 97 | ) 98 | for link in stream_links 99 | ] 100 | return Server(name="kwik", links=links, episode_title=episode.title) 101 | -------------------------------------------------------------------------------- /viu_media/libs/provider/anime/utils/debug.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import logging 3 | import os 4 | from typing import Type 5 | 6 | from ..base import BaseAnimeProvider 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def debug_provider(provider_function): 12 | @functools.wraps(provider_function) 13 | def _provider_function_wrapper(self, *args, **kwargs): 14 | provider_name = self.__class__.__name__.upper() 15 | if not os.environ.get("VIU_DEBUG"): 16 | try: 17 | return provider_function(self, *args, **kwargs) 18 | except Exception as e: 19 | logger.error(f"[{provider_name}@{provider_function.__name__}]: {e}") 20 | else: 21 | return provider_function(self, *args, **kwargs) 22 | 23 | return _provider_function_wrapper 24 | 25 | 26 | def test_anime_provider(AnimeProvider: Type[BaseAnimeProvider]): 27 | import shutil 28 | import subprocess 29 | 30 | from httpx import Client 31 | 32 | from .....core.constants import APP_ASCII_ART 33 | from .....core.utils.networking import random_user_agent 34 | from ..params import AnimeParams, EpisodeStreamsParams, SearchParams 35 | 36 | anime_provider = AnimeProvider( 37 | Client(headers={"User-Agent": random_user_agent(), **AnimeProvider.HEADERS}) 38 | ) 39 | print(APP_ASCII_ART.read_text(encoding="utf-8")) 40 | query = input("What anime would you like to stream: ") 41 | search_results = anime_provider.search(SearchParams(query=query)) 42 | if not search_results: 43 | return 44 | for i, search_result in enumerate(search_results.results): 45 | print(f"{i + 1}: {search_result.title}") 46 | result = search_results.results[ 47 | int(input(f"Select result (1-{len(search_results.results)}): ")) - 1 48 | ] 49 | anime = anime_provider.get(AnimeParams(id=result.id, query=query)) 50 | 51 | if not anime: 52 | return 53 | translation_type = input("Preferred Translation Type: [dub,sub,raw]: ") 54 | for episode in getattr(anime.episodes, translation_type): 55 | print(episode) 56 | episode_number = input("What episode do you wish to watch: ") 57 | episode_streams = anime_provider.episode_streams( 58 | EpisodeStreamsParams( 59 | query=query, 60 | anime_id=anime.id, 61 | episode=episode_number, 62 | translation_type=translation_type, # type:ignore 63 | ) 64 | ) 65 | 66 | if not episode_streams: 67 | return 68 | episode_streams = list(episode_streams) 69 | for i, stream in enumerate(episode_streams): 70 | print(f"{i + 1}: {stream.name}") 71 | stream = episode_streams[int(input("Select your preferred server: ")) - 1] 72 | for i, link in enumerate(stream.links): 73 | print(f"{i + 1}: {link.quality}") 74 | link = stream.links[int(input("Select your preferred quality: ")) - 1] 75 | if executable := shutil.which("mpv"): 76 | cmd = executable 77 | elif executable := shutil.which("xdg-open"): 78 | cmd = executable 79 | elif executable := shutil.which("open"): 80 | cmd = executable 81 | else: 82 | return 83 | 84 | print( 85 | "Now streaming: ", 86 | anime.title, 87 | "Episode: ", 88 | stream.episode_title if stream.episode_title else episode_number, 89 | ) 90 | subprocess.run([cmd, link.link]) 91 | -------------------------------------------------------------------------------- /viu_media/cli/commands/anilist/commands/stats.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | import click 4 | 5 | if TYPE_CHECKING: 6 | from viu_media.core.config import AppConfig 7 | 8 | 9 | @click.command(help="Print out your anilist stats") 10 | @click.pass_obj 11 | def stats(config: "AppConfig"): 12 | import shutil 13 | import subprocess 14 | 15 | from rich.console import Console 16 | from rich.markdown import Markdown 17 | from rich.panel import Panel 18 | 19 | from .....libs.media_api.api import create_api_client 20 | from ....service.auth import AuthService 21 | from ....service.feedback import FeedbackService 22 | 23 | console = Console() 24 | 25 | feedback = FeedbackService(config) 26 | auth = AuthService(config.general.media_api) 27 | 28 | media_api_client = create_api_client(config.general.media_api, config) 29 | 30 | try: 31 | # Check authentication 32 | 33 | if profile := auth.get_auth(): 34 | if not media_api_client.authenticate(profile.token): 35 | feedback.error( 36 | "Authentication Required", 37 | f"You must be logged in to {config.general.media_api} to sync your media list.", 38 | ) 39 | feedback.info( 40 | "Run this command to authenticate:", 41 | f"viu {config.general.media_api} auth", 42 | ) 43 | raise click.Abort() 44 | 45 | # Check if kitten is available for image display 46 | KITTEN_EXECUTABLE = shutil.which("kitten") 47 | if not KITTEN_EXECUTABLE: 48 | feedback.warning( 49 | "Kitten not found - profile image will not be displayed" 50 | ) 51 | else: 52 | # Display profile image using kitten icat 53 | if profile.user_profile.avatar_url: 54 | console.clear() 55 | image_x = int(console.size.width * 0.1) 56 | image_y = int(console.size.height * 0.1) 57 | img_w = console.size.width // 3 58 | img_h = console.size.height // 3 59 | 60 | image_process = subprocess.run( 61 | [ 62 | KITTEN_EXECUTABLE, 63 | "icat", 64 | "--clear", 65 | "--place", 66 | f"{img_w}x{img_h}@{image_x}x{image_y}", 67 | profile.user_profile.avatar_url, 68 | ], 69 | check=False, 70 | ) 71 | 72 | if image_process.returncode != 0: 73 | feedback.warning("Failed to display profile image") 74 | 75 | # Display user information 76 | about_text = getattr(profile, "about", "") or "No description available" 77 | 78 | console.print( 79 | Panel( 80 | Markdown(about_text), 81 | title=f"📊 {profile.user_profile.name}'s Profile", 82 | ) 83 | ) 84 | 85 | # You can add more stats here if the API provides them 86 | feedback.success("User profile displayed successfully") 87 | 88 | except Exception as e: 89 | feedback.error("Unexpected error occurred", str(e)) 90 | raise click.Abort() 91 | -------------------------------------------------------------------------------- /viu_media/core/config/defaults.py: -------------------------------------------------------------------------------- 1 | from ..constants import APP_DATA_DIR, DEFAULTS_DIR, PLATFORM, USER_VIDEOS_DIR 2 | from ..utils import detect 3 | 4 | # GeneralConfig 5 | GENERAL_WELCOME_SCREEN = True 6 | GENERAL_PYGMENT_STYLE = "github-dark" 7 | GENERAL_PREFERRED_SPINNER = "smiley" 8 | GENERAL_API_CLIENT = "anilist" 9 | GENERAL_PREFERRED_TRACKER = "local" 10 | GENERAL_DESKTOP_NOTIFICATION_DURATION = 5 * 60 11 | GENERAL_PROVIDER = "allanime" 12 | 13 | 14 | def GENERAL_SELECTOR(): 15 | return "fzf" if detect.has_fzf() else "default" 16 | 17 | 18 | GENERAL_AUTO_SELECT_ANIME_RESULT = True 19 | GENERAL_ICONS = True 20 | 21 | 22 | def GENERAL_PREVIEW(): 23 | return "full" if detect.is_running_kitty_terminal() else "none" 24 | 25 | 26 | GENERAL_SCALE_PREVIEW = True 27 | GENERAL_SCALE_PREVIEW = False 28 | 29 | 30 | def GENERAL_IMAGE_RENDERER(): 31 | return "icat" if detect.is_running_kitty_terminal() else "chafa" 32 | 33 | 34 | GENERAL_MANGA_VIEWER = "feh" 35 | GENERAL_CHECK_FOR_UPDATES = True 36 | GENERAL_SHOW_NEW_RELEASE = True 37 | GENERAL_UPDATE_CHECK_INTERVAL = 12 38 | GENERAL_CACHE_REQUESTS = True 39 | GENERAL_MAX_CACHE_LIFETIME = "03:00:00" 40 | GENERAL_NORMALIZE_TITLES = True 41 | GENERAL_DISCORD = False 42 | GENERAL_RECENT = 50 43 | 44 | # StreamConfig 45 | STREAM_PLAYER = "mpv" 46 | STREAM_QUALITY = "1080" 47 | STREAM_TRANSLATION_TYPE = "sub" 48 | STREAM_SERVER = "TOP" 49 | STREAM_AUTO_NEXT = False 50 | STREAM_CONTINUE_FROM_WATCH_HISTORY = True 51 | STREAM_PREFERRED_WATCH_HISTORY = "local" 52 | STREAM_AUTO_SKIP = False 53 | STREAM_EPISODE_COMPLETE_AT = 80 54 | STREAM_YTDLP_FORMAT = "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best" 55 | STREAM_FORCE_FORWARD_TRACKING = True 56 | STREAM_DEFAULT_MEDIA_LIST_TRACKING = "prompt" 57 | STREAM_SUB_LANG = "eng" 58 | 59 | 60 | def STREAM_USE_IPC(): 61 | return True if PLATFORM != "win32" and not detect.is_running_in_termux() else False 62 | 63 | 64 | # WorkerConfig 65 | WORKER_ENABLED = True 66 | WORKER_NOTIFICATION_CHECK_INTERVAL = 15 # minutes 67 | WORKER_DOWNLOAD_CHECK_INTERVAL = 5 # minutes 68 | 69 | # FzfConfig 70 | FZF_OPTS = DEFAULTS_DIR / "fzf-opts" 71 | FZF_HEADER_COLOR = "95,135,175" 72 | FZF_PREVIEW_HEADER_COLOR = "215,0,95" 73 | FZF_PREVIEW_SEPARATOR_COLOR = "208,208,208" 74 | 75 | # RofiConfig 76 | _ROFI_THEMES_DIR = DEFAULTS_DIR / "rofi-themes" 77 | ROFI_THEME_MAIN = _ROFI_THEMES_DIR / "main.rasi" 78 | ROFI_THEME_INPUT = _ROFI_THEMES_DIR / "input.rasi" 79 | ROFI_THEME_CONFIRM = _ROFI_THEMES_DIR / "confirm.rasi" 80 | ROFI_THEME_PREVIEW = _ROFI_THEMES_DIR / "preview.rasi" 81 | 82 | # MpvConfig 83 | MPV_ARGS = "" 84 | MPV_PRE_ARGS = "" 85 | 86 | # VlcConfig 87 | VLC_ARGS = "" 88 | 89 | # AnilistConfig 90 | ANILIST_PER_PAGE = 15 91 | ANILIST_SORT_BY = "SEARCH_MATCH" 92 | ANILIST_MEDIA_LIST_SORT_BY = "MEDIA_POPULARITY_DESC" 93 | ANILIST_PREFERRED_LANGUAGE = "english" 94 | 95 | # DownloadsConfig 96 | DOWNLOADS_DOWNLOADER = "auto" 97 | DOWNLOADS_DOWNLOADS_DIR = USER_VIDEOS_DIR 98 | DOWNLOADS_ENABLE_TRACKING = True 99 | DOWNLOADS_NO_CHECK_CERTIFICATE = True 100 | DOWNLOADS_MAX_CONCURRENT = 3 101 | DOWNLOADS_RETRY_ATTEMPTS = 2 102 | DOWNLOADS_RETRY_DELAY = 60 103 | DOWNLOADS_MERGE_SUBTITLES = True 104 | DOWNLOADS_CLEANUP_AFTER_MERGE = True 105 | 106 | 107 | # RegistryConfig 108 | MEDIA_REGISTRY_DIR = USER_VIDEOS_DIR / ".registry" 109 | MEDIA_REGISTRY_INDEX_DIR = APP_DATA_DIR 110 | 111 | # session config 112 | SESSIONS_DIR = APP_DATA_DIR / ".sessions" 113 | -------------------------------------------------------------------------------- /viu_media/cli/utils/icat.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | import subprocess 3 | import sys 4 | import termios 5 | import tty 6 | from sys import exit 7 | 8 | from rich.align import Align 9 | from rich.console import Console 10 | from rich.panel import Panel 11 | from rich.text import Text 12 | 13 | console = Console() 14 | 15 | 16 | def get_key(): 17 | """Read a single keypress (including arrows).""" 18 | fd = sys.stdin.fileno() 19 | old = termios.tcgetattr(fd) 20 | try: 21 | tty.setraw(fd) 22 | ch1 = sys.stdin.read(1) 23 | if ch1 == "\x1b": 24 | ch2 = sys.stdin.read(2) 25 | return ch1 + ch2 26 | return ch1 27 | finally: 28 | termios.tcsetattr(fd, termios.TCSADRAIN, old) 29 | 30 | 31 | def draw_banner_at(msg: str, row: int): 32 | """Move cursor to `row`, then render a centered, cyan-bordered panel.""" 33 | sys.stdout.write(f"\x1b[{row};1H") 34 | text = Text(msg, justify="center") 35 | panel = Panel(Align(text, align="center"), border_style="cyan", padding=(1, 2)) 36 | console.print(panel) 37 | 38 | 39 | def icat_manga_viewer(image_links: list[str], window_title: str): 40 | ICAT = shutil.which("kitty") 41 | if not ICAT: 42 | console.print("[bold red]kitty (for icat) not found[/]") 43 | exit(1) 44 | 45 | idx, total = 0, len(image_links) 46 | title = f"{window_title} ({total} images)" 47 | show_banner = True 48 | 49 | try: 50 | while True: 51 | console.clear() 52 | term_width, term_height = shutil.get_terminal_size((80, 24)) 53 | panel_height = 0 54 | 55 | # Calculate space for image based on banner visibility 56 | if show_banner: 57 | msg_lines = 3 # Title + blank + controls 58 | panel_height = msg_lines + 4 # Padding and borders 59 | image_height = term_height - panel_height - 1 60 | else: 61 | image_height = term_height 62 | 63 | subprocess.run( 64 | [ 65 | ICAT, 66 | "+kitten", 67 | "icat", 68 | "--clear", 69 | "--scale-up", 70 | "--place", 71 | f"{term_width}x{image_height}@0x0", 72 | "--z-index", 73 | "-1", 74 | image_links[idx], 75 | ], 76 | check=False, 77 | ) 78 | 79 | if show_banner: 80 | controls = ( 81 | f"[{idx + 1}/{total}] Prev: [h/←] Next: [l/→] " 82 | f"Toggle Banner: [b] Quit: [q/Ctrl-C]" 83 | ) 84 | msg = f"{title}\n\n{controls}" 85 | start_row = term_height - panel_height 86 | draw_banner_at(msg, start_row) 87 | 88 | # key handling 89 | key = get_key() 90 | if key in ("l", "\x1b[C"): 91 | idx = (idx + 1) % total 92 | elif key in ("h", "\x1b[D"): 93 | idx = (idx - 1) % total 94 | elif key == "b": 95 | show_banner = not show_banner 96 | elif key in ("q", "\x03"): 97 | break 98 | 99 | except KeyboardInterrupt: 100 | pass 101 | finally: 102 | console.clear() 103 | console.print("Exited viewer.", style="bold") 104 | --------------------------------------------------------------------------------