1import argparse 2import os 3from datetime import timedelta 4 5import flexget.components.series.utils 6from flexget import options 7from flexget.event import event 8from flexget.manager import Session 9from flexget.terminal import TerminalTable, colorize, console, table_parser 10 11from . import db 12 13# Environment variables to set defaults for `series list` and `series show` 14ENV_SHOW_SORTBY_FIELD = 'FLEXGET_SERIES_SHOW_SORTBY_FIELD' 15ENV_SHOW_SORTBY_ORDER = 'FLEXGET_SERIES_SHOW_SORTBY_ORDER' 16ENV_LIST_CONFIGURED = 'FLEXGET_SERIES_LIST_CONFIGURED' 17ENV_LIST_PREMIERES = 'FLEXGET_SERIES_LIST_PREMIERES' 18ENV_LIST_SORTBY_FIELD = 'FLEXGET_SERIES_LIST_SORTBY_FIELD' 19ENV_LIST_SORTBY_ORDER = 'FLEXGET_SERIES_LIST_SORTBY_ORDER' 20ENV_ADD_QUALITY = 'FLEXGET_SERIES_ADD_QUALITY' 21# Colors for console output 22SORT_COLUMN_COLOR = 'yellow' 23NEW_EP_COLOR = 'green' 24FRESH_EP_COLOR = 'yellow' 25OLD_EP_COLOR = 'default' 26BEHIND_EP_COLOR = 'red' 27UNDOWNLOADED_RELEASE_COLOR = 'default' 28DOWNLOADED_RELEASE_COLOR = 'green' 29ERROR_COLOR = 'red' 30 31 32def do_cli(manager, options): 33 if hasattr(options, 'table_type') and options.table_type == 'porcelain': 34 flexget.terminal.disable_colors() 35 if options.series_action == 'list': 36 display_summary(options) 37 elif options.series_action == 'show': 38 display_details(options) 39 elif options.series_action == 'remove': 40 remove(manager, options) 41 elif options.series_action == 'forget': 42 remove(manager, options, forget=True) 43 elif options.series_action == 'begin': 44 begin(manager, options) 45 elif options.series_action == 'add': 46 add(manager, options) 47 48 49def display_summary(options): 50 """ 51 Display series summary. 52 :param options: argparse options from the CLI 53 """ 54 porcelain = options.table_type == 'porcelain' 55 configured = options.configured or os.environ.get(ENV_LIST_CONFIGURED, 'configured') 56 premieres = ( 57 True if (os.environ.get(ENV_LIST_PREMIERES) == 'yes' or options.premieres) else False 58 ) 59 sort_by = options.sort_by or os.environ.get(ENV_LIST_SORTBY_FIELD, 'name') 60 if options.order is not None: 61 descending = True if options.order == 'desc' else False 62 else: 63 descending = True if os.environ.get(ENV_LIST_SORTBY_ORDER) == 'desc' else False 64 with Session() as session: 65 kwargs = { 66 'configured': configured, 67 'premieres': premieres, 68 'session': session, 69 'sort_by': sort_by, 70 'descending': descending, 71 } 72 if sort_by == 'name': 73 kwargs['sort_by'] = 'show_name' 74 else: 75 kwargs['sort_by'] = 'last_download_date' 76 77 query = db.get_series_summary(**kwargs) 78 header = ['Name', 'Begin', 'Last Encountered', 'Age', 'Downloaded', 'Identified By'] 79 for index, value in enumerate(header): 80 if value.lower() == options.sort_by: 81 header[index] = colorize(SORT_COLUMN_COLOR, value) 82 table = TerminalTable(*header, table_type=options.table_type) 83 84 for series in query: 85 name_column = series.name 86 87 behind = (0,) 88 begin = series.begin.identifier if series.begin else '-' 89 latest_release = '-' 90 age_col = '-' 91 episode_id = '-' 92 latest = db.get_latest_release(series) 93 identifier_type = series.identified_by 94 if identifier_type == 'auto': 95 identifier_type = colorize('yellow', 'auto') 96 if latest: 97 behind = db.new_entities_after(latest) 98 latest_release = get_latest_status(latest) 99 # colorize age 100 age_col = latest.age 101 if latest.age_timedelta is not None: 102 if latest.age_timedelta < timedelta(days=1): 103 age_col = colorize(NEW_EP_COLOR, latest.age) 104 elif latest.age_timedelta < timedelta(days=3): 105 age_col = colorize(FRESH_EP_COLOR, latest.age) 106 elif latest.age_timedelta > timedelta(days=400): 107 age_col = colorize(OLD_EP_COLOR, latest.age) 108 episode_id = latest.identifier 109 if not porcelain: 110 if behind[0] > 0: 111 name_column += colorize( 112 BEHIND_EP_COLOR, ' {} {} behind'.format(behind[0], behind[1]) 113 ) 114 115 table.add_row(name_column, begin, episode_id, age_col, latest_release, identifier_type) 116 console(table) 117 if not porcelain: 118 if not query.count(): 119 console('Use `flexget series list all` to view all known series.') 120 else: 121 console('Use `flexget series show NAME` to get detailed information.') 122 123 124def begin(manager, options): 125 series_name = options.series_name 126 series_name = series_name.replace(r'\!', '!') 127 normalized_name = flexget.components.series.utils.normalize_series_name(series_name) 128 with Session() as session: 129 series = db.shows_by_exact_name(normalized_name, session) 130 if options.forget: 131 if not series: 132 console('Series `%s` was not found in the database.' % series_name) 133 else: 134 series = series[0] 135 series.begin = None 136 console('The begin episode for `%s` has been forgotten.' % series.name) 137 session.commit() 138 manager.config_changed() 139 elif options.episode_id: 140 ep_id = options.episode_id 141 if not series: 142 console('Series not yet in database. Adding `%s`.' % series_name) 143 series = db.Series() 144 series.name = series_name 145 session.add(series) 146 else: 147 series = series[0] 148 try: 149 _, entity_type = db.set_series_begin(series, ep_id) 150 except ValueError as e: 151 console(e) 152 else: 153 if entity_type == 'season': 154 console('`%s` was identified as a season.' % ep_id) 155 ep_id += 'E01' 156 console( 157 'Releases for `%s` will be accepted starting with `%s`.' % (series.name, ep_id) 158 ) 159 session.commit() 160 manager.config_changed() 161 162 163def remove(manager, options, forget=False): 164 name = options.series_name 165 if options.episode_id: 166 for identifier in options.episode_id: 167 try: 168 db.remove_series_entity(name, identifier, forget) 169 except ValueError as e: 170 console(e.args[0]) 171 else: 172 console( 173 'Removed entities(s) matching `%s` from series `%s`.' 174 % (identifier, name.capitalize()) 175 ) 176 else: 177 # remove whole series 178 try: 179 db.remove_series(name, forget) 180 except ValueError as e: 181 console(e.args[0]) 182 else: 183 console('Removed series `%s` from database.' % name.capitalize()) 184 185 manager.config_changed() 186 187 188def get_latest_status(episode): 189 """ 190 :param episode: Instance of Episode 191 :return: Status string for given episode 192 """ 193 status = '' 194 for release in sorted(episode.releases, key=lambda r: r.quality): 195 if not release.downloaded: 196 continue 197 status += release.quality.name 198 if release.proper_count > 0: 199 status += '-proper' 200 if release.proper_count > 1: 201 status += str(release.proper_count) 202 status += ', ' 203 return status.rstrip(', ') if status else None 204 205 206def display_details(options): 207 """Display detailed series information, ie. series show NAME""" 208 name = options.series_name 209 sort_by = options.sort_by or os.environ.get(ENV_SHOW_SORTBY_FIELD, 'age') 210 if options.order is not None: 211 reverse = True if options.order == 'desc' else False 212 else: 213 reverse = True if os.environ.get(ENV_SHOW_SORTBY_ORDER) == 'desc' else False 214 with Session() as session: 215 name = flexget.components.series.utils.normalize_series_name(name) 216 # Sort by length of name, so that partial matches always show shortest matching title 217 matches = db.shows_by_name(name, session=session) 218 if not matches: 219 console(colorize(ERROR_COLOR, 'ERROR: Unknown series `%s`' % name)) 220 return 221 # Pick the best matching series 222 series = matches[0] 223 table_title = series.name 224 if len(matches) > 1: 225 warning = ( 226 colorize('red', ' WARNING: ') + 'Multiple series match to `{}`.\n ' 227 'Be more specific to see the results of other matches:\n\n' 228 ' {}'.format(name, ', '.join(s.name for s in matches[1:])) 229 ) 230 if not options.table_type == 'porcelain': 231 console(warning) 232 header = ['Identifier', 'Last seen', 'Release titles', 'Quality', 'Proper'] 233 table_data = [] 234 entities = db.get_all_entities(series, session=session, sort_by=sort_by, reverse=reverse) 235 for entity in entities: 236 if not entity.releases: 237 continue 238 if entity.identifier is None: 239 identifier = colorize(ERROR_COLOR, 'MISSING') 240 age = '' 241 else: 242 identifier = entity.identifier 243 age = entity.age 244 entity_data = [identifier, age] 245 release_titles = [] 246 release_qualities = [] 247 release_propers = [] 248 for release in entity.releases: 249 title = release.title 250 quality = release.quality.name 251 if not release.downloaded: 252 title = colorize(UNDOWNLOADED_RELEASE_COLOR, title) 253 quality = quality 254 else: 255 title += ' *' 256 title = colorize(DOWNLOADED_RELEASE_COLOR, title) 257 quality = quality 258 release_titles.append(title) 259 release_qualities.append(quality) 260 release_propers.append('Yes' if release.proper_count > 0 else '') 261 entity_data.append('\n'.join(release_titles)) 262 entity_data.append('\n'.join(release_qualities)) 263 entity_data.append('\n'.join(release_propers)) 264 table_data.append(entity_data) 265 footer = ' %s \n' % (colorize(DOWNLOADED_RELEASE_COLOR, '* Downloaded')) 266 if not series.identified_by: 267 footer += ( 268 '\n Series plugin is still learning which episode numbering mode is \n' 269 ' correct for this series (identified_by: auto).\n' 270 ' Few duplicate downloads can happen with different numbering schemes\n' 271 ' during this time.' 272 ) 273 else: 274 footer += '\n `%s` uses `%s` mode to identify episode numbering.' % ( 275 series.name, 276 series.identified_by, 277 ) 278 begin_text = 'option' 279 if series.begin: 280 footer += ' \n Begin for `%s` is set to `%s`.' % (series.name, series.begin.identifier) 281 begin_text = 'and `begin` options' 282 footer += ' \n See `identified_by` %s for more information.' % begin_text 283 table = TerminalTable(*header, table_type=options.table_type, title=table_title) 284 for row in table_data: 285 table.add_row(*row) 286 console(table) 287 if not options.table_type == 'porcelain': 288 console(footer) 289 290 291def add(manager, options): 292 series_name = options.series_name 293 entity_ids = options.entity_id 294 quality = options.quality or os.environ.get(ENV_ADD_QUALITY, None) 295 series_name = series_name.replace(r'\!', '!') 296 normalized_name = flexget.components.series.utils.normalize_series_name(series_name) 297 with Session() as session: 298 series = db.shows_by_exact_name(normalized_name, session) 299 if not series: 300 console('Series not yet in database, adding `%s`' % series_name) 301 series = db.Series() 302 series.name = series_name 303 session.add(series) 304 else: 305 series = series[0] 306 for ent_id in entity_ids: 307 try: 308 db.add_series_entity(session, series, ent_id, quality=quality) 309 except ValueError as e: 310 console(e.args[0]) 311 else: 312 console('Added entity `%s` to series `%s`.' % (ent_id, series.name.title())) 313 session.commit() 314 manager.config_changed() 315 316 317@event('options.register') 318def register_parser_arguments(): 319 # Register the command 320 parser = options.register_command( 321 'series', do_cli, help='View and manipulate the series plugin database' 322 ) 323 324 # Parent parser for subcommands that need a series name 325 series_parser = argparse.ArgumentParser(add_help=False) 326 series_parser.add_argument( 327 'series_name', help='The name of the series', metavar='<series name>' 328 ) 329 330 # Set up our subparsers 331 subparsers = parser.add_subparsers(title='actions', metavar='<action>', dest='series_action') 332 list_parser = subparsers.add_parser( 333 'list', parents=[table_parser], help='List a summary of the different series being tracked' 334 ) 335 list_parser.add_argument( 336 'configured', 337 nargs='?', 338 choices=['configured', 'unconfigured', 'all'], 339 help='Limit list to series that are currently in the config or not (default: %(default)s)', 340 ) 341 list_parser.add_argument( 342 '--premieres', 343 action='store_true', 344 help='limit list to series which only have episode 1 (and maybe also 2) downloaded', 345 ) 346 list_parser.add_argument( 347 '--sort-by', choices=('name', 'age'), help='Choose list sort attribute' 348 ) 349 order = list_parser.add_mutually_exclusive_group(required=False) 350 order.add_argument( 351 '--descending', 352 dest='order', 353 action='store_const', 354 const='desc', 355 help='Sort in descending order', 356 ) 357 order.add_argument( 358 '--ascending', 359 dest='order', 360 action='store_const', 361 const='asc', 362 help='Sort in ascending order', 363 ) 364 365 show_parser = subparsers.add_parser( 366 'show', 367 parents=[series_parser, table_parser], 368 help='Show the releases FlexGet has seen for a given series', 369 ) 370 show_parser.add_argument( 371 '--sort-by', 372 choices=('age', 'identifier'), 373 help='Choose releases list sort: age (default) or identifier', 374 ) 375 show_order = show_parser.add_mutually_exclusive_group(required=False) 376 show_order.add_argument( 377 '--descending', 378 dest='order', 379 action='store_const', 380 const='desc', 381 help='Sort in descending order', 382 ) 383 show_order.add_argument( 384 '--ascending', 385 dest='order', 386 action='store_const', 387 const='asc', 388 help='Sort in ascending order', 389 ) 390 391 begin_parser = subparsers.add_parser( 392 'begin', parents=[series_parser], help='set the episode to start getting a series from' 393 ) 394 begin_opts = begin_parser.add_mutually_exclusive_group(required=True) 395 begin_opts.add_argument( 396 '--forget', dest='forget', action='store_true', help='Forget begin episode', required=False 397 ) 398 begin_opts.add_argument( 399 'episode_id', 400 metavar='<episode ID>', 401 help='Episode ID to start getting the series from (e.g. S02E01, 2013-12-11, or 9, ' 402 'depending on how the series is numbered). You can also enter a season ID such as ' 403 ' S02.', 404 nargs='?', 405 default='', 406 ) 407 408 addshow_parser = subparsers.add_parser( 409 'add', parents=[series_parser], help='Add episode(s) and season(s) to series history' 410 ) 411 addshow_parser.add_argument( 412 'entity_id', 413 nargs='+', 414 default=None, 415 metavar='<entity_id>', 416 help='Episode or season entity ID(s) to add', 417 ) 418 addshow_parser.add_argument( 419 '--quality', 420 default=None, 421 metavar='<quality>', 422 help='Quality string to be stored for all entity ID(s)', 423 ) 424 425 forget_parser = subparsers.add_parser( 426 'forget', 427 parents=[series_parser], 428 help='Removes episodes or whole series from the entire database ' 429 '(including seen plugin)', 430 ) 431 forget_parser.add_argument( 432 'episode_id', nargs='*', default=None, help='Entity ID(s) to forget (optional)' 433 ) 434 delete_parser = subparsers.add_parser( 435 'remove', 436 parents=[series_parser], 437 help='Removes episodes or whole series from the series database only', 438 ) 439 delete_parser.add_argument( 440 'episode_id', nargs='*', default=None, help='Episode ID to forget (optional)' 441 ) 442