mirror of
https://github.com/Benexl/FastAnime.git
synced 2025-12-05 20:40:09 -08:00
Compare commits
35 Commits
feature/an
...
29ce664e4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29ce664e4c | ||
|
|
2217f011af | ||
|
|
5960a7c502 | ||
|
|
bd0309ee85 | ||
|
|
3724f06e33 | ||
|
|
d20af89fc8 | ||
|
|
3872b4c8a8 | ||
|
|
9545b893e1 | ||
|
|
1519c8be17 | ||
|
|
9a619b41f4 | ||
|
|
0c3a963cc4 | ||
|
|
192818362b | ||
|
|
2d8c1d3569 | ||
|
|
e37f9213f6 | ||
|
|
097db713bc | ||
|
|
106278e386 | ||
|
|
44b3663644 | ||
|
|
925c30c06e | ||
|
|
7401a1ad8f | ||
|
|
9a0bb65e52 | ||
|
|
1d129a5771 | ||
|
|
515660b0f6 | ||
|
|
9f5c895bf5 | ||
|
|
5634214fb8 | ||
|
|
66c0ada29d | ||
|
|
02465b4ddb | ||
|
|
5ffd94ac24 | ||
|
|
d2864df6d0 | ||
|
|
2a28e3b9a3 | ||
|
|
7b8027a8b3 | ||
|
|
2a36152c38 | ||
|
|
2048c7b743 | ||
|
|
133fd4c1c8 | ||
|
|
e22120fe99 | ||
|
|
44e6220662 |
3
.envrc
3
.envrc
@@ -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
57
.github/workflows/stale.yml
vendored
Normal 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
|
||||
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -41,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):\
|
||||
"""
|
||||
|
||||
|
||||
0
viu_media/assets/scripts/fzf/character_info.py
Normal file
0
viu_media/assets/scripts/fzf/character_info.py
Normal file
0
viu_media/assets/scripts/fzf/episode_info.py
Normal file
0
viu_media/assets/scripts/fzf/episode_info.py
Normal file
89
viu_media/assets/scripts/fzf/info.py
Normal file
89
viu_media/assets/scripts/fzf/info.py
Normal 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}"""))
|
||||
212
viu_media/assets/scripts/fzf/preview.py
Normal file
212
viu_media/assets/scripts/fzf/preview.py
Normal 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...")
|
||||
0
viu_media/assets/scripts/fzf/review_info.py
Normal file
0
viu_media/assets/scripts/fzf/review_info.py
Normal 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(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Update command for Viu CLI."""
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class MediaTag(Enum):
|
||||
#
|
||||
# TECHNICAL
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# pyright: reportAttributeAccessIssue=false, reportPossiblyUnboundVariable=false
|
||||
"""
|
||||
HTML parsing utilities with optional lxml support.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user