mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2025-10-23 15:11:08 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c9aa195e | ||
|
|
776d8f3df0 | ||
|
|
67caf71295 | ||
|
|
3ed76d199c | ||
|
|
4de9251f95 | ||
|
|
d2fb409ad9 | ||
|
|
fdff3a1c56 | ||
|
|
5d1f2eb458 | ||
|
|
3efac3d474 | ||
|
|
f578904b76 | ||
|
|
9f20159605 | ||
|
|
4decb0d107 |
99
.github/workflows/docker.yml
vendored
99
.github/workflows/docker.yml
vendored
@@ -1,99 +0,0 @@
|
||||
name: Build and Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths: # run only when this file changed at all
|
||||
- "unshackle/core/__init__.py"
|
||||
pull_request: {} # optional – delete if you don’t build on PRs
|
||||
workflow_dispatch: {} # manual override
|
||||
|
||||
jobs:
|
||||
detect-version-change:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.vdiff.outputs.changed }}
|
||||
version: ${{ steps.vdiff.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { fetch-depth: 2 } # we need the previous commit :contentReference[oaicite:1]{index=1}
|
||||
|
||||
- name: Extract & compare version
|
||||
id: vdiff
|
||||
shell: bash
|
||||
run: |
|
||||
current=$(grep -oP '__version__ = "\K[^"]+' unshackle/core/__init__.py)
|
||||
prev=$(git show HEAD^:unshackle/core/__init__.py \
|
||||
| grep -oP '__version__ = "\K[^"]+' || echo '')
|
||||
echo "version=$current" >>"$GITHUB_OUTPUT"
|
||||
echo "changed=$([ "$current" != "$prev" ] && echo true || echo false)" >>"$GITHUB_OUTPUT"
|
||||
echo "Current=$current Previous=$prev"
|
||||
|
||||
build-and-push:
|
||||
needs: detect-version-change
|
||||
if: needs.detect-version-change.outputs.changed == 'true' # only run when bumped :contentReference[oaicite:2]{index=2}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: { contents: read, packages: write }
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from __init__.py
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep -oP '__version__ = "\K[^"]+' unshackle/core/__init__.py)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major_minor=$(echo $VERSION | cut -d. -f1-2)" >> $GITHUB_OUTPUT
|
||||
echo "major=$(echo $VERSION | cut -d. -f1)" >> $GITHUB_OUTPUT
|
||||
echo "Extracted version: $VERSION"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=v${{ steps.version.outputs.version }},enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.version.outputs.version }},enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.version.outputs.major_minor }},enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.version.outputs.major }},enable={{is_default_branch}}
|
||||
|
||||
- name: Show planned tags
|
||||
run: |
|
||||
echo "Planning to create the following tags:"
|
||||
echo "${{ steps.meta.outputs.tags }}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Test Docker image
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest env check
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -5,7 +5,40 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
## [1.2.0] - 2025-07-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Update Checker**: Automatic GitHub release version checking on startup
|
||||
- Configurable update notifications via `update_checks` setting in unshackle.yaml
|
||||
- Non-blocking HTTP requests with 5-second timeout for performance
|
||||
- Smart semantic version comparison supporting all version formats (x.y.z, x.y, x)
|
||||
- Graceful error handling for network issues and API failures
|
||||
- User-friendly update notifications with current → latest version display
|
||||
- Direct links to GitHub releases page for easy updates
|
||||
- **HDR10+ Support**: Enhanced HDR10+ metadata processing for hybrid tracks
|
||||
- HDR10+ tool binary support (`hdr10plus_tool`) added to binaries module
|
||||
- HDR10+ to Dolby Vision conversion capabilities in hybrid processing
|
||||
- Enhanced metadata extraction for HDR10+ content
|
||||
- **Duration Fix Handling**: Added duration correction for video and hybrid tracks
|
||||
- **Temporary Directory Management**: Automatic creation of temp directories for attachment downloads
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced configuration system with new `update_checks` boolean option (defaults to true)
|
||||
- Updated sample unshackle.yaml with update checker configuration documentation
|
||||
- Improved console styling consistency using `bright_black` for dimmed text
|
||||
- **Environment Dependency Check**: Complete overhaul with detailed categorization and status summary
|
||||
- Organized dependencies by category (Core, HDR, Download, Subtitle, Player, Network)
|
||||
- Enhanced status reporting with compact summary display
|
||||
- Improved tool requirement tracking and missing dependency alerts
|
||||
- **Hybrid Track Processing**: Significant improvements to HDR10+ and Dolby Vision handling
|
||||
- Enhanced metadata extraction and processing workflows
|
||||
- Better integration with HDR processing tools
|
||||
|
||||
### Removed
|
||||
|
||||
- **Docker Workflow**: Removed Docker build and publish GitHub Actions workflow for manual builds
|
||||
|
||||
## [1.1.0] - 2025-07-29
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "unshackle"
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
description = "Modular Movie, TV, and Music Archival Software."
|
||||
authors = [{ name = "unshackle team" }]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
|
||||
@@ -295,11 +295,41 @@ class dl:
|
||||
|
||||
with console.status("Loading Key Vaults...", spinner="dots"):
|
||||
self.vaults = Vaults(self.service)
|
||||
total_vaults = len(config.key_vaults)
|
||||
failed_vaults = []
|
||||
|
||||
for vault in config.key_vaults:
|
||||
vault_type = vault["type"]
|
||||
del vault["type"]
|
||||
self.vaults.load(vault_type, **vault)
|
||||
self.log.info(f"Loaded {len(self.vaults)} Vaults")
|
||||
vault_name = vault.get("name", vault_type)
|
||||
vault_copy = vault.copy()
|
||||
del vault_copy["type"]
|
||||
|
||||
if vault_type.lower() == "sqlite":
|
||||
try:
|
||||
self.vaults.load_critical(vault_type, **vault_copy)
|
||||
self.log.debug(f"Successfully loaded vault: {vault_name} ({vault_type})")
|
||||
except Exception as e:
|
||||
self.log.error(f"vault failure: {vault_name} ({vault_type}) - {e}")
|
||||
raise
|
||||
else:
|
||||
# Other vaults (MySQL, HTTP, API) - soft fail
|
||||
if not self.vaults.load(vault_type, **vault_copy):
|
||||
failed_vaults.append(vault_name)
|
||||
self.log.debug(f"Failed to load vault: {vault_name} ({vault_type})")
|
||||
else:
|
||||
self.log.debug(f"Successfully loaded vault: {vault_name} ({vault_type})")
|
||||
|
||||
loaded_count = len(self.vaults)
|
||||
if failed_vaults:
|
||||
self.log.warning(f"Failed to load {len(failed_vaults)} vault(s): {', '.join(failed_vaults)}")
|
||||
self.log.info(f"Loaded {loaded_count}/{total_vaults} Vaults")
|
||||
|
||||
# Debug: Show detailed vault status
|
||||
if loaded_count > 0:
|
||||
vault_names = [vault.name for vault in self.vaults]
|
||||
self.log.debug(f"Active vaults: {', '.join(vault_names)}")
|
||||
else:
|
||||
self.log.debug("No vaults are currently active")
|
||||
|
||||
self.proxy_providers = []
|
||||
if no_proxy:
|
||||
@@ -403,6 +433,7 @@ class dl:
|
||||
# Check if dovi_tool is available when hybrid mode is requested
|
||||
if any(r == Video.Range.HYBRID for r in range_):
|
||||
from unshackle.core.binaries import DoviTool
|
||||
|
||||
if not DoviTool:
|
||||
self.log.error("Unable to run hybrid mode: dovi_tool not detected")
|
||||
self.log.error("Please install dovi_tool from https://github.com/quietvoid/dovi_tool")
|
||||
@@ -907,32 +938,59 @@ class dl:
|
||||
|
||||
# Check if we're in hybrid mode
|
||||
if any(r == Video.Range.HYBRID for r in range_) and title.tracks.videos:
|
||||
# Hybrid mode: process DV and HDR10 tracks together
|
||||
# Hybrid mode: process DV and HDR10 tracks separately for each resolution
|
||||
self.log.info("Processing Hybrid HDR10+DV tracks...")
|
||||
|
||||
# Run the hybrid processing
|
||||
Hybrid(title.tracks.videos, self.service)
|
||||
# Group video tracks by resolution
|
||||
resolutions_processed = set()
|
||||
hdr10_tracks = [v for v in title.tracks.videos if v.range == Video.Range.HDR10]
|
||||
dv_tracks = [v for v in title.tracks.videos if v.range == Video.Range.DV]
|
||||
|
||||
# After hybrid processing, the output file should be in temp directory
|
||||
hybrid_output_path = config.directories.temp / "HDR10-DV.hevc"
|
||||
for hdr10_track in hdr10_tracks:
|
||||
resolution = hdr10_track.height
|
||||
if resolution in resolutions_processed:
|
||||
continue
|
||||
resolutions_processed.add(resolution)
|
||||
|
||||
# Create a single mux task for the hybrid output
|
||||
task_description = "Multiplexing Hybrid HDR10+DV"
|
||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||
# Find matching DV track for this resolution (use the lowest DV resolution)
|
||||
matching_dv = min(dv_tracks, key=lambda v: v.height) if dv_tracks else None
|
||||
|
||||
# Create tracks with the hybrid video output
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
if matching_dv:
|
||||
# Create track pair for this resolution
|
||||
resolution_tracks = [hdr10_track, matching_dv]
|
||||
|
||||
# Create a new video track for the hybrid output
|
||||
# Use the HDR10 track as a template but update its path
|
||||
hdr10_track = next((v for v in title.tracks.videos if v.range == Video.Range.HDR10), None)
|
||||
if hdr10_track:
|
||||
hybrid_track = deepcopy(hdr10_track)
|
||||
hybrid_track.path = hybrid_output_path
|
||||
hybrid_track.range = Video.Range.DV # It's now a DV track
|
||||
task_tracks.videos = [hybrid_track]
|
||||
for track in resolution_tracks:
|
||||
track.needs_duration_fix = True
|
||||
|
||||
multiplex_tasks.append((task_id, task_tracks))
|
||||
# Run the hybrid processing for this resolution
|
||||
Hybrid(resolution_tracks, self.service)
|
||||
|
||||
# Create unique output filename for this resolution
|
||||
hybrid_filename = f"HDR10-DV-{resolution}p.hevc"
|
||||
hybrid_output_path = config.directories.temp / hybrid_filename
|
||||
|
||||
# The Hybrid class creates HDR10-DV.hevc, rename it for this resolution
|
||||
default_output = config.directories.temp / "HDR10-DV.hevc"
|
||||
if default_output.exists():
|
||||
shutil.move(str(default_output), str(hybrid_output_path))
|
||||
|
||||
# Create a mux task for this resolution
|
||||
task_description = f"Multiplexing Hybrid HDR10+DV {resolution}p"
|
||||
task_id = progress.add_task(f"{task_description}...", total=None, start=False)
|
||||
|
||||
# Create tracks with the hybrid video output for this resolution
|
||||
task_tracks = Tracks(title.tracks) + title.tracks.chapters + title.tracks.attachments
|
||||
|
||||
# Create a new video track for the hybrid output
|
||||
hybrid_track = deepcopy(hdr10_track)
|
||||
hybrid_track.path = hybrid_output_path
|
||||
hybrid_track.range = Video.Range.DV # It's now a DV track
|
||||
hybrid_track.needs_duration_fix = True
|
||||
task_tracks.videos = [hybrid_track]
|
||||
|
||||
multiplex_tasks.append((task_id, task_tracks))
|
||||
|
||||
console.print()
|
||||
else:
|
||||
# Normal mode: process each video track separately
|
||||
for video_track in title.tracks.videos or [None]:
|
||||
|
||||
@@ -25,52 +25,127 @@ def env() -> None:
|
||||
@env.command()
|
||||
def check() -> None:
|
||||
"""Checks environment for the required dependencies."""
|
||||
table = Table(title="Dependencies", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Required", justify="center")
|
||||
table.add_column("Installed", justify="center")
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
# Define all dependencies with their binary objects and required status
|
||||
dependencies = [
|
||||
{"name": "FFMpeg", "binary": binaries.FFMPEG, "required": True},
|
||||
{"name": "FFProbe", "binary": binaries.FFProbe, "required": True},
|
||||
{"name": "shaka-packager", "binary": binaries.ShakaPackager, "required": True},
|
||||
{"name": "MKVToolNix", "binary": binaries.MKVToolNix, "required": True},
|
||||
{"name": "Mkvpropedit", "binary": binaries.Mkvpropedit, "required": True},
|
||||
{"name": "CCExtractor", "binary": binaries.CCExtractor, "required": False},
|
||||
{"name": "FFPlay", "binary": binaries.FFPlay, "required": False},
|
||||
{"name": "SubtitleEdit", "binary": binaries.SubtitleEdit, "required": False},
|
||||
{"name": "Aria2(c)", "binary": binaries.Aria2, "required": False},
|
||||
{"name": "HolaProxy", "binary": binaries.HolaProxy, "required": False},
|
||||
{"name": "MPV", "binary": binaries.MPV, "required": False},
|
||||
{"name": "Caddy", "binary": binaries.Caddy, "required": False},
|
||||
{"name": "N_m3u8DL-RE", "binary": binaries.N_m3u8DL_RE, "required": False},
|
||||
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False},
|
||||
# Define all dependencies
|
||||
all_deps = [
|
||||
# Core Media Tools
|
||||
{"name": "FFmpeg", "binary": binaries.FFMPEG, "required": True, "desc": "Media processing", "cat": "Core"},
|
||||
{"name": "FFprobe", "binary": binaries.FFProbe, "required": True, "desc": "Media analysis", "cat": "Core"},
|
||||
{"name": "MKVToolNix", "binary": binaries.MKVToolNix, "required": True, "desc": "MKV muxing", "cat": "Core"},
|
||||
{
|
||||
"name": "mkvpropedit",
|
||||
"binary": binaries.Mkvpropedit,
|
||||
"required": True,
|
||||
"desc": "MKV metadata",
|
||||
"cat": "Core",
|
||||
},
|
||||
{
|
||||
"name": "shaka-packager",
|
||||
"binary": binaries.ShakaPackager,
|
||||
"required": True,
|
||||
"desc": "DRM decryption",
|
||||
"cat": "DRM",
|
||||
},
|
||||
# HDR Processing
|
||||
{"name": "dovi_tool", "binary": binaries.DoviTool, "required": False, "desc": "Dolby Vision", "cat": "HDR"},
|
||||
{
|
||||
"name": "HDR10Plus_tool",
|
||||
"binary": binaries.HDR10PlusTool,
|
||||
"required": False,
|
||||
"desc": "HDR10+ metadata",
|
||||
"cat": "HDR",
|
||||
},
|
||||
# Downloaders
|
||||
{"name": "aria2c", "binary": binaries.Aria2, "required": False, "desc": "Multi-thread DL", "cat": "Download"},
|
||||
{
|
||||
"name": "N_m3u8DL-RE",
|
||||
"binary": binaries.N_m3u8DL_RE,
|
||||
"required": False,
|
||||
"desc": "HLS/DASH/ISM",
|
||||
"cat": "Download",
|
||||
},
|
||||
# Subtitle Tools
|
||||
{
|
||||
"name": "SubtitleEdit",
|
||||
"binary": binaries.SubtitleEdit,
|
||||
"required": False,
|
||||
"desc": "Sub conversion",
|
||||
"cat": "Subtitle",
|
||||
},
|
||||
{
|
||||
"name": "CCExtractor",
|
||||
"binary": binaries.CCExtractor,
|
||||
"required": False,
|
||||
"desc": "CC extraction",
|
||||
"cat": "Subtitle",
|
||||
},
|
||||
# Media Players
|
||||
{"name": "FFplay", "binary": binaries.FFPlay, "required": False, "desc": "Simple player", "cat": "Player"},
|
||||
{"name": "MPV", "binary": binaries.MPV, "required": False, "desc": "Advanced player", "cat": "Player"},
|
||||
# Network Tools
|
||||
{
|
||||
"name": "HolaProxy",
|
||||
"binary": binaries.HolaProxy,
|
||||
"required": False,
|
||||
"desc": "Proxy service",
|
||||
"cat": "Network",
|
||||
},
|
||||
{"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"},
|
||||
]
|
||||
|
||||
for dep in dependencies:
|
||||
# Track overall status
|
||||
all_required_installed = True
|
||||
total_installed = 0
|
||||
total_required = 0
|
||||
missing_required = []
|
||||
|
||||
# Create a single table
|
||||
table = Table(
|
||||
title="Environment Dependencies", title_style="bold", show_header=True, header_style="bold", expand=False
|
||||
)
|
||||
table.add_column("Category", style="bold cyan", width=10)
|
||||
table.add_column("Tool", width=16)
|
||||
table.add_column("Status", justify="center", width=10)
|
||||
table.add_column("Req", justify="center", width=4)
|
||||
table.add_column("Purpose", style="bright_black", width=20)
|
||||
|
||||
last_cat = None
|
||||
for dep in all_deps:
|
||||
path = dep["binary"]
|
||||
|
||||
# Required column
|
||||
if dep["required"]:
|
||||
required = "[red]Yes[/red]"
|
||||
else:
|
||||
required = "No"
|
||||
# Category column (only show when it changes)
|
||||
category = dep["cat"] if dep["cat"] != last_cat else ""
|
||||
last_cat = dep["cat"]
|
||||
|
||||
# Installed column
|
||||
# Status
|
||||
if path:
|
||||
installed = "[green]:heavy_check_mark:[/green]"
|
||||
path_output = str(path)
|
||||
status = "[green]✓[/green]"
|
||||
total_installed += 1
|
||||
else:
|
||||
installed = "[red]:x:[/red]"
|
||||
path_output = "Not Found"
|
||||
status = "[red]✗[/red]"
|
||||
if dep["required"]:
|
||||
all_required_installed = False
|
||||
missing_required.append(dep["name"])
|
||||
|
||||
# Add to the table
|
||||
table.add_row(dep["name"], required, installed, path_output)
|
||||
if dep["required"]:
|
||||
total_required += 1
|
||||
|
||||
# Display the result
|
||||
console.print(Padding(table, (1, 5)))
|
||||
# Required column (compact)
|
||||
req = "[red]Y[/red]" if dep["required"] else "[bright_black]-[/bright_black]"
|
||||
|
||||
# Add row
|
||||
table.add_row(category, dep["name"], status, req, dep["desc"])
|
||||
|
||||
console.print(Padding(table, (1, 2)))
|
||||
|
||||
# Compact summary
|
||||
summary_parts = [f"[bold]Total:[/bold] {total_installed}/{len(all_deps)}"]
|
||||
|
||||
if all_required_installed:
|
||||
summary_parts.append("[green]All required tools installed ✓[/green]")
|
||||
else:
|
||||
summary_parts.append(f"[red]Missing required: {', '.join(missing_required)}[/red]")
|
||||
|
||||
console.print(Padding(" ".join(summary_parts), (1, 2)))
|
||||
|
||||
|
||||
@env.command()
|
||||
@@ -86,7 +161,7 @@ def info() -> None:
|
||||
tree.add(f"[repr.number]{i}.[/] [text2]{path.resolve()}[/]")
|
||||
console.print(Padding(tree, (0, 5)))
|
||||
|
||||
table = Table(title="Directories", expand=True)
|
||||
table = Table(title="Directories", title_style="bold", expand=True)
|
||||
table.add_column("Name", no_wrap=True)
|
||||
table.add_column("Path", no_wrap=False, overflow="fold")
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@ def copy(to_vault: str, from_vaults: list[str], service: Optional[str] = None) -
|
||||
vault_type = vault["type"]
|
||||
vault_args = vault.copy()
|
||||
del vault_args["type"]
|
||||
vaults.load(vault_type, **vault_args)
|
||||
if not vaults.load(vault_type, **vault_args):
|
||||
raise click.ClickException(f"Failed to load vault ({vault_name}).")
|
||||
|
||||
to_vault: Vault = vaults.vaults[0]
|
||||
from_vaults: list[Vault] = vaults.vaults[1:]
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.2.0"
|
||||
|
||||
@@ -15,6 +15,7 @@ from unshackle.core.commands import Commands
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import ComfyRichHandler, console
|
||||
from unshackle.core.constants import context_settings
|
||||
from unshackle.core.update_checker import UpdateChecker
|
||||
from unshackle.core.utilities import rotate_log_file
|
||||
|
||||
LOGGING_PATH = None
|
||||
@@ -79,6 +80,22 @@ def main(version: bool, debug: bool, log_path: Path) -> None:
|
||||
if version:
|
||||
return
|
||||
|
||||
if config.update_checks:
|
||||
try:
|
||||
latest_version = UpdateChecker.check_for_updates_sync(__version__)
|
||||
if latest_version:
|
||||
console.print(
|
||||
f"\n[yellow]⚠️ Update available![/yellow] "
|
||||
f"Current: {__version__} → Latest: [green]{latest_version}[/green]",
|
||||
justify="center",
|
||||
)
|
||||
console.print(
|
||||
"Visit: https://github.com/unshackle-dl/unshackle/releases/latest\n",
|
||||
justify="center",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@atexit.register
|
||||
def save_log():
|
||||
|
||||
@@ -52,6 +52,7 @@ N_m3u8DL_RE = find("N_m3u8DL-RE", "n-m3u8dl-re")
|
||||
MKVToolNix = find("mkvmerge")
|
||||
Mkvpropedit = find("mkvpropedit")
|
||||
DoviTool = find("dovi_tool")
|
||||
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -69,5 +70,6 @@ __all__ = (
|
||||
"MKVToolNix",
|
||||
"Mkvpropedit",
|
||||
"DoviTool",
|
||||
"HDR10PlusTool",
|
||||
"find",
|
||||
)
|
||||
|
||||
@@ -78,6 +78,7 @@ class Config:
|
||||
self.set_terminal_bg: bool = kwargs.get("set_terminal_bg", False)
|
||||
self.tag: str = kwargs.get("tag") or ""
|
||||
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
|
||||
self.update_checks: bool = kwargs.get("update_checks", True)
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: Path) -> Config:
|
||||
|
||||
@@ -62,6 +62,7 @@ class Attachment:
|
||||
session = session or requests.Session()
|
||||
response = session.get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
config.directories.temp.mkdir(parents=True, exist_ok=True)
|
||||
download_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(download_path, "wb") as f:
|
||||
|
||||
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
from rich.padding import Padding
|
||||
from rich.rule import Rule
|
||||
|
||||
from unshackle.core.binaries import DoviTool
|
||||
from unshackle.core.binaries import DoviTool, HDR10PlusTool
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
|
||||
@@ -20,6 +20,7 @@ class Hybrid:
|
||||
"""
|
||||
Takes the Dolby Vision and HDR10(+) streams out of the VideoTracks.
|
||||
It will then attempt to inject the Dolby Vision metadata layer to the HDR10(+) stream.
|
||||
If no DV track is available but HDR10+ is present, it will convert HDR10+ to DV.
|
||||
"""
|
||||
global directories
|
||||
from unshackle.core.tracks import Video
|
||||
@@ -29,17 +30,35 @@ class Hybrid:
|
||||
self.rpu_file = "RPU.bin"
|
||||
self.hdr_type = "HDR10"
|
||||
self.hevc_file = f"{self.hdr_type}-DV.hevc"
|
||||
self.hdr10plus_to_dv = False
|
||||
self.hdr10plus_file = "HDR10Plus.json"
|
||||
|
||||
console.print(Padding(Rule("[rule.text]HDR10+DV Hybrid"), (1, 2)))
|
||||
# Get resolution info from HDR10 track for display
|
||||
hdr10_track = next((v for v in videos if v.range == Video.Range.HDR10), None)
|
||||
hdr10p_track = next((v for v in videos if v.range == Video.Range.HDR10P), None)
|
||||
track_for_res = hdr10_track or hdr10p_track
|
||||
self.resolution = f"{track_for_res.height}p" if track_for_res and track_for_res.height else "Unknown"
|
||||
|
||||
console.print(Padding(Rule(f"[rule.text]HDR10+DV Hybrid ({self.resolution})"), (1, 2)))
|
||||
|
||||
for video in self.videos:
|
||||
if not video.path or not os.path.exists(video.path):
|
||||
self.log.exit(f" - Video track {video.id} was not downloaded before injection.")
|
||||
|
||||
if not any(video.range == Video.Range.DV for video in self.videos) or not any(
|
||||
video.range == Video.Range.HDR10 for video in self.videos
|
||||
):
|
||||
self.log.exit(" - Two VideoTracks available but one of them is not DV nor HDR10(+).")
|
||||
# Check if we have DV track available
|
||||
has_dv = any(video.range == Video.Range.DV for video in self.videos)
|
||||
has_hdr10 = any(video.range == Video.Range.HDR10 for video in self.videos)
|
||||
has_hdr10p = any(video.range == Video.Range.HDR10P for video in self.videos)
|
||||
|
||||
if not has_hdr10:
|
||||
self.log.exit(" - No HDR10 track available for hybrid processing.")
|
||||
|
||||
# If we have HDR10+ but no DV, we can convert HDR10+ to DV
|
||||
if not has_dv and has_hdr10p:
|
||||
self.log.info("✓ No DV track found, but HDR10+ is available. Will convert HDR10+ to DV.")
|
||||
self.hdr10plus_to_dv = True
|
||||
elif not has_dv:
|
||||
self.log.exit(" - No DV track available and no HDR10+ to convert.")
|
||||
|
||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||
self.log.info("✓ Already Injected")
|
||||
@@ -53,18 +72,28 @@ class Hybrid:
|
||||
|
||||
if video.range == Video.Range.HDR10:
|
||||
self.extract_stream(save_path, "HDR10")
|
||||
elif video.range == Video.Range.HDR10P:
|
||||
self.extract_stream(save_path, "HDR10")
|
||||
self.hdr_type = "HDR10+"
|
||||
elif video.range == Video.Range.DV:
|
||||
self.extract_stream(save_path, "DV")
|
||||
# self.extract_dv_stream(video, save_path)
|
||||
|
||||
self.extract_rpu([video for video in videos if video.range == Video.Range.DV][0])
|
||||
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
||||
self.rpu_file = "RPU_UNT.bin"
|
||||
self.level_6()
|
||||
# Mode 3 conversion already done during extraction when not untouched
|
||||
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
||||
# RPU already extracted with mode 3
|
||||
pass
|
||||
if self.hdr10plus_to_dv:
|
||||
# Extract HDR10+ metadata and convert to DV
|
||||
hdr10p_video = next(v for v in videos if v.range == Video.Range.HDR10P)
|
||||
self.extract_hdr10plus(hdr10p_video)
|
||||
self.convert_hdr10plus_to_dv()
|
||||
else:
|
||||
# Regular DV extraction
|
||||
dv_video = next(v for v in videos if v.range == Video.Range.DV)
|
||||
self.extract_rpu(dv_video)
|
||||
if os.path.isfile(config.directories.temp / "RPU_UNT.bin"):
|
||||
self.rpu_file = "RPU_UNT.bin"
|
||||
self.level_6()
|
||||
# Mode 3 conversion already done during extraction when not untouched
|
||||
elif os.path.isfile(config.directories.temp / "RPU.bin"):
|
||||
# RPU already extracted with mode 3
|
||||
pass
|
||||
|
||||
self.injecting()
|
||||
|
||||
@@ -72,9 +101,9 @@ class Hybrid:
|
||||
if self.source == ("itunes" or "appletvplus"):
|
||||
Path.unlink(config.directories.temp / "hdr10.mkv")
|
||||
Path.unlink(config.directories.temp / "dv.mkv")
|
||||
Path.unlink(config.directories.temp / "DV.hevc")
|
||||
Path.unlink(config.directories.temp / "HDR10.hevc")
|
||||
Path.unlink(config.directories.temp / f"{self.rpu_file}")
|
||||
Path.unlink(config.directories.temp / "HDR10.hevc", missing_ok=True)
|
||||
Path.unlink(config.directories.temp / "DV.hevc", missing_ok=True)
|
||||
Path.unlink(config.directories.temp / f"{self.rpu_file}", missing_ok=True)
|
||||
|
||||
def ffmpeg_simple(self, save_path, output):
|
||||
"""Simple ffmpeg execution without progress tracking"""
|
||||
@@ -106,142 +135,6 @@ class Hybrid:
|
||||
self.log.error(f"x Failed extracting {type_} stream")
|
||||
sys.exit(1)
|
||||
|
||||
def ffmpeg_task(self, save_path, output, task_id):
|
||||
p = subprocess.Popen(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
str(save_path),
|
||||
"-c:v",
|
||||
"copy",
|
||||
str(output),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
self.progress.start_task(task_id)
|
||||
|
||||
for line in p.stderr:
|
||||
if "frame=" in line:
|
||||
self.progress.update(task_id, advance=0)
|
||||
p.wait()
|
||||
|
||||
return p.returncode
|
||||
|
||||
def extract_hdr10_stream(self, video, save_path):
|
||||
type_ = "HDR10"
|
||||
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
|
||||
return
|
||||
if self.source == "itunes" or self.source == "appletvplus":
|
||||
self.log.info("+ Muxing HDR10 stream for fixing MP4 file")
|
||||
subprocess.run(
|
||||
[
|
||||
"mkvmerge",
|
||||
"-o",
|
||||
Path(config.directories.temp / "hdr10.mkv"),
|
||||
save_path,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.log.info(f"+ Extracting {type_} stream")
|
||||
extract_stream = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-stats",
|
||||
"-i",
|
||||
Path(config.directories.temp / "hdr10.mkv"),
|
||||
"-c:v",
|
||||
"copy",
|
||||
Path(config.directories.temp / f"{type_}.hevc"),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if extract_stream.returncode:
|
||||
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
|
||||
self.log.error(f"x Failed extracting {type_} stream")
|
||||
sys.exit(1)
|
||||
else:
|
||||
extract_stream = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-stats",
|
||||
"-i",
|
||||
save_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
Path(config.directories.temp / f"{type_}.hevc"),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if extract_stream.returncode:
|
||||
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
|
||||
self.log.error(f"x Failed extracting {type_} stream")
|
||||
sys.exit(1)
|
||||
|
||||
def extract_dv_stream(self, video, save_path):
|
||||
type_ = "DV"
|
||||
if os.path.isfile(Path(config.directories.temp / f"{type_}.hevc")):
|
||||
return
|
||||
if self.source == "itunes" or self.source == "appletvplus":
|
||||
self.log.info("+ Muxing Dolby Vision stream for fixing MP4 file")
|
||||
subprocess.run(
|
||||
[
|
||||
"mkvmerge",
|
||||
"-o",
|
||||
Path(config.directories.temp / "dv.mkv"),
|
||||
save_path,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
self.log.info("+ Extracting Dolby Vision stream")
|
||||
extract_stream = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-nostdin",
|
||||
"-stats",
|
||||
"-i",
|
||||
Path(config.directories.temp / "dv.mkv"),
|
||||
"-an",
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-f",
|
||||
"hevc",
|
||||
Path(config.directories.temp / "out_1.h265"),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if extract_stream.returncode:
|
||||
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
|
||||
self.log.error(f"x Failed extracting {type_} stream")
|
||||
sys.exit(1)
|
||||
else:
|
||||
extract_stream = subprocess.run(
|
||||
[
|
||||
"mp4demuxer",
|
||||
"--input-file",
|
||||
save_path,
|
||||
"--output-folder",
|
||||
Path(config.directories.temp),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if extract_stream.returncode:
|
||||
Path.unlink(Path(config.directories.temp / f"{type_}.hevc"))
|
||||
self.log.error(f"x Failed extracting {type_} stream")
|
||||
sys.exit(1)
|
||||
|
||||
def extract_rpu(self, video, untouched=False):
|
||||
if os.path.isfile(config.directories.temp / "RPU.bin") or os.path.isfile(
|
||||
config.directories.temp / "RPU_UNT.bin"
|
||||
@@ -315,51 +208,31 @@ class Hybrid:
|
||||
# Update rpu_file to use the edited version
|
||||
self.rpu_file = "RPU_L6.bin"
|
||||
|
||||
def mode_3(self):
|
||||
"""Convert RPU to Mode 3"""
|
||||
with open(config.directories.temp / "M3.json", "w+") as mode3_file:
|
||||
json.dump({"mode": 3}, mode3_file, indent=3)
|
||||
|
||||
if not os.path.isfile(config.directories.temp / "RPU_M3.bin"):
|
||||
self.log.info("+ Converting RPU to Mode 3")
|
||||
mode3 = subprocess.run(
|
||||
[
|
||||
str(DoviTool),
|
||||
"editor",
|
||||
"-i",
|
||||
config.directories.temp / self.rpu_file,
|
||||
"-j",
|
||||
config.directories.temp / "M3.json",
|
||||
"-o",
|
||||
config.directories.temp / "RPU_M3.bin",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
if mode3.returncode:
|
||||
Path.unlink(config.directories.temp / "RPU_M3.bin")
|
||||
self.log.exit("x Failed converting RPU to Mode 3")
|
||||
|
||||
self.rpu_file = "RPU_M3.bin"
|
||||
|
||||
def injecting(self):
|
||||
if os.path.isfile(config.directories.temp / self.hevc_file):
|
||||
return
|
||||
|
||||
self.log.info(f"+ Injecting Dolby Vision metadata into {self.hdr_type} stream")
|
||||
|
||||
inject_cmd = [
|
||||
str(DoviTool),
|
||||
"inject-rpu",
|
||||
"-i",
|
||||
config.directories.temp / "HDR10.hevc",
|
||||
"--rpu-in",
|
||||
config.directories.temp / self.rpu_file,
|
||||
]
|
||||
|
||||
# If we converted from HDR10+, optionally remove HDR10+ metadata during injection
|
||||
# Default to removing HDR10+ metadata since we're converting to DV
|
||||
if self.hdr10plus_to_dv:
|
||||
inject_cmd.append("--drop-hdr10plus")
|
||||
self.log.info(" - Removing HDR10+ metadata during injection")
|
||||
|
||||
inject_cmd.extend(["-o", config.directories.temp / self.hevc_file])
|
||||
|
||||
inject = subprocess.run(
|
||||
[
|
||||
str(DoviTool),
|
||||
"inject-rpu",
|
||||
"-i",
|
||||
config.directories.temp / f"{self.hdr_type}.hevc",
|
||||
"--rpu-in",
|
||||
config.directories.temp / self.rpu_file,
|
||||
"-o",
|
||||
config.directories.temp / self.hevc_file,
|
||||
],
|
||||
inject_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
@@ -367,3 +240,80 @@ class Hybrid:
|
||||
if inject.returncode:
|
||||
Path.unlink(config.directories.temp / self.hevc_file)
|
||||
self.log.exit("x Failed injecting Dolby Vision metadata into HDR10 stream")
|
||||
|
||||
def extract_hdr10plus(self, _video):
|
||||
"""Extract HDR10+ metadata from the video stream"""
|
||||
if os.path.isfile(config.directories.temp / self.hdr10plus_file):
|
||||
return
|
||||
|
||||
if not HDR10PlusTool:
|
||||
self.log.exit("x HDR10Plus_tool not found. Please install it to use HDR10+ to DV conversion.")
|
||||
|
||||
self.log.info("+ Extracting HDR10+ metadata")
|
||||
|
||||
# HDR10Plus_tool needs raw HEVC stream
|
||||
extraction = subprocess.run(
|
||||
[
|
||||
str(HDR10PlusTool),
|
||||
"extract",
|
||||
str(config.directories.temp / "HDR10.hevc"),
|
||||
"-o",
|
||||
str(config.directories.temp / self.hdr10plus_file),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
if extraction.returncode:
|
||||
self.log.exit("x Failed extracting HDR10+ metadata")
|
||||
|
||||
# Check if the extracted file has content
|
||||
if os.path.getsize(config.directories.temp / self.hdr10plus_file) == 0:
|
||||
self.log.exit("x No HDR10+ metadata found in the stream")
|
||||
|
||||
def convert_hdr10plus_to_dv(self):
|
||||
"""Convert HDR10+ metadata to Dolby Vision RPU"""
|
||||
if os.path.isfile(config.directories.temp / "RPU.bin"):
|
||||
return
|
||||
|
||||
self.log.info("+ Converting HDR10+ metadata to Dolby Vision")
|
||||
|
||||
# First create the extra metadata JSON for dovi_tool
|
||||
extra_metadata = {
|
||||
"cm_version": "V29",
|
||||
"length": 0, # dovi_tool will figure this out
|
||||
"level6": {
|
||||
"max_display_mastering_luminance": 1000,
|
||||
"min_display_mastering_luminance": 1,
|
||||
"max_content_light_level": 0,
|
||||
"max_frame_average_light_level": 0,
|
||||
},
|
||||
}
|
||||
|
||||
with open(config.directories.temp / "extra.json", "w") as f:
|
||||
json.dump(extra_metadata, f, indent=2)
|
||||
|
||||
# Generate DV RPU from HDR10+ metadata
|
||||
conversion = subprocess.run(
|
||||
[
|
||||
str(DoviTool),
|
||||
"generate",
|
||||
"-j",
|
||||
str(config.directories.temp / "extra.json"),
|
||||
"--hdr10plus-json",
|
||||
str(config.directories.temp / self.hdr10plus_file),
|
||||
"-o",
|
||||
str(config.directories.temp / "RPU.bin"),
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
if conversion.returncode:
|
||||
self.log.exit("x Failed converting HDR10+ to Dolby Vision")
|
||||
|
||||
self.log.info("✓ HDR10+ successfully converted to Dolby Vision Profile 8")
|
||||
|
||||
# Clean up temporary files
|
||||
Path.unlink(config.directories.temp / "extra.json")
|
||||
Path.unlink(config.directories.temp / self.hdr10plus_file)
|
||||
|
||||
@@ -331,21 +331,31 @@ class Tracks:
|
||||
if not vt.path or not vt.path.exists():
|
||||
raise ValueError("Video Track must be downloaded before muxing...")
|
||||
events.emit(events.Types.TRACK_MULTIPLEX, track=vt)
|
||||
cl.extend(
|
||||
[
|
||||
"--language",
|
||||
f"0:{vt.language}",
|
||||
"--default-track",
|
||||
f"0:{i == 0}",
|
||||
"--original-flag",
|
||||
f"0:{vt.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
"(",
|
||||
str(vt.path),
|
||||
")",
|
||||
]
|
||||
)
|
||||
|
||||
# Prepare base arguments
|
||||
video_args = [
|
||||
"--language",
|
||||
f"0:{vt.language}",
|
||||
"--default-track",
|
||||
f"0:{i == 0}",
|
||||
"--original-flag",
|
||||
f"0:{vt.is_original_lang}",
|
||||
"--compression",
|
||||
"0:none", # disable extra compression
|
||||
]
|
||||
|
||||
# Add FPS fix if needed (typically for hybrid mode to prevent sync issues)
|
||||
if hasattr(vt, "needs_duration_fix") and vt.needs_duration_fix and vt.fps:
|
||||
video_args.extend(
|
||||
[
|
||||
"--default-duration",
|
||||
f"0:{vt.fps}fps" if isinstance(vt.fps, str) else f"0:{vt.fps:.3f}fps",
|
||||
"--fix-bitstream-timing-information",
|
||||
"0:1",
|
||||
]
|
||||
)
|
||||
|
||||
cl.extend(video_args + ["(", str(vt.path), ")"])
|
||||
|
||||
for i, at in enumerate(self.audio):
|
||||
if not at.path or not at.path.exists():
|
||||
|
||||
@@ -237,6 +237,8 @@ class Video(Track):
|
||||
except Exception as e:
|
||||
raise ValueError("Expected fps to be a number, float, or a string as numerator/denominator form, " + str(e))
|
||||
|
||||
self.needs_duration_fix = False
|
||||
|
||||
def __str__(self) -> str:
|
||||
return " | ".join(
|
||||
filter(
|
||||
|
||||
106
unshackle/core/update_checker.py
Normal file
106
unshackle/core/update_checker.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class UpdateChecker:
|
||||
"""Check for available updates from the GitHub repository."""
|
||||
|
||||
REPO_URL = "https://api.github.com/repos/unshackle-dl/unshackle/releases/latest"
|
||||
TIMEOUT = 5
|
||||
|
||||
@staticmethod
|
||||
def _compare_versions(current: str, latest: str) -> bool:
|
||||
"""
|
||||
Simple semantic version comparison.
|
||||
|
||||
Args:
|
||||
current: Current version string (e.g., "1.1.0")
|
||||
latest: Latest version string (e.g., "1.2.0")
|
||||
|
||||
Returns:
|
||||
True if latest > current, False otherwise
|
||||
"""
|
||||
try:
|
||||
current_parts = [int(x) for x in current.split(".")]
|
||||
latest_parts = [int(x) for x in latest.split(".")]
|
||||
|
||||
max_length = max(len(current_parts), len(latest_parts))
|
||||
current_parts.extend([0] * (max_length - len(current_parts)))
|
||||
latest_parts.extend([0] * (max_length - len(latest_parts)))
|
||||
|
||||
for current_part, latest_part in zip(current_parts, latest_parts):
|
||||
if latest_part > current_part:
|
||||
return True
|
||||
elif latest_part < current_part:
|
||||
return False
|
||||
|
||||
return False
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def check_for_updates(cls, current_version: str) -> Optional[str]:
|
||||
"""
|
||||
Check if there's a newer version available on GitHub.
|
||||
|
||||
Args:
|
||||
current_version: The current version string (e.g., "1.1.0")
|
||||
|
||||
Returns:
|
||||
The latest version string if an update is available, None otherwise
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, lambda: requests.get(cls.REPO_URL, timeout=cls.TIMEOUT))
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
latest_version = data.get("tag_name", "").lstrip("v")
|
||||
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
if cls._compare_versions(current_version, latest_version):
|
||||
return latest_version
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def check_for_updates_sync(cls, current_version: str) -> Optional[str]:
|
||||
"""
|
||||
Synchronous version of update check.
|
||||
|
||||
Args:
|
||||
current_version: The current version string (e.g., "1.1.0")
|
||||
|
||||
Returns:
|
||||
The latest version string if an update is available, None otherwise
|
||||
"""
|
||||
try:
|
||||
response = requests.get(cls.REPO_URL, timeout=cls.TIMEOUT)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
latest_version = data.get("tag_name", "").lstrip("v")
|
||||
|
||||
if not latest_version:
|
||||
return None
|
||||
|
||||
if cls._compare_versions(current_version, latest_version):
|
||||
return latest_version
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -25,8 +25,20 @@ class Vaults:
|
||||
def __len__(self) -> int:
|
||||
return len(self.vaults)
|
||||
|
||||
def load(self, type_: str, **kwargs: Any) -> None:
|
||||
"""Load a Vault into the vaults list."""
|
||||
def load(self, type_: str, **kwargs: Any) -> bool:
|
||||
"""Load a Vault into the vaults list. Returns True if successful, False otherwise."""
|
||||
module = _MODULES.get(type_)
|
||||
if not module:
|
||||
raise ValueError(f"Unable to find vault command by the name '{type_}'.")
|
||||
try:
|
||||
vault = module(**kwargs)
|
||||
self.vaults.append(vault)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def load_critical(self, type_: str, **kwargs: Any) -> None:
|
||||
"""Load a critical Vault that must succeed or raise an exception."""
|
||||
module = _MODULES.get(type_)
|
||||
if not module:
|
||||
raise ValueError(f"Unable to find vault command by the name '{type_}'.")
|
||||
|
||||
@@ -4,6 +4,9 @@ tag: user_tag
|
||||
# Set terminal background color (custom option not in CONFIG.md)
|
||||
set_terminal_bg: false
|
||||
|
||||
# Check for updates from GitHub repository on startup (default: true)
|
||||
update_checks: true
|
||||
|
||||
# Muxing configuration
|
||||
muxing:
|
||||
set_title: false
|
||||
@@ -129,6 +132,15 @@ filenames:
|
||||
# API key for The Movie Database (TMDB)
|
||||
tmdb_api_key: ""
|
||||
|
||||
# conversion_method:
|
||||
# - auto (default): Smart routing - subby for WebVTT/SAMI, standard for others
|
||||
# - subby: Always use subby with advanced processing
|
||||
# - pycaption: Use only pycaption library (no SubtitleEdit, no subby)
|
||||
# - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption
|
||||
subtitle:
|
||||
conversion_method: auto
|
||||
sdh_method: auto
|
||||
|
||||
# Configuration for pywidevine's serve functionality
|
||||
serve:
|
||||
users:
|
||||
|
||||
Reference in New Issue
Block a user