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