Compare commits
4
Commits
f449d692a8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8541d5d7 | ||
|
|
afdb0ac636 | ||
|
|
50ea658fc7 | ||
|
|
be47aede02 |
No files matched your search
+85
-12
@@ -7,9 +7,11 @@ import dataclasses
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from functools import partial
|
||||
@@ -201,6 +203,62 @@ def _has_images(d: Path) -> bool:
|
||||
return any(f.suffix.lower() in IMAGE_EXTS for f in d.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
_TRICKLEBAR_APP = Path("/Applications/TrickleBar.app")
|
||||
|
||||
|
||||
def download_url(url: str, destination: Path) -> None:
|
||||
destination = destination.resolve()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if _TRICKLEBAR_APP.is_dir():
|
||||
logging.info(f"downloading with TrickleBar → {destination}")
|
||||
download_url_with_tricklebar(url=url, destination=destination)
|
||||
else:
|
||||
logging.info(f"downloading with aria2 → {destination}")
|
||||
download_url_with_aria2(url=url, destination=destination)
|
||||
|
||||
|
||||
def download_url_with_aria2(url: str, destination: Path) -> None:
|
||||
run_command(args=[
|
||||
"aria2c", "--continue=true", f"--dir={destination.parent}",
|
||||
f"--out={destination.name}", url,
|
||||
])
|
||||
|
||||
|
||||
def download_url_with_tricklebar(url: str, destination: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="imageset-") as temporary_directory:
|
||||
directory = Path(temporary_directory)
|
||||
completion_pipe = directory / "completion"
|
||||
callback = directory / "on_done.sh"
|
||||
os.mkfifo(path=completion_pipe, mode=0o600)
|
||||
callback.write_text(data=(
|
||||
"#!/bin/sh\n"
|
||||
f"[ -p {shlex.quote(str(completion_pipe))} ] || exit 0\n"
|
||||
f"printf '%s\\0' \"$1\" \"$2\" > {shlex.quote(str(completion_pipe))}\n"
|
||||
))
|
||||
callback.chmod(mode=0o700)
|
||||
query = urllib.parse.urlencode(query={
|
||||
"on_done": str(callback), "url": url,
|
||||
"dir": str(destination.parent), "out": destination.name, "continue": "true",
|
||||
}, quote_via=urllib.parse.quote)
|
||||
logging.info("waiting for TrickleBar; press Ctrl+C to stop waiting")
|
||||
run_command(args=["open", f"tricklebar://add-download?{query}"])
|
||||
with completion_pipe.open(mode="rb") as completion:
|
||||
status, downloaded_path, _ = completion.read().split(b"\0", maxsplit=2)
|
||||
if status != b"complete":
|
||||
raise click.ClickException(
|
||||
f"TrickleBar download failed: {os.fsdecode(status)}"
|
||||
)
|
||||
if not downloaded_path or not Path(os.fsdecode(downloaded_path)).is_file():
|
||||
raise click.ClickException("TrickleBar completed without a downloaded file")
|
||||
if Path(os.fsdecode(downloaded_path)).resolve() != destination.resolve():
|
||||
raise click.ClickException(
|
||||
f"TrickleBar downloaded to {os.fsdecode(downloaded_path)} instead of {destination}; "
|
||||
"update TrickleBar to a version supporting dir/out URL options"
|
||||
)
|
||||
if destination.with_name(f"{destination.name}.aria2").exists():
|
||||
raise click.ClickException(f"TrickleBar download is unfinished: {destination}")
|
||||
|
||||
|
||||
def _needs_resize(d: Path) -> bool:
|
||||
images = [f for f in d.rglob("*") if f.is_file() and f.suffix.lower() in IMAGE_EXTS and not f.is_relative_to(d / "resized")]
|
||||
if not images:
|
||||
@@ -284,6 +342,9 @@ def process_imageset(path: Path, processor: ImageProcessor) -> None:
|
||||
return
|
||||
|
||||
for archive in archives:
|
||||
if archive.with_name(f"{archive.name}.aria2").exists():
|
||||
logging.warning(f"skipping {archive}: download is unfinished")
|
||||
return
|
||||
logging.info(f"extracting {archive} → {output_dir}")
|
||||
if archive.suffix.lower() == ".zip":
|
||||
try:
|
||||
@@ -342,7 +403,7 @@ def remove_empty_dirs(root_dir: Path) -> None:
|
||||
for d in removed:
|
||||
d.rmdir()
|
||||
if removed:
|
||||
print(f"removed {len(removed)} empty dir(s) in {root_dir}")
|
||||
logging.info(f"removed {len(removed)} empty dir(s) in {root_dir}")
|
||||
|
||||
|
||||
def move_to_trash(file_paths: list[Path]) -> None:
|
||||
@@ -423,9 +484,9 @@ def gallery_cmd(urls: tuple[str, ...], name: str | None, cwd: Path | None) -> No
|
||||
output_dir = (cwd or Path(".")) / resolved_name
|
||||
|
||||
if output_dir.exists() and _has_images(output_dir):
|
||||
print(f"skipping gallery-dl: {output_dir} already has images")
|
||||
logging.info(f"skipping gallery-dl: {output_dir} already has images")
|
||||
else:
|
||||
print(f"gallery-dl → {output_dir}")
|
||||
logging.info(f"gallery-dl → {output_dir}")
|
||||
args = ["uvx", "gallery-dl"]
|
||||
if name or cwd:
|
||||
args += ["-D", str(output_dir)]
|
||||
@@ -445,24 +506,36 @@ def filehost_cmd(urls: tuple[str, ...], name: str | None, cwd: Path, token: str)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for url in urls:
|
||||
print(f"unrestricting {url}")
|
||||
logging.info(f"unrestricting {url}")
|
||||
dl_url = resolve_download_url_with_alldebrid(token, url)
|
||||
meta = _resolve_url(dl_url)
|
||||
if meta.suggested_extension in _VIDEO_EXTS:
|
||||
video_path = (cwd or Path(".")) / (resolved_name + meta.suggested_extension)
|
||||
print(f"video detected → {video_path}")
|
||||
if video_path.exists() and video_path.stat().st_size > 5 * 1024 * 1024:
|
||||
print(f"skipping: {video_path.name} already downloaded")
|
||||
logging.info(f"video detected → {video_path}")
|
||||
if video_path.with_name(f"{video_path.name}.aria2").exists():
|
||||
logging.warning(f"unfinished download; resuming {video_path}")
|
||||
if (
|
||||
video_path.exists()
|
||||
and not video_path.with_name(f"{video_path.name}.aria2").exists()
|
||||
and video_path.stat().st_size > 5 * 1024 * 1024
|
||||
):
|
||||
logging.info(f"skipping: {video_path.name} already downloaded")
|
||||
continue
|
||||
run_command(["aria2c", "-o", str(video_path), dl_url])
|
||||
download_url(url=dl_url, destination=video_path)
|
||||
continue
|
||||
|
||||
dest = output_dir / meta.suggested_filename
|
||||
if dest.exists() and dest.stat().st_size > 5 * 1024 * 1024:
|
||||
print(f"skipping: {dest.name} already downloaded")
|
||||
if dest.with_name(f"{dest.name}.aria2").exists():
|
||||
logging.warning(f"unfinished download; resuming {dest}")
|
||||
if (
|
||||
dest.exists()
|
||||
and not dest.with_name(f"{dest.name}.aria2").exists()
|
||||
and dest.stat().st_size > 5 * 1024 * 1024
|
||||
):
|
||||
logging.info(f"skipping: {dest.name} already downloaded")
|
||||
continue
|
||||
print(f"downloading {meta.suggested_filename} → {output_dir}")
|
||||
run_command(["aria2c", f"--dir={output_dir}", dl_url])
|
||||
logging.info(f"downloading {meta.suggested_filename} → {output_dir}")
|
||||
download_url(url=dl_url, destination=dest)
|
||||
|
||||
process_imageset(path=output_dir, processor=detect_resizer())
|
||||
|
||||
|
||||
Regular → Executable
+175
@@ -6,7 +6,10 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from functools import partial
|
||||
from unittest.mock import Mock
|
||||
@@ -297,5 +300,177 @@ def test_resume_resizing(
|
||||
trash.assert_called_once_with(file_paths=[image_path])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extension", [".zip", ".mp4"])
|
||||
@pytest.mark.parametrize("unfinished", [False, True])
|
||||
def test_filehost_resumes_unfinished_downloads(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
extension: str,
|
||||
unfinished: bool,
|
||||
) -> None:
|
||||
directory = tmp_path / "album"
|
||||
directory.mkdir()
|
||||
destination = (
|
||||
directory / "download.zip" if extension == ".zip" else tmp_path / "album.mp4"
|
||||
)
|
||||
with destination.open(mode="wb") as output:
|
||||
output.truncate(6 * 1024 * 1024)
|
||||
control_file = destination.with_name(f"{destination.name}.aria2")
|
||||
if unfinished:
|
||||
control_file.touch()
|
||||
url = "https://example.com/download"
|
||||
monkeypatch.setattr(
|
||||
download_imageset, "resolve_download_url_with_alldebrid", Mock(return_value=url)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
download_imageset,
|
||||
"_resolve_url",
|
||||
Mock(return_value=download_imageset.UrlMeta(
|
||||
suggested_filename=f"download{extension}",
|
||||
suggested_extension=extension,
|
||||
size_bytes=destination.stat().st_size,
|
||||
)),
|
||||
)
|
||||
run_command = Mock()
|
||||
monkeypatch.setattr(download_imageset, "_TRICKLEBAR_APP", tmp_path / "missing.app")
|
||||
monkeypatch.setattr(download_imageset, "run_command", run_command)
|
||||
monkeypatch.setattr(download_imageset, "process_imageset", Mock())
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli=download_imageset.cli,
|
||||
args=["filehost", url, "--name", "album", "--cwd", str(tmp_path), "--token", "test"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
if unfinished:
|
||||
assert f"unfinished download; resuming {destination}" in caplog.text
|
||||
run_command.assert_called_once_with(args=[
|
||||
"aria2c", "--continue=true", f"--dir={destination.parent}",
|
||||
f"--out={destination.name}", url,
|
||||
])
|
||||
else:
|
||||
assert "unfinished download; resuming" not in caplog.text
|
||||
run_command.assert_not_called()
|
||||
|
||||
|
||||
def test_aria2_download_failure_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
failure = subprocess.CalledProcessError(returncode=1, cmd="aria2c")
|
||||
monkeypatch.setattr(download_imageset, "run_command", Mock(side_effect=failure))
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
download_imageset.download_url_with_aria2(
|
||||
url="https://example.com/archive.zip", destination=Path("archive.zip")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "file_state", "error"),
|
||||
[
|
||||
("complete", "valid", None),
|
||||
("error", "valid", "download failed"),
|
||||
("complete", "missing", "without a downloaded file"),
|
||||
("complete", "wrong_path", "instead of"),
|
||||
("complete", "unfinished", "download is unfinished"),
|
||||
],
|
||||
)
|
||||
def test_tricklebar_waits_for_callback(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
status: str, file_state: str, error: str | None,
|
||||
) -> None:
|
||||
destination = tmp_path / "download & photos.zip"
|
||||
downloaded_path = tmp_path / "wrong.zip" if file_state == "wrong_path" else destination
|
||||
if file_state != "missing":
|
||||
downloaded_path.write_bytes(data=b"downloaded archive")
|
||||
if file_state == "unfinished":
|
||||
destination.with_name(f"{destination.name}.aria2").touch()
|
||||
url = "https://example.com/photos.zip?token=a+b&other=value"
|
||||
opened = threading.Event()
|
||||
callback_paths: list[Path] = []
|
||||
|
||||
def open_download(args: list[str]) -> None:
|
||||
assert args[0] == "open"
|
||||
parsed = urllib.parse.urlparse(url=args[1])
|
||||
assert "+" not in parsed.query
|
||||
assert parsed.scheme == "tricklebar"
|
||||
assert parsed.netloc == "add-download"
|
||||
query = urllib.parse.parse_qs(qs=parsed.query)
|
||||
assert query["url"] == [url]
|
||||
assert query["dir"] == [str(destination.parent)]
|
||||
assert query["out"] == [destination.name]
|
||||
assert query["continue"] == ["true"]
|
||||
callback_paths.append(Path(query["on_done"][0]))
|
||||
opened.set()
|
||||
|
||||
monkeypatch.setattr(download_imageset, "run_command", open_download)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
download = executor.submit(
|
||||
download_imageset.download_url_with_tricklebar, url=url, destination=destination
|
||||
)
|
||||
assert opened.wait(timeout=5)
|
||||
assert not download.done()
|
||||
callback = callback_paths[0]
|
||||
subprocess.run(args=[str(callback), status, str(downloaded_path), "gid", url], check=True)
|
||||
if error is None:
|
||||
assert download.result(timeout=5) is None
|
||||
else:
|
||||
with pytest.raises(download_imageset.click.ClickException, match=error):
|
||||
download.result(timeout=5)
|
||||
|
||||
assert not callback.parent.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("installed", [False, True])
|
||||
def test_download_selects_backend(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, installed: bool
|
||||
) -> None:
|
||||
application = tmp_path / "TrickleBar.app"
|
||||
if installed:
|
||||
application.mkdir()
|
||||
monkeypatch.setattr(download_imageset, "_TRICKLEBAR_APP", application)
|
||||
aria2 = Mock()
|
||||
tricklebar = Mock()
|
||||
monkeypatch.setattr(download_imageset, "download_url_with_aria2", aria2)
|
||||
monkeypatch.setattr(download_imageset, "download_url_with_tricklebar", tricklebar)
|
||||
destination = tmp_path / "photos" / "album.zip"
|
||||
url = "https://example.com/album.zip"
|
||||
|
||||
download_imageset.download_url(url=url, destination=destination)
|
||||
|
||||
assert destination.parent.is_dir()
|
||||
selected = tricklebar if installed else aria2
|
||||
unused = aria2 if installed else tricklebar
|
||||
selected.assert_called_once_with(url=url, destination=destination.resolve())
|
||||
unused.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("process_directory", [False, True])
|
||||
def test_process_skips_unfinished_archive(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
process_directory: bool,
|
||||
) -> None:
|
||||
archive_path = tmp_path / "gallery.zip"
|
||||
with zipfile.ZipFile(file=archive_path, mode="w") as archive:
|
||||
archive.writestr(zinfo_or_arcname="photo.png", data=b"image data")
|
||||
archive_path.with_name(f"{archive_path.name}.aria2").touch()
|
||||
trash = Mock()
|
||||
monkeypatch.setattr(download_imageset, "move_to_trash", trash)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli=download_imageset.cli,
|
||||
args=["process", str(tmp_path if process_directory else archive_path)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "download is unfinished" in caplog.text
|
||||
assert not (tmp_path / "photo.png").exists()
|
||||
assert not (tmp_path / "gallery").exists()
|
||||
assert archive_path.is_file()
|
||||
trash.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))
|
||||
Executable
+192
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
import argparse
|
||||
from bisect import bisect_left, bisect_right
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Shorten still sections using FFmpeg stream copying, without re-encoding.",
|
||||
epilog="Cuts follow video keyframes and audio packets. Still sections may remain longer than requested. "
|
||||
"Audio during removed time is also removed, including any narration.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("input", type=Path, help="Input video")
|
||||
parser.add_argument("--output", type=Path, help="Output video (default: INPUT.short with the original extension)")
|
||||
parser.add_argument("--min-still", type=float, default=2.0,
|
||||
help="Minimum still section duration to detect, in seconds")
|
||||
parser.add_argument("--keep-still", type=float, default=0.5,
|
||||
help="Target seconds to retain per still section; keyframe alignment may retain more")
|
||||
parser.add_argument("--noise", type=float, default=0.001,
|
||||
help="Frame difference tolerance, from 0 to 1; higher values ignore more movement")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Report cuts without writing output")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Replace an existing output file")
|
||||
arguments = parser.parse_args()
|
||||
if not math.isfinite(arguments.min_still) or arguments.min_still <= 0:
|
||||
parser.error("--min-still must be positive and finite")
|
||||
if not math.isfinite(arguments.keep_still) or arguments.keep_still <= 0:
|
||||
parser.error("--keep-still must be positive and finite")
|
||||
if not 0 <= arguments.noise <= 1:
|
||||
parser.error("--noise must be between 0 and 1")
|
||||
return arguments
|
||||
|
||||
|
||||
def probe_video(path: Path) -> tuple[float, float]:
|
||||
result = subprocess.run(
|
||||
args=["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
metadata = json.loads(result.stdout)
|
||||
video = next((stream for stream in metadata["streams"] if stream["codec_type"] == "video"), None)
|
||||
if video is None:
|
||||
raise ValueError("Input has no video stream")
|
||||
duration = float(video.get("duration") or metadata["format"].get("duration", 0))
|
||||
if not math.isfinite(duration) or duration <= 0:
|
||||
raise ValueError("Cannot determine video duration")
|
||||
start_time = float(video.get("start_time") or metadata["format"].get("start_time", 0))
|
||||
return duration, start_time
|
||||
|
||||
|
||||
def probe_keyframes(path: Path, start_time: float) -> list[tuple[float, float]]:
|
||||
result = subprocess.run(
|
||||
args=["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_packets",
|
||||
"-show_entries", "packet=pts_time,dts_time,flags", "-of", "csv=p=0", str(path)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
keyframes = []
|
||||
for line in result.stdout.splitlines():
|
||||
fields = line.split(",")
|
||||
if len(fields) >= 3 and "K" in fields[2] and fields[0] != "N/A":
|
||||
presentation_time = float(fields[0]) - start_time
|
||||
decode_time = float(fields[1]) - start_time if fields[1] != "N/A" else presentation_time
|
||||
keyframes.append((max(0.0, presentation_time), decode_time))
|
||||
if not keyframes:
|
||||
raise ValueError("No video keyframes found; cannot safely trim by stream copying")
|
||||
return sorted(keyframes)
|
||||
|
||||
|
||||
def parse_freezes(log: str, duration: float) -> list[tuple[float, float]]:
|
||||
freezes = []
|
||||
start = None
|
||||
for kind, timestamp in re.findall(r"lavfi\.freezedetect\.freeze_(start|end):\s*([-+\d.eE]+)", log):
|
||||
timestamp = min(duration, max(0.0, float(timestamp)))
|
||||
if kind == "start":
|
||||
start = timestamp
|
||||
elif start is not None:
|
||||
if timestamp > start:
|
||||
freezes.append((start, timestamp))
|
||||
start = None
|
||||
if start is not None and duration > start:
|
||||
freezes.append((start, duration))
|
||||
return freezes
|
||||
|
||||
|
||||
def detect_freezes(path: Path, duration: float, min_still: float, noise: float) -> list[tuple[float, float]]:
|
||||
logging.info("Detecting still sections...")
|
||||
result = subprocess.run(
|
||||
args=[
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-nostats", "-i", str(path),
|
||||
"-map", "0:v:0", "-vf", f"setpts=PTS-STARTPTS,freezedetect=noise={noise}:duration={min_still}",
|
||||
"-an", "-f", "null", "-",
|
||||
],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
return parse_freezes(log=result.stderr, duration=duration)
|
||||
|
||||
|
||||
def plan_segments(duration: float, freezes: list[tuple[float, float]], keep_still: float,
|
||||
keyframes: list[float]) -> list[tuple[float, float]]:
|
||||
segments = []
|
||||
cursor = 0.0
|
||||
for start, end in freezes:
|
||||
if end - start <= keep_still:
|
||||
continue
|
||||
first = bisect_left(keyframes, start + keep_still / 2)
|
||||
last = bisect_right(keyframes, end - keep_still / 2) - 1
|
||||
if first >= len(keyframes) or last < 0 or keyframes[first] >= keyframes[last]:
|
||||
logging.info(f"Still section {start:.2f}–{end:.2f}s: no removable span between keyframes")
|
||||
continue
|
||||
cut_start = keyframes[first]
|
||||
cut_end = keyframes[last]
|
||||
logging.info(f"Still section {start:.2f}–{end:.2f}s: retain {end - start - (cut_end - cut_start):.2f}s")
|
||||
if cut_start > cursor:
|
||||
segments.append((cursor, cut_start))
|
||||
cursor = cut_end
|
||||
if cursor < duration:
|
||||
segments.append((cursor, duration))
|
||||
return segments
|
||||
|
||||
|
||||
def copy_video(source: Path, output: Path, segments: list[tuple[float, float]], duration: float,
|
||||
start_time: float, keyframes: list[tuple[float, float]], overwrite: bool) -> None:
|
||||
decode_times = dict(keyframes)
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
playlist = Path(temporary_directory) / "segments.ffconcat"
|
||||
local_source = Path(temporary_directory) / f"source{source.suffix}"
|
||||
local_source.symlink_to(target=source)
|
||||
entries = ["ffconcat version 1.0"]
|
||||
for start, end in segments:
|
||||
entries.extend([f"file '{local_source.name}'", f"inpoint {start + start_time:.6f}"])
|
||||
if end < duration:
|
||||
# The concat demuxer's outpoint uses DTS, while inpoint uses PTS.
|
||||
entries.append(f"outpoint {decode_times[end] + start_time:.6f}")
|
||||
entries.append(f"duration {end - start:.6f}")
|
||||
playlist.write_text(data="\n".join(entries) + "\n")
|
||||
command = [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "warning", "-stats",
|
||||
"-y" if overwrite else "-n", "-f", "concat", "-safe", "0", "-auto_convert", "0", "-i", str(playlist),
|
||||
"-map", "0:v:0", "-map", "0:a?", "-c", "copy",
|
||||
]
|
||||
if output.suffix.lower() in {".mp4", ".mov"}:
|
||||
command.extend(["-movflags", "+faststart"])
|
||||
command.append(str(output))
|
||||
subprocess.run(args=command, check=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
arguments = parse_args()
|
||||
try:
|
||||
for executable in ("ffmpeg", "ffprobe"):
|
||||
if shutil.which(cmd=executable) is None:
|
||||
raise ValueError(f"{executable} must be installed and available on PATH")
|
||||
source = arguments.input.resolve(strict=True)
|
||||
output = (arguments.output or source.with_name(f"{source.stem}.short{source.suffix or '.mkv'}")).resolve()
|
||||
if source == output:
|
||||
raise ValueError("Input and output must be different files")
|
||||
if output.exists() and source.samefile(output):
|
||||
raise ValueError("Input and output must be different files")
|
||||
if not arguments.dry_run and output.exists() and not arguments.overwrite:
|
||||
raise ValueError(f"Output exists: {output}; use --overwrite to replace it")
|
||||
duration, start_time = probe_video(path=source)
|
||||
freezes = detect_freezes(path=source, duration=duration, min_still=arguments.min_still, noise=arguments.noise)
|
||||
keyframes = probe_keyframes(path=source, start_time=start_time)
|
||||
segments = plan_segments(duration=duration, freezes=freezes, keep_still=arguments.keep_still,
|
||||
keyframes=[time for time, _ in keyframes])
|
||||
retained = sum(end - start for start, end in segments)
|
||||
logging.info(f"{duration:.2f}s → {retained:.2f}s; removing {duration - retained:.2f}s ({(1 - retained / duration):.1%})")
|
||||
if arguments.dry_run:
|
||||
return
|
||||
copy_video(source=source, output=output, segments=segments, duration=duration,
|
||||
start_time=start_time, keyframes=keyframes, overwrite=arguments.overwrite)
|
||||
logging.info(f"Saved {output}")
|
||||
except (OSError, ValueError, subprocess.CalledProcessError) as error:
|
||||
logging.error(str(error))
|
||||
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
|
||||
logging.error(error.stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = ["pytest"]
|
||||
# ///
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import ffmpeg_remove_still as script
|
||||
|
||||
|
||||
@pytest.mark.parametrize("log, expected", [
|
||||
("", []),
|
||||
("lavfi.freezedetect.freeze_start: 0", [(0.0, 10.0)]),
|
||||
("lavfi.freezedetect.freeze_start: 2\nlavfi.freezedetect.freeze_end: 5", [(2.0, 5.0)]),
|
||||
("lavfi.freezedetect.freeze_start: 2\nlavfi.freezedetect.freeze_end: 5\n"
|
||||
"lavfi.freezedetect.freeze_start: 8", [(2.0, 5.0), (8.0, 10.0)]),
|
||||
])
|
||||
def test_parse_freezes(log: str, expected: list[tuple[float, float]]) -> None:
|
||||
assert script.parse_freezes(log=log, duration=10.0) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("freezes, keep_still, keyframes, expected", [
|
||||
([], 1.0, [0.0], [(0.0, 10.0)]),
|
||||
([(0.0, 10.0)], 1.0, [0.0, 0.5, 9.5], [(0.0, 0.5), (9.5, 10.0)]),
|
||||
([(2.0, 6.0)], 2.0, [0.0, 3.0, 5.0], [(0.0, 3.0), (5.0, 10.0)]),
|
||||
([(2.0, 3.0)], 2.0, [0.0, 2.5], [(0.0, 10.0)]),
|
||||
([(0.0, 4.0), (6.0, 10.0)], 1.0, [0.0, 0.5, 3.5, 6.5, 9.5],
|
||||
[(0.0, 0.5), (3.5, 6.5), (9.5, 10.0)]),
|
||||
([(2.0, 6.0)], 1.0, [0.0, 3.0, 5.0, 7.0], [(0.0, 3.0), (5.0, 10.0)]),
|
||||
([(2.0, 6.0)], 1.0, [0.0, 4.0, 8.0], [(0.0, 10.0)]),
|
||||
([(2.0, 6.0)], 1.0, [0.0, 8.0], [(0.0, 10.0)]),
|
||||
])
|
||||
def test_plan_segments(freezes: list[tuple[float, float]], keep_still: float,
|
||||
keyframes: list[float], expected: list[tuple[float, float]]) -> None:
|
||||
assert script.plan_segments(duration=10.0, freezes=freezes, keep_still=keep_still, keyframes=keyframes) == expected
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which(cmd="ffmpeg") or not shutil.which(cmd="ffprobe"), reason="Requires FFmpeg")
|
||||
@pytest.mark.parametrize("has_audio, keep_still, keyframe_interval, expected_duration", [
|
||||
(False, 0.5, 5, 4.0), (True, 0.5, 5, 4.0), (True, 1.5, 5, 6.0),
|
||||
(True, 0.5, 100, 8.0),
|
||||
])
|
||||
def test_copies_packets_without_encoding(tmp_path: Path, has_audio: bool, keep_still: float,
|
||||
keyframe_interval: int, expected_duration: float) -> None:
|
||||
source = tmp_path / "demo's recording.mp4"
|
||||
output = tmp_path / "short.mp4"
|
||||
command = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin"]
|
||||
for pattern in ["testsrc2=size=64x64:rate=10:duration=1", "color=red:size=64x64:rate=10:duration=3",
|
||||
"testsrc2=size=64x64:rate=10:duration=1", "color=blue:size=64x64:rate=10:duration=3"]:
|
||||
command.extend(["-f", "lavfi", "-i", pattern])
|
||||
if has_audio:
|
||||
command.extend(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=8"])
|
||||
command.extend(["-filter_complex", "[0:v][1:v][2:v][3:v]concat=n=4:v=1:a=0[v]", "-map", "[v]"])
|
||||
if has_audio:
|
||||
command.extend(["-map", "4:a", "-c:a", "aac"])
|
||||
command.extend(["-c:v", "libx264", "-g", str(keyframe_interval), "-keyint_min", str(keyframe_interval),
|
||||
"-sc_threshold", "0", "-bf", "2", "-flags", "+cgop", str(source)])
|
||||
subprocess.run(args=command, check=True, capture_output=True)
|
||||
copied = subprocess.run(
|
||||
args=[sys.executable, str(Path(script.__file__).resolve()), str(source), "--output", str(output),
|
||||
"--min-still", "2", "--keep-still", str(keep_still)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert "non-monoton" not in copied.stderr.lower()
|
||||
result = subprocess.run(
|
||||
args=["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(output)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
metadata = json.loads(result.stdout)
|
||||
video = next(stream for stream in metadata["streams"] if stream["codec_type"] == "video")
|
||||
assert float(video["duration"]) == pytest.approx(expected_duration, abs=0.15)
|
||||
audio = [stream for stream in metadata["streams"] if stream["codec_type"] == "audio"]
|
||||
assert bool(audio) == has_audio
|
||||
if has_audio:
|
||||
assert float(audio[0]["duration"]) == pytest.approx(float(video["duration"]), abs=0.1)
|
||||
packet_hashes = []
|
||||
for path in (source, output):
|
||||
packets = subprocess.run(
|
||||
args=["ffprobe", "-v", "error", "-show_packets", "-show_data_hash", "sha256",
|
||||
"-show_entries", "packet=stream_index,data_hash", "-of", "json", str(path)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
packet_hashes.append({(packet["stream_index"], packet["data_hash"])
|
||||
for packet in json.loads(packets.stdout)["packets"]})
|
||||
assert packet_hashes[1] <= packet_hashes[0]
|
||||
decoded = subprocess.run(
|
||||
args=["ffmpeg", "-v", "error", "-i", str(output), "-f", "null", "-"],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert decoded.stderr == ""
|
||||
original = source.read_bytes()
|
||||
refused = subprocess.run(
|
||||
args=[sys.executable, str(Path(script.__file__).resolve()), str(source), "--output", str(source), "--overwrite"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert refused.returncode == 1
|
||||
assert source.read_bytes() == original
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))
|
||||
+72
-1
@@ -162,7 +162,12 @@
|
||||
<div x-data="app()" class="gapped" @keydown.window="onKeydown">
|
||||
<form @submit.prevent="search" class="search-form">
|
||||
<div class="row">
|
||||
<input class="input" type="text" x-ref="query" x-model="form.query" placeholder="Search videos" style="width: 300px" />
|
||||
<input class="input" type="text" x-ref="query" x-model="form.query" list="search-history" placeholder="Search videos" style="width: 300px" />
|
||||
<datalist id="search-history">
|
||||
<template x-for="query in searchHistory" :key="query">
|
||||
<option :value="query"></option>
|
||||
</template>
|
||||
</datalist>
|
||||
<input class="button" type="submit" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -225,8 +230,38 @@
|
||||
|
||||
<script defer src="./alpine@3.x.x.cdn.min.js"></script>
|
||||
<script>
|
||||
const searchHistory = {
|
||||
/** @returns {string[]} The most recent searches. */
|
||||
get history() {
|
||||
try {
|
||||
const history = JSON.parse(localStorage.getItem("snot.searchHistory") || "[]");
|
||||
return Array.isArray(history) ? history.filter((query) => typeof query === "string" && query.trim()).slice(0, 40) : [];
|
||||
} catch (error) {
|
||||
console.warn("Could not load search history", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string[]} The updated searches.
|
||||
*/
|
||||
add(value) {
|
||||
const query = value.trim();
|
||||
if (!query) return this.history;
|
||||
const history = [query, ...this.history.filter((item) => item !== query)].slice(0, 40);
|
||||
try {
|
||||
localStorage.setItem("snot.searchHistory", JSON.stringify(history));
|
||||
} catch (error) {
|
||||
console.warn("Could not save search history", error);
|
||||
}
|
||||
return history;
|
||||
},
|
||||
};
|
||||
|
||||
/** @returns {object} The Alpine application state and methods. */
|
||||
const app = () => ({
|
||||
results: [],
|
||||
searchHistory: [],
|
||||
errorMessage: "",
|
||||
form: {
|
||||
query: "",
|
||||
@@ -236,9 +271,17 @@
|
||||
minSizeMB: 0,
|
||||
},
|
||||
sort: { by: null, dir: "asc" },
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string} The Alfred trigger URL.
|
||||
*/
|
||||
buildAlfredURL(url) {
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/video/?argument=${encodeURIComponent(url)}`;
|
||||
},
|
||||
/**
|
||||
* @param {string} field The result field to sort by.
|
||||
* @returns {void}
|
||||
*/
|
||||
toggleSort(field) {
|
||||
if (this.sort.by === field) {
|
||||
this.sort.dir = this.sort.dir === "asc" ? "desc" : "asc";
|
||||
@@ -247,6 +290,10 @@
|
||||
this.sort.dir = "asc";
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @param {KeyboardEvent} e
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onKeydown(e) {
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
|
||||
return;
|
||||
@@ -256,6 +303,7 @@
|
||||
this.$refs.filterInput.focus();
|
||||
}
|
||||
},
|
||||
/** @returns {object[]} The filtered and sorted results. */
|
||||
get visibleResults() {
|
||||
const isEmpty = !this.filter.query && this.filter.minSizeMB === 0;
|
||||
let rows = isEmpty
|
||||
@@ -284,6 +332,10 @@
|
||||
{ key: "file_path", value: (it) => it.file_path, label: "File Path" },
|
||||
{ key: "created_at", value: (it) => (it.created_at ? it.created_at.slice(0, 10) : ""), label: "Created At", sortable: true },
|
||||
],
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @returns {string} The actor filter token.
|
||||
*/
|
||||
actorToken(actor) {
|
||||
const escaped = actor.replaceAll('"', '\\"');
|
||||
if (escaped.includes(" ")) {
|
||||
@@ -291,11 +343,20 @@
|
||||
}
|
||||
return `actor:${escaped}`;
|
||||
},
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @returns {boolean} Whether the actor is already in the query.
|
||||
*/
|
||||
hasActorFilter(actor) {
|
||||
const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replaceAll('"', '\\"');
|
||||
const pattern = new RegExp(`(^|\\s|\\|)actor:(?:"${escaped}"|${escaped})(?=\\s|\\||$)`, "i");
|
||||
return pattern.test(this.form.query);
|
||||
},
|
||||
/**
|
||||
* @param {string} actor
|
||||
* @param {MouseEvent} event
|
||||
* @returns {void}
|
||||
*/
|
||||
addActorFilter(actor, event) {
|
||||
if (!actor) {
|
||||
return;
|
||||
@@ -315,12 +376,15 @@
|
||||
this.form.query = this.form.query ? `${this.form.query} ${token}` : token;
|
||||
this.search();
|
||||
},
|
||||
/** @returns {Promise<void>} */
|
||||
async init() {
|
||||
this.searchHistory = searchHistory.history;
|
||||
if (window.ENV?.query) {
|
||||
this.form.query = window.ENV?.query ?? "";
|
||||
await this.search();
|
||||
}
|
||||
},
|
||||
/** @returns {string[] | null} Playable URLs, if available. */
|
||||
get playableURLs() {
|
||||
const playable = this.visibleResults.some((item) => item.file_path.startsWith("/mnt/box/files"));
|
||||
if (!playable) {
|
||||
@@ -332,6 +396,7 @@
|
||||
.sort(() => Math.random() - 0.5);
|
||||
return urls;
|
||||
},
|
||||
/** @returns {string | null} The playlist trigger URL, if available. */
|
||||
get playlistLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
@@ -341,6 +406,7 @@
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/mpv/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
/** @returns {string | null} The grid trigger URL, if available. */
|
||||
get gridLink() {
|
||||
const urls = this.playableURLs;
|
||||
if (!urls) {
|
||||
@@ -350,8 +416,10 @@
|
||||
return `alfred://runtrigger/dev.abdus.alfred.media/gridvid/?argument=${playlistArg}`;
|
||||
},
|
||||
|
||||
/** @returns {Promise<void>} */
|
||||
async search() {
|
||||
this.errorMessage = "";
|
||||
const query = this.form.query.trim();
|
||||
const response = await fetch("/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -363,6 +431,9 @@
|
||||
return;
|
||||
}
|
||||
this.results = await response.json();
|
||||
if (query) {
|
||||
this.searchHistory = searchHistory.add(query);
|
||||
}
|
||||
window.app.setTitle(`s·m·u·t - ${this.results.length} results for "${this.form.query}"`);
|
||||
},
|
||||
});
|
||||
|
||||
+15
-1
@@ -31,9 +31,10 @@ import click
|
||||
import httpx
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
HandlerFunc = Callable[["Request"], "Response"]
|
||||
client = httpx.Client(base_url="http://klein:18543", timeout=30)
|
||||
client = httpx.Client(base_url="http://klein.talpa-carat.ts.net:18543", timeout=30)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
@@ -747,6 +748,19 @@ def scan(paths: list[Path]):
|
||||
scan_videos(video_paths=list(paths))
|
||||
|
||||
|
||||
@cli.command()
|
||||
def purge() -> None:
|
||||
"""Remove box entries not seen in the last 24 hours."""
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=24)
|
||||
response = client.delete(
|
||||
"/videos",
|
||||
params={"category": "eq.box", "last_seen_at": f"lt.{cutoff.isoformat()}"},
|
||||
headers={"Prefer": "return=representation"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logging.info(f"Purged {len(response.json())} box entries")
|
||||
|
||||
|
||||
@cli.command("mark-deletion")
|
||||
@click.argument("paths", nargs=-1, type=Path)
|
||||
def mark_deletion(paths: list[Path]):
|
||||
|
||||
Reference in new issue
Block a user