Compare commits
No commits in common. "647e3a54f168fee3758739eeed29a47e7cdf3e6e" and "57c4b6853f1b33ab55a630c5b0810ba667ba017a" have entirely different histories.
647e3a54f1
...
57c4b6853f
13 changed files with 10 additions and 717 deletions
44
README.rst
44
README.rst
|
@ -48,50 +48,6 @@ Before running the service for the first time and every time you update it
|
||||||
from this git repository, run ``FLASK_APP=fhost flask db upgrade``.
|
from this git repository, run ``FLASK_APP=fhost flask db upgrade``.
|
||||||
|
|
||||||
|
|
||||||
Moderation UI
|
|
||||||
-------------
|
|
||||||
|
|
||||||
.. image:: modui.webp
|
|
||||||
:height: 300
|
|
||||||
|
|
||||||
0x0 features a TUI program for file moderation. With it, you can view a list
|
|
||||||
of uploaded files, as well as extended information on them. It allows you to
|
|
||||||
take actions like removing files temporarily or permanently, as well as
|
|
||||||
blocking IP addresses and associated files.
|
|
||||||
|
|
||||||
If a sufficiently recent version of python-mpv with libmpv is present and
|
|
||||||
your terminal supports it, you also get graphical file previews, including
|
|
||||||
video playback. Upstream mpv currently supports sixels and the
|
|
||||||
`kitty graphics protocol <https://sw.kovidgoyal.net/kitty/graphics-protocol/>`_.
|
|
||||||
For this to work, set the ``MOD_PREVIEW_PROTO`` option in ``instance/config.py``.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
* `Textual <https://textual.textualize.io/>`_
|
|
||||||
|
|
||||||
Optional:
|
|
||||||
|
|
||||||
* `python-mpv <https://github.com/jaseg/python-mpv>`_
|
|
||||||
(graphical previews)
|
|
||||||
* `PyAV <https://github.com/PyAV-Org/PyAV>`_
|
|
||||||
(information on multimedia files)
|
|
||||||
* `PyMuPDF <https://github.com/pymupdf/PyMuPDF>`_
|
|
||||||
(previews and file information for PDF, XPS, EPUB, MOBI and FB2)
|
|
||||||
* `libarchive-c <https://github.com/Changaco/python-libarchive-c>`_
|
|
||||||
(archive content listing)
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
`Mosh <https://mosh.org/>`_ currently does not support sixels or kitty graphics.
|
|
||||||
|
|
||||||
.. hint::
|
|
||||||
You may need to set the ``COLORTERM`` environment variable to
|
|
||||||
``truecolor``.
|
|
||||||
|
|
||||||
.. tip::
|
|
||||||
Using compression with SSH (``-C`` option) can significantly
|
|
||||||
reduce the bandwidth requirements for graphics.
|
|
||||||
|
|
||||||
|
|
||||||
NSFW Detection
|
NSFW Detection
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
|
|
2
fhost.py
2
fhost.py
|
@ -295,7 +295,7 @@ class File(db.Model):
|
||||||
f.size = len(data)
|
f.size = len(data)
|
||||||
|
|
||||||
if not f.nsfw_score and app.config["NSFW_DETECT"]:
|
if not f.nsfw_score and app.config["NSFW_DETECT"]:
|
||||||
f.nsfw_score = nsfw.detect(str(p))
|
f.nsfw_score = nsfw.detect(p)
|
||||||
|
|
||||||
db.session.add(f)
|
db.session.add(f)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -58,17 +58,6 @@ FHOST_MIN_EXPIRATION = 30 * 24 * 60 * 60 * 1000
|
||||||
FHOST_MAX_EXPIRATION = 365 * 24 * 60 * 60 * 1000
|
FHOST_MAX_EXPIRATION = 365 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
# This should be detected automatically when running behind a reverse proxy, but needs
|
|
||||||
# to be set for URL resolution to work in e.g. the moderation UI.
|
|
||||||
# SERVER_NAME = "example.com"
|
|
||||||
|
|
||||||
|
|
||||||
# Specifies which graphics protocol to use for the media previews in the moderation UI.
|
|
||||||
# Requires pympv with libmpv >= 0.36.0 and terminal support.
|
|
||||||
# Available choices are "sixel" and "kitty".
|
|
||||||
# MOD_PREVIEW_PROTO = "sixel"
|
|
||||||
|
|
||||||
|
|
||||||
# Use the X-SENDFILE header to speed up serving files w/ compatible webservers
|
# Use the X-SENDFILE header to speed up serving files w/ compatible webservers
|
||||||
#
|
#
|
||||||
# Some webservers can be configured use the X-Sendfile header to handle sending
|
# Some webservers can be configured use the X-Sendfile header to handle sending
|
||||||
|
|
|
@ -58,19 +58,14 @@ def upgrade():
|
||||||
return # There are no currently unexpired files
|
return # There are no currently unexpired files
|
||||||
|
|
||||||
# Calculate an expiration date for all existing files
|
# Calculate an expiration date for all existing files
|
||||||
|
files = session.scalars(
|
||||||
q = session.scalars(
|
|
||||||
sa.select(File)
|
sa.select(File)
|
||||||
.where(
|
.where(
|
||||||
sa.not_(File.removed)
|
sa.not_(File.removed),
|
||||||
|
File.sha256.in_(unexpired_files)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
updates = [] # We coalesce updates to the database here
|
updates = [] # We coalesce updates to the database here
|
||||||
|
|
||||||
# SQLite has a hard limit on the number of variables so we
|
|
||||||
# need to do this the slow way
|
|
||||||
files = [f for f in q if f.sha256 in unexpired_files]
|
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
file_path = storage / file.sha256
|
file_path = storage / file.sha256
|
||||||
stat = os.stat(file_path)
|
stat = os.stat(file_path)
|
||||||
|
|
56
mod.css
56
mod.css
|
@ -1,56 +0,0 @@
|
||||||
#ftable {
|
|
||||||
width: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#infopane {
|
|
||||||
width: 50%;
|
|
||||||
outline-top: hkey $primary;
|
|
||||||
background: $panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
#finfo {
|
|
||||||
background: $boost;
|
|
||||||
height: 12;
|
|
||||||
width: 1fr;
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mpv {
|
|
||||||
display: none;
|
|
||||||
height: 20%;
|
|
||||||
width: 1fr;
|
|
||||||
content-align: center middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
#ftextlog {
|
|
||||||
height: 1fr;
|
|
||||||
width: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter_container {
|
|
||||||
height: auto;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter_label {
|
|
||||||
content-align: right middle;
|
|
||||||
height: 1fr;
|
|
||||||
width: 20%;
|
|
||||||
margin: 0 1 0 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter_input {
|
|
||||||
width: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification {
|
|
||||||
dock: bottom;
|
|
||||||
layer: notification;
|
|
||||||
width: auto;
|
|
||||||
margin: 2 4;
|
|
||||||
padding: 1 2;
|
|
||||||
background: $background;
|
|
||||||
color: $text;
|
|
||||||
height: auto;
|
|
||||||
|
|
||||||
}
|
|
279
mod.py
279
mod.py
|
@ -1,279 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
from itertools import zip_longest
|
|
||||||
from sys import stdout
|
|
||||||
import time
|
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.widgets import DataTable, Header, Footer, TextLog, Static, Input
|
|
||||||
from textual.containers import Horizontal, Vertical
|
|
||||||
from textual.screen import Screen
|
|
||||||
from textual import log
|
|
||||||
from rich.text import Text
|
|
||||||
from jinja2.filters import do_filesizeformat
|
|
||||||
|
|
||||||
from fhost import db, File, su, app as fhost_app, in_upload_bl
|
|
||||||
from modui import *
|
|
||||||
|
|
||||||
fhost_app.app_context().push()
|
|
||||||
|
|
||||||
class NullptrMod(Screen):
|
|
||||||
BINDINGS = [
|
|
||||||
("q", "quit_app", "Quit"),
|
|
||||||
("f1", "filter(1, 'Lookup name:')", "Lookup name"),
|
|
||||||
("f2", "filter(2, 'Filter IP address:')", "Filter IP"),
|
|
||||||
("f3", "filter(3, 'Filter MIME Type:')", "Filter MIME"),
|
|
||||||
("f4", "filter(4, 'Filter extension:')", "Filter Ext."),
|
|
||||||
("f5", "refresh", "Refresh"),
|
|
||||||
("f6", "filter_clear", "Clear filter"),
|
|
||||||
("r", "remove_file(False)", "Remove file"),
|
|
||||||
("ctrl+r", "remove_file(True)", "Ban file"),
|
|
||||||
("p", "ban_ip(False)", "Ban IP"),
|
|
||||||
("ctrl+p", "ban_ip(True)", "Nuke IP"),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def action_quit_app(self):
|
|
||||||
self.mpvw.shutdown()
|
|
||||||
await self.app.action_quit()
|
|
||||||
|
|
||||||
def action_refresh(self):
|
|
||||||
ftable = self.query_one("#ftable")
|
|
||||||
ftable.watch_query(None, None)
|
|
||||||
|
|
||||||
def action_filter_clear(self):
|
|
||||||
self.query_one("#filter_container").display = False
|
|
||||||
ftable = self.query_one("#ftable")
|
|
||||||
ftable.focus()
|
|
||||||
ftable.query = ftable.base_query
|
|
||||||
|
|
||||||
def action_filter(self, fcol: int, label: str):
|
|
||||||
self.query_one("#filter_label").update(label)
|
|
||||||
finput = self.query_one("#filter_input")
|
|
||||||
self.filter_col = fcol
|
|
||||||
self.query_one("#filter_container").display = True
|
|
||||||
finput.focus()
|
|
||||||
self._refresh_layout()
|
|
||||||
|
|
||||||
if self.current_file:
|
|
||||||
match fcol:
|
|
||||||
case 1: finput.value = ""
|
|
||||||
case 2: finput.value = self.current_file.addr
|
|
||||||
case 3: finput.value = self.current_file.mime
|
|
||||||
case 4: finput.value = self.current_file.ext
|
|
||||||
|
|
||||||
def on_input_submitted(self, message: Input.Submitted) -> None:
|
|
||||||
self.query_one("#filter_container").display = False
|
|
||||||
ftable = self.query_one("#ftable")
|
|
||||||
ftable.focus()
|
|
||||||
|
|
||||||
if len(message.value):
|
|
||||||
match self.filter_col:
|
|
||||||
case 1:
|
|
||||||
try: ftable.query = ftable.base_query.filter(File.id == su.debase(message.value))
|
|
||||||
except ValueError: pass
|
|
||||||
case 2: ftable.query = ftable.base_query.filter(File.addr == message.value)
|
|
||||||
case 3: ftable.query = ftable.base_query.filter(File.mime.like(message.value))
|
|
||||||
case 4: ftable.query = ftable.base_query.filter(File.ext.like(message.value))
|
|
||||||
else:
|
|
||||||
ftable.query = ftable.base_query
|
|
||||||
|
|
||||||
def action_remove_file(self, permanent: bool) -> None:
|
|
||||||
if self.current_file:
|
|
||||||
self.current_file.delete(permanent)
|
|
||||||
db.session.commit()
|
|
||||||
self.mount(Notification(f"{'Banned' if permanent else 'Removed'} file {self.current_file.getname()}"))
|
|
||||||
self.action_refresh()
|
|
||||||
|
|
||||||
def action_ban_ip(self, nuke: bool) -> None:
|
|
||||||
if self.current_file:
|
|
||||||
if not fhost_app.config["FHOST_UPLOAD_BLACKLIST"]:
|
|
||||||
self.mount(Notification("Failed: FHOST_UPLOAD_BLACKLIST not set!"))
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
if in_upload_bl(self.current_file.addr):
|
|
||||||
txt = f"{self.current_file.addr} is already banned"
|
|
||||||
else:
|
|
||||||
with fhost_app.open_instance_resource(fhost_app.config["FHOST_UPLOAD_BLACKLIST"], "a") as bl:
|
|
||||||
print(self.current_file.addr.lstrip("::ffff:"), file=bl)
|
|
||||||
txt = f"Banned {self.current_file.addr}"
|
|
||||||
|
|
||||||
if nuke:
|
|
||||||
tsize = 0
|
|
||||||
trm = 0
|
|
||||||
for f in File.query.filter(File.addr == self.current_file.addr):
|
|
||||||
if f.getpath().is_file():
|
|
||||||
tsize += f.size or f.getpath().stat().st_size
|
|
||||||
trm += 1
|
|
||||||
f.delete(True)
|
|
||||||
db.session.commit()
|
|
||||||
txt += f", removed {trm} {'files' if trm != 1 else 'file'} totaling {do_filesizeformat(tsize, True)}"
|
|
||||||
self.mount(Notification(txt))
|
|
||||||
self._refresh_layout()
|
|
||||||
ftable = self.query_one("#ftable")
|
|
||||||
ftable.watch_query(None, None)
|
|
||||||
|
|
||||||
def on_update(self) -> None:
|
|
||||||
stdout.write("\033[?25l")
|
|
||||||
stdout.flush()
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header()
|
|
||||||
yield Horizontal(
|
|
||||||
FileTable(id="ftable", zebra_stripes=True),
|
|
||||||
Vertical(
|
|
||||||
DataTable(id="finfo", show_header=False),
|
|
||||||
MpvWidget(id="mpv"),
|
|
||||||
TextLog(id="ftextlog"),
|
|
||||||
id="infopane"))
|
|
||||||
yield Horizontal(Static("Filter:", id="filter_label"), Input(id="filter_input"), id="filter_container")
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.current_file = None
|
|
||||||
|
|
||||||
self.ftable = self.query_one("#ftable")
|
|
||||||
self.ftable.focus()
|
|
||||||
|
|
||||||
self.finfo = self.query_one("#finfo")
|
|
||||||
self.finfo.add_columns("key", "value")
|
|
||||||
|
|
||||||
self.mpvw = self.query_one("#mpv")
|
|
||||||
self.ftlog = self.query_one("#ftextlog")
|
|
||||||
|
|
||||||
self.mimehandler = mime.MIMEHandler()
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Archive, self.handle_libarchive)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Text, self.handle_text)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.AV, self.handle_mpv)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Document, self.handle_mupdf)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_libarchive)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_mpv)
|
|
||||||
self.mimehandler.register(mime.MIMECategory.Fallback, self.handle_raw)
|
|
||||||
|
|
||||||
def handle_libarchive(self, cat):
|
|
||||||
import libarchive
|
|
||||||
with libarchive.file_reader(str(self.current_file.getpath())) as a:
|
|
||||||
self.ftlog.write("\n".join(e.path for e in a))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_text(self, cat):
|
|
||||||
with open(self.current_file.getpath(), "r") as sf:
|
|
||||||
data = sf.read(1000000).replace("\033","")
|
|
||||||
self.ftlog.write(data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_mupdf(self, cat):
|
|
||||||
import fitz
|
|
||||||
with fitz.open(self.current_file.getpath(),
|
|
||||||
filetype=self.current_file.ext.lstrip(".")) as doc:
|
|
||||||
p = doc.load_page(0)
|
|
||||||
pix = p.get_pixmap(dpi=72)
|
|
||||||
imgdata = pix.tobytes("ppm").hex()
|
|
||||||
|
|
||||||
self.mpvw.styles.height = "40%"
|
|
||||||
self.mpvw.start_mpv("hex://" + imgdata, 0)
|
|
||||||
|
|
||||||
self.ftlog.write(Text.from_markup(f"[bold]Pages:[/bold] {doc.page_count}"))
|
|
||||||
self.ftlog.write(Text.from_markup("[bold]Metadata:[/bold]"))
|
|
||||||
for k, v in doc.metadata.items():
|
|
||||||
self.ftlog.write(Text.from_markup(f" [bold]{k}:[/bold] {v}"))
|
|
||||||
toc = doc.get_toc()
|
|
||||||
if len(toc):
|
|
||||||
self.ftlog.write(Text.from_markup("[bold]TOC:[/bold]"))
|
|
||||||
for lvl, title, page in toc:
|
|
||||||
self.ftlog.write(f"{' ' * lvl} {page}: {title}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
def handle_mpv(self, cat):
|
|
||||||
if cat == mime.MIMECategory.AV or self.current_file.nsfw_score >= 0:
|
|
||||||
self.mpvw.styles.height = "20%"
|
|
||||||
self.mpvw.start_mpv(str(self.current_file.getpath()), 0)
|
|
||||||
|
|
||||||
import av
|
|
||||||
with av.open(str(self.current_file.getpath())) as c:
|
|
||||||
self.ftlog.write(Text("Format:", style="bold"))
|
|
||||||
self.ftlog.write(f" {c.format.long_name}")
|
|
||||||
if len(c.metadata):
|
|
||||||
self.ftlog.write(Text("Metadata:", style="bold"))
|
|
||||||
for k, v in c.metadata.items():
|
|
||||||
self.ftlog.write(f" {k}: {v}")
|
|
||||||
for s in c.streams:
|
|
||||||
self.ftlog.write(Text(f"Stream {s.index}:", style="bold"))
|
|
||||||
self.ftlog.write(f" Type: {s.type}")
|
|
||||||
if s.base_rate:
|
|
||||||
self.ftlog.write(f" Frame rate: {s.base_rate}")
|
|
||||||
if len(s.metadata):
|
|
||||||
self.ftlog.write(Text(" Metadata:", style="bold"))
|
|
||||||
for k, v in s.metadata.items():
|
|
||||||
self.ftlog.write(f" {k}: {v}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def handle_raw(self, cat):
|
|
||||||
def hexdump(binf, length):
|
|
||||||
def fmt(s):
|
|
||||||
if isinstance(s, str):
|
|
||||||
c = chr(int(s, 16))
|
|
||||||
else:
|
|
||||||
c = chr(s)
|
|
||||||
s = c
|
|
||||||
if c.isalpha(): return f"\0[chartreuse1]{s}\0[/chartreuse1]"
|
|
||||||
if c.isdigit(): return f"\0[gold1]{s}\0[/gold1]"
|
|
||||||
if not c.isprintable():
|
|
||||||
g = "grey50" if c == "\0" else "cadet_blue"
|
|
||||||
return f"\0[{g}]{s if len(s) == 2 else '.'}\0[/{g}]"
|
|
||||||
return s
|
|
||||||
return Text.from_markup("\n".join(f"{' '.join(map(fmt, map(''.join, zip(*[iter(c.hex())] * 2))))}"
|
|
||||||
f"{' ' * (16 - len(c))}"
|
|
||||||
f" {''.join(map(fmt, c))}"
|
|
||||||
for c in map(lambda x: bytes([n for n in x if n != None]),
|
|
||||||
zip_longest(*[iter(binf.read(min(length, 16 * 10)))] * 16))))
|
|
||||||
|
|
||||||
with open(self.current_file.getpath(), "rb") as binf:
|
|
||||||
self.ftlog.write(hexdump(binf, self.current_file.size))
|
|
||||||
if self.current_file.size > 16*10*2:
|
|
||||||
binf.seek(self.current_file.size-16*10)
|
|
||||||
self.ftlog.write(" [...] ".center(64, '─'))
|
|
||||||
self.ftlog.write(hexdump(binf, self.current_file.size - binf.tell()))
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def on_file_table_selected(self, message: FileTable.Selected) -> None:
|
|
||||||
f = message.file
|
|
||||||
self.current_file = f
|
|
||||||
self.finfo.clear()
|
|
||||||
self.finfo.add_rows([
|
|
||||||
("ID:", str(f.id)),
|
|
||||||
("File name:", f.getname()),
|
|
||||||
("URL:", f.geturl() if fhost_app.config["SERVER_NAME"] else "⚠ Set SERVER_NAME in config.py to display"),
|
|
||||||
("File size:", do_filesizeformat(f.size, True)),
|
|
||||||
("MIME type:", f.mime),
|
|
||||||
("SHA256 checksum:", f.sha256),
|
|
||||||
("Uploaded by:", Text(f.addr)),
|
|
||||||
("Management token:", f.mgmt_token),
|
|
||||||
("Secret:", f.secret),
|
|
||||||
("Is NSFW:", ("Yes" if f.is_nsfw else "No") + f" (Score: {f.nsfw_score:0.4f})"),
|
|
||||||
("Is banned:", "Yes" if f.removed else "No"),
|
|
||||||
("Expires:", time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(File.get_expiration(f.expiration, f.size)/1000)))
|
|
||||||
])
|
|
||||||
|
|
||||||
self.mpvw.stop_mpv(True)
|
|
||||||
self.ftlog.remove()
|
|
||||||
self.query_one("#infopane").mount(TextLog(id="ftextlog"))
|
|
||||||
self.ftlog = self.query_one("#ftextlog")
|
|
||||||
|
|
||||||
if f.getpath().is_file():
|
|
||||||
self.mimehandler.handle(f.mime, f.ext)
|
|
||||||
self.ftlog.scroll_home(animate=False)
|
|
||||||
|
|
||||||
class NullptrModApp(App):
|
|
||||||
CSS_PATH = "mod.css"
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.title = "0x0 File Moderation Interface"
|
|
||||||
self.main_screen = NullptrMod()
|
|
||||||
self.install_screen(self.main_screen, name="main")
|
|
||||||
self.push_screen("main")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = NullptrModApp()
|
|
||||||
app.run()
|
|
BIN
modui.webp
BIN
modui.webp
Binary file not shown.
Before Width: | Height: | Size: 339 KiB |
|
@ -1,3 +0,0 @@
|
||||||
from .filetable import FileTable
|
|
||||||
from .notification import Notification
|
|
||||||
from .mpvwidget import MpvWidget
|
|
|
@ -1,72 +0,0 @@
|
||||||
from textual.widgets import DataTable, Static
|
|
||||||
from textual.reactive import Reactive
|
|
||||||
from textual.message import Message, MessageTarget
|
|
||||||
from textual import events, log
|
|
||||||
from jinja2.filters import do_filesizeformat
|
|
||||||
|
|
||||||
from fhost import File
|
|
||||||
from modui import mime
|
|
||||||
|
|
||||||
class FileTable(DataTable):
|
|
||||||
query = Reactive(None)
|
|
||||||
order_col = Reactive(0)
|
|
||||||
order_desc = Reactive(True)
|
|
||||||
limit = 10000
|
|
||||||
colmap = [File.id, File.removed, File.nsfw_score, None, File.ext, File.size, File.mime]
|
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
self.add_columns("#", "☣️", "🔞", "📂", "name", "size", "mime")
|
|
||||||
self.base_query = File.query.filter(File.size != None)
|
|
||||||
self.query = self.base_query
|
|
||||||
|
|
||||||
class Selected(Message):
|
|
||||||
def __init__(self, sender: MessageTarget, f: File) -> None:
|
|
||||||
self.file = f
|
|
||||||
super().__init__(sender)
|
|
||||||
|
|
||||||
def watch_order_col(self, old, value) -> None:
|
|
||||||
self.watch_query(None, None)
|
|
||||||
|
|
||||||
def watch_order_desc(self, old, value) -> None:
|
|
||||||
self.watch_query(None, None)
|
|
||||||
|
|
||||||
def watch_query(self, old, value) -> None:
|
|
||||||
def fmt_file(f: File) -> tuple:
|
|
||||||
return (
|
|
||||||
str(f.id),
|
|
||||||
"🔴" if f.removed else " ",
|
|
||||||
"🚩" if f.is_nsfw else " ",
|
|
||||||
"👻" if not f.getpath().is_file() else " ",
|
|
||||||
f.getname(),
|
|
||||||
do_filesizeformat(f.size, True),
|
|
||||||
f"{mime.mimemoji.get(f.mime.split('/')[0], mime.mimemoji.get(f.mime)) or ' '} " + f.mime,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (self.query):
|
|
||||||
self.clear()
|
|
||||||
order = FileTable.colmap[self.order_col]
|
|
||||||
q = self.query
|
|
||||||
if order: q = q.order_by(order.desc() if self.order_desc else order, File.id)
|
|
||||||
self.add_rows(map(fmt_file, q.limit(self.limit)))
|
|
||||||
|
|
||||||
def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
|
|
||||||
region = self._get_cell_region(self.cursor_row, 0)
|
|
||||||
spacing = self._get_cell_border()
|
|
||||||
self.scroll_to_region(region, animate=animate, spacing=spacing)
|
|
||||||
|
|
||||||
async def watch_cursor_cell(self, old, value) -> None:
|
|
||||||
super().watch_cursor_cell(old, value)
|
|
||||||
if value[0] < len(self.data) and value[0] >= 0:
|
|
||||||
f = File.query.get(int(self.data[value[0]][0]))
|
|
||||||
await self.emit(self.Selected(self, f))
|
|
||||||
|
|
||||||
def on_click(self, event: events.Click) -> None:
|
|
||||||
super().on_click(event)
|
|
||||||
meta = self.get_style_at(event.x, event.y).meta
|
|
||||||
if meta:
|
|
||||||
if meta["row"] == -1:
|
|
||||||
qi = FileTable.colmap[meta["column"]]
|
|
||||||
if meta["column"] == self.order_col:
|
|
||||||
self.order_desc = not self.order_desc
|
|
||||||
self.order_col = meta["column"]
|
|
126
modui/mime.py
126
modui/mime.py
|
@ -1,126 +0,0 @@
|
||||||
from enum import Enum
|
|
||||||
from textual import log
|
|
||||||
|
|
||||||
mimemoji = {
|
|
||||||
"audio" : "🔈",
|
|
||||||
"video" : "🎞",
|
|
||||||
"text" : "📄",
|
|
||||||
"image" : "🖼",
|
|
||||||
"application/zip" : "🗜️",
|
|
||||||
"application/x-zip-compressed" : "🗜️",
|
|
||||||
"application/x-tar" : "🗄",
|
|
||||||
"application/x-cpio" : "🗄",
|
|
||||||
"application/x-xz" : "🗜️",
|
|
||||||
"application/x-7z-compressed" : "🗜️",
|
|
||||||
"application/gzip" : "🗜️",
|
|
||||||
"application/zstd" : "🗜️",
|
|
||||||
"application/x-rar" : "🗜️",
|
|
||||||
"application/x-rar-compressed" : "🗜️",
|
|
||||||
"application/vnd.ms-cab-compressed" : "🗜️",
|
|
||||||
"application/x-bzip2" : "🗜️",
|
|
||||||
"application/x-lzip" : "🗜️",
|
|
||||||
"application/x-iso9660-image" : "💿",
|
|
||||||
"application/pdf" : "📕",
|
|
||||||
"application/epub+zip" : "📕",
|
|
||||||
"application/mxf" : "🎞",
|
|
||||||
"application/vnd.android.package-archive" : "📦",
|
|
||||||
"application/vnd.debian.binary-package" : "📦",
|
|
||||||
"application/x-rpm" : "📦",
|
|
||||||
"application/x-dosexec" : "⚙",
|
|
||||||
"application/x-execuftable" : "⚙",
|
|
||||||
"application/x-sharedlib" : "⚙",
|
|
||||||
"application/java-archive" : "☕",
|
|
||||||
"application/x-qemu-disk" : "🖴",
|
|
||||||
"application/pgp-encrypted" : "🔏",
|
|
||||||
}
|
|
||||||
|
|
||||||
MIMECategory = Enum("MIMECategory",
|
|
||||||
["Archive", "Text", "AV", "Document", "Fallback"]
|
|
||||||
)
|
|
||||||
|
|
||||||
class MIMEHandler:
|
|
||||||
def __init__(self):
|
|
||||||
self.handlers = {
|
|
||||||
MIMECategory.Archive : [[
|
|
||||||
"application/zip",
|
|
||||||
"application/x-zip-compressed",
|
|
||||||
"application/x-tar",
|
|
||||||
"application/x-cpio",
|
|
||||||
"application/x-xz",
|
|
||||||
"application/x-7z-compressed",
|
|
||||||
"application/gzip",
|
|
||||||
"application/zstd",
|
|
||||||
"application/x-rar",
|
|
||||||
"application/x-rar-compressed",
|
|
||||||
"application/vnd.ms-cab-compressed",
|
|
||||||
"application/x-bzip2",
|
|
||||||
"application/x-lzip",
|
|
||||||
"application/x-iso9660-image",
|
|
||||||
"application/vnd.android.package-archive",
|
|
||||||
"application/vnd.debian.binary-package",
|
|
||||||
"application/x-rpm",
|
|
||||||
"application/java-archive",
|
|
||||||
"application/vnd.openxmlformats"
|
|
||||||
], []],
|
|
||||||
MIMECategory.Text : [[
|
|
||||||
"text",
|
|
||||||
"application/json",
|
|
||||||
"application/xml",
|
|
||||||
], []],
|
|
||||||
MIMECategory.AV : [[
|
|
||||||
"audio", "video", "image",
|
|
||||||
"application/mxf"
|
|
||||||
], []],
|
|
||||||
MIMECategory.Document : [[
|
|
||||||
"application/pdf",
|
|
||||||
"application/epub",
|
|
||||||
"application/x-mobipocket-ebook",
|
|
||||||
], []],
|
|
||||||
MIMECategory.Fallback : [[], []]
|
|
||||||
}
|
|
||||||
|
|
||||||
self.exceptions = {
|
|
||||||
MIMECategory.Archive : {
|
|
||||||
".cbz" : MIMECategory.Document,
|
|
||||||
".xps" : MIMECategory.Document,
|
|
||||||
".epub" : MIMECategory.Document,
|
|
||||||
},
|
|
||||||
MIMECategory.Text : {
|
|
||||||
".fb2" : MIMECategory.Document,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def register(self, category, handler):
|
|
||||||
self.handlers[category][1].append(handler)
|
|
||||||
|
|
||||||
def handle(self, mime, ext):
|
|
||||||
def getcat(s):
|
|
||||||
cat = MIMECategory.Fallback
|
|
||||||
for k, v in self.handlers.items():
|
|
||||||
s = s.split(";")[0]
|
|
||||||
if s in v[0] or s.split("/")[0] in v[0]:
|
|
||||||
cat = k
|
|
||||||
break
|
|
||||||
|
|
||||||
for x in v[0]:
|
|
||||||
if s.startswith(x):
|
|
||||||
cat = k
|
|
||||||
break
|
|
||||||
|
|
||||||
if cat in self.exceptions:
|
|
||||||
cat = self.exceptions[cat].get(ext) or cat
|
|
||||||
|
|
||||||
return cat
|
|
||||||
|
|
||||||
cat = getcat(mime)
|
|
||||||
for handler in self.handlers[cat][1]:
|
|
||||||
try:
|
|
||||||
if handler(cat): return
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
for handler in self.handlers[MIMECategory.Fallback][1]:
|
|
||||||
try:
|
|
||||||
if handler(None): return
|
|
||||||
except: pass
|
|
||||||
|
|
||||||
raise RuntimeError(f"Unhandled MIME type category: {cat}")
|
|
|
@ -1,88 +0,0 @@
|
||||||
import time
|
|
||||||
import fcntl, struct, termios
|
|
||||||
from sys import stdout
|
|
||||||
|
|
||||||
from textual import events, log
|
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
from fhost import app as fhost_app
|
|
||||||
|
|
||||||
class MpvWidget(Static):
|
|
||||||
def __init__(self, **kwargs):
|
|
||||||
super().__init__(**kwargs)
|
|
||||||
|
|
||||||
self.mpv = None
|
|
||||||
self.vo = fhost_app.config.get("MOD_PREVIEW_PROTO")
|
|
||||||
|
|
||||||
if not self.vo in ["sixel", "kitty"]:
|
|
||||||
self.update("⚠ Previews not enabled. \n\nSet MOD_PREVIEW_PROTO to 'sixel' or 'kitty' in config.py,\nwhichever is supported by your terminal.")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
import mpv
|
|
||||||
self.mpv = mpv.MPV()
|
|
||||||
self.mpv.profile = "sw-fast"
|
|
||||||
self.mpv["vo"] = self.vo
|
|
||||||
self.mpv[f"vo-{self.vo}-config-clear"] = False
|
|
||||||
self.mpv[f"vo-{self.vo}-alt-screen"] = False
|
|
||||||
self.mpv[f"vo-sixel-buffered"] = True
|
|
||||||
self.mpv["audio"] = False
|
|
||||||
self.mpv["loop-file"] = "inf"
|
|
||||||
self.mpv["image-display-duration"] = 0.5 if self.vo == "sixel" else "inf"
|
|
||||||
except Exception as e:
|
|
||||||
self.mpv = None
|
|
||||||
self.update(f"⚠ Previews require python-mpv with libmpv 0.36.0 or later \n\nError was:\n{type(e).__name__}: {e}")
|
|
||||||
|
|
||||||
def start_mpv(self, f: str|None = None, pos: float|str|None = None) -> None:
|
|
||||||
self.display = True
|
|
||||||
self.screen._refresh_layout()
|
|
||||||
|
|
||||||
if self.mpv:
|
|
||||||
if self.content_region.x:
|
|
||||||
r, c, w, h = struct.unpack('hhhh', fcntl.ioctl(0, termios.TIOCGWINSZ, '12345678'))
|
|
||||||
width = int((w / c) * self.content_region.width)
|
|
||||||
height = int((h / r) * (self.content_region.height + (1 if self.vo == "sixel" else 0)))
|
|
||||||
self.mpv[f"vo-{self.vo}-left"] = self.content_region.x + 1
|
|
||||||
self.mpv[f"vo-{self.vo}-top"] = self.content_region.y + 1
|
|
||||||
self.mpv[f"vo-{self.vo}-rows"] = self.content_region.height + (1 if self.vo == "sixel" else 0)
|
|
||||||
self.mpv[f"vo-{self.vo}-cols"] = self.content_region.width
|
|
||||||
self.mpv[f"vo-{self.vo}-width"] = width
|
|
||||||
self.mpv[f"vo-{self.vo}-height"] = height
|
|
||||||
|
|
||||||
if pos != None:
|
|
||||||
self.mpv["start"] = pos
|
|
||||||
|
|
||||||
if f:
|
|
||||||
self.mpv.loadfile(f)
|
|
||||||
else:
|
|
||||||
self.mpv.playlist_play_index(0)
|
|
||||||
|
|
||||||
def stop_mpv(self, wait: bool = False) -> None:
|
|
||||||
if self.mpv:
|
|
||||||
if not self.mpv.idle_active:
|
|
||||||
self.mpv.stop(True)
|
|
||||||
if wait:
|
|
||||||
time.sleep(0.1)
|
|
||||||
self.clear_mpv()
|
|
||||||
self.display = False
|
|
||||||
|
|
||||||
def on_resize(self, size) -> None:
|
|
||||||
if self.mpv:
|
|
||||||
if not self.mpv.idle_active:
|
|
||||||
t = self.mpv.time_pos
|
|
||||||
self.stop_mpv()
|
|
||||||
if t:
|
|
||||||
self.mpv["start"] = t
|
|
||||||
self.start_mpv()
|
|
||||||
|
|
||||||
def clear_mpv(self) -> None:
|
|
||||||
if self.vo == "kitty":
|
|
||||||
stdout.write("\033_Ga=d;\033\\")
|
|
||||||
stdout.flush()
|
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
|
||||||
if self.mpv:
|
|
||||||
self.mpv.stop()
|
|
||||||
del self.mpv
|
|
||||||
if self.vo == "kitty":
|
|
||||||
stdout.write("\033_Ga=d;\033\\\033[?25l")
|
|
||||||
stdout.flush()
|
|
|
@ -1,8 +0,0 @@
|
||||||
from textual.widgets import Static
|
|
||||||
|
|
||||||
class Notification(Static):
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
self.set_timer(3, self.remove)
|
|
||||||
|
|
||||||
def on_click(self) -> None:
|
|
||||||
self.remove()
|
|
|
@ -1,25 +1,10 @@
|
||||||
click
|
|
||||||
Flask_Migrate
|
|
||||||
validators
|
|
||||||
alembic
|
alembic
|
||||||
requests
|
|
||||||
Jinja2
|
Jinja2
|
||||||
Flask
|
Flask
|
||||||
flask_sqlalchemy
|
|
||||||
python_magic
|
|
||||||
|
|
||||||
# vscan
|
|
||||||
clamd
|
|
||||||
|
|
||||||
# nsfw detection
|
|
||||||
numpy
|
numpy
|
||||||
|
SQLAlchemy
|
||||||
# mod ui
|
requests
|
||||||
av
|
Flask_SQLAlchemy
|
||||||
PyMuPDF
|
validators
|
||||||
libarchive_c
|
flask_migrate
|
||||||
textual
|
python_magic
|
||||||
python-mpv
|
|
||||||
|
|
||||||
# dev
|
|
||||||
pytest
|
|
||||||
|
|
Loading…
Reference in a new issue