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