Compare commits

...

35 Commits

Author SHA1 Message Date
Benexl
29ce664e4c Merge remote-tracking branch 'origin/master' into feature/preview-scripts-rewrite-to-python 2025-11-03 11:16:36 +03:00
Benexl
2217f011af fix(core-constants): use project name over cli name 2025-11-01 20:06:53 +03:00
Benexl
5960a7c502 feat(notifications): use seconds instead of minutes 2025-11-01 19:50:46 +03:00
Benexl
bd0309ee85 feat(dev): add .venv/bin to path using direnv 2025-11-01 19:15:45 +03:00
Benexl
3724f06e33 fix(allanime-anime-provider): not giving different qualities 2025-11-01 17:26:45 +03:00
Benexl
d20af89fc8 feat(debug-anime-provider-utils): allow for quality selection 2025-11-01 16:48:51 +03:00
Benexl
3872b4c8a8 feat(search-command): allow quality selection 2025-11-01 16:48:07 +03:00
Benexl
9545b893e1 feat(search-command): if no title is provided as an option prompt it 2025-11-01 16:47:28 +03:00
Benexl
1519c8be17 feat: create the preview script in the cache/preview dir 2025-11-01 00:59:38 +03:00
Benexl
9a619b41f4 feat: use prefix in preview-script.py filename 2025-11-01 00:55:19 +03:00
Benexl
0c3a963cc4 feat: use ?? where episodes are unknown 2025-11-01 00:50:45 +03:00
Benexl
192818362b feat: next episode should come last in its grp for better ui ux 2025-11-01 00:04:05 +03:00
Benexl
2d8c1d3569 feat: remove colon for better ui 2025-10-31 23:50:12 +03:00
Benexl
e37f9213f6 feat: include romaji title in synonymns if not already there 2025-10-31 23:44:11 +03:00
Benexl
097db713bc feat: refactor ruling logic to function 2025-10-31 23:37:45 +03:00
Benexl
106278e386 feat: improve synopsis separator styling 2025-10-31 23:35:31 +03:00
Benexl
44b3663644 feat: grp studio, synonymns and tags separately for better ui / ux 2025-10-31 23:23:33 +03:00
Benexl
925c30c06e fix: typo should be text not info 2025-10-31 23:23:03 +03:00
Benexl
7401a1ad8f feat: prefer to use direct implementation of graphics protocol over external tools 2025-10-31 23:04:56 +03:00
Benexl
9a0bb65e52 feat: implement image preview 2025-10-31 22:49:41 +03:00
Benexl
1d129a5771 fix: remove extra bracket 2025-10-31 22:37:36 +03:00
Benexl
515660b0f6 feat: implement the main preview text logic in python 2025-10-31 22:32:51 +03:00
Benexl
9f5c895bf5 chore: temporarily relocate initial bash preview scripts to old folder 2025-10-31 22:32:14 +03:00
Benexl
5634214fb8 chore(ci): update stale.yml to emphasize devs limited time 2025-10-27 00:33:36 +03:00
Benexl
66c0ada29d chore(ci): update days to closure of pr or issue 2025-10-27 00:24:07 +03:00
Benexl
02465b4ddb chore(ci): add stale.yml 2025-10-27 00:19:07 +03:00
Benexl
5ffd94ac24 chore(pre-commit): update pre-commit config to use only Ruff 2025-10-26 23:47:28 +03:00
Benexl
d2864df6d0 style(dev): add extra space inorder to pass ruff fmt 2025-10-26 23:37:19 +03:00
Benexl
2a28e3b9a3 chore: temporarily disable tests in workflow 2025-10-26 23:32:05 +03:00
Benexl
7b8027a8b3 fix(viu): correct import path 2025-10-26 23:28:23 +03:00
Benexl
2a36152c38 fix(provider-scraping-html-parser): pyright errors 2025-10-26 23:26:36 +03:00
Benexl
2048c7b743 fix(inquirer-selector): pyright errors 2025-10-26 23:25:55 +03:00
Benexl
133fd4c1c8 chore: run ruff check --fix 2025-10-26 23:20:30 +03:00
Benexl
e22120fe99 fix(allanime-anime-provider-utils): pyright errors 2025-10-26 23:19:36 +03:00
Benexl
44e6220662 chore: cleanup; directly implement syncplay logic in the actual players 2025-10-26 23:16:23 +03:00
41 changed files with 481 additions and 185 deletions

3
.envrc
View File

@@ -1,5 +1,6 @@
VIU_APP_NAME="viu-dev"
export VIU_APP_NAME
PATH="./.venv/bin/:$PATH"
export PATH VIU_APP_NAME
if command -v nix >/dev/null;then
use flake
fi

57
.github/workflows/stale.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Mark Stale Issues and Pull Requests
on:
schedule:
# Runs every day at 6:30 UTC
- cron: "30 6 * * *"
# Allows you to run this workflow manually from the Actions tab for testing
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: |
Greetings @{{author}},
This bug report is like an ancient scroll detailing a legendary beast. Our small guild of developers is often on many quests at once, so our response times can be slower than a tortoise in a time-stop spell. We deeply appreciate your patience!
**Seeking Immediate Help or Discussion?**
Our **[Discord Tavern](https://discord.gg/HBEmAwvbHV)** is the best place to get a quick response from the community for general questions or setup help!
**Want to Be the Hero?**
You could try to tame this beast yourself! With modern grimoires (like AI coding assistants) and our **[Contribution Guide](https://github.com/viu-media/Viu/blob/master/CONTRIBUTIONS.md)**, you might just be the hero we're waiting for. We would be thrilled to review your solution!
---
To keep our quest board tidy, we need to know if this creature is still roaming the lands in the latest version of `viu`. If we don't get an update within **7 days**, we'll assume it has vanished and archive the scroll.
Thanks for being our trusted scout!
stale-pr-message: |
Hello @{{author}}, it looks like this powerful contribution has been left in the middle of its training arc! 💪
Our review dojo is managed by just a few senseis who are sometimes away on long missions, so thank you for your patience as we work through the queue.
We were excited to see this new technique being developed. Are you still planning to complete its training, or have you embarked on a different quest? If you need a sparring partner (reviewer) or some guidance from a senpai, just let us know!
To keep our dojo tidy, we'll be archiving unfinished techniques. If we don't hear back within **7 days**, we'll assume it's time to close this PR for now. You can always resume your training and reopen it when you're ready.
Thank you for your incredible effort!
# --- Labels and Timing ---
stale-issue-label: "stale"
stale-pr-label: "stale"
# How many days of inactivity before an issue/PR is marked as stale.
days-before-stale: 14
# How many days of inactivity to wait before closing a stale issue/PR.
days-before-close: 7

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
python-version: ["3.11", "3.12"]
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
@@ -41,5 +41,7 @@ jobs:
- name: Run type checking
run: uv run pyright
- name: Run tests
run: uv run pytest tests
# TODO: write tests
# - name: Run tests
# run: uv run pytest tests

View File

@@ -1,33 +1,10 @@
default_language_version:
python: python3.12
repos:
- repo: https://github.com/pycqa/isort
rev: 5.12.0
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.2
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"]
- repo: https://github.com/PyCQA/autoflake
rev: v2.2.1
hooks:
- id: autoflake
args:
[
"--in-place",
"--remove-unused-variables",
"--remove-all-unused-imports",
]
# - repo: https://github.com/astral-sh/ruff-pre-commit
# rev: v0.4.10
# hooks:
# - id: ruff
# args: [--fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.4.2
hooks:
- id: black
name: black
#language_version: python3.10
# Run the linter.
- id: ruff-check
args: [--fix]
# Run the formatter.
- id: ruff-format

View File

@@ -1,9 +1,10 @@
#!/usr/bin/env -S uv run --script
import httpx
import json
from viu_media.core.utils.graphql import execute_graphql
from pathlib import Path
from collections import defaultdict
from pathlib import Path
import httpx
from viu_media.core.utils.graphql import execute_graphql
DEV_DIR = Path(__file__).resolve().parent
media_tags_type_py = (
@@ -26,6 +27,7 @@ template = """\
from enum import Enum
class MediaTag(Enum):\
"""

View File

@@ -0,0 +1,89 @@
import sys
from rich.console import Console
from rich.table import Table
from rich.rule import Rule
from rich.markdown import Markdown
console = Console(force_terminal=True, color_system="truecolor")
HEADER_COLOR = sys.argv[1]
SEPARATOR_COLOR = sys.argv[2]
def rule(title: str | None = None):
console.print(Rule(style=f"rgb({SEPARATOR_COLOR})"))
console.print("{TITLE}", justify="center")
left = [
(
"Score",
"Favorites",
"Popularity",
"Status",
),
(
"Episodes",
"Duration",
"Next Episode",
),
(
"Genres",
"Format",
),
(
"List Status",
"Progress",
),
(
"Start Date",
"End Date",
),
("Studios",),
("Synonymns",),
("Tags",),
]
right = [
(
"{SCORE}",
"{FAVOURITES}",
"{POPULARITY}",
"{STATUS}",
),
(
"{EPISODES}",
"{DURATION}",
"{NEXT_EPISODE}",
),
(
"{GENRES}",
"{FORMAT}",
),
(
"{USER_STATUS}",
"{USER_PROGRESS}",
),
(
"{START_DATE}",
"{END_DATE}",
),
("{STUDIOS}",),
("{SYNONYMNS}",),
("{TAGS}",),
]
for L_grp, R_grp in zip(left, right):
table = Table.grid(expand=True)
table.add_column(justify="left", no_wrap=True)
table.add_column(justify="right", overflow="fold")
for L, R in zip(L_grp, R_grp):
table.add_row(f"[bold rgb({HEADER_COLOR})]{L} [/]", f"{R}")
rule()
console.print(table)
rule()
console.print(Markdown("""{SYNOPSIS}"""))

View File

@@ -0,0 +1,212 @@
#!/usr/bin/env python3
#
# FZF Preview Script Template
#
# This script is a template. The placeholders in curly braces, like {NAME}
# are dynamically filled by python using .replace()
from pathlib import Path
from hashlib import sha256
import subprocess
import os
import shutil
import sys
from rich.console import Console
from rich.rule import Rule
# dynamically filled variables
PREVIEW_MODE = "{PREVIEW_MODE}"
IMAGE_CACHE_DIR = Path("{IMAGE_CACHE_DIR}")
INFO_CACHE_DIR = Path("{INFO_CACHE_DIR}")
IMAGE_RENDERER = "{IMAGE_RENDERER}"
HEADER_COLOR = "{HEADER_COLOR}"
SEPARATOR_COLOR = "{SEPARATOR_COLOR}"
PREFIX = "{PREFIX}"
SCALE_UP = "{SCALE_UP}" == "True"
# fzf passes the title with quotes, so we need to trim them
TITLE = sys.argv[1]
hash = f"{PREFIX}-{sha256(TITLE.encode('utf-8')).hexdigest()}"
def fzf_image_preview(file_path: str):
# Environment variables from fzf
FZF_PREVIEW_COLUMNS = os.environ.get("FZF_PREVIEW_COLUMNS")
FZF_PREVIEW_LINES = os.environ.get("FZF_PREVIEW_LINES")
FZF_PREVIEW_TOP = os.environ.get("FZF_PREVIEW_TOP")
KITTY_WINDOW_ID = os.environ.get("KITTY_WINDOW_ID")
GHOSTTY_BIN_DIR = os.environ.get("GHOSTTY_BIN_DIR")
PLATFORM = os.environ.get("PLATFORM")
# Compute terminal dimensions
dim = (
f"{FZF_PREVIEW_COLUMNS}x{FZF_PREVIEW_LINES}"
if FZF_PREVIEW_COLUMNS and FZF_PREVIEW_LINES
else "x"
)
if dim == "x":
try:
rows, cols = (
subprocess.check_output(
["stty", "size"], text=True, stderr=subprocess.DEVNULL
)
.strip()
.split()
)
dim = f"{cols}x{rows}"
except Exception:
dim = "80x24"
# Adjust dimension if icat not used and preview area fills bottom of screen
if (
IMAGE_RENDERER != "icat"
and not KITTY_WINDOW_ID
and FZF_PREVIEW_TOP
and FZF_PREVIEW_LINES
):
try:
term_rows = int(
subprocess.check_output(["stty", "size"], text=True).split()[0]
)
if int(FZF_PREVIEW_TOP) + int(FZF_PREVIEW_LINES) == term_rows:
dim = f"{FZF_PREVIEW_COLUMNS}x{int(FZF_PREVIEW_LINES) - 1}"
except Exception:
pass
# Helper to run commands
def run(cmd):
subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr)
def command_exists(cmd):
return shutil.which(cmd) is not None
# ICAT / KITTY path
if IMAGE_RENDERER == "icat" and not GHOSTTY_BIN_DIR:
icat_cmd = None
if command_exists("kitten"):
icat_cmd = ["kitten", "icat"]
elif command_exists("icat"):
icat_cmd = ["icat"]
elif command_exists("kitty"):
icat_cmd = ["kitty", "icat"]
if icat_cmd:
run(
icat_cmd
+ [
"--clear",
"--transfer-mode=memory",
"--unicode-placeholder",
"--stdin=no",
f"--place={dim}@0x0",
file_path,
]
)
else:
print("No icat-compatible viewer found (kitten/icat/kitty)")
elif GHOSTTY_BIN_DIR:
try:
cols = int(FZF_PREVIEW_COLUMNS or "80") - 1
lines = FZF_PREVIEW_LINES or "24"
dim = f"{cols}x{lines}"
except Exception:
pass
if command_exists("kitten"):
run(
[
"kitten",
"icat",
"--clear",
"--transfer-mode=memory",
"--unicode-placeholder",
"--stdin=no",
f"--place={dim}@0x0",
file_path,
]
)
elif command_exists("icat"):
run(
[
"icat",
"--clear",
"--transfer-mode=memory",
"--unicode-placeholder",
"--stdin=no",
f"--place={dim}@0x0",
file_path,
]
)
elif command_exists("chafa"):
run(["chafa", "-s", dim, file_path])
elif command_exists("chafa"):
# Platform specific rendering
if PLATFORM == "android":
run(["chafa", "-s", dim, file_path])
elif PLATFORM == "windows":
run(["chafa", "-f", "sixel", "-s", dim, file_path])
else:
run(["chafa", "-s", dim, file_path])
print()
elif command_exists("imgcat"):
width, height = dim.split("x")
run(["imgcat", "-W", width, "-H", height, file_path])
else:
print(
"⚠️ Please install a terminal image viewer (icat, kitten, imgcat, or chafa)."
)
def fzf_text_preview(file_path: str):
from base64 import standard_b64encode
def serialize_gr_command(**cmd):
payload = cmd.pop("payload", None)
cmd = ",".join(f"{k}={v}" for k, v in cmd.items())
ans = []
w = ans.append
w(b"\033_G")
w(cmd.encode("ascii"))
if payload:
w(b";")
w(payload)
w(b"\033\\")
return b"".join(ans)
def write_chunked(**cmd):
data = standard_b64encode(cmd.pop("data"))
while data:
chunk, data = data[:4096], data[4096:]
m = 1 if data else 0
sys.stdout.buffer.write(serialize_gr_command(payload=chunk, m=m, **cmd))
sys.stdout.flush()
cmd.clear()
with open(file_path, "rb") as f:
write_chunked(a="T", f=100, data=f.read())
console = Console(force_terminal=True, color_system="truecolor")
if PREVIEW_MODE == "image" or PREVIEW_MODE == "full":
preview_image_path = IMAGE_CACHE_DIR / f"{hash}.png"
if preview_image_path.exists():
fzf_image_preview(str(preview_image_path))
print()
else:
print("🖼️ Loading image...")
console.print(Rule(style=f"rgb({SEPARATOR_COLOR})"))
if PREVIEW_MODE == "text" or PREVIEW_MODE == "full":
preview_info_path = INFO_CACHE_DIR / f"{hash}.py"
if preview_info_path.exists():
subprocess.run(
[sys.executable, str(preview_info_path), HEADER_COLOR, SEPARATOR_COLOR]
)
else:
console.print("📝 Loading details...")

View File

@@ -30,7 +30,6 @@ if TYPE_CHECKING:
@click.option(
"--anime-title",
"-t",
required=True,
shell_complete=anime_titles_shell_complete,
multiple=True,
help="Specify which anime to download",
@@ -52,6 +51,10 @@ def search(config: AppConfig, **options: "Unpack[Options]"):
from ...libs.provider.anime.provider import create_provider
from ...libs.selectors.selector import create_selector
if not options["anime_title"]:
raw = click.prompt("What are you in the mood for? (comma-separated)")
options["anime_title"] = [a.strip() for a in raw.split(",") if a.strip()]
feedback = FeedbackService(config)
provider = create_provider(config.general.provider)
selector = create_selector(config)
@@ -173,6 +176,22 @@ def stream_anime(
if not server_name:
raise ViuError("Server not selected")
server = servers[server_name]
quality = [
ep_stream.link
for ep_stream in server.links
if ep_stream.quality == config.stream.quality
]
if not quality:
feedback.warning("Preferred quality not found, selecting quality...")
stream_link = selector.choose(
"Select Quality", [link.quality for link in server.links]
)
if not stream_link:
raise ViuError("Quality not selected")
stream_link = next(
(link.link for link in server.links if link.quality == stream_link), None
)
stream_link = server.links[0].link
if not stream_link:
raise ViuError(

View File

@@ -1,6 +1,5 @@
"""Update command for Viu CLI."""
import sys
from typing import TYPE_CHECKING
import click

View File

@@ -296,8 +296,7 @@ class DownloadService:
message=message,
app_name="Viu",
app_icon=app_icon,
timeout=self.app_config.general.desktop_notification_duration
* 60,
timeout=self.app_config.general.desktop_notification_duration,
)
except: # noqa: E722
pass
@@ -318,7 +317,7 @@ class DownloadService:
message=message,
app_name="Viu",
app_icon=app_icon,
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
except: # noqa: E722
pass

View File

@@ -41,7 +41,7 @@ class FeedbackService:
message=message,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
return
except: # noqa: E722
@@ -67,7 +67,7 @@ class FeedbackService:
message=message,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
return
except: # noqa: E722
@@ -94,7 +94,7 @@ class FeedbackService:
message=message,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
return
except: # noqa: E722
@@ -120,7 +120,7 @@ class FeedbackService:
message=message,
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
return
except: # noqa: E722
@@ -176,7 +176,7 @@ class FeedbackService:
message="No current way to display info in rofi, use fzf and the terminal instead",
app_name=CLI_NAME,
app_icon=str(ICON_PATH),
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
return
except: # noqa: E722

View File

@@ -101,7 +101,7 @@ class NotificationService:
message=message,
app_name="Viu",
app_icon=app_icon, # plyer supports file paths or URLs depending on platform
timeout=self.app_config.general.desktop_notification_duration * 60,
timeout=self.app_config.general.desktop_notification_duration,
)
logger.info(f"Displayed notification: {message}")
self._mark_seen(

View File

@@ -1,7 +1,7 @@
import logging
import os
import re
from hashlib import sha256
import sys
from typing import Dict, List, Optional
import httpx
@@ -117,7 +117,7 @@ def _get_episode_image(episode: str, media_item: MediaItem) -> str:
logger = logging.getLogger(__name__)
os.environ["SHELL"] = "bash"
# os.environ["SHELL"] = sys.executable
PREVIEWS_CACHE_DIR = APP_CACHE_DIR / "previews"
IMAGES_CACHE_DIR = PREVIEWS_CACHE_DIR / "images"
@@ -127,21 +127,11 @@ CHARACTERS_CACHE_DIR = PREVIEWS_CACHE_DIR / "characters"
AIRING_SCHEDULE_CACHE_DIR = PREVIEWS_CACHE_DIR / "airing_schedule"
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.template.sh").read_text(
encoding="utf-8"
)
TEMPLATE_REVIEW_PREVIEW_SCRIPT = (
FZF_SCRIPTS_DIR / "review-preview.template.sh"
).read_text(encoding="utf-8")
TEMPLATE_CHARACTER_PREVIEW_SCRIPT = (
FZF_SCRIPTS_DIR / "character-preview.template.sh"
).read_text(encoding="utf-8")
TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = (
FZF_SCRIPTS_DIR / "airing-schedule-preview.template.sh"
).read_text(encoding="utf-8")
DYNAMIC_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "dynamic-preview.template.sh").read_text(
encoding="utf-8"
)
TEMPLATE_PREVIEW_SCRIPT = (FZF_SCRIPTS_DIR / "preview.py").read_text(encoding="utf-8")
TEMPLATE_REVIEW_PREVIEW_SCRIPT = ""
TEMPLATE_CHARACTER_PREVIEW_SCRIPT = ""
TEMPLATE_AIRING_SCHEDULE_PREVIEW_SCRIPT = ""
DYNAMIC_PREVIEW_SCRIPT = ""
EPISODE_PATTERN = re.compile(r"^Episode\s+(\d+)\s-\s.*")
@@ -300,30 +290,28 @@ def get_anime_preview(
logger.error(f"Failed to start background caching: {e}")
# Continue with script generation even if caching fails
# Prepare values to inject into the template
path_sep = "\\" if PLATFORM == "win32" else "/"
# Format the template with the dynamic values
replacements = {
"PREVIEW_MODE": config.general.preview,
"IMAGE_CACHE_PATH": str(IMAGES_CACHE_DIR),
"INFO_CACHE_PATH": str(INFO_CACHE_DIR),
"PATH_SEP": path_sep,
"IMAGE_CACHE_DIR": str(IMAGES_CACHE_DIR),
"INFO_CACHE_DIR": str(INFO_CACHE_DIR),
"IMAGE_RENDERER": config.general.image_renderer,
# Color codes
"C_TITLE": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_KEY": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_VALUE": ansi.get_true_fg(HEADER_COLOR, bold=True),
"C_RULE": ansi.get_true_fg(SEPARATOR_COLOR, bold=True),
"RESET": ansi.RESET,
"PREFIX": "",
"SCALE_UP": " --scale-up" if config.general.preview_scale_up else "",
"HEADER_COLOR": ",".join(HEADER_COLOR),
"SEPARATOR_COLOR": ",".join(SEPARATOR_COLOR),
"PREFIX": "search-results",
"SCALE_UP": str(config.general.preview_scale_up),
}
for key, value in replacements.items():
preview_script = preview_script.replace(f"{{{key}}}", value)
return preview_script
(PREVIEWS_CACHE_DIR / "search-results-preview-script.py").write_text(
preview_script, encoding="utf-8"
)
preview_script_final = f"{sys.executable} {PREVIEWS_CACHE_DIR / 'search-results-preview-script.py'} {{}}"
return preview_script_final
def get_episode_preview(

View File

@@ -31,20 +31,18 @@ logger = logging.getLogger(__name__)
FZF_SCRIPTS_DIR = SCRIPTS_DIR / "fzf"
TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.template.sh").read_text(
TEMPLATE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "info.py").read_text(encoding="utf-8")
TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode_info.py").read_text(
encoding="utf-8"
)
TEMPLATE_EPISODE_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "episode-info.template.sh").read_text(
TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review_info.py").read_text(
encoding="utf-8"
)
TEMPLATE_REVIEW_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "review-info.template.sh").read_text(
TEMPLATE_CHARACTER_INFO_SCRIPT = (FZF_SCRIPTS_DIR / "character_info.py").read_text(
encoding="utf-8"
)
TEMPLATE_CHARACTER_INFO_SCRIPT = (
FZF_SCRIPTS_DIR / "character-info.template.sh"
).read_text(encoding="utf-8")
TEMPLATE_AIRING_SCHEDULE_INFO_SCRIPT = (
FZF_SCRIPTS_DIR / "airing-schedule-info.template.sh"
FZF_SCRIPTS_DIR / "airing_schedule_info.py"
).read_text(encoding="utf-8")
@@ -103,29 +101,29 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
raise RuntimeError("PreviewCacheWorker is not running")
for media_item, title_str in zip(media_items, titles):
hash_id = self._get_cache_hash(title_str)
selection_title = self._get_selection_title(title_str)
# Submit image download task if needed
if config.general.preview in ("full", "image") and media_item.cover_image:
image_path = self.images_cache_dir / f"{hash_id}.png"
image_path = self.images_cache_dir / f"{selection_title}.png"
if not image_path.exists():
self.submit_function(
self._download_and_save_image,
media_item.cover_image.large,
hash_id,
selection_title,
)
# Submit info generation task if needed
if config.general.preview in ("full", "text"):
info_text = self._generate_info_text(media_item, config)
self.submit_function(self._save_info_text, info_text, hash_id)
self.submit_function(self._save_info_text, info_text, selection_title)
def _download_and_save_image(self, url: str, hash_id: str) -> None:
def _download_and_save_image(self, url: str, selection_title: str) -> None:
"""Download an image and save it to cache."""
if not self._http_client:
raise RuntimeError("HTTP client not initialized")
image_path = self.images_cache_dir / f"{hash_id}.png"
image_path = self.images_cache_dir / f"{selection_title}.png"
try:
with self._http_client.stream("GET", url) as response:
@@ -135,7 +133,7 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
for chunk in response.iter_bytes():
f.write(chunk)
logger.debug(f"Successfully cached image: {hash_id}")
logger.debug(f"Successfully cached image: {selection_title}")
except Exception as e:
logger.error(f"Failed to download image {url}: {e}")
@@ -159,11 +157,13 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
media_item.format.value if media_item.format else "UNKNOWN"
),
"NEXT_EPISODE": formatter.shell_safe(
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X)')}"
f"Episode {media_item.next_airing.episode} on {formatter.format_date(media_item.next_airing.airing_at, '%A, %d %B %Y at %X')}"
if media_item.next_airing
else "N/A"
),
"EPISODES": formatter.shell_safe(str(media_item.episodes)),
"EPISODES": formatter.shell_safe(
str(media_item.episodes) if media_item.episodes else "??"
),
"DURATION": formatter.shell_safe(
formatter.format_media_duration(media_item.duration)
),
@@ -190,7 +190,12 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
)
),
"SYNONYMNS": formatter.shell_safe(
formatter.format_list_with_commas(media_item.synonymns)
formatter.format_list_with_commas(
[media_item.title.romaji] + media_item.synonymns
if media_item.title.romaji
and media_item.title.romaji not in media_item.synonymns
else media_item.synonymns
)
),
"USER_STATUS": formatter.shell_safe(
media_item.user_status.status.value
@@ -216,22 +221,22 @@ class PreviewCacheWorker(ManagedBackgroundWorker):
return info_script
def _save_info_text(self, info_text: str, hash_id: str) -> None:
def _save_info_text(self, info_text: str, selection_title: str) -> None:
"""Save info text to cache."""
try:
info_path = self.info_cache_dir / hash_id
info_path = self.info_cache_dir / f"{selection_title}.py"
with AtomicWriter(info_path) as f:
f.write(info_text)
logger.debug(f"Successfully cached info: {hash_id}")
logger.debug(f"Successfully cached info: {selection_title}")
except IOError as e:
logger.error(f"Failed to write info cache for {hash_id}: {e}")
logger.error(f"Failed to write info cache for {selection_title}: {e}")
raise
def _get_cache_hash(self, text: str) -> str:
def _get_selection_title(self, text: str) -> str:
"""Generate a cache hash for the given text."""
from hashlib import sha256
return sha256(text.encode("utf-8")).hexdigest()
return f"search-results-{sha256(text.encode('utf-8')).hexdigest()}"
def _on_task_completed(self, task: WorkerTask, future) -> None:
"""Handle task completion with enhanced logging."""

View File

@@ -6,7 +6,7 @@ GENERAL_PYGMENT_STYLE = "github-dark"
GENERAL_PREFERRED_SPINNER = "smiley"
GENERAL_API_CLIENT = "anilist"
GENERAL_PREFERRED_TRACKER = "local"
GENERAL_DESKTOP_NOTIFICATION_DURATION = 5
GENERAL_DESKTOP_NOTIFICATION_DURATION = 5 * 60
GENERAL_PROVIDER = "allanime"

View File

@@ -25,7 +25,7 @@ ANILIST_AUTH = (
)
try:
APP_DIR = Path(str(resources.files(CLI_NAME.lower())))
APP_DIR = Path(str(resources.files(PROJECT_NAME.lower())))
except ModuleNotFoundError:
from pathlib import Path

View File

@@ -6,6 +6,7 @@
from enum import Enum
class MediaTag(Enum):
#
# TECHNICAL

View File

@@ -30,8 +30,6 @@ def test_media_api(api_client: BaseApiClient):
"""
from ....core.constants import APP_ASCII_ART
from ..params import (
MediaAiringScheduleParams,
MediaCharactersParams,
MediaRecommendationParams,
MediaRelationsParams,
MediaSearchParams,

View File

@@ -1,65 +0,0 @@
"""
Syncplay integration for Viu.
This module provides a procedural function to launch Syncplay with the given media and options.
"""
import shutil
import subprocess
from .tools import exit_app
def SyncPlayer(url: str, anime_title=None, headers={}, subtitles=[], *args):
"""
Launch Syncplay for synchronized playback with friends.
Args:
url: The media URL to play.
anime_title: Optional title to display in the player.
headers: Optional HTTP headers to pass to the player.
subtitles: Optional list of subtitle dicts with 'url' keys.
*args: Additional arguments (unused).
Returns:
Tuple of ("0", "0") for compatibility.
"""
# TODO: handle m3u8 multi quality streams
#
# check for SyncPlay
SYNCPLAY_EXECUTABLE = shutil.which("syncplay")
if not SYNCPLAY_EXECUTABLE:
print("Syncplay not found")
exit_app(1)
return "0", "0"
# start SyncPlayer
mpv_args = []
if headers:
mpv_headers = "--http-header-fields="
for header_name, header_value in headers.items():
mpv_headers += f"{header_name}:{header_value},"
mpv_args.append(mpv_headers)
for subtitle in subtitles:
mpv_args.append(f"--sub-file={subtitle['url']}")
if not anime_title:
subprocess.run(
[
SYNCPLAY_EXECUTABLE,
url,
],
check=False,
)
else:
subprocess.run(
[
SYNCPLAY_EXECUTABLE,
url,
"--",
f"--force-media-title={anime_title}",
*mpv_args,
],
check=False,
)
# for compatability
return "0", "0"

View File

@@ -88,4 +88,5 @@ def decode_hex_string(hex_string):
# Decode each hex pair
decoded_chars = [hex_to_char.get(pair.lower(), pair) for pair in hex_pairs]
return "".join(decoded_chars)
# TODO: Better type handling
return "".join(decoded_chars) # type: ignore

View File

@@ -1,5 +1,3 @@
from typing import Any
from ..types import (
Anime,
AnimeEpisodeInfo,
@@ -87,13 +85,16 @@ def map_to_anime_result(
def map_to_server(
episode: AnimeEpisodeInfo, translation_type: Any, quality: Any, stream_link: Any
episode: AnimeEpisodeInfo,
translation_type: str,
stream_links: list[tuple[str, str]],
) -> Server:
links = [
EpisodeStream(
link=stream_link,
quality=quality,
link=link[1],
quality=link[0] if link[0] in ["360", "480", "720", "1080"] else "1080", # type:ignore
translation_type=translation_type_map[translation_type],
)
for link in stream_links
]
return Server(name="kwik", links=links, episode_title=episode.title)

View File

@@ -131,15 +131,17 @@ class AnimePahe(BaseAnimeProvider):
res_dicts = [extract_attributes(item) for item in resolutionMenuItems]
quality = None
translation_type = None
stream_link = None
stream_links = []
# TODO: better document the scraping process
for res_dict in res_dicts:
# the actual attributes are data attributes in the original html 'prefixed with data-'
embed_url = res_dict["src"]
logger.debug(f"Found embed url: {embed_url}")
data_audio = "dub" if res_dict["audio"] == "eng" else "sub"
if data_audio != params.translation_type:
logger.debug(f"Found {data_audio} but wanted {params.translation_type}")
continue
if not embed_url:
@@ -155,22 +157,26 @@ class AnimePahe(BaseAnimeProvider):
)
embed_response.raise_for_status()
embed_page = embed_response.text
logger.debug("Processing embed page for JS decoding")
decoded_js = process_animepahe_embed_page(embed_page)
if not decoded_js:
logger.error("failed to decode embed page")
continue
logger.debug(f"Decoded JS: {decoded_js[:100]}...")
juicy_stream = JUICY_STREAM_REGEX.search(decoded_js)
if not juicy_stream:
logger.error("failed to find juicy stream")
continue
logger.debug(f"Found juicy stream: {juicy_stream.group(1)}")
juicy_stream = juicy_stream.group(1)
quality = res_dict["resolution"]
logger.debug(f"Found quality: {quality}")
translation_type = data_audio
stream_link = juicy_stream
stream_links.append((quality, juicy_stream))
if translation_type and quality and stream_link:
yield map_to_server(episode, translation_type, quality, stream_link)
if translation_type and stream_links:
yield map_to_server(episode, translation_type, stream_links)
@lru_cache()
def _get_episode_info(

View File

@@ -69,6 +69,9 @@ def test_anime_provider(AnimeProvider: Type[BaseAnimeProvider]):
for i, stream in enumerate(episode_streams):
print(f"{i + 1}: {stream.name}")
stream = episode_streams[int(input("Select your preferred server: ")) - 1]
for i, link in enumerate(stream.links):
print(f"{i + 1}: {link.quality}")
link = stream.links[int(input("Select your preferred quality: ")) - 1]
if executable := shutil.which("mpv"):
cmd = executable
elif executable := shutil.which("xdg-open"):
@@ -84,4 +87,4 @@ def test_anime_provider(AnimeProvider: Type[BaseAnimeProvider]):
"Episode: ",
stream.episode_title if stream.episode_title else episode_number,
)
subprocess.run([cmd, stream.links[0].link])
subprocess.run([cmd, link.link])

View File

@@ -1,3 +1,4 @@
# pyright: reportAttributeAccessIssue=false, reportPossiblyUnboundVariable=false
"""
HTML parsing utilities with optional lxml support.

View File

@@ -1,4 +1,4 @@
from InquirerPy.prompts import FuzzyPrompt
from InquirerPy.prompts import FuzzyPrompt # pyright: ignore[reportPrivateImportUsage]
from rich.prompt import Confirm, Prompt
from ..base import BaseSelector

View File

@@ -8,7 +8,7 @@ if getattr(sys, "frozen", False):
sys.path.insert(0, application_path)
# Import and run the main application
from viu import Cli
from viu_media import Cli
if __name__ == "__main__":
Cli()