mediasort/mediaweb/__init__.py

301 lines
11 KiB
Python

import os
import logging
import cherrypy
from time import sleep
from queue import Queue
from threading import Thread
from urllib.parse import urlparse
from dataclasses import dataclass, field
from deluge_client import DelugeRPCClient
from jinja2 import Environment, FileSystemLoader, select_autoescape
from mediaweb import shows
APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
@dataclass
class Cache:
torrents: dict = field(default_factory=dict)
shows: dict = field(default_factory=dict)
moves: dict = field(default_factory=dict)
class ClientCache(object):
def __init__(self, client, libpath):
self.client = client
self.data = Cache()
self.q = Queue()
self.inflight = False
self.libpath = libpath
self.background_t = Thread(target=self.background, daemon=True)
self.background_t.start()
self.timer_t = Thread(target=self.timer, daemon=True)
self.timer_t.start()
def refresh(self):
if not self.inflight and self.q.qsize() == 0: # best effort duplicate work reduction
self.q.put(None)
def background(self):
while True:
self.q.get() # block until we need to do something
self.inflight = True
logging.info("performing background tasks...")
self.build_showindex()
self.build_torrentindex()
self.queue_sorts()
self.q.task_done()
self.inflight = False
logging.info("background tasks complete")
def timer(self):
while True:
self.refresh()
logging.info("sleeping...")
sleep(300) # TODO configurable task interval
def build_torrentindex(self):
logging.info("refreshing torrents")
self.data.torrents = self.client.core.get_torrents_status({"label": "sickrage"}, # TODO parameterize
['name', 'label', 'save_path', 'is_seed',
'is_finished', 'progress', 'files'])
def build_showindex(self):
logging.info("updating show index")
data = shows.create_index([self.libpath])
self.data.shows = sorted(data, key=lambda x: x.name)
def queue_sorts(self):
logging.info("precomputing sorts")
for thash, torrent in self.data.torrents.items():
if thash not in self.data.moves:
matches = shows.match_episode(get_fname(torrent), self.data.shows)
if matches:
self.data.moves[thash] = matches[0]
class MediaWeb(object):
def __init__(self, rpc, templater, options):
self.tpl = templater
self.rpc = rpc
self.options = options
def render(self, template, **kwargs):
"""
Render a template
"""
return self.tpl.get_template(template).render(**kwargs,
options=self.options,
torrents=self.rpc.data.torrents,
shows=self.rpc.data.shows,
moves=self.rpc.data.moves,
**self.get_default_vars())
def get_default_vars(self):
return {}
@cherrypy.expose
def index(self, action=None):
if action:
if action == "update":
self.rpc.refresh()
raise cherrypy.HTTPRedirect("/")
return self.render("index.html")
@cherrypy.expose
def move(self, thash, dest=None, otherdest=None):
torrent = self.rpc.client.core.get_torrent_status(thash, []) # TODO reduce to needed fields
if cherrypy.request.method == "POST" and (dest or otherdest):
target = otherdest or dest
self.rpc.client.core.move_storage([thash], target)
self.rpc.refresh()
raise cherrypy.HTTPRedirect("/")
return self.render("moveform.html", torrent=torrent)
@cherrypy.expose
def sort(self, thash, dest=None):
torrent = self.rpc.client.core.get_torrent_status(thash, []) # TODO reduce to needed fields
# find the actual file among the torrent's files - really we just pick the biggest one
fname = get_fname(torrent)
# find candidate dest locations
matches = shows.match_episode(fname, self.rpc.data.shows)
if cherrypy.request.method == "POST" and dest:
# pick the candidate dest the user specified
thematch = None
for m in matches:
if m.dest.dir == dest:
thematch = m
break
self.execute_move(torrent, thematch)
self.rpc.refresh()
# TODO summary display
return "OK"
return self.render("sortform.html", torrent=torrent, matches=matches)
def execute_move(self, torrent, match):
# resolve the pathmap
pmap = self.options['pathmap']
fname = get_fname(torrent)
in_library_path = os.path.join(match.dest.dir, match.subdest, match.ep.file) # path relative from the library's root
client_full_path = os.path.join(torrent["save_path"], fname) # absolute storage path in deluge
assert client_full_path[0:len(pmap[0])] == pmap[0] # sanity check: deluge's path must starts with our pathmap
local_torrent_path = os.path.join(pmap[1], client_full_path[len(pmap[0]):].lstrip("/")) # our perspective's path to the file
local_library_path = os.path.join(self.options["library_path"], in_library_path) # where we will place the file in the library
# hard link into library
os.link(local_torrent_path, local_library_path)
client_stashdir = os.path.join(pmap[0],
self.options["stashprefix"],
get_mapped_stashdir(self.options["trackermap"], torrent["trackers"]))
# move deluge path to stash dir
self.rpc.client.core.move_storage([torrent["hash"]], client_stashdir)
# label torrent as sorted
self.rpc.client.label.set_torrent(torrent["hash"], self.options["label"])
@cherrypy.expose
def autosort(self, torrents):
if not isinstance(torrents, list):
torrents = [torrents]
for thash in torrents:
torrent = self.rpc.client.core.get_torrent_status(thash, []) # TODO reduce to needed fields
self.execute_move(torrent, self.rpc.data.moves[thash])
self.rpc.refresh()
# TODO summary display of results
return f"autosorted: {repr(torrents)}"
def get_fname(torrent):
finfo = None
fsize = 0
for tfile in torrent["files"]:
if tfile["size"] > fsize:
finfo = tfile
return finfo["path"]
def get_mapped_stashdir(mapping, trackers):
tracker = None
for tracker in trackers:
for k, v in mapping.items():
if k in tracker["url"]:
return v
return urlparse(tracker["url"]).hostname.lower() if tracker else "other"
def tsortbyname(dict_items):
return sorted(dict_items, key=lambda x: x[1]["name"].lower())
def main():
import argparse
import signal
parser = argparse.ArgumentParser(description="mediaweb server")
parser.add_argument('-p', '--port', help="tcp port to listen on",
default=int(os.environ.get("MEDIAWEB_PORT", 8080)), type=int)
parser.add_argument('-o', '--library', default=os.environ.get("STORAGE_URL"), help="media library path")
parser.add_argument('-a', '--pathmap', help="torrent to library path mapping")
parser.add_argument('--debug', action="store_true", help="enable development options")
parser.add_argument('-s', '--server', help="deluge uris", action="append", required=True)
parser.add_argument('--ui-movedests', help="move destination options in the UI", nargs="+", required=True)
parser.add_argument('--stashprefix', help="dir to move finished files in deluge", default="Sorted")
parser.add_argument('--trackermap', help="map tracker names to shash dir names", nargs="+")
parser.add_argument('--label', help="label to apply to sorted torrents", default="sorted")
args = parser.parse_args()
options = {
"movedests": args.ui_movedests,
"pathmap": args.pathmap.split(":"),
"library_path": args.library,
"stashprefix": args.stashprefix,
"trackermap": {i.split(":")[0]: i.split(":")[1] for i in args.trackermap},
"label": args.label
}
# TODO smarter argparser that understands env vars
if not args.library:
parser.error("--library or MEDIAWEB_DLDIR is required")
logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
tpl_dir = os.path.join(APPROOT, "templates")
tpl = Environment(loader=FileSystemLoader(tpl_dir),
autoescape=select_autoescape(['html', 'xml']))
tpl.filters.update(tsortbyname=tsortbyname)
def validate_password(realm, user, passw):
return user == passw # lol
# assume 1 deluge server for now
uri = urlparse(args.server[0])
assert uri.scheme == "deluge"
rpc = DelugeRPCClient(uri.hostname, uri.port if uri.port else 58846, uri.username, uri.password, decode_utf8=True)
rpc_cache = ClientCache(rpc, args.library)
web = MediaWeb(rpc_cache, tpl, options)
cherrypy.tree.mount(web, '/', {'/': {'tools.auth_basic.on': True,
'tools.auth_basic.realm': 'mediaweb',
'tools.auth_basic.checkpassword': validate_password, }})
# General config options
cherrypy.config.update({
'tools.sessions.on': False,
'request.show_tracebacks': True,
'server.show_tracebacks': True,
'server.socket_port': args.port,
'server.thread_pool': 1 if args.debug else 5,
'server.socket_host': '0.0.0.0',
'log.screen': False,
'engine.autoreload.on': args.debug
})
# Setup signal handling and run it.
def signal_handler(signum, stack):
logging.critical('Got sig {}, exiting...'.format(signum))
cherrypy.engine.exit()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
try:
cherrypy.engine.start()
cherrypy.engine.block()
finally:
logging.info("API has shut down")
cherrypy.engine.exit()
if __name__ == '__main__':
main()
# https://github.com/deluge-torrent/deluge/blob/1.3-stable/deluge/ui/console/commands/info.py#L46
# https://deluge.readthedocs.io/en/latest/reference/api.html?highlight=rpc