substui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
substui/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Substui: encontre legendas sem sair do terminal."""
2
+
3
+ __version__ = "0.1.0"
4
+
substui/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from substui.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
5
+
substui/app.py ADDED
@@ -0,0 +1,328 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+ from typing import ClassVar
6
+
7
+ from rich.text import Text
8
+ from textual import work
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.widgets import (
13
+ Button,
14
+ DataTable,
15
+ DirectoryTree,
16
+ Footer,
17
+ Header,
18
+ Input,
19
+ Label,
20
+ Select,
21
+ Static,
22
+ )
23
+
24
+ from substui.settings import SettingsStore
25
+ from substui.subtitles import SearchResult, SubtitleCandidate, SubtitleService
26
+
27
+ VIDEO_EXTENSIONS = {
28
+ ".3g2", ".3gp", ".avi", ".flv", ".m2ts", ".m4v", ".mkv", ".mov",
29
+ ".mp4", ".mpeg", ".mpg", ".mts", ".ogm", ".ogv", ".rm", ".rmvb",
30
+ ".ts", ".vob", ".webm", ".wmv",
31
+ }
32
+
33
+ LANGUAGES = (
34
+ ("Português (Brasil)", "pt-BR"),
35
+ ("Português", "pt"),
36
+ ("English", "en"),
37
+ ("Español", "es"),
38
+ ("Français", "fr"),
39
+ ("Deutsch", "de"),
40
+ ("Italiano", "it"),
41
+ )
42
+
43
+
44
+ class VideoTree(DirectoryTree):
45
+ @staticmethod
46
+ def filter_paths(paths: Iterable[Path]) -> Iterable[Path]:
47
+ return [path for path in paths if path.is_dir() or path.suffix.lower() in VIDEO_EXTENSIONS]
48
+
49
+
50
+ class SubsTui(App[None]):
51
+ TITLE = "SubsTUI"
52
+ SUB_TITLE = "Legendas sem sair do terminal"
53
+ CSS = """
54
+ Screen { background: #0d0b12; color: #e9e4f5; }
55
+ Header { background: #16131d; color: #d8b4fe; }
56
+ Footer { background: #16131d; color: #b9a7e6; }
57
+ Footer > .footer--key { background: #a78bfa; color: #0d0b12; }
58
+ #toolbar { height: 5; padding: 1 2; background: #16131d; }
59
+ #path { width: 1fr; }
60
+ #language { width: 24; margin-left: 1; }
61
+ Input { border: tall #6b6577; background: #0d0b12; color: #e9e4f5; }
62
+ Input:focus { border: tall #a78bfa; }
63
+ Select { border: tall #6b6577; background: #0d0b12; color: #e9e4f5; }
64
+ Select:focus { border: tall #a78bfa; }
65
+ #main { height: 1fr; padding: 1; }
66
+ #browser-pane {
67
+ width: 38%; min-width: 36; margin-right: 1;
68
+ border: round #6b6577; background: #100e16;
69
+ }
70
+ #tree { height: 1fr; padding: 0 1; }
71
+ DirectoryTree > .directory-tree--extension { color: #6b6577; }
72
+ DirectoryTree > .directory-tree--folder { color: #b9a7e6; }
73
+ Tree > .tree--cursor { background: #302741; color: #e9e4f5; }
74
+ .pane-title { height: 3; padding: 1 2; color: #d8b4fe; text-style: bold; }
75
+ #results-pane { width: 1fr; padding: 0 1; border: round #6b6577; background: #100e16; }
76
+ #selected { height: 3; padding: 1; color: #d8b4fe; text-style: bold; }
77
+ #status { height: 3; padding: 1 2; background: #16131d; color: #b9a7e6; }
78
+ #results { height: 1fr; }
79
+ DataTable > .datatable--header { background: #241e30; color: #d8b4fe; text-style: bold; }
80
+ DataTable > .datatable--cursor { background: #3b3150; color: #e9e4f5; }
81
+ DataTable > .datatable--odd-row { background: #14111b; }
82
+ #actions { height: 3; align: right middle; padding-right: 1; }
83
+ Button { margin-left: 1; background: #241e30; color: #e9e4f5; border: none; }
84
+ Button:hover { background: #3b3150; }
85
+ Button.-primary { background: #a78bfa; color: #0d0b12; }
86
+ Button.-success { background: #86d6a2; color: #0d0b12; }
87
+ .busy { color: #f0c560; }
88
+ .success { color: #86d6a2; }
89
+ .error { color: #ee7d92; }
90
+ """
91
+
92
+ BINDINGS: ClassVar[list[Binding]] = [
93
+ Binding("ctrl+q", "quit", "Sair"),
94
+ Binding("r", "search", "Buscar"),
95
+ Binding("b", "best", "Baixar melhor"),
96
+ Binding("d", "download", "Baixar selecionada"),
97
+ Binding("f", "toggle_all", "Mostrar todos"),
98
+ Binding("u", "up", "Pasta acima"),
99
+ Binding("ctrl+l", "focus_path", "Ir para pasta"),
100
+ ]
101
+
102
+ def __init__(self, settings_store: SettingsStore | None = None) -> None:
103
+ super().__init__()
104
+ self.store = settings_store or SettingsStore()
105
+ self.settings = self.store.load()
106
+ self.service = SubtitleService()
107
+ self.video_path: Path | None = None
108
+ self.search_result: SearchResult | None = None
109
+ self.candidates: dict[str, SubtitleCandidate] = {}
110
+ self.show_all_results = False
111
+
112
+ def compose(self) -> ComposeResult:
113
+ start = self._valid_start_path()
114
+ yield Header()
115
+ with Horizontal(id="toolbar"):
116
+ yield Input(str(start), placeholder="Digite uma pasta e pressione Enter", id="path")
117
+ yield Select(LANGUAGES, value=self.settings.language, allow_blank=False, id="language")
118
+ yield Button("Abrir", id="open-path", variant="primary")
119
+ with Horizontal(id="main"):
120
+ with Vertical(id="browser-pane"):
121
+ yield Label("▌ ARQUIVOS DE VÍDEO", classes="pane-title")
122
+ yield VideoTree(start, id="tree")
123
+ with Vertical(id="results-pane"):
124
+ yield Static("▌ Selecione um arquivo de vídeo", id="selected")
125
+ yield DataTable(id="results", cursor_type="row", zebra_stripes=True)
126
+ with Horizontal(id="actions"):
127
+ yield Button("Mostrar todos", id="toggle-all")
128
+ yield Button("Buscar", id="search")
129
+ yield Button("Baixar melhor", id="best", variant="primary")
130
+ yield Button("Baixar selecionada", id="download", variant="success")
131
+ yield Static("Pronto.", id="status")
132
+ yield Footer()
133
+
134
+ def on_mount(self) -> None:
135
+ table = self.query_one("#results", DataTable)
136
+ table.add_columns("#", "MATCH", "FONTE", "IDIOMA", "RELEASE · SINAIS")
137
+
138
+ def _valid_start_path(self) -> Path:
139
+ path = Path(self.settings.start_path).expanduser()
140
+ return path if path.is_dir() else Path.home()
141
+
142
+ @property
143
+ def language(self) -> str:
144
+ value = self.query_one("#language", Select).value
145
+ return str(value) if value is not Select.BLANK else "pt-BR"
146
+
147
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
148
+ if event.path.suffix.lower() not in VIDEO_EXTENSIONS:
149
+ return
150
+ self.video_path = event.path
151
+ self.query_one("#selected", Static).update(f"▌ {event.path.name}")
152
+ self.run_search()
153
+
154
+ def on_input_submitted(self, event: Input.Submitted) -> None:
155
+ if event.input.id == "path":
156
+ self.open_path(event.value)
157
+
158
+ def on_select_changed(self, event: Select.Changed) -> None:
159
+ if event.select.id != "language" or event.value is Select.BLANK:
160
+ return
161
+ self.settings.language = str(event.value)
162
+ self.store.save(self.settings)
163
+ self.set_status(f"Idioma alterado para {event.value}.", "success")
164
+
165
+ def on_button_pressed(self, event: Button.Pressed) -> None:
166
+ actions = {
167
+ "open-path": lambda: self.open_path(self.query_one("#path", Input).value),
168
+ "search": self.action_search,
169
+ "best": self.action_best,
170
+ "download": self.action_download,
171
+ "toggle-all": self.action_toggle_all,
172
+ }
173
+ action = actions.get(event.button.id or "")
174
+ if action:
175
+ action()
176
+
177
+ def open_path(self, raw_path: str) -> None:
178
+ path = Path(raw_path.strip().strip('"')).expanduser().resolve()
179
+ if not path.is_dir():
180
+ self.set_status(f"Pasta inexistente: {path}", "error")
181
+ return
182
+ tree = self.query_one("#tree", VideoTree)
183
+ tree.path = path
184
+ tree.reload()
185
+ self.query_one("#path", Input).value = str(path)
186
+ self.settings.start_path = str(path)
187
+ self.store.save(self.settings)
188
+ self.set_status(f"Pasta aberta: {path}", "success")
189
+
190
+ def action_up(self) -> None:
191
+ current = Path(self.query_one("#path", Input).value)
192
+ self.open_path(str(current.parent))
193
+
194
+ def action_focus_path(self) -> None:
195
+ self.query_one("#path", Input).focus()
196
+
197
+ def action_search(self) -> None:
198
+ if self.video_path:
199
+ self.run_search()
200
+ else:
201
+ self.set_status("Selecione um vídeo primeiro.", "error")
202
+
203
+ def action_best(self) -> None:
204
+ if not self.video_path:
205
+ self.set_status("Selecione um vídeo primeiro.", "error")
206
+ return
207
+ self.run_download_best()
208
+
209
+ def action_download(self) -> None:
210
+ table = self.query_one("#results", DataTable)
211
+ if not self.search_result or table.row_count == 0:
212
+ self.set_status("Faça uma busca e selecione uma legenda.", "error")
213
+ return
214
+ row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
215
+ candidate = self.candidates.get(str(row_key.value))
216
+ if candidate:
217
+ self.run_download(candidate)
218
+
219
+ def action_toggle_all(self) -> None:
220
+ if not self.search_result:
221
+ self.set_status("Faça uma busca primeiro.", "error")
222
+ return
223
+ self.show_all_results = not self.show_all_results
224
+ self.render_results()
225
+
226
+ def set_status(self, message: str, kind: str = "") -> None:
227
+ status = self.query_one("#status", Static)
228
+ status.set_classes(kind)
229
+ status.update(message)
230
+
231
+ @work(thread=True, exclusive=True, group="subtitle")
232
+ def run_search(self) -> None:
233
+ assert self.video_path is not None
234
+ video_path = self.video_path
235
+ language = self.language
236
+ self.call_from_thread(self.set_status, f"Buscando legendas em {language}…", "busy")
237
+ try:
238
+ result = self.service.search(video_path, language)
239
+ # Falhas de rede e parsing variam entre os plugins dos provedores.
240
+ except Exception as error: # noqa: BLE001
241
+ self.call_from_thread(self.set_status, f"Falha na busca: {error}", "error")
242
+ return
243
+ self.call_from_thread(self.show_results, result)
244
+
245
+ def show_results(self, result: SearchResult) -> None:
246
+ self.search_result = result
247
+ self.candidates = {candidate.key: candidate for candidate in result.candidates}
248
+ self.show_all_results = False
249
+ self.render_results()
250
+
251
+ def render_results(self) -> None:
252
+ assert self.search_result is not None
253
+ table = self.query_one("#results", DataTable)
254
+ table.clear()
255
+ all_candidates = self.search_result.candidates
256
+ compatible = [candidate for candidate in all_candidates if candidate.compatible]
257
+ visible = all_candidates if self.show_all_results else compatible
258
+ for rank, candidate in enumerate(visible, start=1):
259
+ color = (
260
+ "#86d6a2"
261
+ if candidate.confidence >= 90
262
+ else "#f0c560"
263
+ if candidate.compatible
264
+ else "#ee7d92"
265
+ )
266
+ quality = Text(f"{candidate.confidence:>3}% {candidate.rank_label}", style=f"bold {color}")
267
+ detail = Text(candidate.release, style="#e9e4f5")
268
+ if candidate.reasons:
269
+ detail.append(f" · {' · '.join(candidate.reasons)}", style="#b9a7e6")
270
+ table.add_row(
271
+ Text(f"{rank:>2}", style="#6b6577"),
272
+ quality,
273
+ Text(candidate.provider, style="#a78bfa"),
274
+ candidate.language,
275
+ detail,
276
+ key=candidate.key,
277
+ )
278
+ toggle = self.query_one("#toggle-all", Button)
279
+ toggle.label = "Ocultar fracos" if self.show_all_results else "Mostrar todos"
280
+ total = len(all_candidates)
281
+ good = len(compatible)
282
+ hidden = total - good
283
+ if good:
284
+ suffix = f" · {hidden} resultado(s) fraco(s) ocultado(s)" if hidden else ""
285
+ self.set_status(
286
+ f"✓ {good} compatível(is) de {total}{suffix}. Selecione e pressione D.",
287
+ "success",
288
+ )
289
+ elif total:
290
+ self.set_status(
291
+ f"⚠ Nenhum match confiável entre {total} resultado(s). Pressione F para revisar.",
292
+ "error",
293
+ )
294
+ else:
295
+ self.set_status(f"Nenhuma legenda encontrada em {self.language}.", "error")
296
+
297
+ @work(thread=True, exclusive=True, group="subtitle")
298
+ def run_download(self, candidate: SubtitleCandidate) -> None:
299
+ assert self.search_result is not None
300
+ self.call_from_thread(self.set_status, f"Baixando de {candidate.provider}…", "busy")
301
+ try:
302
+ path = self.service.download(self.search_result, candidate)
303
+ except Exception as error: # noqa: BLE001
304
+ self.call_from_thread(self.set_status, f"Falha no download: {error}", "error")
305
+ return
306
+ self.call_from_thread(self.set_status, f"Legenda salva em {path}", "success")
307
+
308
+ @work(thread=True, exclusive=True, group="subtitle")
309
+ def run_download_best(self) -> None:
310
+ assert self.video_path is not None
311
+ video_path = self.video_path
312
+ language = self.language
313
+ self.call_from_thread(self.set_status, "Buscando e baixando a melhor legenda…", "busy")
314
+ try:
315
+ path = self.service.download_best(video_path, language)
316
+ except Exception as error: # noqa: BLE001
317
+ self.call_from_thread(self.set_status, f"Falha no download: {error}", "error")
318
+ return
319
+ self.call_from_thread(self.set_status, f"Legenda salva em {path}", "success")
320
+
321
+
322
+ def main() -> None:
323
+ SubsTui().run()
324
+
325
+
326
+ if __name__ == "__main__":
327
+ main()
328
+
substui/matching.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from dataclasses import dataclass
6
+ from difflib import SequenceMatcher
7
+ from typing import Any
8
+
9
+ from guessit import guessit
10
+
11
+ ARTICLES = {
12
+ "a",
13
+ "an",
14
+ "as",
15
+ "das",
16
+ "de",
17
+ "do",
18
+ "dos",
19
+ "el",
20
+ "la",
21
+ "las",
22
+ "le",
23
+ "les",
24
+ "o",
25
+ "os",
26
+ "the",
27
+ "un",
28
+ "une",
29
+ }
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class MatchQuality:
34
+ confidence: int
35
+ label: str
36
+ compatible: bool
37
+ reasons: tuple[str, ...]
38
+
39
+
40
+ def normalize_title(value: str) -> str:
41
+ text = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
42
+ words = re.findall(r"[a-z0-9]+", text.lower())
43
+ meaningful = [word for word in words if word not in ARTICLES]
44
+ return " ".join(meaningful or words)
45
+
46
+
47
+ def title_similarity(left: str, right: str) -> float:
48
+ left_normalized = normalize_title(left)
49
+ right_normalized = normalize_title(right)
50
+ if not left_normalized or not right_normalized:
51
+ return 0.0
52
+ sequence = SequenceMatcher(None, left_normalized, right_normalized).ratio()
53
+ left_words = set(left_normalized.split())
54
+ right_words = set(right_normalized.split())
55
+ token_f1 = 2 * len(left_words & right_words) / (len(left_words) + len(right_words))
56
+ return max(sequence, token_f1)
57
+
58
+
59
+ def rank_match(video: Any, release: str, matches: tuple[str, ...]) -> MatchQuality:
60
+ """Turn Subliminal's technical matches into a user-facing confidence rank."""
61
+ match_set = set(matches)
62
+ if "hash" in match_set:
63
+ return MatchQuality(100, "EXATO", True, ("hash do arquivo",))
64
+
65
+ is_episode = hasattr(video, "series")
66
+ kind = "episode" if is_episode else "movie"
67
+ guessed = dict(guessit(release, {"type": kind})) if not release.isdecimal() else {}
68
+ reasons: list[str] = []
69
+ confidence = 0
70
+ incompatible = False
71
+
72
+ identity_key = "series" if is_episode else "title"
73
+ video_title = str(getattr(video, identity_key, "") or "")
74
+ guessed_title = str(guessed.get("title", "") or "")
75
+
76
+ if identity_key in match_set:
77
+ confidence += 45
78
+ reasons.append("título")
79
+ if {"imdb_id", "tmdb_id"} & match_set:
80
+ confidence = max(confidence, 70)
81
+ reasons.append("ID do filme")
82
+
83
+ if guessed_title and video_title:
84
+ similarity = title_similarity(video_title, guessed_title)
85
+ if similarity >= 0.90:
86
+ confidence += 15
87
+ reasons.append("nome exato")
88
+ elif similarity >= 0.70:
89
+ confidence += 8
90
+ reasons.append("nome parecido")
91
+ elif similarity < 0.62 and not ({"imdb_id", "tmdb_id"} & match_set):
92
+ confidence -= 35
93
+ reasons.append("título divergente")
94
+
95
+ video_year = getattr(video, "year", None)
96
+ guessed_year = guessed.get("year")
97
+ if guessed_year and video_year:
98
+ if int(guessed_year) == int(video_year):
99
+ confidence += 20
100
+ reasons.append(str(video_year))
101
+ else:
102
+ confidence -= 60
103
+ incompatible = True
104
+ reasons.append(f"ano diferente ({guessed_year})")
105
+ elif "year" in match_set:
106
+ confidence += 20
107
+ reasons.append(str(video_year or "ano"))
108
+
109
+ if is_episode:
110
+ for field, points, label in (
111
+ ("season", 12, "temporada"),
112
+ ("episode", 18, "episódio"),
113
+ ):
114
+ guessed_value = guessed.get(field)
115
+ video_value = getattr(video, field, None)
116
+ if field in match_set:
117
+ confidence += points
118
+ reasons.append(label)
119
+ if guessed_value is not None and video_value is not None and guessed_value != video_value:
120
+ incompatible = True
121
+ confidence -= 50
122
+ reasons.append(f"{label} diferente")
123
+
124
+ guessed_technical = {
125
+ "source": guessed.get("source"),
126
+ "resolution": guessed.get("screen_size"),
127
+ "video_codec": guessed.get("video_codec"),
128
+ }
129
+ for field, guessed_value in guessed_technical.items():
130
+ video_value = getattr(video, field, None)
131
+ if guessed_value and video_value and str(guessed_value).lower() != str(video_value).lower():
132
+ penalty = 10 if field == "source" else 2
133
+ confidence -= penalty
134
+ label = {"source": "fonte", "resolution": "resolução", "video_codec": "vídeo"}[field]
135
+ reasons.append(f"{label} diferente ({guessed_value})")
136
+
137
+ technical = (
138
+ ("release_group", 6, "grupo"),
139
+ ("streaming_service", 5, "streaming"),
140
+ ("source", 5, "fonte"),
141
+ ("fps", 3, "FPS"),
142
+ ("audio_codec", 3, "áudio"),
143
+ ("resolution", 3, "resolução"),
144
+ ("video_codec", 3, "vídeo"),
145
+ )
146
+ for field, points, label in technical:
147
+ if field in match_set:
148
+ confidence += points
149
+ reasons.append(label)
150
+
151
+ confidence = max(0, min(100, confidence))
152
+ compatible = not incompatible and confidence >= 60
153
+ if confidence >= 90:
154
+ label = "ÓTIMO"
155
+ elif confidence >= 75:
156
+ label = "BOM"
157
+ elif confidence >= 60:
158
+ label = "POSSÍVEL"
159
+ else:
160
+ label = "BAIXO"
161
+ return MatchQuality(confidence, label, compatible, tuple(dict.fromkeys(reasons)))
162
+
substui/settings.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from pathlib import Path
6
+
7
+ from platformdirs import user_config_path
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class Settings:
12
+ language: str = "pt-BR"
13
+ start_path: str = str(Path.home())
14
+
15
+
16
+ class SettingsStore:
17
+ def __init__(self, path: Path | None = None) -> None:
18
+ self.path = path or user_config_path("substui", appauthor=False) / "settings.json"
19
+
20
+ def load(self) -> Settings:
21
+ try:
22
+ data = json.loads(self.path.read_text(encoding="utf-8"))
23
+ return Settings(
24
+ language=str(data.get("language", "pt-BR")),
25
+ start_path=str(data.get("start_path", Path.home())),
26
+ )
27
+ except (OSError, ValueError, TypeError):
28
+ return Settings()
29
+
30
+ def save(self, settings: Settings) -> bool:
31
+ try:
32
+ self.path.parent.mkdir(parents=True, exist_ok=True)
33
+ self.path.write_text(
34
+ json.dumps(asdict(settings), ensure_ascii=False, indent=2),
35
+ encoding="utf-8",
36
+ )
37
+ except OSError:
38
+ return False
39
+ return True
40
+
substui/subtitles.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from substui.matching import rank_match
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class SubtitleCandidate:
12
+ key: str
13
+ provider: str
14
+ language: str
15
+ score: int
16
+ confidence: int
17
+ rank_label: str
18
+ compatible: bool
19
+ reasons: tuple[str, ...]
20
+ matches: tuple[str, ...]
21
+ release: str
22
+ raw: Any
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class SearchResult:
27
+ video: Any
28
+ candidates: list[SubtitleCandidate]
29
+
30
+
31
+ class SubtitleService:
32
+ """Pequena fachada sobre o Subliminal para manter a UI desacoplada."""
33
+
34
+ def __init__(self) -> None:
35
+ self._configure_cache()
36
+
37
+ @staticmethod
38
+ def _configure_cache() -> None:
39
+ from subliminal import region
40
+
41
+ if not region.is_configured:
42
+ region.configure("dogpile.cache.memory")
43
+
44
+ @staticmethod
45
+ def _language(code: str) -> Any:
46
+ from babelfish import Language
47
+
48
+ return Language.fromietf(code)
49
+
50
+ def search(self, video_path: Path, language: str) -> SearchResult:
51
+ from subliminal import compute_score, list_subtitles, scan_video
52
+
53
+ video = scan_video(str(video_path))
54
+ found = list_subtitles([video], {self._language(language)}).get(video, [])
55
+ candidates: list[SubtitleCandidate] = []
56
+ for index, subtitle in enumerate(found):
57
+ matches = tuple(sorted(subtitle.get_matches(video)))
58
+ release = self._release_name(subtitle)
59
+ quality = rank_match(video, release, matches)
60
+ candidates.append(
61
+ SubtitleCandidate(
62
+ key=f"{subtitle.provider_name}:{subtitle.id}:{index}",
63
+ provider=subtitle.provider_name,
64
+ language=str(subtitle.language),
65
+ score=int(compute_score(subtitle, video)),
66
+ confidence=quality.confidence,
67
+ rank_label=quality.label,
68
+ compatible=quality.compatible,
69
+ reasons=quality.reasons,
70
+ matches=matches,
71
+ release=release,
72
+ raw=subtitle,
73
+ )
74
+ )
75
+ candidates.sort(
76
+ key=lambda candidate: (
77
+ candidate.compatible,
78
+ candidate.confidence,
79
+ candidate.score,
80
+ ),
81
+ reverse=True,
82
+ )
83
+ return SearchResult(video=video, candidates=candidates)
84
+
85
+ @staticmethod
86
+ def _release_name(subtitle: Any) -> str:
87
+ for attribute in (
88
+ "release_info",
89
+ "movie_release_name",
90
+ "release",
91
+ "file_name",
92
+ "filename",
93
+ "movie_full_name",
94
+ "movie_name",
95
+ "movie_title",
96
+ "title",
97
+ ):
98
+ value = getattr(subtitle, attribute, None)
99
+ if value:
100
+ return str(value).replace("\n", " ")
101
+ return str(getattr(subtitle, "id", "—"))
102
+
103
+ def download(self, result: SearchResult, candidate: SubtitleCandidate) -> Path:
104
+ from subliminal import download_subtitles, save_subtitles
105
+
106
+ download_subtitles([candidate.raw])
107
+ target = Path(candidate.raw.get_path(result.video))
108
+ saved = save_subtitles(result.video, [candidate.raw])
109
+ if not saved:
110
+ raise RuntimeError("O provedor não retornou conteúdo para salvar.")
111
+ return target
112
+
113
+ def download_best(self, video_path: Path, language: str) -> Path:
114
+ result = self.search(video_path, language)
115
+ candidate = next((item for item in result.candidates if item.compatible), None)
116
+ if candidate is None:
117
+ raise LookupError(f"Nenhuma legenda compatível encontrada em {language}.")
118
+ return self.download(result, candidate)
119
+
120
+
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.5
2
+ Name: substui
3
+ Version: 0.1.0
4
+ Summary: Navegador TUI para buscar e baixar legendas
5
+ Project-URL: Homepage, https://github.com/codyzzd/substui
6
+ Project-URL: Repository, https://github.com/codyzzd/substui.git
7
+ Project-URL: Issues, https://github.com/codyzzd/substui/issues
8
+ Project-URL: Changelog, https://github.com/codyzzd/substui/releases
9
+ Author: codyzzd
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: opensubtitles,subliminal,subtitles,terminal,tui
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Multimedia :: Video
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: platformdirs>=4.0
25
+ Requires-Dist: subliminal<3,>=2.3
26
+ Requires-Dist: textual<8,>=0.89
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2; extra == 'dev'
29
+ Requires-Dist: pytest>=8; extra == 'dev'
30
+ Requires-Dist: ruff>=0.8; extra == 'dev'
31
+ Requires-Dist: twine>=6; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # SubsTUI
35
+
36
+ [![CI](https://github.com/codyzzd/substui/actions/workflows/ci.yml/badge.svg)](https://github.com/codyzzd/substui/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/substui?label=PyPI)](https://pypi.org/project/substui/)
38
+ [![Python](https://img.shields.io/badge/Python-3.10%2B-a78bfa)](https://www.python.org/)
39
+ [![Licença MIT](https://img.shields.io/badge/licen%C3%A7a-MIT-86d6a2)](LICENSE)
40
+
41
+ Encontre a legenda certa sem sair do terminal.
42
+
43
+ O SubsTUI é um navegador de arquivos com interface TUI que pesquisa, compara e baixa legendas
44
+ ao lado dos seus vídeos. Ele usa o [Subliminal](https://github.com/Diaoul/subliminal) para consultar
45
+ múltiplos provedores e acrescenta um ranking próprio para separar o release correto de resultados
46
+ com título parecido, ano errado ou outra fonte de vídeo.
47
+
48
+ > O projeto está em fase alfa. A busca depende da disponibilidade e dos limites dos provedores.
49
+
50
+ ## Comece com um comando
51
+
52
+ Com o [`uv`](https://docs.astral.sh/uv/) instalado, execute o SubsTUI sem clonar nem instalar o
53
+ projeto:
54
+
55
+ ```bash
56
+ uvx --from git+https://github.com/codyzzd/substui.git substui
57
+ ```
58
+
59
+ Depois da primeira publicação no PyPI, o comando fica ainda menor:
60
+
61
+ ```bash
62
+ uvx substui
63
+ ```
64
+
65
+ O `uvx` baixa o pacote e suas dependências em um ambiente isolado e abre a TUI. Ele funciona no
66
+ Windows, Linux e macOS. Assim como o `npx` requer Node, este método requer apenas o `uv`.
67
+
68
+ ### Instalar o uv
69
+
70
+ Windows, com Winget:
71
+
72
+ ```powershell
73
+ winget install --id=astral-sh.uv -e
74
+ ```
75
+
76
+ macOS, com Homebrew:
77
+
78
+ ```bash
79
+ brew install uv
80
+ ```
81
+
82
+ Linux e macOS, com o instalador oficial:
83
+
84
+ ```bash
85
+ curl -LsSf https://astral.sh/uv/install.sh | sh
86
+ ```
87
+
88
+ No Windows também é possível usar o instalador oficial:
89
+
90
+ ```powershell
91
+ powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
92
+ ```
93
+
94
+ ## Instalação permanente
95
+
96
+ Para deixar o comando `substui` disponível de qualquer pasta:
97
+
98
+ ```bash
99
+ uv tool install git+https://github.com/codyzzd/substui.git
100
+ substui
101
+ ```
102
+
103
+ Após a publicação no PyPI:
104
+
105
+ ```bash
106
+ uv tool install substui
107
+ substui
108
+ ```
109
+
110
+ O `pipx` continua disponível como alternativa para quem já usa Python:
111
+
112
+ ```bash
113
+ pipx install substui
114
+ substui
115
+ ```
116
+
117
+ Se o comando não aparecer imediatamente após uma instalação permanente, execute
118
+ `uv tool update-shell` e abra um terminal novo.
119
+
120
+ ## O que ele faz
121
+
122
+ - Navega pelas pastas exibindo somente diretórios e arquivos de vídeo.
123
+ - Pesquisa em vários provedores, incluindo OpenSubtitles e OpenSubtitles.com.
124
+ - Ranqueia legendas por título, ano, hash, fonte, resolução, codecs e grupo de release.
125
+ - Classifica cada resultado como `EXATO`, `ÓTIMO`, `BOM`, `POSSÍVEL` ou `BAIXO`.
126
+ - Oculta resultados claramente incompatíveis, mas permite revisá-los com `F`.
127
+ - Baixa uma opção escolhida ou seleciona automaticamente o melhor match confiável.
128
+ - Salva idioma e última pasta entre execuções.
129
+ - Grava a legenda ao lado do vídeo, normalmente como `video.pt-BR.srt`.
130
+
131
+ ## Como funciona o ranking
132
+
133
+ O score técnico do Subliminal é combinado com uma análise do nome real do release:
134
+
135
+ - Um hash igual recebe prioridade máxima.
136
+ - Título, ano, temporada e episódio definem a identidade do conteúdo.
137
+ - `BluRay`, `WEB-DL`, `TELESYNC`, resolução, codecs e grupo ajudam a identificar o corte correto.
138
+ - Ano, temporada ou episódio divergentes invalidam o resultado.
139
+ - Uma fonte diferente reduz a confiança porque pode indicar tempos e cortes diferentes.
140
+
141
+ Assim, uma legenda para `The.Odyssey.2026.1080p.BluRay` aparece acima de um making-of, de uma
142
+ versão de 2016 ou de outro filme que apenas contém “Odyssey” no nome.
143
+
144
+ ## Atalhos
145
+
146
+ | Tecla | Ação |
147
+ |---|---|
148
+ | `↑` / `↓` / `Enter` | Navegar e selecionar |
149
+ | `R` | Buscar legendas novamente |
150
+ | `D` | Baixar a legenda selecionada |
151
+ | `B` | Baixar o melhor match confiável |
152
+ | `F` | Mostrar ou ocultar resultados fracos |
153
+ | `U` | Subir uma pasta |
154
+ | `Ctrl+L` | Digitar outro caminho |
155
+ | `Ctrl+Q` | Sair |
156
+
157
+ O idioma é escolhido no topo da interface. Português do Brasil (`pt-BR`) é o padrão.
158
+
159
+ ## Provedores
160
+
161
+ Alguns provedores funcionam sem conta; outros podem exigir credenciais, limitar requisições ou
162
+ ficar temporariamente indisponíveis. O SubsTUI não hospeda legendas: ele consulta os provedores
163
+ configurados pelo Subliminal e salva somente o resultado escolhido pelo usuário.
164
+
165
+ ## Desenvolvimento
166
+
167
+ ```bash
168
+ git clone https://github.com/codyzzd/substui.git
169
+ cd substui
170
+ python -m venv .venv
171
+ python -m pip install -e ".[dev]"
172
+ python -m pytest
173
+ python -m ruff check .
174
+ python -m build
175
+ ```
176
+
177
+ O CI testa o projeto no Windows e Linux com Python 3.10 e 3.13. Consulte
178
+ [RELEASING.md](RELEASING.md) para publicar uma nova versão no PyPI.
179
+
180
+ ## Roadmap
181
+
182
+ - Tela para configurar provedores e credenciais.
183
+ - Pré-visualização de trechos antes do download.
184
+ - Download em lote para uma temporada ou pasta.
185
+ - Binários independentes para Windows, Linux e macOS.
186
+ - Instalação por gerenciadores como Winget e Homebrew.
187
+
188
+ ## Contribuindo
189
+
190
+ Issues e pull requests são bem-vindos. Veja [CONTRIBUTING.md](CONTRIBUTING.md) antes de enviar uma
191
+ mudança maior.
192
+
193
+ ## Licença
194
+
195
+ Distribuído sob a [licença MIT](LICENSE).
196
+
@@ -0,0 +1,11 @@
1
+ substui/__init__.py,sha256=-VDCM6Cg_m0bx3CT_gv-JvLbtCMjEjhJSeK3a0UTNgo,79
2
+ substui/__main__.py,sha256=peYXu_dAQP1YCbpLDtx1xvzv6p2y_DUzWgqL3OG7L0M,69
3
+ substui/app.py,sha256=OAsgpSpxutAovII9n1sTw0sAXvyuXYbUU6dLubuugUU,13357
4
+ substui/matching.py,sha256=oGYqn14CEFfsgICAUk6bjdMIh6H_Yh1cJHwU6QQAGmE,5238
5
+ substui/settings.py,sha256=yWZv_zTyPsYiyLrFmdOLP7lfOrIj0wLsXknJEaNatgk,1159
6
+ substui/subtitles.py,sha256=pS5rf8eKvdtfkZe_DiyaY2bBhZIQtlZyxGbXL-scddg,3823
7
+ substui-0.1.0.dist-info/METADATA,sha256=284LH5Bgl6eFPZ0fTGyUwMTouNCqi0AYK4QxceRdxDA,6443
8
+ substui-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ substui-0.1.0.dist-info/entry_points.txt,sha256=jI-MWti2amHZh1F5KFRdsMEE6akxaN8dhk7_2S1OjMg,45
10
+ substui-0.1.0.dist-info/licenses/LICENSE,sha256=eE75ud0N00f5Ro_VvJ8BVxqcuR6wEjRYgLiQWb86PUA,1093
11
+ substui-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ substui = substui.app:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 codyzzd e contribuidores do SubsTUI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+