Compare commits

...

12 Commits

Author SHA1 Message Date
Benedict Xavier
4caafda123 Merge branch 'master' into copilot/improve-installation-section 2026-01-01 10:49:48 +03:00
Benexl
9ef834c94c fix: update shell_safe function to improve string literal escaping 2026-01-01 10:43:30 +03:00
copilot-swe-agent[bot]
5e9255b3d5 docs: Restructure binary installation to reduce redundancy
Co-authored-by: Benexl <81157281+Benexl@users.noreply.github.com>
2026-01-01 07:31:33 +00:00
copilot-swe-agent[bot]
fd535ad3e3 docs: Fix link consistency and chmod order in binary installation
Co-authored-by: Benexl <81157281+Benexl@users.noreply.github.com>
2026-01-01 07:30:45 +00:00
copilot-swe-agent[bot]
121e02a7e2 docs: Clarify binary installation with exact filenames and Windows steps
Co-authored-by: Benexl <81157281+Benexl@users.noreply.github.com>
2026-01-01 07:29:46 +00:00
copilot-swe-agent[bot]
2bb62fd0af docs: Make binary installation instructions more specific
Co-authored-by: Benexl <81157281+Benexl@users.noreply.github.com>
2026-01-01 07:28:46 +00:00
copilot-swe-agent[bot]
a752a9efdd docs: Add pre-built binaries section to README installation
Co-authored-by: Benexl <81157281+Benexl@users.noreply.github.com>
2026-01-01 07:27:24 +00:00
copilot-swe-agent[bot]
ac490d9a4b Initial plan 2026-01-01 07:25:17 +00:00
Benexl
1ce2d2740d feat: implement get_clean_env function to manage environment variables for subprocesses 2025-12-31 21:43:43 +03:00
Benexl
ce6294a17b fix: exclude OpenSSL libraries on Linux to avoid version conflicts 2025-12-31 21:14:08 +03:00
Benexl
b550956a3e fix: update Ubuntu version in release binaries workflow to 22.04 2025-12-31 21:03:29 +03:00
Benexl
e382e4c046 chore: bump version to 3.3.7 in pyproject.toml and uv.lock 2025-12-31 20:51:00 +03:00
14 changed files with 233 additions and 153 deletions

View File

@@ -1,152 +1,152 @@
name: Build Release Binaries
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Tag/version to build (leave empty for latest)"
required: false
type: string
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Tag/version to build (leave empty for latest)"
required: false
type: string
permissions:
contents: write
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
asset_name: viu-linux-x86_64
executable: viu
- os: windows-latest
target: windows
asset_name: viu-windows-x86_64.exe
executable: viu.exe
- os: macos-latest
target: macos
asset_name: viu-macos-x86_64
executable: viu
build:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-22.04
target: linux
asset_name: viu-linux-x86_64
executable: viu
- os: windows-latest
target: windows
asset_name: viu-windows-x86_64.exe
executable: viu.exe
- os: macos-latest
target: macos
asset_name: viu-macos-x86_64
executable: viu
runs-on: ${{ matrix.os }}
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libdbus-1-dev libglib2.0-dev
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libdbus-1-dev libglib2.0-dev
- name: Install dependencies
run: uv sync --all-extras --all-groups
- name: Install dependencies
run: uv sync --all-extras --all-groups
- name: Build executable with PyInstaller
run: uv run pyinstaller bundle/pyinstaller.spec --distpath dist --workpath build/pyinstaller --clean
- name: Build executable with PyInstaller
run: uv run pyinstaller bundle/pyinstaller.spec --distpath dist --workpath build/pyinstaller --clean
- name: Rename executable
shell: bash
run: mv dist/${{ matrix.executable }} dist/${{ matrix.asset_name }}
- name: Rename executable
shell: bash
run: mv dist/${{ matrix.executable }} dist/${{ matrix.asset_name }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: dist/${{ matrix.asset_name }}
if-no-files-found: error
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: dist/${{ matrix.asset_name }}
if-no-files-found: error
- name: Upload to Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: dist/${{ matrix.asset_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Build for macOS ARM (Apple Silicon)
build-macos-arm:
runs-on: macos-14
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --all-extras --all-groups
- name: Build executable with PyInstaller
run: uv run pyinstaller bundle/pyinstaller.spec --distpath dist --workpath build/pyinstaller --clean
- name: Rename executable
run: mv dist/viu dist/viu-macos-arm64
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: viu-macos-arm64
path: dist/viu-macos-arm64
if-no-files-found: error
- name: Upload to Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: dist/viu-macos-arm64
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Create checksums after all builds complete
checksums:
needs: [build, build-macos-arm]
runs-on: ubuntu-latest
- name: Upload to Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: dist/${{ matrix.asset_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
# Build for macOS ARM (Apple Silicon)
build-macos-arm:
runs-on: macos-14
- name: Generate checksums
run: |
cd artifacts
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Upload checksums to Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true
- name: Install dependencies
run: uv sync --all-extras --all-groups
- name: Build executable with PyInstaller
run: uv run pyinstaller bundle/pyinstaller.spec --distpath dist --workpath build/pyinstaller --clean
- name: Rename executable
run: mv dist/viu dist/viu-macos-arm64
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: viu-macos-arm64
path: dist/viu-macos-arm64
if-no-files-found: error
- name: Upload to Release
if: github.event_name == 'release'
uses: softprops/action-gh-release@v2
with:
files: dist/viu-macos-arm64
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Create checksums after all builds complete
checksums:
needs: [build, build-macos-arm]
runs-on: ubuntu-latest
if: github.event_name == 'release'
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Generate checksums
run: |
cd artifacts
sha256sum * > SHA256SUMS.txt
cat SHA256SUMS.txt
- name: Upload checksums to Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/SHA256SUMS.txt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -49,7 +49,7 @@
## Installation
Viu runs on any platform with Python 3.10+, including Windows, macOS, Linux, and Android (via Termux, see other installation methods).
Viu runs on Windows, macOS, Linux, and Android (via Termux). Pre-built binaries are available for quick installation without Python, or you can install via Python 3.10+ package managers.
### Prerequisites
@@ -64,6 +64,39 @@ For the best experience, please install these external tools:
* [**ffmpeg**](https://www.ffmpeg.org/) - Required for downloading HLS streams and merging subtitles.
* [**webtorrent-cli**](https://github.com/webtorrent/webtorrent-cli) - For streaming torrents directly.
### Pre-built Binaries (Recommended for Quick Start)
The easiest way to get started is to download a pre-built, self-contained binary from the [**releases page**](https://github.com/viu-media/viu/releases/latest). These binaries include all dependencies and **do not require Python** to be installed.
**Available for:**
* **Linux** (x86_64): `viu-linux-x86_64`
* **Windows** (x86_64): `viu-windows-x86_64.exe`
* **macOS** (Intel x86_64): `viu-macos-x86_64`
* **macOS** (Apple Silicon ARM64): `viu-macos-arm64`
**Installation Steps:**
1. Download the appropriate binary for your platform from the [**releases page**](https://github.com/viu-media/viu/releases/latest).
2. **Linux/macOS:** Make it executable:
```bash
# Replace with the actual binary name you downloaded
chmod +x viu-linux-x86_64
```
Then move it to a directory in your PATH:
```bash
# Option 1: System-wide installation (requires sudo)
sudo mv viu-linux-x86_64 /usr/local/bin/viu
# Option 2: User directory installation
mkdir -p ~/.local/bin
mv viu-linux-x86_64 ~/.local/bin/viu
# Make sure ~/.local/bin is in your PATH
```
**Windows:** Simply rename `viu-windows-x86_64.exe` to `viu.exe` and place it in a directory in your PATH, or run it directly.
3. Verify the installation:
```bash
viu --version
```
### Recommended Installation (uv)
The best way to install Viu is with [**uv**](https://github.com/astral-sh/uv), a lightning-fast Python package manager.

View File

@@ -39,10 +39,18 @@ hiddenimports = [
'viu_media.cli.interactive.menu.media.servers',
] + collect_submodules('viu_media')
# Exclude OpenSSL libraries on Linux to avoid version conflicts
import sys
binaries = []
if sys.platform == 'linux':
# Remove any bundled libssl or libcrypto
binaries = [b for b in binaries if not any(lib in b[0] for lib in ['libssl', 'libcrypto'])]
a = Analysis(
['../viu_media/viu.py'],
pathex=[],
binaries=[],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],

View File

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

2
uv.lock generated
View File

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

View File

@@ -9,6 +9,8 @@ import importlib.util
import click
import httpx
from viu_media.core.utils import detect
logger = logging.getLogger(__name__)
@@ -138,6 +140,7 @@ def render(url: str, capture: bool = False, size: str = "30x30") -> Optional[str
[icat_executable, "--align", "left", url],
capture_output=capture,
text=capture,
env=detect.get_clean_env(),
)
if process.returncode == 0:
return process.stdout if capture else None

View File

@@ -21,7 +21,7 @@ from rich.progress import (
)
from rich.prompt import Confirm
from ..utils.file import sanitize_filename
from ..utils.detect import get_clean_env
from ..exceptions import ViuError
from ..patterns import TORRENT_REGEX
from ..utils.networking import get_remote_filename
@@ -372,6 +372,7 @@ class DefaultDownloader(BaseDownloader):
capture_output=params.silent, # Only suppress ffmpeg output if silent
text=True,
check=True,
env=get_clean_env(),
)
final_output_path = video_path.parent / merged_filename

View File

@@ -11,7 +11,7 @@ from rich.prompt import Confirm
import yt_dlp
from yt_dlp.utils import sanitize_filename
from ..utils.detect import get_clean_env
from ..exceptions import ViuError
from ..patterns import TORRENT_REGEX
from ..utils.networking import get_remote_filename
@@ -224,7 +224,7 @@ class YtDLPDownloader(BaseDownloader):
# Run the ffmpeg command
try:
subprocess.run(args)
subprocess.run(args, env=get_clean_env())
final_output_path = video_path.parent / merged_filename
if final_output_path.exists():

View File

@@ -83,3 +83,21 @@ def get_python_executable() -> str:
return "python"
else:
return sys.executable
def get_clean_env() -> dict[str, str]:
"""
Returns a copy of the environment with LD_LIBRARY_PATH fixed for system subprocesses
when running as a PyInstaller frozen application.
This prevents system binaries (like mpv, ffmpeg) from loading incompatible
libraries from the PyInstaller bundle.
"""
env = os.environ.copy()
if is_frozen():
# PyInstaller saves the original LD_LIBRARY_PATH in LD_LIBRARY_PATH_ORIG
if "LD_LIBRARY_PATH_ORIG" in env:
env["LD_LIBRARY_PATH"] = env["LD_LIBRARY_PATH_ORIG"]
else:
# If orig didn't exist, LD_LIBRARY_PATH shouldn't exist for the subprocess
env.pop("LD_LIBRARY_PATH", None)
return env

View File

@@ -186,19 +186,19 @@ def shell_safe(text: Optional[str]) -> str:
"""
Escapes a string for safe inclusion in a Python script string literal.
This is used when generating Python cache scripts with embedded text content.
For Python triple-quoted strings, we need to:
For Python string literals, we need to:
- Escape backslashes first (so existing backslashes don't interfere)
- Escape triple quotes (to not break the string literal)
- Remove or replace problematic characters
- Escape double quotes (to not break double-quoted string literals)
- Escape single quotes (to not break single-quoted string literals)
"""
if not text:
return ""
# Escape backslashes first
result = text.replace("\\", "\\\\")
# Escape triple quotes (both types) for Python triple-quoted string literals
result = result.replace('"""', r'\"\"\"')
result = result.replace("'''", r"\'\'\'")
# Escape both quote types for safe inclusion in any string literal
result = result.replace('"', r"\"")
result = result.replace("'", r"\'")
return result

View File

@@ -97,7 +97,7 @@ class MpvPlayer(BasePlayer):
"is.xyz.mpv/.MPVActivity",
]
subprocess.run(args)
subprocess.run(args,env=detect.get_clean_env())
return PlayerResult(params.episode)
@@ -146,6 +146,7 @@ class MpvPlayer(BasePlayer):
text=True,
encoding="utf-8",
check=False,
env=detect.get_clean_env(),
)
if proc.stdout:
for line in reversed(proc.stdout.split("\n")):
@@ -185,7 +186,7 @@ class MpvPlayer(BasePlayer):
logger.info(f"Starting MPV with IPC socket: {socket_path}")
process = subprocess.Popen(pre_args + mpv_args)
process = subprocess.Popen(pre_args + mpv_args,env=detect.get_clean_env())
return process
@@ -210,7 +211,7 @@ class MpvPlayer(BasePlayer):
args.append("--player-args")
args.extend(mpv_args)
subprocess.run(args)
subprocess.run(args,env=detect.get_clean_env())
return PlayerResult(params.episode)
def _stream_on_desktop_with_syncplay(self, params: PlayerParams) -> PlayerResult:
@@ -232,7 +233,7 @@ class MpvPlayer(BasePlayer):
if mpv_args := self._create_mpv_cli_options(params):
args.append("--")
args.extend(mpv_args)
subprocess.run(args)
subprocess.run(args,env=detect.get_clean_env())
return PlayerResult(params.episode)

View File

@@ -103,7 +103,7 @@ class VlcPlayer(BasePlayer):
params.title,
]
subprocess.run(args)
subprocess.run(args,env=detect.get_clean_env())
return PlayerResult(episode=params.episode)
@@ -134,7 +134,7 @@ class VlcPlayer(BasePlayer):
if self.config.args:
args.extend(self.config.args.split(","))
subprocess.run(args, encoding="utf-8")
subprocess.run(args, encoding="utf-8",env=detect.get_clean_env())
return PlayerResult(episode=params.episode)
def _stream_on_desktop_with_webtorrent_cli(
@@ -159,7 +159,7 @@ class VlcPlayer(BasePlayer):
args.append("--player-args")
args.extend(self.config.args.split(","))
subprocess.run(args)
subprocess.run(args,env=detect.get_clean_env())
return PlayerResult(episode=params.episode)

View File

@@ -5,6 +5,8 @@ import subprocess
from rich.prompt import Prompt
from viu_media.core.utils import detect
from ....core.config import FzfConfig
from ....core.exceptions import ViuError
from ..base import BaseSelector
@@ -49,6 +51,7 @@ class FzfSelector(BaseSelector):
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
env=detect.get_clean_env(),
)
if result.returncode != 0:
return None
@@ -76,6 +79,7 @@ class FzfSelector(BaseSelector):
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
env=detect.get_clean_env(),
)
if result.returncode != 0:
return []
@@ -117,7 +121,16 @@ class FzfSelector(BaseSelector):
lines = result.stdout.strip().splitlines()
return lines[-1] if lines else (default or "")
def search(self, prompt, search_command, *, preview=None, header=None, initial_query=None, initial_results=None):
def search(
self,
prompt,
search_command,
*,
preview=None,
header=None,
initial_query=None,
initial_results=None,
):
"""Enhanced search using fzf's --reload flag for dynamic search."""
# Build the header with optional custom header line
display_header = self.header
@@ -156,6 +169,7 @@ class FzfSelector(BaseSelector):
stdout=subprocess.PIPE,
text=True,
encoding="utf-8",
env=detect.get_clean_env(),
)
if result.returncode != 0:
return None

View File

@@ -43,6 +43,7 @@ class RofiSelector(BaseSelector):
input=rofi_input,
stdout=subprocess.PIPE,
text=True,
env=detect.get_clean_env()
)
if result.returncode == 0:
@@ -106,6 +107,7 @@ class RofiSelector(BaseSelector):
input=rofi_input,
stdout=subprocess.PIPE,
text=True,
env=detect.get_clean_env()
)
if result.returncode == 0: