+42
README.rst
+42
README.rst
···
48
48
from this git repository, run ``FLASK_APP=fhost flask db upgrade``.
49
49
50
50
51
+
Moderation UI
52
+
-------------
53
+
54
+
0x0 features a TUI program for file moderation. With it, you can view a list
55
+
of uploaded files, as well as extended information on them. It allows you to
56
+
take actions like removing files temporarily or permanently, as well as
57
+
blocking IP addresses and associated files.
58
+
59
+
If a sufficiently recent version of python-mpv with libmpv is present and
60
+
your terminal supports it, you also get graphical file previews, including
61
+
video playback. Upstream mpv currently supports sixel graphics, but there is
62
+
`an open pull request <https://github.com/mpv-player/mpv/pull/11002>`_ that
63
+
adds support for the `kitty graphics protocol <https://sw.kovidgoyal.net/kitty/graphics-protocol/>`_.
64
+
For this to work, set the ``MOD_PREVIEW_PROTO`` option in ``instance/config.py``.
65
+
66
+
Requirements:
67
+
68
+
* `Textual <https://textual.textualize.io/>`_
69
+
70
+
Optional:
71
+
72
+
* `python-mpv <https://github.com/jaseg/python-mpv>`_
73
+
(graphical previews)
74
+
* `PyAV <https://github.com/PyAV-Org/PyAV>`_
75
+
(information on multimedia files)
76
+
* `PyMuPDF <https://github.com/pymupdf/PyMuPDF>`_
77
+
(previews and file information for PDF, XPS, EPUB, MOBI and FB2)
78
+
* `libarchive-c <https://github.com/Changaco/python-libarchive-c>`_
79
+
(archive content listing)
80
+
81
+
.. note::
82
+
`Mosh <https://mosh.org/>`_ currently does not support sixels or kitty graphics.
83
+
84
+
.. hint::
85
+
You may need to set the ``COLORTERM`` environment variable to
86
+
``truecolor``.
87
+
88
+
.. tip::
89
+
Using compression with SSH (``-C`` option) can significantly
90
+
reduce the bandwidth requirements for graphics.
91
+
92
+
51
93
NSFW Detection
52
94
--------------
53
95
+11
instance/config.example.py
+11
instance/config.example.py
···
58
58
FHOST_MAX_EXPIRATION = 365 * 24 * 60 * 60 * 1000
59
59
60
60
61
+
# This should be detected automatically when running behind a reverse proxy, but needs
62
+
# to be set for URL resolution to work in e.g. the moderation UI.
63
+
# SERVER_NAME = "example.com"
64
+
65
+
66
+
# Specifies which graphics protocol to use for the media previews in the moderation UI.
67
+
# Requires pympv with libmpv >= 0.36.0 and terminal support.
68
+
# Available choices are "sixel" and "kitty".
69
+
# MOD_PREVIEW_PROTO = "sixel"
70
+
71
+
61
72
# Use the X-SENDFILE header to speed up serving files w/ compatible webservers
62
73
#
63
74
# Some webservers can be configured use the X-Sendfile header to handle sending
+56
mod.css
+56
mod.css
···
1
+
#ftable {
2
+
width: 1fr;
3
+
}
4
+
5
+
#infopane {
6
+
width: 50%;
7
+
outline-top: hkey $primary;
8
+
background: $panel;
9
+
}
10
+
11
+
#finfo {
12
+
background: $boost;
13
+
height: 12;
14
+
width: 1fr;
15
+
box-sizing: content-box;
16
+
}
17
+
18
+
#mpv {
19
+
display: none;
20
+
height: 20%;
21
+
width: 1fr;
22
+
content-align: center middle;
23
+
}
24
+
25
+
#ftextlog {
26
+
height: 1fr;
27
+
width: 1fr;
28
+
}
29
+
30
+
#filter_container {
31
+
height: auto;
32
+
display: none;
33
+
}
34
+
35
+
#filter_label {
36
+
content-align: right middle;
37
+
height: 1fr;
38
+
width: 20%;
39
+
margin: 0 1 0 2;
40
+
}
41
+
42
+
#filter_input {
43
+
width: 1fr;
44
+
}
45
+
46
+
Notification {
47
+
dock: bottom;
48
+
layer: notification;
49
+
width: auto;
50
+
margin: 2 4;
51
+
padding: 1 2;
52
+
background: $background;
53
+
color: $text;
54
+
height: auto;
55
+
56
+
}
+279
mod.py
+279
mod.py
···
1
+
#!/usr/bin/env python3
2
+
3
+
from itertools import zip_longest
4
+
from sys import stdout
5
+
import time
6
+
7
+
from textual.app import App, ComposeResult
8
+
from textual.widgets import DataTable, Header, Footer, TextLog, Static, Input
9
+
from textual.containers import Horizontal, Vertical
10
+
from textual.screen import Screen
11
+
from textual import log
12
+
from rich.text import Text
13
+
from jinja2.filters import do_filesizeformat
14
+
15
+
from fhost import db, File, su, app as fhost_app, in_upload_bl
16
+
from modui import *
17
+
18
+
fhost_app.app_context().push()
19
+
20
+
class NullptrMod(Screen):
21
+
BINDINGS = [
22
+
("q", "quit_app", "Quit"),
23
+
("f1", "filter(1, 'Lookup name:')", "Lookup name"),
24
+
("f2", "filter(2, 'Filter IP address:')", "Filter IP"),
25
+
("f3", "filter(3, 'Filter MIME Type:')", "Filter MIME"),
26
+
("f4", "filter(4, 'Filter extension:')", "Filter Ext."),
27
+
("f5", "refresh", "Refresh"),
28
+
("f6", "filter_clear", "Clear filter"),
29
+
("r", "remove_file(False)", "Remove file"),
30
+
("ctrl+r", "remove_file(True)", "Ban file"),
31
+
("p", "ban_ip(False)", "Ban IP"),
32
+
("ctrl+p", "ban_ip(True)", "Nuke IP"),
33
+
]
34
+
35
+
async def action_quit_app(self):
36
+
self.mpvw.shutdown()
37
+
await self.app.action_quit()
38
+
39
+
def action_refresh(self):
40
+
ftable = self.query_one("#ftable")
41
+
ftable.watch_query(None, None)
42
+
43
+
def action_filter_clear(self):
44
+
self.query_one("#filter_container").display = False
45
+
ftable = self.query_one("#ftable")
46
+
ftable.focus()
47
+
ftable.query = ftable.base_query
48
+
49
+
def action_filter(self, fcol: int, label: str):
50
+
self.query_one("#filter_label").update(label)
51
+
finput = self.query_one("#filter_input")
52
+
self.filter_col = fcol
53
+
self.query_one("#filter_container").display = True
54
+
finput.focus()
55
+
self._refresh_layout()
56
+
57
+
if self.current_file:
58
+
match fcol:
59
+
case 1: finput.value = ""
60
+
case 2: finput.value = self.current_file.addr
61
+
case 3: finput.value = self.current_file.mime
62
+
case 4: finput.value = self.current_file.ext
63
+
64
+
def on_input_submitted(self, message: Input.Submitted) -> None:
65
+
self.query_one("#filter_container").display = False
66
+
ftable = self.query_one("#ftable")
67
+
ftable.focus()
68
+
69
+
if len(message.value):
70
+
match self.filter_col:
71
+
case 1:
72
+
try: ftable.query = ftable.base_query.filter(File.id == su.debase(message.value))
73
+
except ValueError: pass
74
+
case 2: ftable.query = ftable.base_query.filter(File.addr == message.value)
75
+
case 3: ftable.query = ftable.base_query.filter(File.mime.like(message.value))
76
+
case 4: ftable.query = ftable.base_query.filter(File.ext.like(message.value))
77
+
else:
78
+
ftable.query = ftable.base_query
79
+
80
+
def action_remove_file(self, permanent: bool) -> None:
81
+
if self.current_file:
82
+
self.current_file.delete(permanent)
83
+
db.session.commit()
84
+
self.mount(Notification(f"{'Banned' if permanent else 'Removed'} file {self.current_file.getname()}"))
85
+
self.action_refresh()
86
+
87
+
def action_ban_ip(self, nuke: bool) -> None:
88
+
if self.current_file:
89
+
if not fhost_app.config["FHOST_UPLOAD_BLACKLIST"]:
90
+
self.mount(Notification("Failed: FHOST_UPLOAD_BLACKLIST not set!"))
91
+
return
92
+
else:
93
+
if in_upload_bl(self.current_file.addr):
94
+
txt = f"{self.current_file.addr} is already banned"
95
+
else:
96
+
with fhost_app.open_instance_resource(fhost_app.config["FHOST_UPLOAD_BLACKLIST"], "a") as bl:
97
+
print(self.current_file.addr.lstrip("::ffff:"), file=bl)
98
+
txt = f"Banned {self.current_file.addr}"
99
+
100
+
if nuke:
101
+
tsize = 0
102
+
trm = 0
103
+
for f in File.query.filter(File.addr == self.current_file.addr):
104
+
if f.getpath().is_file():
105
+
tsize += f.size or f.getpath().stat().st_size
106
+
trm += 1
107
+
f.delete(True)
108
+
db.session.commit()
109
+
txt += f", removed {trm} {'files' if trm != 1 else 'file'} totaling {jinja2.filters.do_filesizeformat(tsize, True)}"
110
+
self.mount(Notification(txt))
111
+
self._refresh_layout()
112
+
ftable = self.query_one("#ftable")
113
+
ftable.watch_query(None, None)
114
+
115
+
def on_update(self) -> None:
116
+
stdout.write("\033[?25l")
117
+
stdout.flush()
118
+
119
+
def compose(self) -> ComposeResult:
120
+
yield Header()
121
+
yield Horizontal(
122
+
FileTable(id="ftable", zebra_stripes=True),
123
+
Vertical(
124
+
DataTable(id="finfo", show_header=False),
125
+
MpvWidget(id="mpv"),
126
+
TextLog(id="ftextlog"),
127
+
id="infopane"))
128
+
yield Horizontal(Static("Filter:", id="filter_label"), Input(id="filter_input"), id="filter_container")
129
+
yield Footer()
130
+
131
+
def on_mount(self) -> None:
132
+
self.current_file = None
133
+
134
+
self.ftable = self.query_one("#ftable")
135
+
self.ftable.focus()
136
+
137
+
self.finfo = self.query_one("#finfo")
138
+
self.finfo.add_columns("key", "value")
139
+
140
+
self.mpvw = self.query_one("#mpv")
141
+
self.ftlog = self.query_one("#ftextlog")
142
+
143
+
self.mimehandler = mime.MIMEHandler()
144
+
self.mimehandler.register(mime.MIMECategory.Archive, self.handle_libarchive)
145
+
self.mimehandler.register(mime.MIMECategory.Text, self.handle_text)
146
+
self.mimehandler.register(mime.MIMECategory.AV, self.handle_mpv)
147
+
self.mimehandler.register(mime.MIMECategory.Document, self.handle_mupdf)
148
+
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_libarchive)
149
+
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_mpv)
150
+
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_raw)
151
+
152
+
def handle_libarchive(self, cat):
153
+
import libarchive
154
+
with libarchive.file_reader(str(self.current_file.getpath())) as a:
155
+
self.ftlog.write("\n".join(e.path for e in a))
156
+
return True
157
+
158
+
def handle_text(self, cat):
159
+
with open(self.current_file.getpath(), "r") as sf:
160
+
data = sf.read(1000000).replace("\033","")
161
+
self.ftlog.write(data)
162
+
return True
163
+
164
+
def handle_mupdf(self, cat):
165
+
import fitz
166
+
with fitz.open(self.current_file.getpath(),
167
+
filetype=self.current_file.ext.lstrip(".")) as doc:
168
+
p = doc.load_page(0)
169
+
pix = p.get_pixmap(dpi=72)
170
+
imgdata = pix.tobytes("ppm").hex()
171
+
172
+
self.mpvw.styles.height = "40%"
173
+
self.mpvw.start_mpv("hex://" + imgdata, 0)
174
+
175
+
self.ftlog.write(Text.from_markup(f"[bold]Pages:[/bold] {doc.page_count}"))
176
+
self.ftlog.write(Text.from_markup("[bold]Metadata:[/bold]"))
177
+
for k, v in doc.metadata.items():
178
+
self.ftlog.write(Text.from_markup(f" [bold]{k}:[/bold] {v}"))
179
+
toc = doc.get_toc()
180
+
if len(toc):
181
+
self.ftlog.write(Text.from_markup("[bold]TOC:[/bold]"))
182
+
for lvl, title, page in toc:
183
+
self.ftlog.write(f"{' ' * lvl} {page}: {title}")
184
+
return True
185
+
186
+
def handle_mpv(self, cat):
187
+
if cat == mime.MIMECategory.AV or self.current_file.nsfw_score >= 0:
188
+
self.mpvw.styles.height = "20%"
189
+
self.mpvw.start_mpv(str(self.current_file.getpath()), 0)
190
+
191
+
import av
192
+
with av.open(str(self.current_file.getpath())) as c:
193
+
self.ftlog.write(Text("Format:", style="bold"))
194
+
self.ftlog.write(f" {c.format.long_name}")
195
+
if len(c.metadata):
196
+
self.ftlog.write(Text("Metadata:", style="bold"))
197
+
for k, v in c.metadata.items():
198
+
self.ftlog.write(f" {k}: {v}")
199
+
for s in c.streams:
200
+
self.ftlog.write(Text(f"Stream {s.index}:", style="bold"))
201
+
self.ftlog.write(f" Type: {s.type}")
202
+
if s.base_rate:
203
+
self.ftlog.write(f" Frame rate: {s.base_rate}")
204
+
if len(s.metadata):
205
+
self.ftlog.write(Text(" Metadata:", style="bold"))
206
+
for k, v in s.metadata.items():
207
+
self.ftlog.write(f" {k}: {v}")
208
+
return True
209
+
return False
210
+
211
+
def handle_raw(self, cat):
212
+
def hexdump(binf, length):
213
+
def fmt(s):
214
+
if isinstance(s, str):
215
+
c = chr(int(s, 16))
216
+
else:
217
+
c = chr(s)
218
+
s = c
219
+
if c.isalpha(): return f"\0[chartreuse1]{s}\0[/chartreuse1]"
220
+
if c.isdigit(): return f"\0[gold1]{s}\0[/gold1]"
221
+
if not c.isprintable():
222
+
g = "grey50" if c == "\0" else "cadet_blue"
223
+
return f"\0[{g}]{s if len(s) == 2 else '.'}\0[/{g}]"
224
+
return s
225
+
return Text.from_markup("\n".join(f"{' '.join(map(fmt, map(''.join, zip(*[iter(c.hex())] * 2))))}"
226
+
f"{' ' * (16 - len(c))}"
227
+
f" {''.join(map(fmt, c))}"
228
+
for c in map(lambda x: bytes([n for n in x if n != None]),
229
+
zip_longest(*[iter(binf.read(min(length, 16 * 10)))] * 16))))
230
+
231
+
with open(self.current_file.getpath(), "rb") as binf:
232
+
self.ftlog.write(hexdump(binf, self.current_file.size))
233
+
if self.current_file.size > 16*10*2:
234
+
binf.seek(self.current_file.size-16*10)
235
+
self.ftlog.write(" [...] ".center(64, '─'))
236
+
self.ftlog.write(hexdump(binf, self.current_file.size - binf.tell()))
237
+
238
+
return True
239
+
240
+
def on_file_table_selected(self, message: FileTable.Selected) -> None:
241
+
f = message.file
242
+
self.current_file = f
243
+
self.finfo.clear()
244
+
self.finfo.add_rows([
245
+
("ID:", str(f.id)),
246
+
("File name:", f.getname()),
247
+
("URL:", f.geturl() if fhost_app.config["SERVER_NAME"] else "⚠ Set SERVER_NAME in config.py to display"),
248
+
("File size:", do_filesizeformat(f.size, True)),
249
+
("MIME type:", f.mime),
250
+
("SHA256 checksum:", f.sha256),
251
+
("Uploaded by:", Text(f.addr)),
252
+
("Management token:", f.mgmt_token),
253
+
("Secret:", f.secret),
254
+
("Is NSFW:", ("Yes" if f.is_nsfw else "No") + f" (Score: {f.nsfw_score:0.4f})"),
255
+
("Is banned:", "Yes" if f.removed else "No"),
256
+
("Expires:", time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(File.get_expiration(f.expiration, f.size)/1000)))
257
+
])
258
+
259
+
self.mpvw.stop_mpv(True)
260
+
self.ftlog.remove()
261
+
self.query_one("#infopane").mount(TextLog(id="ftextlog"))
262
+
self.ftlog = self.query_one("#ftextlog")
263
+
264
+
if f.getpath().is_file():
265
+
self.mimehandler.handle(f.mime, f.ext)
266
+
self.ftlog.scroll_home(animate=False)
267
+
268
+
class NullptrModApp(App):
269
+
CSS_PATH = "mod.css"
270
+
271
+
def on_mount(self) -> None:
272
+
self.title = "0x0 File Moderation Interface"
273
+
self.main_screen = NullptrMod()
274
+
self.install_screen(self.main_screen, name="main")
275
+
self.push_screen("main")
276
+
277
+
if __name__ == "__main__":
278
+
app = NullptrModApp()
279
+
app.run()
+3
modui/__init__.py
+3
modui/__init__.py
+72
modui/filetable.py
+72
modui/filetable.py
···
1
+
from textual.widgets import DataTable, Static
2
+
from textual.reactive import Reactive
3
+
from textual.message import Message, MessageTarget
4
+
from textual import events, log
5
+
from jinja2.filters import do_filesizeformat
6
+
7
+
from fhost import File
8
+
from modui import mime
9
+
10
+
class FileTable(DataTable):
11
+
query = Reactive(None)
12
+
order_col = Reactive(0)
13
+
order_desc = Reactive(True)
14
+
limit = 10000
15
+
colmap = [File.id, File.removed, File.nsfw_score, None, File.ext, File.size, File.mime]
16
+
17
+
def __init__(self, **kwargs):
18
+
super().__init__(**kwargs)
19
+
self.add_columns("#", "☣️", "🔞", "📂", "name", "size", "mime")
20
+
self.base_query = File.query.filter(File.size != None)
21
+
self.query = self.base_query
22
+
23
+
class Selected(Message):
24
+
def __init__(self, sender: MessageTarget, f: File) -> None:
25
+
self.file = f
26
+
super().__init__(sender)
27
+
28
+
def watch_order_col(self, old, value) -> None:
29
+
self.watch_query(None, None)
30
+
31
+
def watch_order_desc(self, old, value) -> None:
32
+
self.watch_query(None, None)
33
+
34
+
def watch_query(self, old, value) -> None:
35
+
def fmt_file(f: File) -> tuple:
36
+
return (
37
+
str(f.id),
38
+
"🔴" if f.removed else " ",
39
+
"🚩" if f.is_nsfw else " ",
40
+
"👻" if not f.getpath().is_file() else " ",
41
+
f.getname(),
42
+
do_filesizeformat(f.size, True),
43
+
f"{mime.mimemoji.get(f.mime.split('/')[0], mime.mimemoji.get(f.mime)) or ' '} " + f.mime,
44
+
)
45
+
46
+
if (self.query):
47
+
self.clear()
48
+
order = FileTable.colmap[self.order_col]
49
+
q = self.query
50
+
if order: q = q.order_by(order.desc() if self.order_desc else order, File.id)
51
+
self.add_rows(map(fmt_file, q.limit(self.limit)))
52
+
53
+
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
54
+
region = self._get_cell_region(self.cursor_row, 0)
55
+
spacing = self._get_cell_border()
56
+
self.scroll_to_region(region, animate=animate, spacing=spacing)
57
+
58
+
async def watch_cursor_cell(self, old, value) -> None:
59
+
super().watch_cursor_cell(old, value)
60
+
if value[0] < len(self.data) and value[0] >= 0:
61
+
f = File.query.get(int(self.data[value[0]][0]))
62
+
await self.emit(self.Selected(self, f))
63
+
64
+
def on_click(self, event: events.Click) -> None:
65
+
super().on_click(event)
66
+
meta = self.get_style_at(event.x, event.y).meta
67
+
if meta:
68
+
if meta["row"] == -1:
69
+
qi = FileTable.colmap[meta["column"]]
70
+
if meta["column"] == self.order_col:
71
+
self.order_desc = not self.order_desc
72
+
self.order_col = meta["column"]
+122
modui/mime.py
+122
modui/mime.py
···
1
+
from enum import Enum
2
+
from textual import log
3
+
4
+
mimemoji = {
5
+
"audio" : "🔈",
6
+
"video" : "🎞",
7
+
"text" : "📄",
8
+
"image" : "🖼",
9
+
"application/zip" : "🗜️",
10
+
"application/x-zip-compressed" : "🗜️",
11
+
"application/x-tar" : "🗄",
12
+
"application/x-cpio" : "🗄",
13
+
"application/x-xz" : "🗜️",
14
+
"application/x-7z-compressed" : "🗜️",
15
+
"application/gzip" : "🗜️",
16
+
"application/zstd" : "🗜️",
17
+
"application/x-rar" : "🗜️",
18
+
"application/x-rar-compressed" : "🗜️",
19
+
"application/vnd.ms-cab-compressed" : "🗜️",
20
+
"application/x-bzip2" : "🗜️",
21
+
"application/x-lzip" : "🗜️",
22
+
"application/x-iso9660-image" : "💿",
23
+
"application/pdf" : "📕",
24
+
"application/epub+zip" : "📕",
25
+
"application/mxf" : "🎞",
26
+
"application/vnd.android.package-archive" : "📦",
27
+
"application/vnd.debian.binary-package" : "📦",
28
+
"application/x-rpm" : "📦",
29
+
"application/x-dosexec" : "⚙",
30
+
"application/x-execuftable" : "⚙",
31
+
"application/x-sharedlib" : "⚙",
32
+
"application/java-archive" : "☕",
33
+
"application/x-qemu-disk" : "🖴",
34
+
"application/pgp-encrypted" : "🔏",
35
+
}
36
+
37
+
MIMECategory = Enum("MIMECategory",
38
+
["Archive", "Text", "AV", "Document", "Fallback"]
39
+
)
40
+
41
+
class MIMEHandler:
42
+
def __init__(self):
43
+
self.handlers = {
44
+
MIMECategory.Archive : [[
45
+
"application/zip",
46
+
"application/x-zip-compressed",
47
+
"application/x-tar",
48
+
"application/x-cpio",
49
+
"application/x-xz",
50
+
"application/x-7z-compressed",
51
+
"application/gzip",
52
+
"application/zstd",
53
+
"application/x-rar",
54
+
"application/x-rar-compressed",
55
+
"application/vnd.ms-cab-compressed",
56
+
"application/x-bzip2",
57
+
"application/x-lzip",
58
+
"application/x-iso9660-image",
59
+
"application/vnd.android.package-archive",
60
+
"application/vnd.debian.binary-package",
61
+
"application/x-rpm",
62
+
"application/java-archive",
63
+
"application/vnd.openxmlformats"
64
+
], []],
65
+
MIMECategory.Text : [["text"], []],
66
+
MIMECategory.AV : [[
67
+
"audio", "video", "image",
68
+
"application/mxf"
69
+
], []],
70
+
MIMECategory.Document : [[
71
+
"application/pdf",
72
+
"application/epub",
73
+
"application/x-mobipocket-ebook",
74
+
], []],
75
+
MIMECategory.Fallback : [[], []]
76
+
}
77
+
78
+
self.exceptions = {
79
+
MIMECategory.Archive : {
80
+
".cbz" : MIMECategory.Document,
81
+
".xps" : MIMECategory.Document,
82
+
".epub" : MIMECategory.Document,
83
+
},
84
+
MIMECategory.Text : {
85
+
".fb2" : MIMECategory.Document,
86
+
}
87
+
}
88
+
89
+
def register(self, category, handler):
90
+
self.handlers[category][1].append(handler)
91
+
92
+
def handle(self, mime, ext):
93
+
def getcat(s):
94
+
cat = MIMECategory.Fallback
95
+
for k, v in self.handlers.items():
96
+
s = s.split(";")[0]
97
+
if s in v[0] or s.split("/")[0] in v[0]:
98
+
cat = k
99
+
break
100
+
101
+
for x in v[0]:
102
+
if s.startswith(x):
103
+
cat = k
104
+
break
105
+
106
+
if cat in self.exceptions:
107
+
cat = self.exceptions[cat].get(ext) or cat
108
+
109
+
return cat
110
+
111
+
cat = getcat(mime)
112
+
for handler in self.handlers[cat][1]:
113
+
try:
114
+
if handler(cat): return
115
+
except: pass
116
+
117
+
for handler in self.handlers[MIMECategory.Fallback][1]:
118
+
try:
119
+
if handler(None): return
120
+
except: pass
121
+
122
+
raise RuntimeError(f"Unhandled MIME type category: {cat}")
+88
modui/mpvwidget.py
+88
modui/mpvwidget.py
···
1
+
import time
2
+
import fcntl, struct, termios
3
+
from sys import stdout
4
+
5
+
from textual import events, log
6
+
from textual.widgets import Static
7
+
8
+
from fhost import app as fhost_app
9
+
10
+
class MpvWidget(Static):
11
+
def __init__(self, **kwargs):
12
+
super().__init__(**kwargs)
13
+
14
+
self.mpv = None
15
+
self.vo = fhost_app.config.get("MOD_PREVIEW_PROTO")
16
+
17
+
if not self.vo in ["sixel", "kitty"]:
18
+
self.update("⚠ Previews not enabled. \n\nSet MOD_PREVIEW_PROTO to 'sixel' or 'kitty' in config.py,\nwhichever is supported by your terminal.")
19
+
else:
20
+
try:
21
+
import mpv
22
+
self.mpv = mpv.MPV()
23
+
self.mpv.profile = "sw-fast"
24
+
self.mpv["vo"] = self.vo
25
+
self.mpv[f"vo-{self.vo}-config-clear"] = False
26
+
self.mpv[f"vo-{self.vo}-alt-screen"] = False
27
+
self.mpv[f"vo-sixel-buffered"] = True
28
+
self.mpv["audio"] = False
29
+
self.mpv["loop-file"] = "inf"
30
+
self.mpv["image-display-duration"] = 0.5 if self.vo == "sixel" else "inf"
31
+
except Exception as e:
32
+
self.mpv = None
33
+
self.update(f"⚠ Previews require python-mpv with libmpv 0.36.0 or later \n\nError was:\n{type(e).__name__}: {e}")
34
+
35
+
def start_mpv(self, f: str|None = None, pos: float|str|None = None) -> None:
36
+
self.display = True
37
+
self.screen._refresh_layout()
38
+
39
+
if self.mpv:
40
+
if self.content_region.x:
41
+
r, c, w, h = struct.unpack('hhhh', fcntl.ioctl(0, termios.TIOCGWINSZ, '12345678'))
42
+
width = int((w / c) * self.content_region.width)
43
+
height = int((h / r) * (self.content_region.height + (1 if self.vo == "sixel" else 0)))
44
+
self.mpv[f"vo-{self.vo}-left"] = self.content_region.x + 1
45
+
self.mpv[f"vo-{self.vo}-top"] = self.content_region.y + 1
46
+
self.mpv[f"vo-{self.vo}-rows"] = self.content_region.height + (1 if self.vo == "sixel" else 0)
47
+
self.mpv[f"vo-{self.vo}-cols"] = self.content_region.width
48
+
self.mpv[f"vo-{self.vo}-width"] = width
49
+
self.mpv[f"vo-{self.vo}-height"] = height
50
+
51
+
if pos != None:
52
+
self.mpv["start"] = pos
53
+
54
+
if f:
55
+
self.mpv.loadfile(f)
56
+
else:
57
+
self.mpv.playlist_play_index(0)
58
+
59
+
def stop_mpv(self, wait: bool = False) -> None:
60
+
if self.mpv:
61
+
if not self.mpv.idle_active:
62
+
self.mpv.stop(True)
63
+
if wait:
64
+
time.sleep(0.1)
65
+
self.clear_mpv()
66
+
self.display = False
67
+
68
+
def on_resize(self, size) -> None:
69
+
if self.mpv:
70
+
if not self.mpv.idle_active:
71
+
t = self.mpv.time_pos
72
+
self.stop_mpv()
73
+
if t:
74
+
self.mpv["start"] = t
75
+
self.start_mpv()
76
+
77
+
def clear_mpv(self) -> None:
78
+
if self.vo == "kitty":
79
+
stdout.write("\033_Ga=d;\033\\")
80
+
stdout.flush()
81
+
82
+
def shutdown(self) -> None:
83
+
if self.mpv:
84
+
self.mpv.stop()
85
+
del self.mpv
86
+
if self.vo == "kitty":
87
+
stdout.write("\033_Ga=d;\033\\\033[?25l")
88
+
stdout.flush()