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 +4 -0
- substui/__main__.py +5 -0
- substui/app.py +328 -0
- substui/matching.py +162 -0
- substui/settings.py +40 -0
- substui/subtitles.py +120 -0
- substui-0.1.0.dist-info/METADATA +196 -0
- substui-0.1.0.dist-info/RECORD +11 -0
- substui-0.1.0.dist-info/WHEEL +4 -0
- substui-0.1.0.dist-info/entry_points.txt +2 -0
- substui-0.1.0.dist-info/licenses/LICENSE +22 -0
substui/__init__.py
ADDED
substui/__main__.py
ADDED
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
|
+
[](https://github.com/codyzzd/substui/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/substui/)
|
|
38
|
+
[](https://www.python.org/)
|
|
39
|
+
[](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,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
|
+
|