Web app for quickly sorting deluge torrents into a library
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

220 lines
7.4 KiB

  1. import os
  2. import logging
  3. import cherrypy
  4. from time import sleep
  5. from queue import Queue
  6. from pprint import pprint
  7. from threading import Thread
  8. from urllib.parse import urlparse
  9. from dataclasses import dataclass, field
  10. from deluge_client import DelugeRPCClient
  11. from jinja2 import Environment, FileSystemLoader, select_autoescape
  12. from mediaweb import shows
  13. APPROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../"))
  14. @dataclass
  15. class Cache:
  16. torrents: dict = field(default_factory=dict)
  17. shows: dict = field(default_factory=dict)
  18. class ClientCache(object):
  19. def __init__(self, client, libpath):
  20. self.client = client
  21. self.data = Cache()
  22. self.q = Queue()
  23. self.inflight = False
  24. self.libpath = libpath
  25. self.background_t = Thread(target=self.background, daemon=True)
  26. self.background_t.start()
  27. self.timer_t = Thread(target=self.timer, daemon=True)
  28. self.timer_t.start()
  29. def refresh(self):
  30. if not self.inflight and self.q.qsize() == 0: # best effort duplicate work reduction
  31. self.q.put(None)
  32. def background(self):
  33. while True:
  34. self.q.get() # block until we need to do something
  35. self.inflight = True
  36. logging.info("performing background tasks...")
  37. self.build_showindex()
  38. self.build_torrentindex()
  39. self.q.task_done()
  40. self.inflight = False
  41. logging.info("background tasks complete")
  42. def timer(self):
  43. while True:
  44. self.refresh()
  45. logging.info("sleeping...")
  46. sleep(300) # TODO configurable task interval
  47. def build_torrentindex(self):
  48. logging.info("refreshing torrents")
  49. self.data.torrents = self.client.core.get_torrents_status({"label": "sickrage"},
  50. ['name', 'label', 'save_path', 'is_seed',
  51. 'is_finished', 'progress'])
  52. def build_showindex(self):
  53. logging.info("updating show index")
  54. data = shows.create_index([self.libpath])
  55. self.data.shows = sorted(data, key=lambda x: x.name)
  56. class MediaWeb(object):
  57. def __init__(self, rpc, templater, uioptions):
  58. self.tpl = templater
  59. self.rpc = rpc
  60. self.uioptions = uioptions
  61. def render(self, template, **kwargs):
  62. """
  63. Render a template
  64. """
  65. return self.tpl.get_template(template).render(**kwargs,
  66. options=self.uioptions,
  67. torrents=self.rpc.data.torrents,
  68. shows=self.rpc.data.shows,
  69. **self.get_default_vars())
  70. def get_default_vars(self):
  71. return {}
  72. @cherrypy.expose
  73. def index(self, action=None):
  74. if action:
  75. if action == "update":
  76. self.rpc.refresh()
  77. raise cherrypy.HTTPRedirect("/")
  78. return self.render("index.html")
  79. @cherrypy.expose
  80. def move(self, thash, dest=None, otherdest=None):
  81. torrent = self.rpc.client.core.get_torrent_status(thash, []) # TODO reduce to needed fields
  82. if cherrypy.request.method == "POST" and (dest or otherdest):
  83. target = otherdest or dest
  84. self.rpc.client.core.move_storage([thash], target)
  85. self.rpc.refresh()
  86. raise cherrypy.HTTPRedirect("/")
  87. return self.render("moveform.html", torrent=torrent)
  88. @cherrypy.expose
  89. def sort(self, thash, dest=None):
  90. torrent = self.rpc.client.core.get_torrent_status(thash, []) # TODO reduce to needed fields
  91. # find the actual file among the torrent's files
  92. # really we just pick the biggest one
  93. finfo = None
  94. fsize = 0
  95. for tfile in torrent["files"]:
  96. if tfile["size"] > fsize:
  97. finfo = tfile
  98. fname = finfo["path"]
  99. matches = shows.match_episode(fname, self.rpc.data.shows)
  100. if cherrypy.request.method == "POST" and dest:
  101. thematch = None
  102. for m in matches:
  103. if m.dest.dir == dest:
  104. thematch = m
  105. break
  106. return f"sort {fname} into {thematch}"
  107. return self.render("sortform.html", torrent=torrent, matches=matches)
  108. def main():
  109. import argparse
  110. import signal
  111. parser = argparse.ArgumentParser(description="mediaweb server")
  112. parser.add_argument('-p', '--port', help="tcp port to listen on",
  113. default=int(os.environ.get("MEDIAWEB_PORT", 8080)), type=int)
  114. parser.add_argument('-o', '--library', default=os.environ.get("STORAGE_URL"), help="media library path")
  115. parser.add_argument('--debug', action="store_true", help="enable development options")
  116. parser.add_argument('-s', '--server', help="deluge uris", action="append", required=True)
  117. parser.add_argument('--ui-movedests', help="move destination options in the UI", nargs="+", required=True)
  118. args = parser.parse_args()
  119. uioptions = {
  120. "movedests": args.ui_movedests,
  121. }
  122. # TODO smarter argparser that understands env vars
  123. if not args.library:
  124. parser.error("--library or MEDIAWEB_DLDIR is required")
  125. logging.basicConfig(level=logging.INFO if args.debug else logging.WARNING,
  126. format="%(asctime)-15s %(levelname)-8s %(filename)s:%(lineno)d %(message)s")
  127. tpl_dir = os.path.join(APPROOT, "templates")
  128. tpl = Environment(loader=FileSystemLoader(tpl_dir),
  129. autoescape=select_autoescape(['html', 'xml']))
  130. # self.tpl.filters.update(basename=os.path.basename,
  131. # ceil=math.ceil
  132. def validate_password(realm, user, passw):
  133. return user == passw # lol
  134. # assume 1 deluge server for now
  135. uri = urlparse(args.server[0])
  136. assert uri.scheme == "deluge"
  137. rpc = DelugeRPCClient(uri.hostname, uri.port if uri.port else 58846, uri.username, uri.password, decode_utf8=True)
  138. rpc_cache = ClientCache(rpc, args.library)
  139. web = MediaWeb(rpc_cache, tpl, uioptions)
  140. cherrypy.tree.mount(web, '/', {'/': {'tools.auth_basic.on': True,
  141. 'tools.auth_basic.realm': 'mediaweb',
  142. 'tools.auth_basic.checkpassword': validate_password, }})
  143. # General config options
  144. cherrypy.config.update({
  145. 'tools.sessions.on': False,
  146. 'request.show_tracebacks': True,
  147. 'server.show_tracebacks': True,
  148. 'server.socket_port': args.port,
  149. 'server.thread_pool': 1 if args.debug else 5,
  150. 'server.socket_host': '0.0.0.0',
  151. 'log.screen': False,
  152. 'engine.autoreload.on': args.debug
  153. })
  154. # Setup signal handling and run it.
  155. def signal_handler(signum, stack):
  156. logging.critical('Got sig {}, exiting...'.format(signum))
  157. cherrypy.engine.exit()
  158. signal.signal(signal.SIGINT, signal_handler)
  159. signal.signal(signal.SIGTERM, signal_handler)
  160. try:
  161. cherrypy.engine.start()
  162. cherrypy.engine.block()
  163. finally:
  164. logging.info("API has shut down")
  165. cherrypy.engine.exit()
  166. if __name__ == '__main__':
  167. main()
  168. # https://github.com/deluge-torrent/deluge/blob/1.3-stable/deluge/ui/console/commands/info.py#L46
  169. # https://deluge.readthedocs.io/en/latest/reference/api.html?highlight=rpc