iteration1

This commit is contained in:
Javanaut
2026-04-12 17:12:32 +02:00
parent 0e4fae538b
commit 559869ca68
15 changed files with 1074 additions and 250 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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:

View File

@@ -1,5 +1,5 @@
VERSION='0.2.4'
DATABASE_VERSION = 2
DATABASE_VERSION = 3
DEFAULT_QUALITY = 32
DEFAULT_AV1_PRESET = 5

View File

@@ -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):

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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))

View File

@@ -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:

View File

@@ -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()

View File

@@ -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()