1import re
2from datetime import datetime, timedelta
3from functools import total_ordering
4from typing import TYPE_CHECKING, Iterable, List, Optional, Tuple, Union
5
6from loguru import logger
7from sqlalchemy import (
8    Boolean,
9    Column,
10    DateTime,
11    ForeignKey,
12    Index,
13    Integer,
14    String,
15    Unicode,
16    and_,
17    delete,
18    desc,
19    func,
20    select,
21    update,
22)
23from sqlalchemy.exc import OperationalError
24from sqlalchemy.ext.hybrid import Comparator, hybrid_property
25from sqlalchemy.orm import backref, relation
26
27from flexget import db_schema, plugin
28from flexget.components.series.utils import normalize_series_name
29from flexget.event import event, fire_event
30from flexget.manager import Session
31from flexget.utils.database import quality_property, with_session
32from flexget.utils.sqlalchemy_utils import (
33    create_index,
34    drop_tables,
35    table_add_column,
36    table_columns,
37    table_exists,
38    table_schema,
39)
40from flexget.utils.tools import parse_episode_identifier
41
42if TYPE_CHECKING:
43    from flexget.components.parsing.parsers.parser_common import SeriesParseResult
44    from flexget.utils.qualities import Quality
45
46
47SCHEMA_VER = 14
48logger = logger.bind(name='series.db')
49Base = db_schema.versioned_base('series', SCHEMA_VER)
50
51
52class NormalizedComparator(Comparator):
53    def operate(self, op, other):
54        if isinstance(other, list):
55            other = [normalize_series_name(o) for o in other]
56        else:
57            other = normalize_series_name(other)
58
59        return op(self.__clause_element__(), other)
60
61
62class Series(Base):
63    """Name is handled case insensitively transparently"""
64
65    __tablename__ = 'series'
66
67    id = Column(Integer, primary_key=True)
68    _name = Column('name', Unicode)
69    _name_normalized = Column('name_lower', Unicode, index=True, unique=True)
70    identified_by = Column(String)
71    begin_episode_id = Column(
72        Integer, ForeignKey('series_episodes.id', name='begin_episode_id', use_alter=True)
73    )
74    begin = relation(
75        'Episode',
76        uselist=False,
77        primaryjoin="Series.begin_episode_id == Episode.id",
78        foreign_keys=[begin_episode_id],
79        post_update=True,
80        backref='begins_series',
81    )
82    episodes = relation(
83        'Episode',
84        backref='series',
85        cascade='all, delete, delete-orphan',
86        primaryjoin='Series.id == Episode.series_id',
87    )
88    in_tasks = relation(
89        'SeriesTask',
90        backref=backref('series', uselist=False),
91        cascade='all, delete, delete-orphan',
92    )
93    alternate_names = relation(
94        'AlternateNames', backref='series', cascade='all, delete, delete-orphan'
95    )
96
97    seasons = relation('Season', backref='series', cascade='all, delete, delete-orphan')
98
99    # Make a special property that does indexed case insensitive lookups on name, but stores/returns specified case
100    @hybrid_property
101    def name(self):
102        return self._name
103
104    @name.setter
105    def name(self, value):
106        self._name = value
107        self._name_normalized = normalize_series_name(value)
108
109    @name.comparator
110    def name(self):
111        return NormalizedComparator(self._name_normalized)
112
113    @property
114    def name_normalized(self):
115        return self._name_normalized
116
117    def __str__(self):
118        return '<Series(id=%s,name=%s)>' % (self.id, self.name)
119
120    def __repr__(self):
121        return str(self).encode('ascii', 'replace')
122
123    def episodes_for_season(self, season_num):
124        return len(
125            [
126                episode
127                for episode in self.episodes
128                if episode.season == season_num and episode.downloaded_releases
129            ]
130        )
131
132    @property
133    def completed_seasons(self):
134        return [season.season for season in self.seasons if season.completed]
135
136
137class Season(Base):
138    __tablename__ = 'series_seasons'
139
140    id = Column(Integer, primary_key=True)
141    identifier = Column(String)
142
143    identified_by = Column(String)
144    season = Column(Integer)
145    series_id = Column(Integer, ForeignKey('series.id'), nullable=False)
146
147    releases = relation('SeasonRelease', backref='season', cascade='all, delete, delete-orphan')
148
149    is_season = True
150
151    @property
152    def completed(self):
153        """
154        Return True if the season has any released marked as downloaded
155        """
156        if not self.releases:
157            return False
158        return any(release.downloaded for release in self.releases)
159
160    @property
161    def downloaded_releases(self):
162        return [release for release in self.releases if release.downloaded]
163
164    @hybrid_property
165    def first_seen(self):
166        if not self.releases:
167            return None
168        return min(release.first_seen for release in self.releases)
169
170    @first_seen.expression
171    def first_seen(cls):
172        return (
173            select([func.min(SeasonRelease.first_seen)])
174            .where(SeasonRelease.season_id == cls.id)
175            .correlate(Season.__table__)
176            .label('first_seen')
177        )
178
179    @property
180    def age(self):
181        """
182        :return: Pretty string representing age of episode. eg "23d 12h" or "No releases seen"
183        """
184        if not self.first_seen:
185            return 'No releases seen'
186        diff = datetime.now() - self.first_seen
187        age_days = diff.days
188        age_hours = diff.seconds // 60 // 60
189        age = ''
190        if age_days:
191            age += '%sd ' % age_days
192        age += '%sh' % age_hours
193        return age
194
195    @property
196    def age_timedelta(self):
197        """
198        :return: Timedelta or None if seasons is never seen
199        """
200        if not self.first_seen:
201            return None
202        return datetime.now() - self.first_seen
203
204    @property
205    def is_premiere(self):
206        return False
207
208    def __str__(self):
209        return '<Season(id=%s,identifier=%s,season=%s,completed=%s)>' % (
210            self.id,
211            self.identifier,
212            self.season,
213            self.completed,
214        )
215
216    def __repr__(self):
217        return str(self).encode('ascii', 'replace')
218
219    def __lt__(self, other):
220        if other is None:
221            logger.trace('comparing {} to None', self)
222            return False
223        if not isinstance(other, (Season, Episode)):
224            logger.error('Cannot compare Season to {}', other)
225            return NotImplemented
226        if self.identified_by != 'ep':
227            logger.error('Can only compare with an \'ep\' style identifier')
228            return NotImplemented
229        logger.trace('checking if {} is smaller than {}', self.season, other.season)
230        return self.season < other.season
231
232    def __hash__(self):
233        return self.id
234
235    def to_dict(self):
236        return {
237            'id': self.id,
238            'identifier': self.identifier,
239            'season': self.season,
240            'identified_by': self.identified_by,
241            'series_id': self.series_id,
242            'first_seen': self.first_seen,
243            'number_of_releases': len(self.releases),
244        }
245
246    @property
247    def latest_release(self):
248        """
249        :return: Latest downloaded Release or None
250        """
251        if not self.releases:
252            return None
253        return sorted(
254            self.downloaded_releases,
255            key=lambda rel: rel.first_seen if rel.downloaded else None,
256            reverse=True,
257        )[0]
258
259
260@total_ordering
261class Episode(Base):
262    __tablename__ = 'series_episodes'
263
264    id = Column(Integer, primary_key=True)
265    identifier = Column(String)
266
267    season = Column(Integer)
268    number = Column(Integer)
269
270    identified_by = Column(String)
271    series_id = Column(Integer, ForeignKey('series.id'), nullable=False)
272    releases = relation('EpisodeRelease', backref='episode', cascade='all, delete, delete-orphan')
273
274    is_season = False
275
276    @hybrid_property
277    def first_seen(self):
278        if not self.releases:
279            return None
280        return min(release.first_seen for release in self.releases)
281
282    @first_seen.expression
283    def first_seen(cls):
284        return (
285            select([func.min(EpisodeRelease.first_seen)])
286            .where(EpisodeRelease.episode_id == cls.id)
287            .correlate(Episode.__table__)
288            .label('first_seen')
289        )
290
291    @property
292    def age(self):
293        """
294        :return: Pretty string representing age of episode. eg "23d 12h" or "No releases seen"
295        """
296        if not self.first_seen:
297            return 'No releases seen'
298        diff = datetime.now() - self.first_seen
299        age_days = diff.days
300        age_hours = diff.seconds // 60 // 60
301        age = ''
302        if age_days:
303            age += '%sd ' % age_days
304        age += '%sh' % age_hours
305        return age
306
307    @property
308    def age_timedelta(self):
309        """
310        :return: Timedelta or None if episode is never seen
311        """
312        if not self.first_seen:
313            return None
314        return datetime.now() - self.first_seen
315
316    @property
317    def is_premiere(self):
318        if self.season == 1 and self.number in (0, 1):
319            return 'Series Premiere'
320        elif self.number in (0, 1):
321            return 'Season Premiere'
322        return False
323
324    @property
325    def downloaded_releases(self):
326        return [release for release in self.releases if release.downloaded]
327
328    @property
329    def latest_release(self):
330        """
331        :return: Latest downloaded Release or None
332        """
333        if not self.releases:
334            return None
335        return sorted(
336            self.downloaded_releases,
337            key=lambda rel: rel.first_seen if rel.downloaded else None,
338            reverse=True,
339        )[0]
340
341    def __str__(self):
342        return '<Episode(id=%s,identifier=%s,season=%s,number=%s)>' % (
343            self.id,
344            self.identifier,
345            self.season,
346            self.number,
347        )
348
349    def __repr__(self):
350        return str(self).encode('ascii', 'replace')
351
352    def __eq__(self, other):
353        if other is None:
354            logger.trace('comparing {} to None', self)
355            return False
356        if isinstance(other, Season):
357            logger.trace('comparing {} to Season', self)
358            return False
359        elif not isinstance(other, Episode):
360            logger.error('Cannot compare Episode with {}', other)
361            return NotImplemented
362        if self.identified_by != other.identified_by:
363            logger.error(
364                'Cannot compare {} identifier with {}', self.identified_by, other.identified_by
365            )
366            return NotImplemented
367        logger.trace('comparing {} with {}', self.identifier, other.identifier)
368        return self.identifier == other.identifier
369
370    def __lt__(self, other):
371        if other is None:
372            logger.trace('comparing {} to None', self)
373            return False
374        elif isinstance(other, Episode):
375            if self.identified_by is None or other.identified_by is None:
376                bad_ep = other if other.identified_by is None else self
377                logger.error('cannot compare episode without an identifier type: {}', bad_ep)
378                return False
379            if self.identified_by != other.identified_by:
380                if self.identified_by == 'special':
381                    logger.trace('Comparing special episode')
382                    return False
383                logger.error('cannot compare {} with {}', self.identified_by, other.identified_by)
384                return NotImplemented
385            if self.identified_by in ['ep', 'sequence']:
386                logger.trace('comparing {} and {}', self, other)
387                return self.season < other.season or (
388                    self.season == other.season and self.number < other.number
389                )
390            elif self.identified_by == 'date':
391                logger.trace('comparing {} and {}', self.identifier, other.identifier)
392                return self.identifier < other.identifier
393            else:
394                logger.error('cannot compare when identifier is {}', self.identified_by)
395                return NotImplemented
396        elif isinstance(other, Season):
397            if self.identified_by != 'ep':
398                logger.error('cannot compare season when identifier is not \'ep\'')
399                return NotImplemented
400            logger.trace('comparing {} with {}', self.season, other.season)
401            return self.season < other.season
402        else:
403            logger.error('can only compare with Episode or Season, not {}', other)
404            return NotImplemented
405
406    def __hash__(self):
407        return self.id
408
409    def to_dict(self):
410        return {
411            'id': self.id,
412            'identifier': self.identifier,
413            'season': self.season,
414            'identified_by': self.identified_by,
415            'number': self.number,
416            'series_id': self.series_id,
417            'first_seen': self.first_seen,
418            'premiere': self.is_premiere,
419            'number_of_releases': len(self.releases),
420        }
421
422
423class EpisodeRelease(Base):
424    __tablename__ = 'episode_releases'
425
426    id = Column(Integer, primary_key=True)
427    episode_id = Column(Integer, ForeignKey('series_episodes.id'), nullable=False, index=True)
428
429    _quality = Column('quality', String)
430    quality = quality_property('_quality')
431    downloaded = Column(Boolean, default=False)
432    proper_count = Column(Integer, default=0)
433    title = Column(Unicode)
434    first_seen = Column(DateTime)
435
436    def __init__(self):
437        self.first_seen = datetime.now()
438
439    @property
440    def proper(self):
441        # TODO: TEMP
442        import warnings
443
444        warnings.warn("accessing deprecated release.proper, use release.proper_count instead")
445        return self.proper_count > 0
446
447    def __str__(self):
448        return '<Release(id=%s,quality=%s,downloaded=%s,proper_count=%s,title=%s)>' % (
449            self.id,
450            self.quality,
451            self.downloaded,
452            self.proper_count,
453            self.title,
454        )
455
456    def __repr__(self):
457        return str(self).encode('ascii', 'replace')
458
459    def to_dict(self):
460        return {
461            'id': self.id,
462            'title': self.title,
463            'downloaded': self.downloaded,
464            'quality': self.quality.name,
465            'proper_count': self.proper_count,
466            'first_seen': self.first_seen,
467            'episode_id': self.episode_id,
468        }
469
470
471class SeasonRelease(Base):
472    __tablename__ = 'season_releases'
473
474    id = Column(Integer, primary_key=True)
475    season_id = Column(Integer, ForeignKey('series_seasons.id'), nullable=False, index=True)
476
477    _quality = Column('quality', String)
478    quality = quality_property('_quality')
479    downloaded = Column(Boolean, default=False)
480    proper_count = Column(Integer, default=0)
481    title = Column(Unicode)
482    first_seen = Column(DateTime)
483
484    def __init__(self):
485        self.first_seen = datetime.now()
486
487    @property
488    def proper(self):
489        # TODO: TEMP
490        import warnings
491
492        warnings.warn("accessing deprecated release.proper, use release.proper_count instead")
493        return self.proper_count > 0
494
495    def __str__(self):
496        return '<Release(id=%s,quality=%s,downloaded=%s,proper_count=%s,title=%s)>' % (
497            self.id,
498            self.quality,
499            self.downloaded,
500            self.proper_count,
501            self.title,
502        )
503
504    def __repr__(self):
505        return str(self).encode('ascii', 'replace')
506
507    def to_dict(self):
508        return {
509            'id': self.id,
510            'title': self.title,
511            'downloaded': self.downloaded,
512            'quality': self.quality.name,
513            'proper_count': self.proper_count,
514            'first_seen': self.first_seen,
515            'season_id': self.season_id,
516        }
517
518
519class AlternateNames(Base):
520    """Similar to Series. Name is handled case insensitively transparently."""
521
522    __tablename__ = 'series_alternate_names'
523    id = Column(Integer, primary_key=True)
524    _alt_name = Column('alt_name', Unicode)
525    _alt_name_normalized = Column('alt_name_normalized', Unicode, index=True, unique=True)
526    series_id = Column(Integer, ForeignKey('series.id'), nullable=False)
527
528    @hybrid_property
529    def alt_name(self):
530        return self._alt_name
531
532    @alt_name.setter
533    def alt_name(self, value):
534        self._alt_name = value
535        self._alt_name_normalized = normalize_series_name(value)
536
537    @alt_name.comparator
538    def alt_name(self):
539        return NormalizedComparator(self._alt_name_normalized)
540
541    @property
542    def name_normalized(self):
543        return self._alt_name_normalized
544
545    def __init__(self, name):
546        self.alt_name = name
547
548    def __str__(self):
549        return '<SeriesAlternateName(series_id=%s, alt_name=%s)>' % (self.series_id, self.alt_name)
550
551    def __repr__(self):
552        return str(self).encode('ascii', 'replace')
553
554
555class SeriesTask(Base):
556    __tablename__ = 'series_tasks'
557
558    id = Column(Integer, primary_key=True)
559    series_id = Column(Integer, ForeignKey('series.id'), nullable=False)
560    name = Column(Unicode, index=True)
561
562    def __init__(self, name):
563        self.name = name
564
565
566Index('episode_series_identifier', Episode.series_id, Episode.identifier)
567
568
569@db_schema.upgrade('series')
570def upgrade(ver: Optional[int], session: Session) -> int:
571    if ver is None:
572        if table_exists('episode_qualities', session):
573            logger.info(
574                'Series database format is too old to upgrade, dropping and recreating tables.'
575            )
576            # Drop the deprecated data
577            drop_tables(['series', 'series_episodes', 'episode_qualities'], session)
578            # Create new tables from the current models
579            Base.metadata.create_all(bind=session.bind)
580        # Upgrade episode_releases table to have a proper count and seed it with appropriate numbers
581        columns = table_columns('episode_releases', session)
582        if 'proper_count' not in columns:
583            logger.info('Upgrading episode_releases table to have proper_count column')
584            table_add_column('episode_releases', 'proper_count', Integer, session)
585            release_table = table_schema('episode_releases', session)
586            for row in session.execute(select([release_table.c.id, release_table.c.title])):
587                # Recalculate the proper_count from title for old episodes
588                proper_count = (
589                    plugin.get('parsing', 'series.db').parse_series(row['title']).proper_count
590                )
591                session.execute(
592                    update(
593                        release_table,
594                        release_table.c.id == row['id'],
595                        {'proper_count': proper_count},
596                    )
597                )
598        ver = 0
599    if ver == 0:
600        logger.info('Migrating first_seen column from series_episodes to episode_releases table.')
601        # Create the column in episode_releases
602        table_add_column('episode_releases', 'first_seen', DateTime, session)
603        # Seed the first_seen value for all the past releases with the first_seen of their episode.
604        episode_table = table_schema('series_episodes', session)
605        release_table = table_schema('episode_releases', session)
606        for row in session.execute(select([episode_table.c.id, episode_table.c.first_seen])):
607            session.execute(
608                update(
609                    release_table,
610                    release_table.c.episode_id == row['id'],
611                    {'first_seen': row['first_seen']},
612                )
613            )
614        ver = 1
615    if ver == 1:
616        logger.info('Adding `identified_by` column to series table.')
617        table_add_column('series', 'identified_by', String, session)
618        ver = 2
619    if ver == 2:
620        logger.info('Creating index on episode_releases table.')
621        create_index('episode_releases', session, 'episode_id')
622        ver = 3
623    if ver == 3:
624        # Remove index on Series.name
625        try:
626            session.execute("DROP INDEX ix_series_name")
627            # This way doesn't work on sqlalchemy 1.4 for some reason
628            # Index('ix_series_name').drop(bind=session.bind)
629        except OperationalError:
630            logger.debug('There was no ix_series_name index to remove.')
631        # Add Series.name_lower column
632        logger.info('Adding `name_lower` column to series table.')
633        table_add_column('series', 'name_lower', Unicode, session)
634        series_table = table_schema('series', session)
635        create_index('series', session, 'name_lower')
636        # Fill in lower case name column
637        session.execute(
638            update(series_table, values={'name_lower': func.lower(series_table.c.name)})
639        )
640        ver = 4
641    if ver == 4:
642        logger.info('Adding `identified_by` column to episodes table.')
643        table_add_column('series_episodes', 'identified_by', String, session)
644        series_table = table_schema('series', session)
645        # Clear out identified_by id series so that they can be auto detected again
646        session.execute(
647            update(series_table, series_table.c.identified_by != 'ep', {'identified_by': None})
648        )
649        # Warn users about a possible config change needed.
650        logger.warning(
651            'If you are using `identified_by: id` for the series plugin for a date-identified '
652            'or abolute-numbered series, you will need to update your config. Two new identified_by modes have '
653            'been added: `date` and `sequence`. In addition, if you are using `identified_by: auto`, it will'
654            'be relearned based on upcoming episodes.'
655        )
656        ver = 5
657    if ver == 5:
658        # Episode advancement now relies on identified_by being filled for the episodes.
659        # This action retroactively marks 'ep' mode for all episodes where the series is already in 'ep' mode.
660        series_table = table_schema('series', session)
661        ep_table = table_schema('series_episodes', session)
662        ep_mode_series = select([series_table.c.id], series_table.c.identified_by == 'ep')
663        where_clause = and_(
664            ep_table.c.series_id.in_(ep_mode_series),
665            ep_table.c.season != None,
666            ep_table.c.number != None,
667            ep_table.c.identified_by == None,
668        )
669        session.execute(update(ep_table, where_clause, {'identified_by': 'ep'}))
670        ver = 6
671    if ver == 6:
672        # Translate old qualities into new quality requirements
673        release_table = table_schema('episode_releases', session)
674        for row in session.execute(select([release_table.c.id, release_table.c.quality])):
675            # Webdl quality no longer has dash
676            new_qual = row['quality'].replace('web-dl', 'webdl')
677            if row['quality'] != new_qual:
678                session.execute(
679                    update(release_table, release_table.c.id == row['id'], {'quality': new_qual})
680                )
681        ver = 7
682    # Normalization rules changed for 7 and 8, but only run this once
683    if ver in [7, 8]:
684        # Merge series that qualify as duplicates with new normalization scheme
685        series_table = table_schema('series', session)
686        ep_table = table_schema('series_episodes', session)
687        all_series = session.execute(select([series_table.c.name, series_table.c.id]))
688        unique_series = {}
689        for row in all_series:
690            unique_series.setdefault(normalize_series_name(row['name']), []).append(row['id'])
691        for series, ids in unique_series.items():
692            session.execute(update(ep_table, ep_table.c.series_id.in_(ids), {'series_id': ids[0]}))
693            if len(ids) > 1:
694                session.execute(delete(series_table, series_table.c.id.in_(ids[1:])))
695            session.execute(
696                update(series_table, series_table.c.id == ids[0], {'name_lower': series})
697            )
698        ver = 9
699    if ver == 9:
700        table_add_column('series', 'begin_episode_id', Integer, session)
701        ver = 10
702    if ver == 10:
703        # Due to bad db cleanups there may be invalid entries in series_tasks table
704        series_tasks = table_schema('series_tasks', session)
705        series_table = table_schema('series', session)
706        logger.verbose('Repairing series_tasks table data')
707        session.execute(
708            delete(series_tasks, ~series_tasks.c.series_id.in_(select([series_table.c.id])))
709        )
710        ver = 11
711    if ver == 11:
712        # SeriesTasks was cleared out due to a bug, make sure they get recalculated next run #2772
713        from flexget.task import config_changed
714
715        config_changed(session=session)
716        ver = 12
717    if ver == 12:
718        # Force identified_by value None to 'auto'
719        series_table = table_schema('series', session)
720        session.execute(
721            update(series_table, series_table.c.identified_by == None, {'identified_by': 'auto'})
722        )
723        ver = 13
724    if ver == 13:
725        # New season_releases table, added by "create_all"
726        logger.info('Adding season_releases table')
727        ver = 14
728    return ver
729
730
731@event('manager.db_cleanup')
732def db_cleanup(manager, session: Session) -> None:
733    # Clean up old undownloaded releases
734    result = (
735        session.query(EpisodeRelease)
736        .filter(EpisodeRelease.downloaded == False)
737        .filter(EpisodeRelease.first_seen < datetime.now() - timedelta(days=120))
738        .delete(False)
739    )
740    if result:
741        logger.verbose('Removed {} undownloaded episode releases.', result)
742    # Clean up episodes without releases
743    result = (
744        session.query(Episode)
745        .filter(~Episode.releases.any())
746        .filter(~Episode.begins_series.any())
747        .delete(False)
748    )
749    if result:
750        logger.verbose('Removed {} episodes without releases.', result)
751    # Clean up series without episodes that aren't in any tasks
752    result = (
753        session.query(Series)
754        .filter(~Series.episodes.any())
755        .filter(~Series.in_tasks.any())
756        .delete(False)
757    )
758    if result:
759        logger.verbose('Removed {} series without episodes.', result)
760
761
762def set_alt_names(alt_names: Iterable[str], db_series: Series, session: Session) -> None:
763    db_alt_names = []
764    for alt_name in alt_names:
765        db_series_alt = (
766            session.query(AlternateNames).filter(AlternateNames.alt_name == alt_name).first()
767        )
768        if db_series_alt:
769            if not db_series_alt.series_id == db_series.id:
770                raise plugin.PluginError(
771                    'Error adding alternate name for `%s`: `%s` is already associated with `%s`. '
772                    'Check your settings.' % (db_series.name, alt_name, db_series_alt.series.name)
773                )
774            else:
775                logger.debug(
776                    'alternate name `{}` already associated with series `{}`, no change needed',
777                    alt_name,
778                    db_series.name,
779                )
780                db_alt_names.append(db_series_alt)
781        else:
782            db_alt_names.append(AlternateNames(alt_name))
783            logger.debug('adding alternate name `{}` to series `{}`', alt_name, db_series.name)
784    db_series.alternate_names[:] = db_alt_names
785
786
787def show_seasons(
788    series: Series,
789    start: int = None,
790    stop: int = None,
791    count: bool = False,
792    descending: bool = False,
793    session: Session = None,
794) -> Union[int, List[Season]]:
795    """Return all seasons of a given series"""
796    seasons = session.query(Season).filter(Season.series_id == series.id)
797    if count:
798        return seasons.count()
799    seasons = (
800        seasons.order_by(Season.season.desc()) if descending else seasons.order_by(Season.season)
801    )
802    return seasons.slice(start, stop).from_self().all()
803
804
805def get_all_entities(
806    series: Series, session: Session, sort_by: str = 'age', reverse: bool = False
807) -> List[Union[Episode, Season]]:
808    episodes = show_episodes(series, session=session)
809    seasons = show_seasons(series, session=session)
810    if sort_by == 'identifier':
811        key = lambda e: e.identifier
812    else:
813        key = lambda e: (e.first_seen or datetime.min, e.identifier)
814    return sorted(episodes + seasons, key=key, reverse=reverse)
815
816
817def get_episode_releases(
818    episode: Episode,
819    downloaded: bool = None,
820    start: int = None,
821    stop: int = None,
822    count: bool = False,
823    descending: bool = False,
824    sort_by: str = None,
825    session: Session = None,
826) -> List[EpisodeRelease]:
827    """Return all releases for a given episode"""
828    releases = session.query(EpisodeRelease).filter(EpisodeRelease.episode_id == episode.id)
829    if downloaded is not None:
830        releases = releases.filter(EpisodeRelease.downloaded == downloaded)
831    if count:
832        return releases.count()
833    releases = releases.slice(start, stop).from_self()
834    if descending:
835        releases = releases.order_by(getattr(EpisodeRelease, sort_by).desc())
836    else:
837        releases = releases.order_by(getattr(EpisodeRelease, sort_by))
838    return releases.all()
839
840
841def get_season_releases(
842    season: Season,
843    downloaded: bool = None,
844    start: int = None,
845    stop: int = None,
846    count: bool = False,
847    descending: bool = False,
848    sort_by: str = None,
849    session: Session = None,
850) -> Union[int, List[SeasonRelease]]:
851    """Return all releases for a given season"""
852    releases = session.query(SeasonRelease).filter(SeasonRelease.season_id == season.id)
853    if downloaded is not None:
854        releases = releases.filter(SeasonRelease.downloaded == downloaded)
855    if count:
856        return releases.count()
857    releases = releases.slice(start, stop).from_self()
858    if descending:
859        releases = releases.order_by(getattr(SeasonRelease, sort_by).desc())
860    else:
861        releases = releases.order_by(getattr(SeasonRelease, sort_by))
862    return releases.all()
863
864
865def episode_in_show(series_id: int, episode_id: int) -> bool:
866    """Return True if `episode_id` is part of show with `series_id`, else return False"""
867    with Session() as session:
868        episode = session.query(Episode).filter(Episode.id == episode_id).one()
869        return episode.series_id == series_id
870
871
872def season_in_show(series_id: int, season_id: int) -> bool:
873    """Return True if `episode_id` is part of show with `series_id`, else return False"""
874    with Session() as session:
875        season = session.query(Season).filter(Season.id == season_id).one()
876        return season.series_id == series_id
877
878
879def release_in_episode(episode_id: int, release_id: int) -> bool:
880    """Return True if `release_id` is part of episode with `episode_id`, else return False"""
881    with Session() as session:
882        release = session.query(EpisodeRelease).filter(EpisodeRelease.id == release_id).one()
883        return release.episode_id == episode_id
884
885
886def release_in_season(season_id: int, release_id: int) -> bool:
887    """Return True if `release_id` is part of episode with `episode_id`, else return False"""
888    with Session() as session:
889        release = session.query(SeasonRelease).filter(SeasonRelease.id == release_id).one()
890        return release.season_id == season_id
891
892
893def _add_alt_name(alt: str, db_series: Series, series_name: str, session: Session) -> None:
894    alt = str(alt)
895    db_series_alt = session.query(AlternateNames).filter(AlternateNames.alt_name == alt).first()
896    if db_series_alt and db_series_alt.series_id == db_series.id:
897        # Already exists, no need to create it then
898        # TODO is checking the list for duplicates faster/better than querying the DB?
899        db_series_alt.alt_name = alt
900    elif db_series_alt:
901        if not db_series_alt.series:
902            # Not sure how this can happen
903            logger.debug(
904                'Found an alternate name not attached to series. Re-attatching `{}` to `{}`.',
905                alt,
906                series_name,
907            )
908            db_series.alternate_names.append(db_series_alt)
909        else:
910            # Alternate name already exists for another series. Not good.
911            raise plugin.PluginError(
912                'Error adding alternate name for `%s`: `%s` is already associated with `%s`. '
913                'Check your settings.' % (series_name, alt, db_series_alt.series.name)
914            )
915    else:
916        logger.debug('adding alternate name `{}` for `{}` into db', alt, series_name)
917        db_series_alt = AlternateNames(alt)
918        db_series.alternate_names.append(db_series_alt)
919        logger.debug('-> added {}', db_series_alt)
920
921
922@with_session
923def get_series_summary(
924    configured: str = None,
925    premieres: bool = None,
926    start: int = None,
927    stop: int = None,
928    count: bool = False,
929    sort_by: str = 'show_name',
930    descending: bool = None,
931    session: Session = None,
932    name: str = None,
933) -> Union[int, Iterable[Series]]:
934    """
935    Return a query with results for all series.
936
937    :param configured: 'configured' for shows in config, 'unconfigured' for shows not in config, 'all' for both.
938        Default is 'all'
939    :param premieres: Return only shows with 1 season and less than 3 episodes
940    :param count: Decides whether to return count of all shows or data itself
941    :param session: Passed session
942    :return:
943    """
944    if not configured:
945        configured = 'configured'
946    elif configured not in ['configured', 'unconfigured', 'all']:
947        raise LookupError(
948            '"configured" parameter must be either "configured", "unconfigured", or "all"'
949        )
950    query = session.query(Series)
951    query = (
952        query.outerjoin(Series.episodes)
953        .outerjoin(Episode.releases)
954        .outerjoin(Series.in_tasks)
955        .group_by(Series.id)
956    )
957    if configured == 'configured':
958        query = query.having(func.count(SeriesTask.id) >= 1)
959    elif configured == 'unconfigured':
960        query = query.having(func.count(SeriesTask.id) < 1)
961    if name:
962        query = query.filter(Series._name_normalized.contains(name))
963    if premieres:
964        query = (
965            query.having(func.max(Episode.season) <= 1).having(func.max(Episode.number) <= 2)
966        ).filter(EpisodeRelease.downloaded == True)
967    if count:
968        return query.group_by(Series).count()
969    if sort_by == 'show_name':
970        order_by = Series.name
971    else:
972        order_by = func.max(EpisodeRelease.first_seen)
973    query = query.order_by(desc(order_by)) if descending else query.order_by(order_by)
974
975    return query.slice(start, stop).from_self()
976
977
978def auto_identified_by(series: Series) -> str:
979    """
980    Determine if series `name` should be considered identified by episode or id format
981
982    Returns 'ep', 'sequence', 'date' or 'id' if enough history is present to identify the series' id type.
983    Returns 'auto' if there is not enough history to determine the format yet
984    """
985
986    session = Session.object_session(series)
987    type_totals = dict(
988        session.query(Episode.identified_by, func.count(Episode.identified_by))
989        .join(Episode.series)
990        .filter(Series.id == series.id)
991        .group_by(Episode.identified_by)
992        .all()
993    )
994    # Remove None and specials from the dict,
995    # we are only considering episodes that we know the type of (parsed with new parser)
996    type_totals.pop(None, None)
997    type_totals.pop('special', None)
998    if not type_totals:
999        return 'auto'
1000    logger.debug('{} episode type totals: {!r}', series.name, type_totals)
1001    # Find total number of parsed episodes
1002    total = sum(type_totals.values())
1003    # See which type has the most
1004    best = max(type_totals, key=lambda x: type_totals[x])
1005
1006    # Ep mode locks in faster than the rest. At 2 seen episodes.
1007    if type_totals.get('ep', 0) >= 2 and type_totals['ep'] > total / 3:
1008        logger.info('identified_by has locked in to type `ep` for {}', series.name)
1009        return 'ep'
1010    # If we have over 3 episodes all of the same type, lock in
1011    if len(type_totals) == 1 and total >= 3:
1012        return best
1013    # Otherwise wait until 5 episodes to lock in
1014    if total >= 5:
1015        logger.info('identified_by has locked in to type `{}` for {}', best, series.name)
1016        return best
1017    logger.verbose(
1018        'identified by is currently on `auto` for {}. '
1019        'Multiple id types may be accepted until it locks in on the appropriate type.',
1020        series.name,
1021    )
1022    return 'auto'
1023
1024
1025def get_latest_season_pack_release(
1026    series: Series, downloaded: bool = True, season: int = None
1027) -> Optional[Season]:
1028    """
1029    Return the latest season pack release for a series
1030
1031    :param Series series: Series object
1032    :param bool downloaded: Flag to return only downloaded season packs
1033    :param season: Filter by season number
1034    :return: Latest release of a season object
1035    """
1036    session = Session.object_session(series)
1037    releases = (
1038        session.query(Season).join(Season.releases, Season.series).filter(Series.id == series.id)
1039    )
1040
1041    if downloaded:
1042        releases = releases.filter(SeasonRelease.downloaded == True)
1043
1044    if season is not None:
1045        releases = releases.filter(Season.season == season)
1046
1047    latest_season_pack_release = releases.order_by(desc(Season.season)).first()
1048    if not latest_season_pack_release:
1049        logger.debug(
1050            'no season packs found for series `{}` with parameters season: {}, downloaded: {}',
1051            series.name,
1052            season,
1053            downloaded,
1054        )
1055        return
1056    logger.debug(
1057        'latest season pack for series {}, with downloaded set to {} and season set to {}',
1058        series,
1059        downloaded,
1060        season,
1061    )
1062    return latest_season_pack_release
1063
1064
1065def get_latest_episode_release(
1066    series: Series, downloaded: bool = True, season: int = None
1067) -> Optional[Episode]:
1068    """
1069    :param series series: SQLAlchemy session
1070    :param downloaded: find only downloaded releases
1071    :param season: season to find newest release for
1072    :return: Instance of Episode or None if not found.
1073    """
1074    session = Session.object_session(series)
1075    releases = (
1076        session.query(Episode)
1077        .join(Episode.releases, Episode.series)
1078        .filter(Series.id == series.id)
1079    )
1080
1081    if downloaded:
1082        releases = releases.filter(EpisodeRelease.downloaded == True)
1083
1084    if season is not None:
1085        releases = releases.filter(Episode.season == season)
1086
1087    if series.identified_by and series.identified_by != 'auto':
1088        releases = releases.filter(Episode.identified_by == series.identified_by)
1089
1090    if series.identified_by in ['ep', 'sequence']:
1091        latest_episode_release = releases.order_by(
1092            desc(Episode.season), desc(Episode.number)
1093        ).first()
1094    elif series.identified_by == 'date':
1095        latest_episode_release = releases.order_by(desc(Episode.identifier)).first()
1096    else:
1097        # We have to label the order_by clause to disambiguate from Release.first_seen #3055
1098        latest_episode_release = releases.order_by(
1099            desc(Episode.first_seen.label('ep_first_seen'))
1100        ).first()
1101
1102    if not latest_episode_release:
1103        logger.debug(
1104            'no episodes found for series `{}` with parameters season: {}, downloaded: {}',
1105            series.name,
1106            season,
1107            downloaded,
1108        )
1109        return
1110    logger.debug(
1111        'latest episode for series {}, with downloaded set to {} and season set to {}',
1112        series,
1113        downloaded,
1114        season,
1115    )
1116    return latest_episode_release
1117
1118
1119def get_latest_release(
1120    series: Series, downloaded: bool = True, season: int = None
1121) -> Union[EpisodeRelease, SeasonRelease, None]:
1122    """
1123    Return the latest downloaded entity of a series, either season pack or episode
1124
1125    :param Series series: Series object
1126    :param bool downloaded: Downloaded flag
1127    :param int season: Filter by season
1128    :return:
1129    """
1130    latest_ep = get_latest_episode_release(series, downloaded, season)
1131    latest_season = get_latest_season_pack_release(series, downloaded, season)
1132
1133    if latest_season is None and latest_ep is None:
1134        return None
1135    return max(latest_season, latest_ep)
1136
1137
1138def new_eps_after(series: Series, since_ep: Episode, session: Session) -> Tuple[int, str]:
1139    """
1140    :param since_ep: Episode instance
1141    :return: Number of episodes since then
1142    """
1143    series_eps = session.query(Episode).join(Episode.series).filter(Series.id == series.id)
1144    if series.identified_by == 'ep':
1145        if since_ep.season is None or since_ep.number is None:
1146            logger.debug(
1147                'new_eps_after for `{}` falling back to timestamp because latest dl in non-ep format',
1148                series.name,
1149            )
1150            return series_eps.filter(Episode.first_seen > since_ep.first_seen).count(), 'eps'
1151        count = series_eps.filter(
1152            (Episode.identified_by == 'ep')
1153            & (
1154                ((Episode.season == since_ep.season) & (Episode.number > since_ep.number))
1155                | (Episode.season > since_ep.season)
1156            )
1157        ).count()
1158    elif series.identified_by == 'seq':
1159        count = series_eps.filter(Episode.number > since_ep.number).count()
1160    elif series.identified_by == 'id':
1161        count = series_eps.filter(Episode.first_seen > since_ep.first_seen).count()
1162    else:
1163        logger.debug('unsupported identified_by `{}`', series.identified_by)
1164        count = 0
1165    return count, 'eps'
1166
1167
1168def new_seasons_after(series: Series, since_season: Season, session: Session) -> Tuple[int, str]:
1169    series_seasons = session.query(Season).join(Season.series).filter(Season.id == series.id)
1170    return series_seasons.filter(Season.first_seen > since_season.first_seen).count(), 'seasons'
1171
1172
1173def new_entities_after(since_entity: Union[Season, Episode]) -> Tuple[int, str]:
1174    session = Session.object_session(since_entity)
1175    series = since_entity.series
1176    if since_entity.is_season:
1177        func = new_seasons_after
1178    else:
1179        func = new_eps_after
1180    return func(series, since_entity, session)
1181
1182
1183def set_series_begin(series: Series, ep_id: Union[str, int]) -> Tuple[str, str]:
1184    """
1185    Set beginning for series
1186
1187    :param Series series: Series instance
1188    :param ep_id: Integer for sequence mode, SxxEyy for episodic and yyyy-mm-dd for date.
1189    :raises ValueError: If malformed ep_id or series in different mode
1190    :return: tuple containing identified_by and identity_type
1191    """
1192    # If identified_by is not explicitly specified, auto-detect it based on begin identifier
1193    # TODO: use some method of series parser to do the identifier parsing
1194    session = Session.object_session(series)
1195    identified_by, entity_type = parse_episode_identifier(ep_id, identify_season=True)
1196    if identified_by == 'ep':
1197        ep_id = ep_id.upper()
1198        if entity_type == 'season':
1199            ep_id += 'E01'
1200    if series.identified_by not in ['auto', '', None]:
1201        if identified_by != series.identified_by:
1202            raise ValueError(
1203                '`begin` value `%s` does not match identifier type for identified_by `%s`'
1204                % (ep_id, series.identified_by)
1205            )
1206    series.identified_by = identified_by
1207    episode = (
1208        session.query(Episode)
1209        .filter(Episode.series_id == series.id)
1210        .filter(Episode.identified_by == series.identified_by)
1211        .filter(Episode.identifier == str(ep_id))
1212        .first()
1213    )
1214    if not episode:
1215        # TODO: Don't duplicate code from self.store method
1216        episode = Episode()
1217        episode.identifier = ep_id
1218        episode.identified_by = identified_by
1219        if identified_by == 'ep':
1220            match = re.match(r'S(\d+)E(\d+)', ep_id)
1221            episode.season = int(match.group(1))
1222            episode.number = int(match.group(2))
1223        elif identified_by == 'sequence':
1224            episode.season = 0
1225            episode.number = ep_id
1226        series.episodes.append(episode)
1227        # Need to flush to get an id on new Episode before assigning it as series begin
1228        session.flush()
1229    series.begin = episode
1230    return identified_by, entity_type
1231
1232
1233def remove_series(name: str, forget: bool = False) -> None:
1234    """
1235    Remove a whole series `name` from database.
1236
1237    :param name: Name of series to be removed
1238    :param forget: Indication whether or not to fire a 'forget' event
1239    """
1240    downloaded_releases = []
1241    with Session() as session:
1242        series = session.query(Series).filter(Series.name == name).all()
1243        if series:
1244            for s in series:
1245                if forget:
1246                    for entity in s.episodes + s.seasons:
1247                        for release in entity.downloaded_releases:
1248                            downloaded_releases.append(release.title)
1249                session.delete(s)
1250            session.commit()
1251            logger.debug('Removed series `{}` from database.', name)
1252        else:
1253            raise ValueError('Unknown series `%s`' % name)
1254    for downloaded_release in downloaded_releases:
1255        fire_event('forget', downloaded_release)
1256
1257
1258def remove_series_entity(name: str, identifier: str, forget: bool = False) -> None:
1259    """
1260    Remove all entities by `identifier` from series `name` from database.
1261
1262    :param name: Name of series to be removed
1263    :param identifier: Series identifier to be deleted,
1264    :param forget: Indication whether or not to fire a 'forget' event
1265    """
1266    downloaded_releases = []
1267    with Session() as session:
1268        series = session.query(Series).filter(Series.name == name).first()
1269        if not series:
1270            raise ValueError('Unknown series `%s`' % name)
1271
1272        def remove_entity(entity):
1273            if not series.begin:
1274                series.identified_by = (
1275                    ''  # reset identified_by flag so that it will be recalculated
1276                )
1277            session.delete(entity)
1278            logger.debug('Entity `{}` from series `{}` removed from database.', identifier, name)
1279            return [release.title for release in entity.downloaded_releases]
1280
1281        name_to_parse = '{} {}'.format(series.name, identifier)
1282        parsed = plugin.get('parsing', 'series.db').parse_series(name_to_parse, name=series.name)
1283        if not parsed.valid:
1284            raise ValueError(
1285                'Invalid identifier for series `{}`: `{}`'.format(series.name, identifier)
1286            )
1287
1288        removed = False
1289        if parsed.season_pack:
1290            season = (
1291                session.query(Season)
1292                .filter(Season.season == parsed.season)
1293                .filter(Season.series_id == series.id)
1294                .first()
1295            )
1296            if season:
1297                removed = True
1298                downloaded_releases = remove_entity(season)
1299        else:
1300            episode = session.query(Episode).filter(Episode.series_id == series.id)
1301            if parsed.episode:
1302                episode = episode.filter(Episode.number == parsed.episode).filter(
1303                    Episode.season == parsed.season
1304                )
1305            else:
1306                episode = episode.filter(Episode.identifier == parsed.identifier)
1307            episode = episode.first()
1308            if episode:
1309                removed = True
1310                downloaded_releases = remove_entity(episode)
1311        if not removed:
1312            raise ValueError(
1313                'Unknown identifier `%s` for series `%s`' % (identifier, name.capitalize())
1314            )
1315
1316    if forget:
1317        for downloaded_release in downloaded_releases:
1318            fire_event('forget', downloaded_release)
1319
1320
1321def delete_episode_release_by_id(release_id: int) -> None:
1322    with Session() as session:
1323        release = session.query(EpisodeRelease).filter(EpisodeRelease.id == release_id).first()
1324        if release:
1325            session.delete(release)
1326            session.commit()
1327            logger.debug('Deleted release ID `{}`', release_id)
1328        else:
1329            raise ValueError('Unknown identifier `%s` for release' % release_id)
1330
1331
1332def delete_season_release_by_id(release_id: int) -> None:
1333    with Session() as session:
1334        release = session.query(SeasonRelease).filter(SeasonRelease.id == release_id).first()
1335        if release:
1336            session.delete(release)
1337            session.commit()
1338            logger.debug('Deleted release ID `{}`', release_id)
1339        else:
1340            raise ValueError('Unknown identifier `%s` for release' % release_id)
1341
1342
1343def shows_by_name(normalized_name: str, session: Session = None) -> List[Series]:
1344    """Returns all series matching `normalized_name`"""
1345    return (
1346        session.query(Series)
1347        .filter(Series._name_normalized.contains(normalized_name))
1348        .order_by(func.char_length(Series.name))
1349        .all()
1350    )
1351
1352
1353def shows_by_exact_name(normalized_name: str, session: Session = None) -> List[Series]:
1354    """Returns all series matching `normalized_name`"""
1355    return (
1356        session.query(Series)
1357        .filter(Series._name_normalized == normalized_name)
1358        .order_by(func.char_length(Series.name))
1359        .all()
1360    )
1361
1362
1363def show_by_id(show_id: int, session: Session = None) -> Series:
1364    """Return an instance of a show by querying its ID"""
1365    return session.query(Series).filter(Series.id == show_id).one()
1366
1367
1368def season_by_id(season_id: int, session: Session = None) -> Season:
1369    """Return an instance of an season by querying its ID"""
1370    return session.query(Season).filter(Season.id == season_id).one()
1371
1372
1373def episode_by_id(episode_id: int, session: Session = None) -> Episode:
1374    """Return an instance of an episode by querying its ID"""
1375    return session.query(Episode).filter(Episode.id == episode_id).one()
1376
1377
1378def episode_release_by_id(release_id: int, session: Session = None) -> EpisodeRelease:
1379    """Return an instance of an episode release by querying its ID"""
1380    return session.query(EpisodeRelease).filter(EpisodeRelease.id == release_id).one()
1381
1382
1383def season_release_by_id(release_id: int, session: Session = None) -> SeasonRelease:
1384    """Return an instance of an episode release by querying its ID"""
1385    return session.query(SeasonRelease).filter(SeasonRelease.id == release_id).one()
1386
1387
1388def show_episodes(
1389    series: Series,
1390    start: int = None,
1391    stop: int = None,
1392    count: bool = False,
1393    descending: bool = False,
1394    session: Session = None,
1395) -> Union[int, List[Episode]]:
1396    """Return all episodes of a given series"""
1397    episodes = session.query(Episode).filter(Episode.series_id == series.id)
1398    if count:
1399        return episodes.count()
1400    # Query episodes in sane order instead of iterating from series.episodes
1401    if series.identified_by == 'sequence':
1402        episodes = (
1403            episodes.order_by(Episode.number.desc())
1404            if descending
1405            else episodes.order_by(Episode.number)
1406        )
1407    elif series.identified_by == 'ep':
1408        episodes = (
1409            episodes.order_by(Episode.season.desc(), Episode.number.desc())
1410            if descending
1411            else episodes.order_by(Episode.season, Episode.number)
1412        )
1413    else:
1414        episodes = (
1415            episodes.order_by(Episode.identifier.desc())
1416            if descending
1417            else episodes.order_by(Episode.identifier)
1418        )
1419    return episodes.slice(start, stop).from_self().all()
1420
1421
1422def store_parser(
1423    session: Session, parser: 'SeriesParseResult', series: Series = None, quality: 'Quality' = None
1424) -> List[Union[SeasonRelease, EpisodeRelease]]:
1425    """
1426    Push series information into database. Returns added/existing release.
1427
1428    :param session: Database session to use
1429    :param parser: parser for release that should be added to database
1430    :param series: Series in database to add release to. Will be looked up if not provided.
1431    :param quality: If supplied, this will override the quality from the series parser
1432    :return: List of Releases
1433    """
1434    if quality is None:
1435        quality = parser.quality
1436    if not series:
1437        # if series does not exist in database, add new
1438        series = (
1439            session.query(Series)
1440            .filter(Series.name == parser.name)
1441            .filter(Series.id != None)
1442            .first()
1443        )
1444        if not series:
1445            logger.debug('adding series `{}` into db', parser.name)
1446            series = Series()
1447            series.name = parser.name
1448            session.add(series)
1449            logger.debug('-> added `{}`', series)
1450
1451    releases = []
1452    for ix, identifier in enumerate(parser.identifiers):
1453        if parser.season_pack:
1454            # Checks if season object exist
1455            season = (
1456                session.query(Season)
1457                .filter(Season.season == parser.season)
1458                .filter(Season.series_id == series.id)
1459                .filter(Season.identifier == identifier)
1460                .first()
1461            )
1462            if not season:
1463                logger.debug('adding season `{}` into series `{}`', identifier, parser.name)
1464                season = Season()
1465                season.identifier = identifier
1466                season.identified_by = parser.id_type
1467                season.season = parser.season
1468                series.seasons.append(season)
1469                logger.debug('-> added season `{}`', season)
1470            session.flush()
1471
1472            # Sets the filter_by, and filter_id for later releases query
1473            filter_id = season.id
1474            table = SeasonRelease
1475            filter_by = table.season_id
1476            entity = season
1477
1478        else:
1479            # if episode does not exist in series, add new
1480            episode = (
1481                session.query(Episode)
1482                .filter(Episode.series_id == series.id)
1483                .filter(Episode.identifier == identifier)
1484                .filter(Episode.series_id != None)
1485                .first()
1486            )
1487            if not episode:
1488                logger.debug('adding episode `{}` into series `{}`', identifier, parser.name)
1489                episode = Episode()
1490                episode.identifier = identifier
1491                episode.identified_by = parser.id_type
1492                # if episodic format
1493                if parser.id_type == 'ep':
1494                    episode.season = parser.season
1495                    episode.number = parser.episode + ix
1496                elif parser.id_type == 'sequence':
1497                    episode.season = 0
1498                    episode.number = parser.id + ix
1499                series.episodes.append(episode)  # pylint:disable=E1103
1500                logger.debug('-> added `{}`', episode)
1501            session.flush()
1502
1503            # Sets the filter_by, and filter_id for later releases query
1504            table = EpisodeRelease
1505            filter_by = table.episode_id
1506            filter_id = episode.id
1507            entity = episode
1508
1509        # if release does not exists in episode or season, add new
1510        #
1511        # NOTE:
1512        #
1513        # filter(Release.episode_id != None) fixes weird bug where release had/has been added
1514        # to database but doesn't have episode_id, this causes all kinds of havoc with the plugin.
1515        # perhaps a bug in sqlalchemy?
1516        release = (
1517            session.query(table)
1518            .filter(filter_by == filter_id)
1519            .filter(table.title == parser.data)
1520            .filter(table.quality == quality)
1521            .filter(table.proper_count == parser.proper_count)
1522            .filter(filter_by != None)
1523            .first()
1524        )
1525        if not release:
1526            logger.debug('adding release `{}`', parser)
1527            release = table()
1528            release.quality = quality
1529            release.proper_count = parser.proper_count
1530            release.title = parser.data
1531            entity.releases.append(release)  # pylint:disable=E1103
1532            logger.debug('-> added `{}`', release)
1533        releases.append(release)
1534    session.flush()  # Make sure autonumber ids are populated
1535    return releases
1536
1537
1538def add_series_entity(
1539    session: Session, series: Series, identifier: str, quality: 'Quality' = None
1540) -> None:
1541    """
1542    Adds entity identified by `identifier` to series `name` in database.
1543
1544    :param series: Series in database to add entity to.
1545    :param identifier: Series identifier to be added.
1546    :param quality: If supplied, this will override the quality from the series parser.
1547    """
1548    name_to_parse = '{} {}'.format(series.name, identifier)
1549    if quality:
1550        name_to_parse += ' {}'.format(quality)
1551    parsed = plugin.get('parsing', 'series.db').parse_series(name_to_parse, name=series.name)
1552    if not parsed.valid:
1553        raise ValueError(
1554            'Invalid identifier for series `{}`: `{}`.'.format(series.name, identifier)
1555        )
1556
1557    added = store_parser(session, parsed, series=series)
1558    if not added:
1559        raise ValueError(
1560            'Unable to add `%s` to series `%s`.' % (identifier, series.name.capitalize())
1561        )
1562    else:
1563        for release in added:
1564            release.downloaded = True
1565        logger.debug('Entity `{}` from series `{}` added to database.', identifier, series.name)
1566