import argparse
import os
from datetime import timedelta
import flexget.components.series.utils
from flexget import options
from flexget.event import event
from flexget.manager import Session
from flexget.terminal import TerminalTable, colorize, console, table_parser
from . import db
# Environment variables to set defaults for `series list` and `series show`
ENV_SHOW_SORTBY_FIELD = 'FLEXGET_SERIES_SHOW_SORTBY_FIELD'
ENV_SHOW_SORTBY_ORDER = 'FLEXGET_SERIES_SHOW_SORTBY_ORDER'
ENV_LIST_CONFIGURED = 'FLEXGET_SERIES_LIST_CONFIGURED'
ENV_LIST_PREMIERES = 'FLEXGET_SERIES_LIST_PREMIERES'
ENV_LIST_SORTBY_FIELD = 'FLEXGET_SERIES_LIST_SORTBY_FIELD'
ENV_LIST_SORTBY_ORDER = 'FLEXGET_SERIES_LIST_SORTBY_ORDER'
ENV_ADD_QUALITY = 'FLEXGET_SERIES_ADD_QUALITY'
# Colors for console output
SORT_COLUMN_COLOR = 'yellow'
NEW_EP_COLOR = 'green'
FRESH_EP_COLOR = 'yellow'
OLD_EP_COLOR = 'default'
BEHIND_EP_COLOR = 'red'
UNDOWNLOADED_RELEASE_COLOR = 'default'
DOWNLOADED_RELEASE_COLOR = 'green'
ERROR_COLOR = 'red'
[docs]
def do_cli(manager, options):
if hasattr(options, 'table_type') and options.table_type == 'porcelain':
flexget.terminal.disable_colors()
if options.series_action == 'list':
display_summary(options)
elif options.series_action == 'show':
display_details(options)
elif options.series_action == 'remove':
remove(manager, options)
elif options.series_action == 'forget':
remove(manager, options, forget=True)
elif options.series_action == 'begin':
begin(manager, options)
elif options.series_action == 'add':
add(manager, options)
[docs]
def display_summary(options):
"""Display series summary.
:param options: argparse options from the CLI
"""
porcelain = options.table_type == 'porcelain'
configured = options.configured or os.environ.get(ENV_LIST_CONFIGURED, 'configured')
premieres = bool(os.environ.get(ENV_LIST_PREMIERES) == 'yes' or options.premieres)
sort_by = options.sort_by or os.environ.get(ENV_LIST_SORTBY_FIELD, 'name')
if options.order is not None:
descending = options.order == 'desc'
else:
descending = os.environ.get(ENV_LIST_SORTBY_ORDER) == 'desc'
with Session() as session:
kwargs = {
'configured': configured,
'premieres': premieres,
'session': session,
'sort_by': sort_by,
'descending': descending,
}
if sort_by == 'name':
kwargs['sort_by'] = 'show_name'
else:
kwargs['sort_by'] = 'last_download_date'
query = db.get_series_summary(**kwargs)
header = ['Name', 'Begin', 'Last Encountered', 'Age', 'Downloaded', 'Identified By']
for index, value in enumerate(header):
if value.lower() == options.sort_by:
header[index] = colorize(SORT_COLUMN_COLOR, value)
table = TerminalTable(*header, table_type=options.table_type)
for series in query:
name_column = series.name
behind = (0,)
begin = series.begin.identifier if series.begin else '-'
latest_release = '-'
age_col = '-'
episode_id = '-'
latest = db.get_latest_release(series)
identifier_type = series.identified_by
if identifier_type == 'auto':
identifier_type = colorize('yellow', 'auto')
if latest:
behind = db.new_entities_after(latest)
latest_release = get_latest_status(latest)
# colorize age
age_col = latest.age
if latest.age_timedelta is not None:
if latest.age_timedelta < timedelta(days=1):
age_col = colorize(NEW_EP_COLOR, latest.age)
elif latest.age_timedelta < timedelta(days=3):
age_col = colorize(FRESH_EP_COLOR, latest.age)
elif latest.age_timedelta > timedelta(days=400):
age_col = colorize(OLD_EP_COLOR, latest.age)
episode_id = latest.identifier
if not porcelain and behind[0] > 0:
name_column += colorize(BEHIND_EP_COLOR, f' {behind[0]} {behind[1]} behind')
table.add_row(name_column, begin, episode_id, age_col, latest_release, identifier_type)
console(table)
if not porcelain:
if not query.count():
console('Use `flexget series list all` to view all known series.')
else:
console('Use `flexget series show NAME` to get detailed information.')
[docs]
def begin(manager, options):
series_name = options.series_name
series_name = series_name.replace(r'\!', '!')
normalized_name = flexget.components.series.utils.normalize_series_name(series_name)
with Session() as session:
series = db.shows_by_exact_name(normalized_name, session)
if options.forget:
if not series:
console(f'Series `{series_name}` was not found in the database.')
else:
series = series[0]
series.begin = None
console(f'The begin episode for `{series.name}` has been forgotten.')
session.commit()
manager.config_changed()
elif options.episode_id:
ep_id = options.episode_id
if not series:
console(f'Series not yet in database. Adding `{series_name}`.')
series = db.Series()
series.name = series_name
session.add(series)
else:
series = series[0]
try:
_, entity_type = db.set_series_begin(series, ep_id)
except ValueError as e:
console(e)
else:
if entity_type == 'season':
console(f'`{ep_id}` was identified as a season.')
ep_id += 'E01'
console(f'Releases for `{series.name}` will be accepted starting with `{ep_id}`.')
session.commit()
manager.config_changed()
[docs]
def remove(manager, options, forget=False):
name = options.series_name
if options.episode_id:
for identifier in options.episode_id:
try:
db.remove_series_entity(name, identifier, forget)
except ValueError as e:
console(e.args[0])
else:
console(
f'Removed entities(s) matching `{identifier}` from series `{name.capitalize()}`.'
)
else:
# remove whole series
try:
db.remove_series(name, forget)
except ValueError as e:
console(e.args[0])
else:
console(f'Removed series `{name.capitalize()}` from database.')
manager.config_changed()
[docs]
def get_latest_status(episode):
"""Return status string for given episode.
:param episode: Instance of Episode
"""
status = ''
for release in sorted(episode.releases, key=lambda r: r.quality):
if not release.downloaded:
continue
status += release.quality.name
if release.proper_count > 0:
status += '-proper'
if release.proper_count > 1:
status += str(release.proper_count)
status += ', '
return status.rstrip(', ') if status else None
[docs]
def display_details(options):
"""Display detailed series information, ie. series show NAME."""
name = options.series_name
sort_by = options.sort_by or os.environ.get(ENV_SHOW_SORTBY_FIELD, 'age')
if options.order is not None:
reverse = options.order == 'desc'
else:
reverse = os.environ.get(ENV_SHOW_SORTBY_ORDER) == 'desc'
with Session() as session:
name = flexget.components.series.utils.normalize_series_name(name)
# Sort by length of name, so that partial matches always show shortest matching title
matches = db.shows_by_name(name, session=session)
if not matches:
console(colorize(ERROR_COLOR, f'ERROR: Unknown series `{name}`'))
return
# Pick the best matching series
series = matches[0]
table_title = series.name
if len(matches) > 1:
warning = (
colorize('red', ' WARNING: ')
+ 'Multiple series match to `{}`.\n '
'Be more specific to see the results of other matches:\n\n'
' {}'.format(name, ', '.join(s.name for s in matches[1:]))
)
if options.table_type != 'porcelain':
console(warning)
header = ['Identifier', 'Last seen', 'Release titles', 'Quality', 'Proper']
table_data = []
entities = db.get_all_entities(series, session=session, sort_by=sort_by, reverse=reverse)
for entity in entities:
if not entity.releases:
continue
if entity.identifier is None:
identifier = colorize(ERROR_COLOR, 'MISSING')
age = ''
else:
identifier = entity.identifier
age = entity.age
entity_data = [identifier, age]
release_titles = []
release_qualities = []
release_propers = []
for release in entity.releases:
title = release.title
quality = release.quality.name
if not release.downloaded:
title = colorize(UNDOWNLOADED_RELEASE_COLOR, title)
else:
title += ' *'
title = colorize(DOWNLOADED_RELEASE_COLOR, title)
release_titles.append(title)
release_qualities.append(quality)
release_propers.append('Yes' if release.proper_count > 0 else '')
entity_data.append('\n'.join(release_titles))
entity_data.append('\n'.join(release_qualities))
entity_data.append('\n'.join(release_propers))
table_data.append(entity_data)
footer = ' {} \n'.format(colorize(DOWNLOADED_RELEASE_COLOR, '* Downloaded'))
if not series.identified_by:
footer += (
'\n Series plugin is still learning which episode numbering mode is \n'
' correct for this series (identified_by: auto).\n'
' Few duplicate downloads can happen with different numbering schemes\n'
' during this time.'
)
else:
footer += f'\n `{series.name}` uses `{series.identified_by}` mode to identify episode numbering.'
begin_text = 'option'
if series.begin:
footer += f' \n Begin for `{series.name}` is set to `{series.begin.identifier}`.'
begin_text = 'and `begin` options'
footer += f' \n See `identified_by` {begin_text} for more information.'
table = TerminalTable(*header, table_type=options.table_type, title=table_title)
for row in table_data:
table.add_row(*row)
console(table)
if options.table_type != 'porcelain':
console(footer)
[docs]
def add(manager, options):
series_name = options.series_name
entity_ids = options.entity_id
quality = options.quality or os.environ.get(ENV_ADD_QUALITY, None)
series_name = series_name.replace(r'\!', '!')
normalized_name = flexget.components.series.utils.normalize_series_name(series_name)
with Session() as session:
series = db.shows_by_exact_name(normalized_name, session)
if not series:
console(f'Series not yet in database, adding `{series_name}`')
series = db.Series()
series.name = series_name
session.add(series)
else:
series = series[0]
for ent_id in entity_ids:
try:
db.add_series_entity(session, series, ent_id, quality=quality)
except ValueError as e:
console(e.args[0])
else:
console(f'Added entity `{ent_id}` to series `{series.name.title()}`.')
session.commit()
manager.config_changed()
[docs]
@event('options.register')
def register_parser_arguments():
# Register the command
parser = options.register_command(
'series', do_cli, help='View and manipulate the series plugin database'
)
# Parent parser for subcommands that need a series name
series_parser = argparse.ArgumentParser(add_help=False)
series_parser.add_argument(
'series_name', help='The name of the series', metavar='<series name>'
)
# Set up our subparsers
subparsers = parser.add_subparsers(title='actions', metavar='<action>', dest='series_action')
list_parser = subparsers.add_parser(
'list', parents=[table_parser], help='List a summary of the different series being tracked'
)
list_parser.add_argument(
'configured',
nargs='?',
choices=['configured', 'unconfigured', 'all'],
help='Limit list to series that are currently in the config or not (default: %(default)s)',
)
list_parser.add_argument(
'--premieres',
action='store_true',
help='limit list to series which only have episode 1 (and maybe also 2) downloaded',
)
list_parser.add_argument(
'--sort-by', choices=('name', 'age'), help='Choose list sort attribute'
)
order = list_parser.add_mutually_exclusive_group(required=False)
order.add_argument(
'--descending',
dest='order',
action='store_const',
const='desc',
help='Sort in descending order',
)
order.add_argument(
'--ascending',
dest='order',
action='store_const',
const='asc',
help='Sort in ascending order',
)
show_parser = subparsers.add_parser(
'show',
parents=[series_parser, table_parser],
help='Show the releases FlexGet has seen for a given series',
)
show_parser.add_argument(
'--sort-by',
choices=('age', 'identifier'),
help='Choose releases list sort: age (default) or identifier',
)
show_order = show_parser.add_mutually_exclusive_group(required=False)
show_order.add_argument(
'--descending',
dest='order',
action='store_const',
const='desc',
help='Sort in descending order',
)
show_order.add_argument(
'--ascending',
dest='order',
action='store_const',
const='asc',
help='Sort in ascending order',
)
begin_parser = subparsers.add_parser(
'begin', parents=[series_parser], help='set the episode to start getting a series from'
)
begin_opts = begin_parser.add_mutually_exclusive_group(required=True)
begin_opts.add_argument(
'--forget', dest='forget', action='store_true', help='Forget begin episode', required=False
)
begin_opts.add_argument(
'episode_id',
metavar='<episode ID>',
help='Episode ID to start getting the series from (e.g. S02E01, 2013-12-11, or 9, '
'depending on how the series is numbered). You can also enter a season ID such as '
' S02.',
nargs='?',
default='',
)
addshow_parser = subparsers.add_parser(
'add', parents=[series_parser], help='Add episode(s) and season(s) to series history'
)
addshow_parser.add_argument(
'entity_id',
nargs='+',
default=None,
metavar='<entity_id>',
help='Episode or season entity ID(s) to add',
)
addshow_parser.add_argument(
'--quality',
default=None,
metavar='<quality>',
help='Quality string to be stored for all entity ID(s)',
)
forget_parser = subparsers.add_parser(
'forget',
parents=[series_parser],
help='Removes episodes or whole series from the entire database (including seen plugin)',
)
forget_parser.add_argument(
'episode_id', nargs='*', default=None, help='Entity ID(s) to forget (optional)'
)
delete_parser = subparsers.add_parser(
'remove',
parents=[series_parser],
help='Removes episodes or whole series from the series database only',
)
delete_parser.add_argument(
'episode_id', nargs='*', default=None, help='Episode ID to forget (optional)'
)