[mirror of https://git.0x0.st/mia/0x0] No-bullshit file hosting and URL shortening service https://0x0.st

Add moderation TUI

This ended up way fancier than I imagined.

+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
··· 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
··· 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
··· 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
··· 1 + from .filetable import FileTable 2 + from .notification import Notification 3 + from .mpvwidget import MpvWidget
+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
··· 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
··· 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()
+8
modui/notification.py
··· 1 + from textual.widgets import Static 2 + 3 + class Notification(Static): 4 + def on_mount(self) -> None: 5 + self.set_timer(3, self.remove) 6 + 7 + def on_click(self) -> None: 8 + self.remove()