From 559869ca6812568c437d2d64a456839d3d1e361a Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sun, 12 Apr 2026 17:12:32 +0200 Subject: [PATCH] iteration1 --- requirements/architecture.md | 4 +- requirements/project.md | 4 +- requirements/shifted_seasons_handling.md | 159 ++++--- src/ffx/cli.py | 42 +- src/ffx/constants.py | 2 +- src/ffx/database.py | 109 ++++- src/ffx/model/pattern.py | 1 + src/ffx/model/shifted_season.py | 24 +- src/ffx/pattern_details_screen.py | 158 ++++++- src/ffx/shifted_season_controller.py | 466 +++++++++++++------ src/ffx/shifted_season_delete_screen.py | 15 +- src/ffx/shifted_season_details_screen.py | 25 +- src/ffx/show_details_screen.py | 14 +- tests/unit/test_database.py | 110 +++++ tests/unit/test_shifted_season_controller.py | 191 ++++++++ 15 files changed, 1074 insertions(+), 250 deletions(-) create mode 100644 tests/unit/test_shifted_season_controller.py diff --git a/requirements/architecture.md b/requirements/architecture.md index 42be71b..51b8314 100644 --- a/requirements/architecture.md +++ b/requirements/architecture.md @@ -53,7 +53,7 @@ - `Pattern`: regex rule tying filenames to one show and one target media schema. - `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`. - `MediaTag`: persisted container-level metadata for a pattern. - - `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers. + - `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers, owned either by a show as fallback or by a pattern as override. - `Property`: internal key-value storage currently used for database versioning. - External interfaces: - CLI commands for conversion, inspection, extraction, and crop detection. @@ -64,7 +64,7 @@ - Only supported media-file extensions are accepted for conversion. - Stored database version must match the runtime-required version. - A normalized descriptor may have at most one default and one forced stream per relevant track type. - - Shifted-season ranges are intended not to overlap for the same show and season. + - Shifted-season ranges are intended not to overlap within the same owner scope and season, and runtime resolution prefers pattern-owned matches over show-owned matches. - TMDB lookups require a show ID and season and episode numbers. - Error-handling approach: - User-facing operational failures are raised as `click.ClickException` or warnings. diff --git a/requirements/project.md b/requirements/project.md index 9018043..db8d1ba 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -31,7 +31,7 @@ - As an operator, I want to inspect a file before conversion so that I can compare its actual streams and tags against the stored target schema. - As a user preparing web-playback files, I want to recode video and audio with a small set of predictable options so that results are compatible and consistently named. - As a user dealing with nonstandard releases, I want CLI overrides for language, title, stream order, default and forced tracks, and season and episode data so that one-off fixes do not require database edits first. -- As a user importing anime or other shifted numbering schemes, I want season and episode offsets per show so that generated filenames align with TMDB and media-library expectations. +- As a user importing anime or other shifted numbering schemes, I want season and episode offsets at the show level with optional pattern-specific overrides so that generated filenames align with TMDB and media-library expectations. ## Functional Requirements @@ -47,7 +47,7 @@ - regex-based filename patterns, - per-pattern media tags, - per-pattern stream definitions, - - shifted-season mappings, + - show-level and pattern-level shifted-season mappings, - internal database version properties. - Detailed show, pattern, and duplicate-match management rules live in `requirements/pattern_management.md`. - The system shall inspect source media using `ffprobe` and derive a structured description of container metadata and streams. diff --git a/requirements/shifted_seasons_handling.md b/requirements/shifted_seasons_handling.md index e018413..b20cd30 100644 --- a/requirements/shifted_seasons_handling.md +++ b/requirements/shifted_seasons_handling.md @@ -16,13 +16,15 @@ Secondary source: ## Scope - Persisting shifted-season rules in SQLite. -- Treating shifted-season rules as show-level data rather than pattern-level - data. -- Matching source season and episode numbers against one stored rule. +- Allowing shifted-season rules to be attached either to a show or to a + specific pattern. +- Selecting at most one active shifted-season rule for one concrete source + season and episode tuple. - Applying additive season and episode offsets to produce target numbering. - Using shifted target numbering during `convert` for TMDB episode lookup and generated season and episode filename tokens. -- Managing shifted-season rules from the Textual show-editing workflow. +- Managing show-level default mappings and pattern-level override mappings from + the Textual editing workflows. ## Out Of Scope @@ -33,11 +35,15 @@ Secondary source: ## Terms -- `shifted-season rule`: one persisted row that belongs to one show and defines - how one source-numbering range maps into target numbering. +- `shifted-season rule`: one persisted row describing how one source-numbering + range maps to target numbering through additive offsets. +- `show-level shifted-season rule`: a rule attached directly to a show and used + as the fallback mapping layer for that show. +- `pattern-level shifted-season rule`: a rule attached directly to a pattern and + used as the override mapping layer for that pattern. - `source numbering`: the season and episode values detected from the current source file or supplied as source-side conversion inputs before shifting. -- `target numbering`: the season and episode values after one matching +- `target numbering`: the season and episode values after one active shifted-season rule has been applied. - `original season`: the source-domain season number a shifted-season rule is eligible to match. @@ -45,16 +51,19 @@ Secondary source: shifted-season rule. - `open bound`: an unbounded start or end of the episode range. Current storage uses `-1` as the internal sentinel for an open bound. -- `sibling shifted-season rules`: all shifted-season rules stored for the same - show. +- `active shifted-season rule`: the single rule selected for one concrete input + after precedence resolution. +- `identity mapping`: the default `1:1` outcome where source numbering is used + unchanged. ## Rules -- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall treat shifted-season - rules as children of a show. Shifted-season rules shall not belong to - patterns. -- `SHIFTED_SEASONS_HANDLING-0002`: Each persisted shifted-season rule shall - belong to exactly one show. +- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall allow a + shifted-season rule to be owned by exactly one of: + - one show + - one pattern +- `SHIFTED_SEASONS_HANDLING-0002`: A single shifted-season rule shall not + belong to both a show and a pattern at the same time. - `SHIFTED_SEASONS_HANDLING-0003`: A shifted-season rule shall carry these fields: `original_season`, `first_episode`, `last_episode`, `season_offset`, and `episode_offset`. @@ -63,11 +72,11 @@ Secondary source: target numbering. - `SHIFTED_SEASONS_HANDLING-0005`: A shifted-season rule shall match a source tuple only when: - - the source season equals `original_season`, + - the source season equals `original_season` - the source episode is greater than or equal to `first_episode` when the - lower bound is closed, + lower bound is closed - the source episode is less than or equal to `last_episode` when the upper - bound is closed. + bound is closed - `SHIFTED_SEASONS_HANDLING-0006`: An open lower or upper episode bound shall represent an unbounded side of the covered source episode range. - `SHIFTED_SEASONS_HANDLING-0007`: If one shifted-season rule matches, target @@ -79,88 +88,90 @@ Secondary source: - `SHIFTED_SEASONS_HANDLING-0009`: Shifted-season handling shall operate in a source-to-target numbering model. Stored rules map detected source numbering to the target numbering used by conversion-facing metadata and output naming. -- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching may identify the owning - show, but shifted-season rule selection shall depend on the show and source - numbering, not on which pattern matched. -- `SHIFTED_SEASONS_HANDLING-0011`: For one show and one `original_season`, - shifted-season rules shall not overlap in their effective episode coverage. At - most one rule may apply to any one source season and episode tuple. -- `SHIFTED_SEASONS_HANDLING-0012`: If a shifted-season rule uses two closed +- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching identifies the owning show + and optionally a more specific owning pattern. Resolution of the active + shifted-season rule shall use this precedence order: + - matching pattern-level rule + - matching show-level rule + - identity mapping +- `SHIFTED_SEASONS_HANDLING-0011`: At most one shifted-season rule may be + active for one concrete source season and episode tuple. Shifted-season rules + shall never stack or compose. +- `SHIFTED_SEASONS_HANDLING-0012`: Within one owner scope, shifted-season rules + shall not overlap in their effective episode coverage for the same + `original_season`. +- `SHIFTED_SEASONS_HANDLING-0013`: If a shifted-season rule uses two closed episode bounds, `last_episode` shall be greater than or equal to `first_episode`. -- `SHIFTED_SEASONS_HANDLING-0013`: Shifted-season rule evaluation shall be +- `SHIFTED_SEASONS_HANDLING-0014`: Shifted-season rule evaluation shall be deterministic. Released behavior shall not depend on arbitrary database row - order when more than one stored rule could match. -- `SHIFTED_SEASONS_HANDLING-0014`: During `convert`, when show, season, and + order when invalid overlapping rules exist. +- `SHIFTED_SEASONS_HANDLING-0015`: A pattern-level rule is permitted to map to + zero offsets. Such a rule is a valid explicit override that beats show-level + fallback and produces identity mapping for its covered source range. +- `SHIFTED_SEASONS_HANDLING-0016`: During `convert`, when show, season, and episode values are available and stored shifting is active, the shifted target numbering shall drive: - TMDB episode lookup - season and episode filename tokens such as `S01E02` - generated episode basenames that include season and episode numbering -- `SHIFTED_SEASONS_HANDLING-0015`: When conversion is supplied explicit +- `SHIFTED_SEASONS_HANDLING-0017`: When conversion is supplied explicit target-domain season or episode values for TMDB naming, the system shall not apply stored shifting on top of those already-targeted values. -- `SHIFTED_SEASONS_HANDLING-0016`: Operator-facing show editing shall expose - list, add, edit, and delete flows for shifted-season rules as part of the - show-management workflow. -- `SHIFTED_SEASONS_HANDLING-0017`: User-facing shifted-season editing should +- `SHIFTED_SEASONS_HANDLING-0018`: Operator-facing editing shall expose + shifted-season rule management in both of these places: + - show editing for show-level default mappings + - pattern editing for pattern-level override mappings +- `SHIFTED_SEASONS_HANDLING-0019`: User-facing shifted-season editing should present open episode bounds as a natural empty-state input rather than forcing operators to type the internal sentinel directly. ## Acceptance -- A show can exist with zero or more shifted-season rules. -- A shifted-season rule is stored against one show, not against one pattern. -- A source tuple matching one stored rule yields exactly one shifted target - season and episode tuple derived by additive offsets. -- A source tuple matching no stored rule retains its original season and - episode values. -- Two shifted-season rules for the same show and original season cannot both be - valid if they cover overlapping episode ranges. -- A rule with closed bounds such as `first_episode=1` and `last_episode=10` - rejects an inverted interval such as `20..10`. -- A show with several patterns still uses one shared shifted-season rule set, - because shifted-season ownership is show-scoped. +- A show can exist with zero or more show-level shifted-season rules. +- A pattern can exist with zero or more pattern-level shifted-season rules. +- A shifted-season rule is stored against exactly one owner scope. +- A source tuple matching a pattern-level rule yields target numbering from that + rule even when a matching show-level rule also exists. +- A source tuple matching no pattern-level rule but matching a show-level rule + yields target numbering from the show-level rule. +- A source tuple matching neither scope yields identity mapping. +- A pattern-level zero-offset rule can explicitly override a nonzero show-level + rule for the same covered source range. +- Two shifted-season rules for the same owner scope and original season cannot + both be valid if they cover overlapping episode ranges. - During `convert`, shifted numbering is what TMDB episode lookup and generated season and episode tokens see when stored shifting is active. -- The TUI show-management flow can display and maintain shifted-season rules for - the current show. +- The TUI can display and maintain shifted-season rules from both the show and + pattern editing flows. ## Current Code Fit -- `src/ffx/model/shifted_season.py` defines the persisted - `ShiftedSeason` entity with `show_id`, `original_season`, episode bounds, and - additive offsets. -- `src/ffx/model/show.py` implements the one-to-many - `Show -> ShiftedSeason` relationship, which already aligns with show-level - ownership. -- `src/ffx/shifted_season_controller.py` implements create, update, lookup, - delete, sibling retrieval, and the runtime `shiftSeason(...)` mapping step. +- `src/ffx/model/show.py` and `src/ffx/model/pattern.py` now both expose + shifted-season relationships, and `src/ffx/model/shifted_season.py` stores + each rule against exactly one owner scope through `show_id` or `pattern_id`. +- `src/ffx/shifted_season_controller.py` now resolves mappings with + pattern-over-show precedence and applies at most one active rule for a source + tuple. - `src/ffx/show_details_screen.py`, `src/ffx/shifted_season_details_screen.py`, and - `src/ffx/shifted_season_delete_screen.py` provide the current Textual CRUD - flow for managing show-scoped shifted-season rules. -- `src/ffx/cli.py` applies `shiftSeason(...)` during `convert` before TMDB - episode lookup and before output season and episode suffix generation. -- The current `convert` implementation disables stored shifting whenever its - TMDB override bucket is present, including cases such as `--show` without an - explicit target season or episode override, so current behavior is broader - than the minimum bypass contract stated above. -- The current code does not fully satisfy the intended validation contract yet: - overlap rejection and update-time range validation are not hardened - sufficiently, and deterministic selection depends too much on invalid overlap - state not being present. + `src/ffx/shifted_season_delete_screen.py` provide reusable shifted-season + editing dialogs, and `src/ffx/pattern_details_screen.py` now exposes the + pattern-level override flow. +- `src/ffx/cli.py` now resolves shifted numbering during `convert` from: + pattern-level match, then show-level match, then identity mapping. +- `src/ffx/database.py` now migrates version-2 databases to version 3 by + preserving existing show-level rows and extending the schema for pattern-level + ownership. ## Risks - The current CLI groups `--show`, `--season`, and `--episode` under one - override bucket used for TMDB-related behavior. The exact source-domain versus - target-domain semantics of each override should stay documented clearly so + override bucket used for TMDB-related behavior. Source-domain versus + target-domain semantics of each override must stay documented clearly so stored shifting is neither skipped nor double-applied unexpectedly. +- Existing version-2 databases only contain show-owned shifted-season rows, so a + version-3 migration must preserve those rows as the show-level fallback layer. - Current modern automated test coverage for shifted-season behavior is light, - so validation and convert-time numbering behavior are not yet strongly locked - down by focused tests. -- Existing databases created before stricter validation may already contain - invalid overlapping or inverted shifted-season rules, so migration and repair - paths should continue to treat explicit validation failures as recoverable - operator signals. + so precedence, migration, and convert-time numbering behavior need focused + tests. diff --git a/src/ffx/cli.py b/src/ffx/cli.py index f3040c2..a2f9d18 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -1180,8 +1180,8 @@ def convert(ctx, ssc = ShiftedSeasonController(context) - - showId = mediaFileProperties.getShowId() + + matchedShowId = mediaFileProperties.getShowId() #HINT: -1 if not set if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']: @@ -1286,19 +1286,43 @@ def convert(ctx, indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] - # Shift season and episode if defined for this show - if ('tmdb' not in cliOverrides.keys() and showId != -1 - and showSeason != -1 and showEpisode != -1): - shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId, - season=showSeason, - episode=showEpisode) + showIdForShift = ( + cliOverrides['tmdb']['show'] + if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] + else matchedShowId + ) + patternIdForShift = currentPattern.getId() if currentPattern is not None else None + hasExplicitTargetSeasonOrEpisode = ( + 'tmdb' in cliOverrides.keys() + and ( + 'season' in cliOverrides['tmdb'] + or 'episode' in cliOverrides['tmdb'] + ) + ) + + # Shift season and episode if defined for the matched pattern or show + if ( + not hasExplicitTargetSeasonOrEpisode + and showSeason != -1 + and showEpisode != -1 + ): + shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason( + showIdForShift, + season=showSeason, + episode=showEpisode, + patternId=patternIdForShift, + ) else: shiftedShowSeason = showSeason shiftedShowEpisode = showEpisode # Assemble target filename accordingly depending on TMDB lookup is enabled #HINT: -1 if not set - showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId()) + showId = ( + cliOverrides['tmdb']['show'] + if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] + else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId()) + ) if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1: diff --git a/src/ffx/constants.py b/src/ffx/constants.py index ec22587..e7f7b59 100644 --- a/src/ffx/constants.py +++ b/src/ffx/constants.py @@ -1,5 +1,5 @@ VERSION='0.2.4' -DATABASE_VERSION = 2 +DATABASE_VERSION = 3 DEFAULT_QUALITY = 32 DEFAULT_AV1_PRESET = 5 diff --git a/src/ffx/database.py b/src/ffx/database.py index 3d5e551..aa94d87 100644 --- a/src/ffx/database.py +++ b/src/ffx/database.py @@ -1,6 +1,6 @@ import os, click -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import sessionmaker # Import the full model package so SQLAlchemy registers every mapped class @@ -71,11 +71,110 @@ def bootstrapDatabaseIfNeeded(databaseContext): def ensureDatabaseVersion(databaseContext): currentDatabaseVersion = getDatabaseVersion(databaseContext) - if currentDatabaseVersion: - if currentDatabaseVersion != DATABASE_VERSION: - raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})") - else: + if not currentDatabaseVersion: setDatabaseVersion(databaseContext, DATABASE_VERSION) + return + + if currentDatabaseVersion > DATABASE_VERSION: + raise DatabaseVersionException( + f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})" + ) + + if currentDatabaseVersion < DATABASE_VERSION: + migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION) + currentDatabaseVersion = getDatabaseVersion(databaseContext) + + if currentDatabaseVersion != DATABASE_VERSION: + raise DatabaseVersionException( + f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})" + ) + + +def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int): + version = int(currentVersion) + + while version < int(targetVersion): + if version == 2: + migrateDatabaseV2ToV3(databaseContext) + version = 3 + setDatabaseVersion(databaseContext, version) + continue + + raise DatabaseVersionException( + f"No migration path from database version {version} to {targetVersion}" + ) + + +def migrateDatabaseV2ToV3(databaseContext): + engine = databaseContext['engine'] + inspector = inspect(engine) + shiftedSeasonColumns = { + column['name'] + for column in inspector.get_columns('shifted_seasons') + } + + if 'pattern_id' in shiftedSeasonColumns: + return + + with engine.begin() as connection: + connection.execute(text("PRAGMA foreign_keys=OFF")) + connection.execute( + text( + """ + CREATE TABLE shifted_seasons_v3 ( + id INTEGER PRIMARY KEY, + show_id INTEGER, + pattern_id INTEGER, + original_season INTEGER, + first_episode INTEGER DEFAULT -1, + last_episode INTEGER DEFAULT -1, + season_offset INTEGER DEFAULT 0, + episode_offset INTEGER DEFAULT 0, + FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE, + FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE, + CHECK ( + (show_id IS NOT NULL AND pattern_id IS NULL) + OR (show_id IS NULL AND pattern_id IS NOT NULL) + ) + ) + """ + ) + ) + connection.execute( + text( + """ + INSERT INTO shifted_seasons_v3 ( + id, + show_id, + pattern_id, + original_season, + first_episode, + last_episode, + season_offset, + episode_offset + ) + SELECT + id, + show_id, + NULL, + original_season, + first_episode, + last_episode, + season_offset, + episode_offset + FROM shifted_seasons + """ + ) + ) + connection.execute(text("DROP TABLE shifted_seasons")) + connection.execute(text("ALTER TABLE shifted_seasons_v3 RENAME TO shifted_seasons")) + connection.execute( + text("CREATE INDEX ix_shifted_seasons_show_id ON shifted_seasons(show_id)") + ) + connection.execute( + text("CREATE INDEX ix_shifted_seasons_pattern_id ON shifted_seasons(pattern_id)") + ) + connection.execute(text("PRAGMA foreign_keys=ON")) def getDatabaseVersion(databaseContext): diff --git a/src/ffx/model/pattern.py b/src/ffx/model/pattern.py index 8d810d3..e3a4986 100644 --- a/src/ffx/model/pattern.py +++ b/src/ffx/model/pattern.py @@ -35,6 +35,7 @@ class Pattern(Base): tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined') media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined') + shifted_seasons = relationship('ShiftedSeason', back_populates='pattern', cascade="all, delete", lazy='joined') quality = Column(Integer, default=0) diff --git a/src/ffx/model/shifted_season.py b/src/ffx/model/shifted_season.py index d0ae795..d5c3244 100644 --- a/src/ffx/model/shifted_season.py +++ b/src/ffx/model/shifted_season.py @@ -1,6 +1,6 @@ import click -from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer from sqlalchemy.orm import relationship from .show import Base, Show @@ -9,6 +9,14 @@ from .show import Base, Show class ShiftedSeason(Base): __tablename__ = 'shifted_seasons' + __table_args__ = ( + CheckConstraint( + "(show_id IS NOT NULL AND pattern_id IS NULL) OR (show_id IS NULL AND pattern_id IS NOT NULL)", + name="ck_shifted_seasons_single_owner", + ), + Index("ix_shifted_seasons_show_id", "show_id"), + Index("ix_shifted_seasons_pattern_id", "pattern_id"), + ) # v1.x id = Column(Integer, primary_key=True) @@ -19,9 +27,12 @@ class ShiftedSeason(Base): # pattern: Mapped[str] = mapped_column(String, nullable=False) # v1.x - show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE")) + show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"), nullable=True) show = relationship(Show, back_populates='shifted_seasons', lazy='joined') + pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"), nullable=True) + pattern = relationship('Pattern', back_populates='shifted_seasons', lazy='joined') + # v2.0 # show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE")) # show: Mapped["Show"] = relationship(back_populates="patterns") @@ -39,6 +50,12 @@ class ShiftedSeason(Base): def getId(self): return self.id + def getShowId(self): + return self.show_id + + def getPatternId(self): + return self.pattern_id + def getOriginalSeason(self): return self.original_season @@ -61,6 +78,8 @@ class ShiftedSeason(Base): shiftedSeasonObj = {} + shiftedSeasonObj['show_id'] = self.getShowId() + shiftedSeasonObj['pattern_id'] = self.getPatternId() shiftedSeasonObj['original_season'] = self.getOriginalSeason() shiftedSeasonObj['first_episode'] = self.getFirstEpisode() shiftedSeasonObj['last_episode'] = self.getLastEpisode() @@ -68,4 +87,3 @@ class ShiftedSeason(Base): shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset() return shiftedSeasonObj - diff --git a/src/ffx/pattern_details_screen.py b/src/ffx/pattern_details_screen.py index fe64352..79abd28 100644 --- a/src/ffx/pattern_details_screen.py +++ b/src/ffx/pattern_details_screen.py @@ -9,6 +9,8 @@ from ffx.model.pattern import Pattern from .track_details_screen import TrackDetailsScreen from .track_delete_screen import TrackDeleteScreen +from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen +from .shifted_season_details_screen import ShiftedSeasonDetailsScreen from .tag_details_screen import TagDetailsScreen from .tag_delete_screen import TagDeleteScreen @@ -24,6 +26,7 @@ from textual.widgets._data_table import CellDoesNotExist from ffx.file_properties import FileProperties from ffx.iso_language import IsoLanguage from ffx.audio_layout import AudioLayout +from ffx.model.shifted_season import ShiftedSeason from ffx.helper import formatRichColor, removeRichColor @@ -34,8 +37,8 @@ class PatternDetailsScreen(Screen): CSS = """ Grid { - grid-size: 7 17; - grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2; + grid-size: 7 20; + grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 8 2 2 2 2; grid-columns: 25 25 25 25 25 25 25; height: 100%; width: 100%; @@ -115,11 +118,13 @@ class PatternDetailsScreen(Screen): show=True, track=True, tag=True, + shifted_season=True, ) self.__pc = controllers['pattern'] self.__sc = controllers['show'] self.__tc = controllers['track'] self.__tac = controllers['tag'] + self.__ssc = controllers['shifted_season'] self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None @@ -258,6 +263,72 @@ class PatternDetailsScreen(Screen): row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) self.tagsTable.add_row(*map(str, row)) + def updateShiftedSeasons(self): + + self.shiftedSeasonsTable.clear() + + if self.__pattern is None: + return + + shiftedSeason: ShiftedSeason + for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()): + shiftedSeasonObj = shiftedSeason.getObj() + + firstEpisode = shiftedSeasonObj['first_episode'] + firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else '' + + lastEpisode = shiftedSeasonObj['last_episode'] + lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else '' + + row = ( + shiftedSeasonObj['original_season'], + firstEpisodeStr, + lastEpisodeStr, + shiftedSeasonObj['season_offset'], + shiftedSeasonObj['episode_offset'], + ) + + self.shiftedSeasonsTable.add_row(*map(str, row)) + + def getSelectedShiftedSeasonObjFromInput(self): + + shiftedSeasonObj = {} + + try: + row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key( + self.shiftedSeasonsTable.cursor_coordinate + ) + + if row_key is not None: + selected_row_data = self.shiftedSeasonsTable.get_row(row_key) + + def parse_int_or_default(value: str, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + shiftedSeasonObj['original_season'] = int(selected_row_data[0]) + shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1) + shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1) + shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0) + shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0) + + if self.__pattern is not None: + shiftedSeasonId = self.__ssc.findShiftedSeason( + patternId=self.__pattern.getId(), + originalSeason=shiftedSeasonObj['original_season'], + firstEpisode=shiftedSeasonObj['first_episode'], + lastEpisode=shiftedSeasonObj['last_episode'], + ) + if shiftedSeasonId is not None: + shiftedSeasonObj['id'] = shiftedSeasonId + + except CellDoesNotExist: + pass + + return shiftedSeasonObj + def on_mount(self): @@ -276,6 +347,7 @@ class PatternDetailsScreen(Screen): self.updateTags() self.updateTracks() + self.updateShiftedSeasons() def compose(self): @@ -304,6 +376,16 @@ class PatternDetailsScreen(Screen): self.tracksTable.cursor_type = 'row' + self.shiftedSeasonsTable = DataTable(classes="seven") + + self.column_key_original_season = self.shiftedSeasonsTable.add_column("Original Season", width=18) + self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=18) + self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=18) + self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=18) + self.column_key_episode_offset = self.shiftedSeasonsTable.add_column("Episode Offset", width=18) + + self.shiftedSeasonsTable.cursor_type = 'row' + yield Header() @@ -345,6 +427,27 @@ class PatternDetailsScreen(Screen): yield Static(" ", classes="seven") # 9 + yield Static("Shifted Seasons") + if self.__pattern is not None: + yield Button("Add", id="button_add_shifted_season") + yield Button("Edit", id="button_edit_shifted_season") + yield Button("Delete", id="button_delete_shifted_season") + else: + yield Static(" ") + yield Static(" ") + yield Static(" ") + + yield Static(" ") + yield Static(" ") + yield Static(" ") + + # 10 + yield self.shiftedSeasonsTable + + # 11 + yield Static(" ", classes="seven") + + # 12 yield Static("Media Tags") yield Button("Add", id="button_add_tag") yield Button("Edit", id="button_edit_tag") @@ -354,13 +457,13 @@ class PatternDetailsScreen(Screen): yield Static(" ") yield Static(" ") - # 10 + # 13 yield self.tagsTable - # 11 + # 14 yield Static(" ", classes="seven") - # 12 + # 15 yield Static("Streams") yield Button("Add", id="button_add_track") yield Button("Edit", id="button_edit_track") @@ -370,21 +473,21 @@ class PatternDetailsScreen(Screen): yield Button("Up", id="button_track_up") yield Button("Down", id="button_track_down") - # 13 + # 16 yield self.tracksTable - # 14 + # 17 yield Static(" ", classes="seven") - # 15 + # 18 yield Static(" ", classes="seven") - # 16 + # 19 yield Button("Save", id="save_button") yield Button("Cancel", id="cancel_button") yield Static(" ", classes="five") - # 17 + # 20 yield Static(" ", classes="seven") yield Footer() @@ -486,6 +589,35 @@ class PatternDetailsScreen(Screen): if event.button.id == "cancel_button": self.app.pop_screen() + if event.button.id == "button_add_shifted_season": + if self.__pattern is not None: + self.app.push_screen( + ShiftedSeasonDetailsScreen(patternId=self.__pattern.getId()), + self.handle_update_shifted_season, + ) + + if event.button.id == "button_edit_shifted_season": + selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput() + if 'id' in selectedShiftedSeasonObj.keys(): + self.app.push_screen( + ShiftedSeasonDetailsScreen( + patternId=self.__pattern.getId(), + shiftedSeasonId=selectedShiftedSeasonObj['id'], + ), + self.handle_update_shifted_season, + ) + + if event.button.id == "button_delete_shifted_season": + selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput() + if 'id' in selectedShiftedSeasonObj.keys(): + self.app.push_screen( + ShiftedSeasonDeleteScreen( + patternId=self.__pattern.getId(), + shiftedSeasonId=selectedShiftedSeasonObj['id'], + ), + self.handle_delete_shifted_season, + ) + numTracks = len(self.getCurrentTrackDescriptors()) @@ -654,3 +786,9 @@ class PatternDetailsScreen(Screen): self.updateTags() else: raise click.ClickException('tag delete failed') + + def handle_update_shifted_season(self, screenResult): + self.updateShiftedSeasons() + + def handle_delete_shifted_season(self, screenResult): + self.updateShiftedSeasons() diff --git a/src/ffx/shifted_season_controller.py b/src/ffx/shifted_season_controller.py index 6fc254d..a5c1a8a 100644 --- a/src/ffx/shifted_season_controller.py +++ b/src/ffx/shifted_season_controller.py @@ -6,225 +6,431 @@ from ffx.model.shifted_season import ShiftedSeason class EpisodeOrderException(Exception): pass + class RangeOverlapException(Exception): pass -class ShiftedSeasonController(): - +class ShiftedSeasonOwnerException(Exception): + pass + + +class ShiftedSeasonController: + def __init__(self, context): - + self.context = context - self.Session = self.context['database']['session'] # convenience + self.Session = self.context['database']['session'] # convenience - def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0): + def _resolve_owner(self, showId=None, patternId=None): + hasShow = showId is not None + hasPattern = patternId is not None + + if hasShow == hasPattern: + raise ShiftedSeasonOwnerException( + "ShiftedSeason rules require exactly one owner: either showId or patternId." + ) + + if hasShow: + if type(showId) is not int: + raise ValueError( + "ShiftedSeasonController: Argument showId is required to be of type int" + ) + return { + 'show_id': int(showId), + 'pattern_id': None, + 'label': f"show #{int(showId)}", + } + + if type(patternId) is not int: + raise ValueError( + "ShiftedSeasonController: Argument patternId is required to be of type int" + ) + return { + 'show_id': None, + 'pattern_id': int(patternId), + 'label': f"pattern #{int(patternId)}", + } + + def _apply_owner_filter(self, query, owner): + if owner['pattern_id'] is not None: + return query.filter(ShiftedSeason.pattern_id == owner['pattern_id']) + return query.filter(ShiftedSeason.show_id == owner['show_id']) + + def _normalize_shifted_season_fields(self, shiftedSeasonObj: dict): + if type(shiftedSeasonObj) is not dict: + raise ValueError( + "ShiftedSeasonController: Argument shiftedSeasonObj is required to be of type dict" + ) + + fields = { + 'original_season': int(shiftedSeasonObj['original_season']), + 'first_episode': int(shiftedSeasonObj['first_episode']), + 'last_episode': int(shiftedSeasonObj['last_episode']), + 'season_offset': int(shiftedSeasonObj['season_offset']), + 'episode_offset': int(shiftedSeasonObj['episode_offset']), + } + + firstEpisode = fields['first_episode'] + lastEpisode = fields['last_episode'] + if firstEpisode != -1 and lastEpisode != -1 and lastEpisode < firstEpisode: + raise EpisodeOrderException( + "ShiftedSeason last_episode must be greater than or equal to first_episode." + ) + + return fields + + def _ranges_overlap(self, firstEpisodeA, lastEpisodeA, firstEpisodeB, lastEpisodeB): + startA = float('-inf') if int(firstEpisodeA) == -1 else int(firstEpisodeA) + endA = float('inf') if int(lastEpisodeA) == -1 else int(lastEpisodeA) + startB = float('-inf') if int(firstEpisodeB) == -1 else int(firstEpisodeB) + endB = float('inf') if int(lastEpisodeB) == -1 else int(lastEpisodeB) + return startA <= endB and startB <= endA + + def _ordered_query(self, session, owner): + q = self._apply_owner_filter(session.query(ShiftedSeason), owner) + return q.order_by( + ShiftedSeason.original_season.asc(), + ShiftedSeason.first_episode.asc(), + ShiftedSeason.last_episode.asc(), + ShiftedSeason.id.asc(), + ) + + def _find_matching_rule(self, session, owner, season: int, episode: int): + for shiftedSeasonEntry in self._ordered_query(session, owner).all(): + if ( + season == shiftedSeasonEntry.getOriginalSeason() + and ( + shiftedSeasonEntry.getFirstEpisode() == -1 + or episode >= shiftedSeasonEntry.getFirstEpisode() + ) + and ( + shiftedSeasonEntry.getLastEpisode() == -1 + or episode <= shiftedSeasonEntry.getLastEpisode() + ) + ): + return shiftedSeasonEntry + return None + + def checkShiftedSeason( + self, + showId: int | None = None, + shiftedSeasonObj: dict | None = None, + shiftedSeasonId: int = 0, + patternId: int | None = None, + ): """ - Check if for a particula season - - shiftedSeasonId + Check whether a shifted-season rule is valid within one owner scope. """ + session = None try: - s = self.Session() + owner = self._resolve_owner(showId=showId, patternId=patternId) + fields = self._normalize_shifted_season_fields(shiftedSeasonObj) + session = self.Session() - originalSeason = shiftedSeasonObj['original_season'] - firstEpisode = int(shiftedSeasonObj['first_episode']) - lastEpisode = int(shiftedSeasonObj['last_episode']) - - q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId)) + q = self._ordered_query(session, owner) if shiftedSeasonId: q = q.filter(ShiftedSeason.id != int(shiftedSeasonId)) - siblingShiftedSeason: ShiftedSeason for siblingShiftedSeason in q.all(): - - siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason - siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode() - siblingLastEpisode = siblingShiftedSeason.getLastEpisode() - - if (originalSeason == siblingOriginalSeason - and lastEpisode >= siblingFirstEpisode - and siblingLastEpisode >= firstEpisode): + if fields['original_season'] != siblingShiftedSeason.getOriginalSeason(): + continue + if self._ranges_overlap( + fields['first_episode'], + fields['last_episode'], + siblingShiftedSeason.getFirstEpisode(), + siblingShiftedSeason.getLastEpisode(), + ): return False + return True + except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex: + raise click.ClickException(str(ex)) except Exception as ex: - raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}" + ) finally: - s.close() + if session is not None: + session.close() + def addShiftedSeason( + self, + showId: int | None = None, + shiftedSeasonObj: dict | None = None, + patternId: int | None = None, + ): - def addShiftedSeason(self, showId: int, shiftedSeasonObj: dict): - - if type(showId) is not int: - raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument showId is required to be of type int") - - if type(shiftedSeasonObj) is not dict: - raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict") - + session = None try: - s = self.Session() + owner = self._resolve_owner(showId=showId, patternId=patternId) + fields = self._normalize_shifted_season_fields(shiftedSeasonObj) - firstEpisode = int(shiftedSeasonObj['first_episode']) - lastEpisode = int(shiftedSeasonObj['last_episode']) + if not self.checkShiftedSeason( + showId=owner['show_id'], + patternId=owner['pattern_id'], + shiftedSeasonObj=fields, + ): + raise RangeOverlapException( + f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}." + ) - if lastEpisode < firstEpisode: - raise EpisodeOrderException() - - q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId)) - - shiftedSeason = ShiftedSeason(show_id = int(showId), - original_season = int(shiftedSeasonObj['original_season']), - first_episode = firstEpisode, - last_episode = lastEpisode, - season_offset = int(shiftedSeasonObj['season_offset']), - episode_offset = int(shiftedSeasonObj['episode_offset'])) - s.add(shiftedSeason) - s.commit() + session = self.Session() + shiftedSeason = ShiftedSeason( + show_id=owner['show_id'], + pattern_id=owner['pattern_id'], + original_season=fields['original_season'], + first_episode=fields['first_episode'], + last_episode=fields['last_episode'], + season_offset=fields['season_offset'], + episode_offset=fields['episode_offset'], + ) + session.add(shiftedSeason) + session.commit() return shiftedSeason.getId() + except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex: + raise click.ClickException(str(ex)) except Exception as ex: - raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}" + ) finally: - s.close() - + if session is not None: + session.close() def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict): if type(shiftedSeasonId) is not int: - raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int") - - if type(shiftedSeasonObj) is not dict: - raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict") + raise ValueError( + "ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int" + ) + session = None try: - s = self.Session() + fields = self._normalize_shifted_season_fields(shiftedSeasonObj) + session = self.Session() - shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first() + shiftedSeason = ( + session.query(ShiftedSeason) + .filter(ShiftedSeason.id == int(shiftedSeasonId)) + .first() + ) - if shiftedSeason is not None: - - shiftedSeason.original_season = int(shiftedSeasonObj['original_season']) - shiftedSeason.first_episode = int(shiftedSeasonObj['first_episode']) - shiftedSeason.last_episode = int(shiftedSeasonObj['last_episode']) - shiftedSeason.season_offset = int(shiftedSeasonObj['season_offset']) - shiftedSeason.episode_offset = int(shiftedSeasonObj['episode_offset']) - - s.commit() - return True - - else: + if shiftedSeason is None: return False + owner = self._resolve_owner( + showId=shiftedSeason.getShowId(), + patternId=shiftedSeason.getPatternId(), + ) + if not self.checkShiftedSeason( + showId=owner['show_id'], + patternId=owner['pattern_id'], + shiftedSeasonObj=fields, + shiftedSeasonId=shiftedSeasonId, + ): + raise RangeOverlapException( + f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}." + ) + + shiftedSeason.original_season = fields['original_season'] + shiftedSeason.first_episode = fields['first_episode'] + shiftedSeason.last_episode = fields['last_episode'] + shiftedSeason.season_offset = fields['season_offset'] + shiftedSeason.episode_offset = fields['episode_offset'] + + session.commit() + return True + + except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex: + raise click.ClickException(str(ex)) except Exception as ex: - raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}" + ) finally: - s.close() + if session is not None: + session.close() - - def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int): - - if type(showId) is not int: - raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int") + def findShiftedSeason( + self, + showId: int | None = None, + originalSeason: int | None = None, + firstEpisode: int | None = None, + lastEpisode: int | None = None, + patternId: int | None = None, + ): if type(originalSeason) is not int: - raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int") + raise ValueError( + "ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int" + ) if type(firstEpisode) is not int: - raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int") + raise ValueError( + "ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int" + ) if type(lastEpisode) is not int: - raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int") + raise ValueError( + "ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int" + ) + session = None try: - s = self.Session() - shiftedSeason = s.query(ShiftedSeason).filter( - ShiftedSeason.show_id == int(showId), - ShiftedSeason.original_season == int(originalSeason), - ShiftedSeason.first_episode == int(firstEpisode), - ShiftedSeason.last_episode == int(lastEpisode), - ).first() + owner = self._resolve_owner(showId=showId, patternId=patternId) + session = self.Session() + shiftedSeason = ( + self._apply_owner_filter(session.query(ShiftedSeason), owner) + .filter( + ShiftedSeason.original_season == int(originalSeason), + ShiftedSeason.first_episode == int(firstEpisode), + ShiftedSeason.last_episode == int(lastEpisode), + ) + .first() + ) return shiftedSeason.getId() if shiftedSeason is not None else None + except ShiftedSeasonOwnerException as ex: + raise click.ClickException(str(ex)) except Exception as ex: - raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}" + ) finally: - s.close() + if session is not None: + session.close() - def getShiftedSeasonSiblings(self, showId: int): - - if type(showId) is not int: - raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int") + def getShiftedSeasonSiblings( + self, + showId: int | None = None, + patternId: int | None = None, + ): + session = None try: - s = self.Session() - q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId)) - - return q.all() + owner = self._resolve_owner(showId=showId, patternId=patternId) + session = self.Session() + return self._ordered_query(session, owner).all() + except ShiftedSeasonOwnerException as ex: + raise click.ClickException(str(ex)) except Exception as ex: - raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}" + ) finally: - s.close() - + if session is not None: + session.close() def getShiftedSeason(self, shiftedSeasonId: int): if type(shiftedSeasonId) is not int: - raise ValueError(f"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int") + raise ValueError( + "ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int" + ) + session = None try: - s = self.Session() - return s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first() + session = self.Session() + return ( + session.query(ShiftedSeason) + .filter(ShiftedSeason.id == int(shiftedSeasonId)) + .first() + ) except Exception as ex: - raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}" + ) finally: - s.close() - + if session is not None: + session.close() def deleteShiftedSeason(self, shiftedSeasonId): if type(shiftedSeasonId) is not int: - raise ValueError(f"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int") + raise ValueError( + "ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int" + ) + session = None try: - s = self.Session() - shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first() + session = self.Session() + shiftedSeason = ( + session.query(ShiftedSeason) + .filter(ShiftedSeason.id == int(shiftedSeasonId)) + .first() + ) if shiftedSeason is not None: - - #DAFUQ: https://stackoverflow.com/a/19245058 - # q.delete() - s.delete(shiftedSeason) - - s.commit() + session.delete(shiftedSeason) + session.commit() return True return False except Exception as ex: - raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}") + raise click.ClickException( + f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}" + ) finally: - s.close() + if session is not None: + session.close() + def shiftSeason(self, showId, season, episode, patternId=None): - def shiftSeason(self, showId, season, episode): + if season == -1 or episode == -1: + return season, episode - shiftedSeasonEntry: ShiftedSeason - for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId): + session = None + try: + session = self.Session() + activeShift = None - if (season == shiftedSeasonEntry.getOriginalSeason() - and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode()) - and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())): + if patternId is not None: + activeShift = self._find_matching_rule( + session, + self._resolve_owner(patternId=patternId), + season=int(season), + episode=int(episode), + ) - shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset() - shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset() + if activeShift is None and showId is not None and showId != -1: + activeShift = self._find_matching_rule( + session, + self._resolve_owner(showId=showId), + season=int(season), + episode=int(episode), + ) - self.context['logger'].info(f"Shifting season: {season} episode: {episode} " - +f"-> season: {shiftedSeason} episode: {shiftedEpisode}") + if activeShift is None: + return season, episode - return shiftedSeason, shiftedEpisode - - return season, episode + shiftedSeason = season + activeShift.getSeasonOffset() + shiftedEpisode = episode + activeShift.getEpisodeOffset() + + ownerLabel = ( + f"pattern #{activeShift.getPatternId()}" + if activeShift.getPatternId() is not None + else f"show #{activeShift.getShowId()}" + ) + self.context['logger'].info( + f"Shifting season via {ownerLabel}: {season}/{episode} -> {shiftedSeason}/{shiftedEpisode}" + ) + + return shiftedSeason, shiftedEpisode + + except ShiftedSeasonOwnerException as ex: + raise click.ClickException(str(ex)) + except Exception as ex: + raise click.ClickException( + f"ShiftedSeasonController.shiftSeason(): {repr(ex)}" + ) + finally: + if session is not None: + session.close() diff --git a/src/ffx/shifted_season_delete_screen.py b/src/ffx/shifted_season_delete_screen.py index cedb579..75f27af 100644 --- a/src/ffx/shifted_season_delete_screen.py +++ b/src/ffx/shifted_season_delete_screen.py @@ -43,7 +43,7 @@ class ShiftedSeasonDeleteScreen(Screen): } """ - def __init__(self, showId = None, shiftedSeasonId = None): + def __init__(self, showId = None, patternId = None, shiftedSeasonId = None): super().__init__() self.context = self.app.getContext() @@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen): self.__ssc = ShiftedSeasonController(context = self.context) self._showId = showId + self._patternId = patternId self.__shiftedSeasonId = shiftedSeasonId @@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen): shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId) - self.query_one("#static_show_id", Static).update(str(self._showId)) + ownerLabel = ( + f"pattern #{self._patternId}" + if self._patternId is not None + else f"show #{self._showId}" + ) + self.query_one("#static_owner", Static).update(ownerLabel) self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason())) self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode())) self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode())) @@ -77,8 +83,8 @@ class ShiftedSeasonDeleteScreen(Screen): yield Static(" ", classes="two") - yield Static("from show") - yield Static(" ", id="static_show_id") + yield Static("from") + yield Static(" ", id="static_owner") yield Static(" ", classes="two") @@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen): if event.button.id == "cancel_button": self.app.pop_screen() - diff --git a/src/ffx/shifted_season_details_screen.py b/src/ffx/shifted_season_details_screen.py index 1c09d5a..74d256e 100644 --- a/src/ffx/shifted_season_details_screen.py +++ b/src/ffx/shifted_season_details_screen.py @@ -81,7 +81,7 @@ class ShiftedSeasonDetailsScreen(Screen): } """ - def __init__(self, showId = None, shiftedSeasonId = None): + def __init__(self, showId = None, patternId = None, shiftedSeasonId = None): super().__init__() self.context = self.app.getContext() @@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen): self.__ssc = ShiftedSeasonController(context = self.context) self.__showId = showId + self.__patternId = patternId self.__shiftedSeasonId = shiftedSeasonId + def _owner_kwargs(self): + if self.__patternId is not None: + return {'patternId': self.__patternId} + return {'showId': self.__showId} + def on_mount(self): if self.__shiftedSeasonId is not None: @@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen): if self.__shiftedSeasonId is not None: - if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj, - shiftedSeasonId = self.__shiftedSeasonId): + if self.__ssc.checkShiftedSeason( + shiftedSeasonObj=shiftedSeasonObj, + shiftedSeasonId=self.__shiftedSeasonId, + **self._owner_kwargs(), + ): if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj): self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj)) else: @@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen): self.app.pop_screen() else: - if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj): - self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj) + if self.__ssc.checkShiftedSeason( + shiftedSeasonObj=shiftedSeasonObj, + **self._owner_kwargs(), + ): + self.__shiftedSeasonId = self.__ssc.addShiftedSeason( + shiftedSeasonObj=shiftedSeasonObj, + **self._owner_kwargs(), + ) self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj)) diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index 311a91f..b343423 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -211,11 +211,17 @@ class ShowDetailsScreen(Screen): if row_key is not None: selected_row_data = self.shiftedSeasonsTable.get_row(row_key) + def parse_int_or_default(value: str, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + shiftedSeasonObj['original_season'] = int(selected_row_data[0]) - shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1 - shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1 - shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0 - shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0 + shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1) + shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1) + shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0) + shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0) if self.__showDescriptor is not None: diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index 27fa2da..af99d11 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +import sqlite3 import sys import tempfile import unittest @@ -15,8 +16,17 @@ if str(SRC_ROOT) not in sys.path: from ffx.constants import DATABASE_VERSION # noqa: E402 from ffx.database import DATABASE_VERSION_KEY, databaseContext, getDatabaseVersion # noqa: E402 +from ffx.model.shifted_season import ShiftedSeason # noqa: E402 from ffx.model.property import Property # noqa: E402 from ffx.model.show import Base # noqa: E402 +from ffx.show_controller import ShowController # noqa: E402 +from ffx.show_descriptor import ShowDescriptor # noqa: E402 +from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402 + + +class StaticConfig: + def getData(self): + return {} class DatabaseContextTests(unittest.TestCase): @@ -78,6 +88,106 @@ class DatabaseContextTests(unittest.TestCase): mocked_create_all.assert_not_called() + def test_database_context_migrates_v2_shifted_seasons_schema_to_v3(self): + database_context = databaseContext(str(self.database_path)) + context = { + "database": database_context, + "config": StaticConfig(), + "logger": object(), + } + try: + ShowController(context).updateShow( + ShowDescriptor(id=1, name="Demo", year=2000) + ) + shifted_season_id = ShiftedSeasonController(context).addShiftedSeason( + showId=1, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 1, + "episode_offset": -10, + }, + ) + finally: + database_context["engine"].dispose() + + connection = sqlite3.connect(self.database_path) + try: + cursor = connection.cursor() + cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_show_id") + cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_pattern_id") + cursor.execute( + "ALTER TABLE shifted_seasons RENAME TO shifted_seasons_v3_current" + ) + cursor.execute( + """ + CREATE TABLE shifted_seasons ( + id INTEGER PRIMARY KEY, + show_id INTEGER, + original_season INTEGER, + first_episode INTEGER DEFAULT -1, + last_episode INTEGER DEFAULT -1, + season_offset INTEGER DEFAULT 0, + episode_offset INTEGER DEFAULT 0, + FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE + ) + """ + ) + cursor.execute( + """ + INSERT INTO shifted_seasons ( + id, + show_id, + original_season, + first_episode, + last_episode, + season_offset, + episode_offset + ) + SELECT + id, + show_id, + original_season, + first_episode, + last_episode, + season_offset, + episode_offset + FROM shifted_seasons_v3_current + """ + ) + cursor.execute("DROP TABLE shifted_seasons_v3_current") + cursor.execute( + "UPDATE properties SET value = '2' WHERE key = ?", + (DATABASE_VERSION_KEY,), + ) + connection.commit() + finally: + connection.close() + + reopened_context = databaseContext(str(self.database_path)) + try: + self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context)) + + Session = reopened_context["session"] + session = Session() + try: + migrated_shifted_season = ( + session.query(ShiftedSeason) + .filter(ShiftedSeason.id == shifted_season_id) + .first() + ) + self.assertIsNotNone(migrated_shifted_season) + self.assertEqual(1, migrated_shifted_season.getShowId()) + self.assertIsNone(migrated_shifted_season.getPatternId()) + self.assertEqual(1, migrated_shifted_season.getOriginalSeason()) + self.assertEqual(1, migrated_shifted_season.getFirstEpisode()) + self.assertEqual(10, migrated_shifted_season.getLastEpisode()) + finally: + session.close() + finally: + reopened_context["engine"].dispose() + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_shifted_season_controller.py b/tests/unit/test_shifted_season_controller.py new file mode 100644 index 0000000..c32f16e --- /dev/null +++ b/tests/unit/test_shifted_season_controller.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import logging +from pathlib import Path +import sys +import tempfile +import unittest + + +SRC_ROOT = Path(__file__).resolve().parents[2] / "src" + +if str(SRC_ROOT) not in sys.path: + sys.path.insert(0, str(SRC_ROOT)) + + +from ffx.database import databaseContext # noqa: E402 +from ffx.model.pattern import Pattern # noqa: E402 +from ffx.model.track import Track # noqa: E402 +from ffx.show_controller import ShowController # noqa: E402 +from ffx.show_descriptor import ShowDescriptor # noqa: E402 +from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402 +from ffx.track_type import TrackType # noqa: E402 + + +class StaticConfig: + def __init__(self, data: dict | None = None): + self._data = data or {} + + def getData(self): + return self._data + + +def make_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + logger.handlers = [] + logger.setLevel(logging.DEBUG) + logger.propagate = False + logger.addHandler(logging.NullHandler()) + return logger + + +def make_context(database_path: Path) -> dict: + return { + "logger": make_logger(f"ffx-test-shifted-{database_path.stem}"), + "config": StaticConfig(), + "database": databaseContext(str(database_path)), + } + + +class ShiftedSeasonControllerTests(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.database_path = Path(self.tempdir.name) / "shifted-season-test.db" + self.context = make_context(self.database_path) + self.show_controller = ShowController(self.context) + self.shifted_season_controller = ShiftedSeasonController(self.context) + + def tearDown(self): + self.context["database"]["engine"].dispose() + self.tempdir.cleanup() + + def add_show(self, show_id: int, name: str = "Demo Show"): + self.show_controller.updateShow( + ShowDescriptor(id=show_id, name=name, year=2000 + show_id) + ) + + def add_pattern(self, show_id: int, expression: str) -> int: + self.add_show(show_id) + Session = self.context["database"]["session"] + session = Session() + try: + pattern = Pattern(show_id=show_id, pattern=expression) + session.add(pattern) + session.flush() + session.add( + Track( + pattern_id=pattern.getId(), + track_type=TrackType.VIDEO.index(), + codec_name="h264", + index=0, + source_index=0, + disposition_flags=0, + audio_layout=0, + ) + ) + session.commit() + return pattern.getId() + finally: + session.close() + + def test_shift_season_uses_show_mapping_when_no_pattern_mapping_exists(self): + pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") + self.shifted_season_controller.addShiftedSeason( + showId=1, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 2, + "episode_offset": 5, + }, + ) + + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=1, + episode=3, + ) + + self.assertEqual((3, 8), (shifted_season, shifted_episode)) + + def test_shift_season_prefers_pattern_mapping_over_show_mapping(self): + pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") + self.shifted_season_controller.addShiftedSeason( + showId=1, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 2, + "episode_offset": 5, + }, + ) + self.shifted_season_controller.addShiftedSeason( + patternId=pattern_id, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 1, + "episode_offset": -2, + }, + ) + + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=1, + episode=3, + ) + + self.assertEqual((2, 1), (shifted_season, shifted_episode)) + + def test_shift_season_pattern_zero_offsets_override_show_mapping_to_identity(self): + pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") + self.shifted_season_controller.addShiftedSeason( + showId=1, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 2, + "episode_offset": 5, + }, + ) + self.shifted_season_controller.addShiftedSeason( + patternId=pattern_id, + shiftedSeasonObj={ + "original_season": 1, + "first_episode": 1, + "last_episode": 10, + "season_offset": 0, + "episode_offset": 0, + }, + ) + + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=1, + episode=3, + ) + + self.assertEqual((1, 3), (shifted_season, shifted_episode)) + + def test_shift_season_falls_back_to_identity_when_no_rule_matches(self): + pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") + + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=4, + episode=20, + ) + + self.assertEqual((4, 20), (shifted_season, shifted_episode)) + + +if __name__ == "__main__": + unittest.main()