Metadata-Version: 2.4
Name: ytslice
Version: 1.1.0
Summary: Batch-extract time-bounded audio/video segments from long YouTube videos via JSON config.
Author: MisterJB
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://gitlab.com/MisterJB/ytslice
Project-URL: Repository, https://gitlab.com/MisterJB/ytslice.git
Project-URL: Issues, https://gitlab.com/MisterJB/ytslice/-/issues
Keywords: youtube,yt-dlp,audio,video,download,segment,extract
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Operating System :: OS Independent
Classifier: Topic :: Multimedia :: Sound/Audio
Classifier: Topic :: Multimedia :: Video
Classifier: Environment :: Console
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: yt-dlp
Requires-Dist: rich<15,>=13
Requires-Dist: mutagen>=1.45
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Dynamic: license-file

# ytslice

Batch-extract time-bounded audio/video segments from long YouTube videos via a JSON config — cached, fail-soft, no manual `yt-dlp` + `ffmpeg`.

---

## Prerequisites

| Dependency | Why | Install |
|---|---|---|
| Python ≥ 3.10 | runtime | `apt install -y python3 python3-pip` |
| `ffmpeg` (+ `ffprobe`) | cuts segments, parses durations | `apt install -y ffmpeg` |
| Deno on PATH | yt-dlp's n-parameter challenge solver (see below) | `curl -fsSL https://deno.land/install.sh \| sh` then `export PATH="$HOME/.deno/bin:$PATH"` |

### Why Deno

YouTube's player obfuscates stream URLs behind a JavaScript challenge (the `n` parameter). yt-dlp resolves it via the `ejs:github` remote-components plugin, which requires a JS runtime — `deno` is the default. Without `deno` on PATH, downloads of newer YouTube videos fail mid-stream with `HTTP 403 Forbidden` and the in-process fallback emits a warning. Install deno via the one-liner above, then `deno --version` should print a version before you proceed.

---

## Install

```bash
pip install .
# or, for development:
pip install -e ".[dev]"
```

The `ytslice` CLI is installed via `[project.scripts]`.

---

## Quick start

`config.json`:

```json
{
  "output_dir": "./output",
  "videos": [
    {
      "url": "https://www.youtube.com/watch?v=REPLACE_ME",
      "quality": "720",
      "segments": [
        { "name": "intro",     "start": "0:00:00", "end": "0:00:30", "mode": "audio" },
        { "name": "highlight", "start": "0:01:00", "end": "0:02:15", "mode": "both"  }
      ]
    }
  ]
}
```

Run:

```bash
ytslice --config config.json
```

Expected outputs (one segment per file, plus `both` produces a pair):

```
output/
└── <video_id>/
    ├── intro-<video_id>.mp3
    ├── highlight-<video_id>.mp3
    └── highlight-<video_id>.mp4
```

---

## Config schema

Top level:

| Field | Type | Required | Notes |
|---|---|---|---|
| `output_dir` | string | no | default `./output`; outputs go to `<output_dir>/<video_id>/` |
| `videos` | array | yes | non-empty list of video objects |

Per video:

| Field | Type | Required | Notes |
|---|---|---|---|
| `url` | string | yes | any URL yt-dlp accepts |
| `quality` | string | no | one of `best | 1080 | 720 | 480 | 360`; default `1080` |
| `segments` | array | yes | non-empty list of segment objects |

Per segment:

| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | yes | becomes the on-disk basename after sanitization |
| `start` | string | yes | `H:MM:SS` or `MM:SS`, minutes/seconds 0–59 |
| `end` | string | yes | same format; must be `> start` |
| `mode` | string | yes | one of `audio` (MP3), `video` (MP4 with audio), `both` (MP3 + MP4) |

The `quality` enum is exactly what `ytslice/config.py` validates against. See `config.example.json` for a runnable example.

---

## CLI flags

| Flag | Description |
|---|---|
| `--config <path>` | path to JSON config (required) |
| `--output-dir <path>` | override `output_dir` from config |
| `--keep-cache` | keep source in cache after a successful run (disables auto-evict) |
| `--no-cache` | bypass cache: do not look up, do not store; downloads to a tempdir and removes it after the run |
| `--clear-cache` | empty the cache root before the run |
| `--dry-run` | validate config and exit without downloading or cutting |
| `-h, --help` | show help |

`--no-cache` and `--keep-cache` are mutually exclusive.

`--verbose` was removed in 1.1.x — use `PYTHONLOGLEVEL=DEBUG` if you need more detail.

### Environment variables

| Variable | Effect |
|---|---|
| `YTSLICE_NO_PROGRESS=1` | force-disable the rich progress UI (non-TTY opt-out) |
| `YTSLICE_CACHE_DIR=<path>` | override cache root (default `~/.cache/ytslice/`) |
| `YTSLICE_E2E=1` | enable network-using tests under `pytest` |

The progress UI also auto-disables when `stderr` is not a TTY.

---

## Cache lifecycle

| Mode | Behavior |
|---|---|
| default | per-video auto-evict on success — the source file is removed from `~/.cache/ytslice/` once all that video's segments succeed |
| `--keep-cache` | source files persist (useful when iterating on segment timestamps) |
| `--no-cache` | source is downloaded to a tempdir, used once, and removed after the run |
| `--clear-cache` | the entire cache root is purged at the start of the run (sentinel-guarded) |

A failed segment keeps the source so a rerun does not re-download. Cache root is `~/.cache/ytslice/` (override via `YTSLICE_CACHE_DIR`); the directory is marked with a `.ytslice-cache` sentinel that `--clear-cache` refuses to purge directories without.

---

## Output filename rules

The full V1 rule, in order:

```
<sanitize_filename(seg.name)>-<video_id><collision_suffix?><extension>
```

- `sanitize_filename` strips path separators, control chars, leading/trailing dots+spaces; collapses whitespace; truncates at 200 chars; falls back to `unnamed` on empty input.
- `<video_id>` is the YouTube ID resolved by yt-dlp (e.g. `dQw4w9WgXcQ`).
- `<collision_suffix?>` is empty when the target path is free; otherwise `_1`, `_2`, … (1-indexed, no zero-pad), allocated to the lowest free index. For `both`-mode segments, the same `_N` is applied to both the `.mp3` and the `.mp4` so the pair never splits across different suffixes.
- `<extension>` is `.mp3` / `.mp4` per mode.

**Rerun caveat:** re-running the same config without clearing `./output/<video_id>/` will produce `_1`, `_2`, … duplicates because pre-existing files are treated as collisions (by design — no silent overwrites). Either `rm -rf ./output/<video_id>/` between runs, or rely on the `_N` accumulation if you want a versioned history.

---

## Clean-Debian walkthrough

End-to-end in `debian:stable-slim` (container runs as root — no `sudo`; on a non-container Debian with a non-root user, prefix the `apt` commands with `sudo` yourself):

```bash
docker run --rm -it debian:stable-slim bash

# system deps
apt update
apt install -y python3 python3-pip python3-venv ffmpeg curl git

# deno (for yt-dlp's n-challenge solver)
curl -fsSL https://deno.land/install.sh | sh
export PATH="$HOME/.deno/bin:$PATH"
deno --version

# ytslice
git clone https://gitlab.com/MisterJB/ytslice.git
cd ytslice
python3 -m venv .venv
. .venv/bin/activate
pip install .

ytslice --help

# run the example (replace REPLACE_ME with a real YouTube ID first)
cp config.example.json config.json
sed -i 's/REPLACE_ME/dQw4w9WgXcQ/' config.json
sed -i 's/ANOTHER/dQw4w9WgXcQ/' config.json
ytslice --config config.json

# verify outputs
ls -la output/dQw4w9WgXcQ/
ffprobe -v error -show_entries format=duration output/dQw4w9WgXcQ/*.mp3 | head

# verify cache auto-evicted on success
ls -la ~/.cache/ytslice/
# Expected: directory exists, contains the .ytslice-cache sentinel,
# but no <video_id>.mp4 — auto-evict-on-success worked.
```

---

## Testing

```bash
pytest                  # offline suite (default; mocks downloader + cutter)
YTSLICE_E2E=1 pytest    # additionally runs network-using tests
```

Done-criteria coverage lives in `tests/test_done_criteria.py`, mapping every PROJECT.md success metric to a runnable assertion.

---

## License

[GPL-3.0-or-later](LICENSE). See the `LICENSE` file for the full text.

---

## Changelog (1.1.x)

- Added `--keep-cache`, `--no-cache`, `--clear-cache`, `--dry-run`.
- Added per-video `quality` field (`best | 1080 | 720 | 480 | 360`, default `1080`).
- Added rich progress UI (auto-disables in non-TTY; opt out via `YTSLICE_NO_PROGRESS=1`).
- Filename collisions now resolved as `_1`, `_2`, … (pair-consistent for `both` mode).
- LICENSE: GPL-3.0-or-later (PEP 639 SPDX).
- **Removed:** `--verbose` (use `PYTHONLOGLEVEL=DEBUG`).
