Metadata-Version: 2.4
Name: tsubasa-sync
Version: 0.1.4
Summary: Pairs subtitle files to their videos and retimes them to match, by cue timing rather than text — so it works in any language.
Author: Sonic
License: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/SonicSandbox/Tsubasa-sync
Project-URL: Documentation, https://github.com/SonicSandbox/Tsubasa-sync/blob/main/docs/USAGE.md
Project-URL: Source, https://github.com/SonicSandbox/Tsubasa-sync
Project-URL: Issues, https://github.com/SonicSandbox/Tsubasa-sync/issues
Keywords: subtitles,subtitle-sync,srt,ass,anime,japanese
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: End Users/Desktop
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Text Processing
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Provides-Extra: parsing
Requires-Dist: anitopy>=2.1; extra == "parsing"
Requires-Dist: guessit>=3.4; extra == "parsing"
Provides-Extra: trash
Requires-Dist: send2trash>=1.8; extra == "trash"
Provides-Extra: gui
Requires-Dist: tkinterdnd2>=0.3; extra == "gui"
Provides-Extra: vad
Requires-Dist: onnxruntime>=1.16; extra == "vad"
Provides-Extra: all
Requires-Dist: anitopy>=2.1; extra == "all"
Requires-Dist: guessit>=3.4; extra == "all"
Requires-Dist: send2trash>=1.8; extra == "all"
Requires-Dist: tkinterdnd2>=0.3; extra == "all"
Dynamic: license-file

# tsubasa

**Works out which subtitle belongs to which video — however differently the two are named — then retimes it to match.**

Point it at a folder of videos and a folder of subtitles. It pairs them and
fixes the timing. **You do not have to name anything, sort anything, or match
anything up by hand.**

```bash
tsubasa ~/Anime                              # subtitles sitting with the videos
tsubasa ~/Anime --subs ~/Downloads/subs      # or in a completely separate place
```

Both are searched recursively, so *"my whole anime folder"* and *"everything I
ever downloaded"* are valid inputs. Point and go.

---

## The hard part is knowing which file goes with which

Most subtitle tools take one video and one subtitle that **you** have already
matched, and shift the timing. That is the easy half.

tsubasa does the half nobody else does: **it decides which subtitle belongs to
which video**, across release groups, languages, writing systems and numbering
schemes that share nothing in common.

These are real pairs it makes, with no help:

```
[SubsPlease] Hell Mode S2 - 10 (1080p) [DD805213].mkv
  ↳ ヘルモード.～やり込み好きのゲーマーは廃設定の異世界で無双する～.S02E22.祈りが満ちて.WEBRip.ABEMA.ja[cc].srt
```
*Different language, different script, different title, **different episode
number** — one counts per season, the other counts from the start of the
series.*

```
[NanakoRaws] Yomi no Tsugai S01E18 (AT-X TV 1080p HEVC AAC).mkv
  ↳ 黄泉のツガイ.S01E18.WEBRip.ABEMA.ja[cc].srt
```
*English romanisation against the original Japanese title.*

```
片田舎のおっさん、剣聖になる S02E01.mkv
  ↳ [Erai-raws] Katainaka no Ossan Kensei ni Naru - 01 [1080p].ass
```
*Japanese video, romanised subtitle, and the subtitle carries no season at
all.*

### How it knows

| | |
| --- | --- |
| **A 221,258-entry alias table** | Every name a show is known by, linked to one entity. Enumerated from Wikidata (CC0) — `ヘルモード` and `Hell Mode` resolve to the same work |
| **Phonetic bridging** | Kana against romaji, so `ナルト` meets `Naruto` without either being in a lookup |
| **A learned decoration vocabulary** | 176 tokens — `1080p`, `WEBRip`, `x265`, `AT-X` — stripped for matching, kept for naming |
| **Season and episode, never episode alone** | *Series 1 Episode 3* must never meet *Series 3 Episode 3* |
| **Absolute vs per-season numbering** | Detected from the library itself when the two sides count differently |
| ⭐ **Timing has the final word** | A name is a hypothesis. The pair is confirmed by whether the two actually line up |

### Measured, on 350 real pairs

| | |
| --- | --- |
| **92.3%** | the right subtitle is among the candidates offered (323 / 350) |
| **80.0%** | settled on names alone (280 / 350) — **the rest are decided by timing**, not left unpaired |
| **0** | wrong-show pairs accepted, across 5,645 real videos against a 116-show pool |

⚠ Turning the alias table off drops name-settling to **51.4%**. The table is
why cross-script pairing works at all.

---

## Then it fixes the timing

Pairing is what it is *best* at; syncing is what it is *for*.

It matches on **when lines appear**, never on what they say — so a Japanese
subtitle can be timed against an English one, or against the video's own
embedded track, and neither has to be in a language anyone here can read.

```
tsubasa  ~/Anime/片田舎のおっさん S2                  24 videos · 26 subtitles

  ✗  04   [shincaps] Katainaka - 04 (AT-X).srt              REFUSED
          no subtitle track; on the audio the first 3:42 want a different
          offset (about +10 s) — a broadcast cut. The streaming release's
          subtitle will pair.

  ✓  01   片田舎のおっさん S02E01.ass      →  Katainaka no Ossan S2 - 01.ja.ass
          +0.13s          96% match · locked · holds throughout
  ⚑  03   [shincaps] ... - 03 (AT-X).srt   →  Katainaka no Ossan S2 - 03.ja.srt
          -33.07 / -42.96 @3:18   CUT 9.9s
                          91% match · strong · 2 segments

  23 synced · 1 refused · 3.1 s
```

| | |
| --- | --- |
| **Broadcast cuts** | A TV edit with ad breaks removed needs *two* offsets. It finds the break and applies both |
| **It refuses** | Below the evidence bar it writes nothing and says why, in a sentence you can act on |
| **Nothing is destroyed** | Superseded files go to the trash, never deleted |
| **Any format in** | SRT, ASS/SSA, VTT, and PGS/VobSub for timing. Written back in the format it came from, in its original encoding |

> **A confidently wrong answer is worse than no answer.** Every threshold sits
> in a measured band. When the evidence isn't there, it says so.

---

## Install

```bash
pip install git+https://github.com/SonicSandbox/Tsubasa-sync.git
```

> ⚠ **The install name and the import name differ.** The distribution is
> `tsubasa-sync`; the module you import is `tsubasa`. The short name was
> already taken on PyPI by an unrelated project.

**Python 3.10+ and numpy** — that is the whole hard dependency list.

| Extra | Gives you | Without it |
| --- | --- | --- |
| `[parsing]` | anitopy + guessit for unusual release names | our own parser, which handles 97.6% of a real catalogue alone |
| `[trash]` | OS-native trash | a local `.tsubasa-trash/` — still recoverable |
| `[gui]` | the desktop window | library and CLI unaffected |

---

## Use it from Python

The importable API is the product; the CLI and desktop app are wrappers over it.

```python
from tsubasa import scan, sync

cands  = scan(videos="/media/anime", subs="/downloads/subs")   # opens nothing
report = sync(cands)                                            # measures. Writes NOTHING
report = sync(cands, write=True)                                # the only call that writes

for r in report:
    print(r.outcome, r.offset, r.match_percent, r.reason)
```

### Fixing subtitles without touching the originals

The common case when embedding this — **add a corrected file, leave everything
else alone:**

```python
sync(cands, write=True, rename=True, dedupe=False)
```

Measured by content hash on a real library:

| Call | Originals lost | Originals rewritten | New files |
| --- | --- | --- | --- |
| `sync(..., write=True)` *(default)* | to trash | 0 | ✔ |
| **`dedupe=False`** | **0** | **0** | ✔ |
| **`out_dir="/somewhere"`** | **0** | **0** | written elsewhere |

Output takes the **video's** basename (`<video>.<lang>.<ext>`), which is what
makes a player load it automatically. `rename=False` retimes in place under the
subtitle's own name instead — the one option that changes a file rather than
adding one.

### If your application already bundles ffmpeg

```python
import tsubasa
tsubasa.set_ffmpeg(my_app_dir / "vendor" / "ffmpeg")   # a directory, or either binary
```

Takes priority over `PATH`, so a different ffmpeg on the machine can't win over
the build you shipped. Pass an absolute path — a library shouldn't resolve
against your working directory.

**ffmpeg is only needed** for containers the native reader can't open and for
audio analysis. A Matroska file with a subtitle track never touches it.

### Check the install from your own app

```python
check = tsubasa.self_check()
if not check.ok:
    for sentence in check.problems:
        log.warning("tsubasa: %s", sentence)
```

tsubasa's data files are optional by design, so **a broken install is silent**:
it runs, and settles far fewer pairs by name. `self_check()` is how you find
out. It opens none of your files and runs nothing.

**Freezing with PyInstaller needs nothing extra.** tsubasa ships a hook that
PyInstaller finds on its own. With any other freezer, copy `tsubasa/data/` in
beside the package — and call `self_check()` in your smoke test either way.

### A subtitle against another subtitle

```python
r = tsubasa.sync_to_reference("Show - 01.ja.srt", "Show - 01.en.srt")
if r.outcome == "CONFIDENT":
    out = tsubasa.render(r)            # bytes, in the file's original encoding
    Path("Show - 01.ja_retimed.srt").write_bytes(out.data)
```

Nothing is written for you. `render()` works on any result — a dry run of
`sync()` too — so your app names and places files its own way.

> ⚠ This makes the two subtitles **agree with each other** — it can't know
> whether the reference matches the video. A reference that covers only part
> of the subtitle is **refused**, because a cut in the uncovered part would be
> invisible; `render(r, force=True)` writes it anyway if you accept that.

### Reading names

```python
scan = tsubasa.scan("/media/anime")
video = scan.videos[0]
video.title, video.season, video.episode        # "Show", 2, 7

scan.unpaired(lang="ja")                        # videos with no Japanese subtitle
tsubasa.parse_subtitle_name("Show.ja[cc].srt")  # stem, lang "ja", tag "ja[cc]"
```

### What comes back

`Result` carries the outcome (`CONFIDENT` / `REFUSED` / `ERROR`), per-segment
offsets, match rate, the confidence word, the language resolved *and* as
written, what was written, what was superseded, and a `reason` that is **never
empty on a non-confident outcome**.

`SyncReport` adds the run-level view: `confident` / `refused` / `errored` /
`written` / `unpaired` / `settled` / `summary()`.

> ⚠ **Two side effects to know before embedding.** `sync()` consults a per-user
> results database (so a settled folder re-runs in a fraction of a second) and
> sends superseded files to the OS trash. Both are overridable — `results=False`,
> `trash_root=...` — but a library keeping state in your user profile shouldn't
> be a surprise you find in production.

---

## Desktop app

```bash
pip install "tsubasa-sync[gui] @ git+https://github.com/SonicSandbox/Tsubasa-sync.git"
tsubasa-gui
```

Drag a folder onto the window and it runs. Refusals and errors pin to the top;
click one to read the full reason. Every option lives behind one button.

---

## What it can't do yet

- **A video with no embedded subtitle track and no bitmap track is refused.**
  Audio analysis is specified but not yet wired.
- **Non-Matroska containers need ffmpeg**, and that path is not yet exercised
  against real files.
- **macOS** is built and tested in CI but not hand-verified.

---

## Licence

**GPL-3.0-or-later.** See `LICENSE`; third-party attribution in
`THIRD_PARTY_LICENSES.md`.

The bundled alias table is derived from [Wikidata](https://www.wikidata.org)
(CC0). **Nothing derived from AniList, TMDB, AniDB or subtitle-site filenames
ships in this package.**
