interfaces.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import datetime
  2. import logging
  3. import os
  4. import sys
  5. import threading
  6. import time
  7. from argparse import ArgumentParser
  8. from io import UnsupportedOperation
  9. from queue import Queue, Empty as QueueEmpty
  10. # This seems like a waste of an import
  11. from .constants import TOKYO_TZ, HHMM_FMT
  12. from .control import ShowroomLiveControllerThread as ShowroomController
  13. from .exceptions import ShowroomStopRequest
  14. from .index import ShowroomIndex
  15. from .settings import ShowroomSettings, DEFAULTS
  16. # build settings and index objects from arguments
  17. # build controller
  18. # start controller
  19. # translate command line instructions to controller commands
  20. cli_logger = logging.getLogger('showroom.cli')
  21. class BasicCLI(object):
  22. @staticmethod
  23. def build_parser():
  24. parser = ArgumentParser(description="Watches Showroom for live videos and downloads them \
  25. when they become available. Most options only apply in --all mode",
  26. epilog="The max-* options, parser, index, and output-dir haven't been \
  27. fully tested yet. A new indexing system is currently in use, but \
  28. no command-line arguments to control it yet exist.")
  29. parser.add_argument('names', nargs='*',
  30. help='A quoted Member Name to watch. Accepts a list of names, separated by spaces. '
  31. 'Currently, the Member Name must match the English Name (engName) key exactly.')
  32. parser.add_argument('--all', '-a', action='store_true', dest='record_all',
  33. help='Watch the main showroom page for live shows and record all of them.')
  34. parser.add_argument('--output-dir', '-o',
  35. help='Directory in which to store active and completed downloads. \
  36. Defaults to "{directory[output]}"'.format(**DEFAULTS))
  37. parser.add_argument('--config', help="Path to config file")
  38. parser.add_argument('--data-dir', '-d',
  39. help='Data directory. Defaults to "{directory[data]}"'.format(**DEFAULTS))
  40. parser.add_argument('--index', '-i', dest="index_dir",
  41. help='Path to an index directory, containing room information in json files \
  42. with a jdex extension. Defaults to "{directory[index]}"'.format(**DEFAULTS))
  43. parser.add_argument('--max-downloads', '-D', type=int,
  44. help='Maximum number of concurrent downloads. \
  45. Defaults to {throttle[max][downloads]}'.format(**DEFAULTS))
  46. parser.add_argument('--max-watches', '-W', type=int,
  47. help='Maximum number of rooms to watch at once (waiting for them to go live). \
  48. Defaults to {throttle[max][watches]}'.format(**DEFAULTS))
  49. parser.add_argument('--max-priority', '-P', type=int,
  50. help='Any members with priority over this value will be ignored. \
  51. Defaults to {throttle[max][priority]}'.format(**DEFAULTS))
  52. parser.add_argument('--live-rate', '-R', dest="onlives_rate", type=float,
  53. help='Seconds between each poll of ONLIVES. \
  54. Defaults to {throttle[rate][onlives]}'.format(**DEFAULTS))
  55. parser.add_argument('--schedule-rate', '-S', dest="upcoming_rate", type=float,
  56. help='Seconds between each check of the schedule. \
  57. Defaults to {throttle[rate][upcoming]}'.format(**DEFAULTS))
  58. # conflicts with config
  59. # parser.add_argument('--comments', dest='comments', action='store_true')
  60. '''
  61. # TODO: Allow the user to provide a schedule with different start and end hours per day.
  62. # Or else instead of stopping entirely, slow down polling during off hours.
  63. parser.add_argument('--end_hour', default=END_HOUR, type=int,
  64. help='Hour to stop recording (will actually stop 10 minutes later). \
  65. Defaults to %(default)s')
  66. parser.add_argument('--resume_hour', default=RESUME_HOUR, type=int,
  67. help='Hour to resume recording (will actually start 10 minutes earlier). \
  68. Defaults to %(default)s')
  69. '''
  70. # TODO: handle names in arg parser
  71. parser.add_argument('--logging', action='store_true', help="Turns on ffmpeg logging.")
  72. parser.add_argument('--noisy', action='store_true', help="Print download links when downloads start")
  73. return parser
  74. # TODO: MessageHandler class that parses a message object and returns the desired string
  75. # based on the stored query
  76. @staticmethod
  77. def _parse_index_filter_list(filter_list):
  78. if len(filter_list['unwanted']) == 0:
  79. return "Downloading all rooms."
  80. elif len(filter_list['wanted']) == 0:
  81. return "Not downloading any rooms."
  82. elif len(filter_list['wanted']) > len(filter_list['unwanted']):
  83. # TODO: word wrap?
  84. names = ', '.join(filter_list['unwanted'])
  85. return "Not downloading the following rooms:\n{}".format(names)
  86. else:
  87. names = ', '.join(filter_list['wanted'])
  88. return "Downloading the following rooms:\n{}".format(names)
  89. # TODO: have these return a single string instead of printing directly
  90. @staticmethod
  91. def _parse_scheduled_rooms(scheduled):
  92. def print_status(item):
  93. if item['mode'] in ('live', 'download'):
  94. return " (LIVE)"
  95. else:
  96. return ""
  97. output = ["{start} {group} {name}{status}".format(start=e['start_time'].strftime(HHMM_FMT),
  98. group=e['room']['group'],
  99. name=e['room']['name'],
  100. status=print_status(e))
  101. for e in scheduled]
  102. print('----------\n{} Scheduled Rooms:'.format(len(output)))
  103. print(*output, sep='\n')
  104. print()
  105. @staticmethod
  106. def _parse_live_rooms(lives):
  107. def print_status(item):
  108. if item['mode'] == 'download':
  109. return " (DOWNLOADING)"
  110. else:
  111. return ""
  112. output = ["{start} {group} {name}{status}".format(start=e['start_time'].strftime(HHMM_FMT),
  113. group=e['room']['group'],
  114. name=e['room']['name'],
  115. status=print_status(e))
  116. for e in lives]
  117. print('----------\n{} LIVE ROOMS:'.format(len(output)))
  118. print(*output, sep='\n')
  119. print()
  120. @staticmethod
  121. def _parse_download_rooms(downloads):
  122. output = ["{start} {group} {name}\n".format(start=e['start_time'].strftime(HHMM_FMT),
  123. group=e['room']['group'],
  124. name=e['room']['name'])
  125. for e in downloads]
  126. print('----------\n{} Downloading Rooms:'.format(len(output)))
  127. print(*output, sep='\n')
  128. print()
  129. @staticmethod
  130. def _parse_download_links(downloads):
  131. def print_status(item):
  132. if item['mode'] == 'download':
  133. return ""
  134. else:
  135. return " (not downloading)"
  136. output = ["{start} {group} {name}{status}\n"
  137. "{web_url}\n{rtmp_url}".format(start=e['start_time'].strftime(HHMM_FMT),
  138. group=e['room']['group'],
  139. name=e['room']['name'],
  140. status=print_status(e),
  141. web_url=e['room']['web_url'],
  142. rtmp_url=e['download']['streaming_urls'])
  143. for e in downloads]
  144. print('----------\nDOWNLOAD LINKS:')
  145. print(*output, sep='\n')
  146. print()
  147. def __init__(self):
  148. args = self.build_parser().parse_args()
  149. if args:
  150. self.settings = ShowroomSettings.from_args(args)
  151. else:
  152. self.settings = ShowroomSettings()
  153. os.environ.update(self.settings.environment)
  154. # does this work? what is it relative to?
  155. self.index = ShowroomIndex(self.settings.directory.index, record_all=self.settings.filter.all)
  156. # DEBUG
  157. cli_logger.debug('Index has {} rooms'.format(len(self.index)))
  158. self.control_thread = ShowroomController(self.index, self.settings)
  159. self.input_queue = InputQueue()
  160. if args.record_all:
  161. self.control_thread.index.filter_all()
  162. else:
  163. self.control_thread.index.filter_add(args.names)
  164. self.control_thread.index.filter_add(self.settings.filter.wanted)
  165. self._time = datetime.datetime.fromtimestamp(0.0, tz=TOKYO_TZ)
  166. # TODO: This needs to be revised
  167. self.query_dict = {"index_filter_list": self._parse_index_filter_list,
  168. "schedule": self._parse_scheduled_rooms,
  169. "lives": self._parse_live_rooms,
  170. "downloads": self._parse_download_rooms,
  171. "downloads_links": self._parse_download_links}
  172. def start(self):
  173. print('Starting up Showroom Watcher...')
  174. self.input_queue.start()
  175. self.control_thread.start()
  176. # Is this the best place to put this message?
  177. def run(self):
  178. """Do stuff."""
  179. while True:
  180. try:
  181. self.read_commands()
  182. except ShowroomStopRequest:
  183. print("Exiting...")
  184. return
  185. # Automatic hourly schedule updates
  186. # curr_time = datetime.datetime.now(tz=TOKYO_TZ)
  187. # if (curr_time - self._time).total_seconds() > 3600.0:
  188. # self._time = curr_time
  189. # print(curr_time.strftime("\n\n%H:%M"))
  190. # self.control_thread.send_command('schedule')
  191. time.sleep(0.2)
  192. self.get_messages()
  193. def read_commands(self):
  194. while not self.input_queue.empty():
  195. try:
  196. line = self.input_queue.get(block=False)
  197. except QueueEmpty:
  198. break
  199. else:
  200. self.parse_command(line)
  201. # TODO: CommandHandler class?
  202. def parse_command(self, line):
  203. # here we take every allowed command and try to translate it to a call on the control_thread
  204. # we need to construct a language though...
  205. # set and get are obvious
  206. # todo: more robust translation
  207. ct = self.control_thread
  208. send = ct.send_command
  209. line = line.lower()
  210. if line.startswith('index'):
  211. if 'index filter' in line:
  212. if "filter all" in line:
  213. send('index_filter', "all")
  214. elif "filter none" in line:
  215. send('index_filter', "none")
  216. elif "filter add" in line:
  217. names = line.split('filter add')[-1].strip()
  218. split_names = [e.strip() for e in names.split(',')]
  219. send('index_filter', add=split_names)
  220. print("Turning on downloads for the following rooms:\n" + ', '.join(names).title())
  221. elif "filter remove" in line:
  222. names = line.split('filter remove')[-1].strip()
  223. split_names = [e.strip() for e in names.split(',')]
  224. send('index_filter', remove=split_names)
  225. # TODO: print a log info message when this actually gets done,
  226. # as chances are the results won't be 100% exactly what's printed here
  227. print("Turning off downloads for the following rooms:\n" + ', '.join(names).title())
  228. elif 'index update' in line:
  229. if "update from web" in line:
  230. send('index_update', src="web")
  231. else:
  232. send('index_update')
  233. # TODO: other set commands
  234. elif line.startswith("get"):
  235. if 'get index filter' in line:
  236. send('index_filter')
  237. elif 'get schedule' in line:
  238. send('schedule')
  239. elif 'get live' in line:
  240. send('lives')
  241. elif 'get download' in line:
  242. send('downloads')
  243. elif 'get links' in line:
  244. # i want the same content but in a different format, what's the right way to do this?
  245. send('downloads_links')
  246. elif line.startswith('schedule'):
  247. send('schedule')
  248. elif line.startswith('live'):
  249. send('lives')
  250. elif line.startswith('download'):
  251. if 'links' in line:
  252. send('downloads_links')
  253. else:
  254. send('downloads')
  255. elif line.startswith('links'):
  256. send('downloads_links')
  257. elif line.strip() == 'help':
  258. print("""
  259. The following commands are recognised:
  260. schedule -- prints a schedule
  261. live -- prints currently live rooms
  262. downloads -- prints current downloads
  263. links -- prints live rooms with links (and full JSON streaming data)
  264. quit -- stop activity and exit
  265. help -- this text
  266. """)
  267. print('\nNOTE: The "links" command has very noisy and unhelpful output at this time.',
  268. 'Also, sometimes commands are ignored. Wait a bit and try again.', sep='\n')
  269. # NOT IMPLEMENTED
  270. # not sure if the index stuff is implemented or not. `get index filter` doesn't work, at least
  271. """
  272. index update from web -- update the index from github (NOT IMPLEMENTED)
  273. index filter all -- selects all rooms for downloading
  274. index filter none -- selects no rooms for downloading
  275. index filter add name1, name2, name3...
  276. index filter remove name1, name2, name3...
  277. -- add or remove rooms from the download list
  278. -- name must match exactly (case insensitive)
  279. index update -- locally update the index
  280. get index filter -- returns info about the filter
  281. """
  282. # No idea if these work, but I know I haven't tested them thoroughly and they're too dangerous.
  283. """
  284. stop -- stop activity (program will continue running)
  285. start -- restart activity
  286. """
  287. # TODO: test these
  288. elif line.strip() == 'stop':
  289. "--stop--"
  290. ct.stop()
  291. ct.join()
  292. print('Stopped')
  293. elif line.strip() == 'start':
  294. "--start--"
  295. ct.start()
  296. print('Started')
  297. elif line.strip() == 'quit':
  298. "--quit--"
  299. print('Quitting...')
  300. ct.stop()
  301. self.input_queue.stop()
  302. ct.join()
  303. raise ShowroomStopRequest
  304. def get_messages(self):
  305. messages = self.control_thread.get_messages()
  306. for msg in messages:
  307. self.parse_message(msg)
  308. def parse_message(self, msg):
  309. query = msg.query
  310. message = msg.content
  311. if query in self.query_dict:
  312. text = self.query_dict[query](message)
  313. if text:
  314. print(text)
  315. class InputQueue(Queue):
  316. def __init__(self):
  317. super().__init__()
  318. self.STDIN = None
  319. self.input_thread = None
  320. def read_commands(self):
  321. while True:
  322. try:
  323. # DEBUG
  324. # print('waiting for line')
  325. line = self.STDIN.readline()
  326. # DEBUG
  327. # print('read line')
  328. except ValueError:
  329. # tried to read from a closed STDIN
  330. return
  331. if line:
  332. self.put(line)
  333. time.sleep(0.1)
  334. def start(self):
  335. # make an alias of stdin so that we can close it later
  336. # TODO: allow taking input from other sources?
  337. try:
  338. fileno = sys.stdin.fileno()
  339. except UnsupportedOperation:
  340. # trying to run this from idle?
  341. raise
  342. if fileno is not None:
  343. self.STDIN = os.fdopen(os.dup(fileno))
  344. else:
  345. self.STDIN = None # this is a failure state!
  346. self.input_thread = threading.Thread(target=self.read_commands)
  347. self.input_thread.daemon = True
  348. self.input_thread.start()
  349. print('\nType "help" for a list of commands.')
  350. def stop(self):
  351. # the alternative is sending SIGKILL or something
  352. if self.STDIN:
  353. self.STDIN.close()
  354. self.input_thread.join()
  355. self.STDIN = None
  356. self.input_thread = None
  357. # clear the queue?