iteration1
This commit is contained in:
@@ -53,7 +53,7 @@
|
|||||||
- `Pattern`: regex rule tying filenames to one show and one target media schema.
|
- `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`.
|
- `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.
|
- `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.
|
- `Property`: internal key-value storage currently used for database versioning.
|
||||||
- External interfaces:
|
- External interfaces:
|
||||||
- CLI commands for conversion, inspection, extraction, and crop detection.
|
- CLI commands for conversion, inspection, extraction, and crop detection.
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
- Only supported media-file extensions are accepted for conversion.
|
- Only supported media-file extensions are accepted for conversion.
|
||||||
- Stored database version must match the runtime-required version.
|
- 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.
|
- 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.
|
- TMDB lookups require a show ID and season and episode numbers.
|
||||||
- Error-handling approach:
|
- Error-handling approach:
|
||||||
- User-facing operational failures are raised as `click.ClickException` or warnings.
|
- User-facing operational failures are raised as `click.ClickException` or warnings.
|
||||||
|
|||||||
@@ -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 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 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 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
|
## Functional Requirements
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
- regex-based filename patterns,
|
- regex-based filename patterns,
|
||||||
- per-pattern media tags,
|
- per-pattern media tags,
|
||||||
- per-pattern stream definitions,
|
- per-pattern stream definitions,
|
||||||
- shifted-season mappings,
|
- show-level and pattern-level shifted-season mappings,
|
||||||
- internal database version properties.
|
- internal database version properties.
|
||||||
- Detailed show, pattern, and duplicate-match management rules live in `requirements/pattern_management.md`.
|
- 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.
|
- The system shall inspect source media using `ffprobe` and derive a structured description of container metadata and streams.
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ Secondary source:
|
|||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- Persisting shifted-season rules in SQLite.
|
- Persisting shifted-season rules in SQLite.
|
||||||
- Treating shifted-season rules as show-level data rather than pattern-level
|
- Allowing shifted-season rules to be attached either to a show or to a
|
||||||
data.
|
specific pattern.
|
||||||
- Matching source season and episode numbers against one stored rule.
|
- 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.
|
- Applying additive season and episode offsets to produce target numbering.
|
||||||
- Using shifted target numbering during `convert` for TMDB episode lookup and
|
- Using shifted target numbering during `convert` for TMDB episode lookup and
|
||||||
generated season and episode filename tokens.
|
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
|
## Out Of Scope
|
||||||
|
|
||||||
@@ -33,11 +35,15 @@ Secondary source:
|
|||||||
|
|
||||||
## Terms
|
## Terms
|
||||||
|
|
||||||
- `shifted-season rule`: one persisted row that belongs to one show and defines
|
- `shifted-season rule`: one persisted row describing how one source-numbering
|
||||||
how one source-numbering range maps into target 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 numbering`: the season and episode values detected from the current
|
||||||
source file or supplied as source-side conversion inputs before shifting.
|
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.
|
shifted-season rule has been applied.
|
||||||
- `original season`: the source-domain season number a shifted-season rule is
|
- `original season`: the source-domain season number a shifted-season rule is
|
||||||
eligible to match.
|
eligible to match.
|
||||||
@@ -45,16 +51,19 @@ Secondary source:
|
|||||||
shifted-season rule.
|
shifted-season rule.
|
||||||
- `open bound`: an unbounded start or end of the episode range. Current storage
|
- `open bound`: an unbounded start or end of the episode range. Current storage
|
||||||
uses `-1` as the internal sentinel for an open bound.
|
uses `-1` as the internal sentinel for an open bound.
|
||||||
- `sibling shifted-season rules`: all shifted-season rules stored for the same
|
- `active shifted-season rule`: the single rule selected for one concrete input
|
||||||
show.
|
after precedence resolution.
|
||||||
|
- `identity mapping`: the default `1:1` outcome where source numbering is used
|
||||||
|
unchanged.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall treat shifted-season
|
- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall allow a
|
||||||
rules as children of a show. Shifted-season rules shall not belong to
|
shifted-season rule to be owned by exactly one of:
|
||||||
patterns.
|
- one show
|
||||||
- `SHIFTED_SEASONS_HANDLING-0002`: Each persisted shifted-season rule shall
|
- one pattern
|
||||||
belong to exactly one show.
|
- `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
|
- `SHIFTED_SEASONS_HANDLING-0003`: A shifted-season rule shall carry these
|
||||||
fields: `original_season`, `first_episode`, `last_episode`,
|
fields: `original_season`, `first_episode`, `last_episode`,
|
||||||
`season_offset`, and `episode_offset`.
|
`season_offset`, and `episode_offset`.
|
||||||
@@ -63,11 +72,11 @@ Secondary source:
|
|||||||
target numbering.
|
target numbering.
|
||||||
- `SHIFTED_SEASONS_HANDLING-0005`: A shifted-season rule shall match a source
|
- `SHIFTED_SEASONS_HANDLING-0005`: A shifted-season rule shall match a source
|
||||||
tuple only when:
|
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
|
- 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
|
- 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
|
- `SHIFTED_SEASONS_HANDLING-0006`: An open lower or upper episode bound shall
|
||||||
represent an unbounded side of the covered source episode range.
|
represent an unbounded side of the covered source episode range.
|
||||||
- `SHIFTED_SEASONS_HANDLING-0007`: If one shifted-season rule matches, target
|
- `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
|
- `SHIFTED_SEASONS_HANDLING-0009`: Shifted-season handling shall operate in a
|
||||||
source-to-target numbering model. Stored rules map detected source numbering
|
source-to-target numbering model. Stored rules map detected source numbering
|
||||||
to the target numbering used by conversion-facing metadata and output naming.
|
to the target numbering used by conversion-facing metadata and output naming.
|
||||||
- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching may identify the owning
|
- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching identifies the owning show
|
||||||
show, but shifted-season rule selection shall depend on the show and source
|
and optionally a more specific owning pattern. Resolution of the active
|
||||||
numbering, not on which pattern matched.
|
shifted-season rule shall use this precedence order:
|
||||||
- `SHIFTED_SEASONS_HANDLING-0011`: For one show and one `original_season`,
|
- matching pattern-level rule
|
||||||
shifted-season rules shall not overlap in their effective episode coverage. At
|
- matching show-level rule
|
||||||
most one rule may apply to any one source season and episode tuple.
|
- identity mapping
|
||||||
- `SHIFTED_SEASONS_HANDLING-0012`: If a shifted-season rule uses two closed
|
- `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
|
episode bounds, `last_episode` shall be greater than or equal to
|
||||||
`first_episode`.
|
`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
|
deterministic. Released behavior shall not depend on arbitrary database row
|
||||||
order when more than one stored rule could match.
|
order when invalid overlapping rules exist.
|
||||||
- `SHIFTED_SEASONS_HANDLING-0014`: During `convert`, when show, season, and
|
- `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
|
episode values are available and stored shifting is active, the shifted target
|
||||||
numbering shall drive:
|
numbering shall drive:
|
||||||
- TMDB episode lookup
|
- TMDB episode lookup
|
||||||
- season and episode filename tokens such as `S01E02`
|
- season and episode filename tokens such as `S01E02`
|
||||||
- generated episode basenames that include season and episode numbering
|
- 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
|
target-domain season or episode values for TMDB naming, the system shall not
|
||||||
apply stored shifting on top of those already-targeted values.
|
apply stored shifting on top of those already-targeted values.
|
||||||
- `SHIFTED_SEASONS_HANDLING-0016`: Operator-facing show editing shall expose
|
- `SHIFTED_SEASONS_HANDLING-0018`: Operator-facing editing shall expose
|
||||||
list, add, edit, and delete flows for shifted-season rules as part of the
|
shifted-season rule management in both of these places:
|
||||||
show-management workflow.
|
- show editing for show-level default mappings
|
||||||
- `SHIFTED_SEASONS_HANDLING-0017`: User-facing shifted-season editing should
|
- 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
|
present open episode bounds as a natural empty-state input rather than forcing
|
||||||
operators to type the internal sentinel directly.
|
operators to type the internal sentinel directly.
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
|
|
||||||
- A show can exist with zero or more shifted-season rules.
|
- A show can exist with zero or more show-level shifted-season rules.
|
||||||
- A shifted-season rule is stored against one show, not against one pattern.
|
- A pattern can exist with zero or more pattern-level shifted-season rules.
|
||||||
- A source tuple matching one stored rule yields exactly one shifted target
|
- A shifted-season rule is stored against exactly one owner scope.
|
||||||
season and episode tuple derived by additive offsets.
|
- A source tuple matching a pattern-level rule yields target numbering from that
|
||||||
- A source tuple matching no stored rule retains its original season and
|
rule even when a matching show-level rule also exists.
|
||||||
episode values.
|
- A source tuple matching no pattern-level rule but matching a show-level rule
|
||||||
- Two shifted-season rules for the same show and original season cannot both be
|
yields target numbering from the show-level rule.
|
||||||
valid if they cover overlapping episode ranges.
|
- A source tuple matching neither scope yields identity mapping.
|
||||||
- A rule with closed bounds such as `first_episode=1` and `last_episode=10`
|
- A pattern-level zero-offset rule can explicitly override a nonzero show-level
|
||||||
rejects an inverted interval such as `20..10`.
|
rule for the same covered source range.
|
||||||
- A show with several patterns still uses one shared shifted-season rule set,
|
- Two shifted-season rules for the same owner scope and original season cannot
|
||||||
because shifted-season ownership is show-scoped.
|
both be valid if they cover overlapping episode ranges.
|
||||||
- During `convert`, shifted numbering is what TMDB episode lookup and generated
|
- During `convert`, shifted numbering is what TMDB episode lookup and generated
|
||||||
season and episode tokens see when stored shifting is active.
|
season and episode tokens see when stored shifting is active.
|
||||||
- The TUI show-management flow can display and maintain shifted-season rules for
|
- The TUI can display and maintain shifted-season rules from both the show and
|
||||||
the current show.
|
pattern editing flows.
|
||||||
|
|
||||||
## Current Code Fit
|
## Current Code Fit
|
||||||
|
|
||||||
- `src/ffx/model/shifted_season.py` defines the persisted
|
- `src/ffx/model/show.py` and `src/ffx/model/pattern.py` now both expose
|
||||||
`ShiftedSeason` entity with `show_id`, `original_season`, episode bounds, and
|
shifted-season relationships, and `src/ffx/model/shifted_season.py` stores
|
||||||
additive offsets.
|
each rule against exactly one owner scope through `show_id` or `pattern_id`.
|
||||||
- `src/ffx/model/show.py` implements the one-to-many
|
- `src/ffx/shifted_season_controller.py` now resolves mappings with
|
||||||
`Show -> ShiftedSeason` relationship, which already aligns with show-level
|
pattern-over-show precedence and applies at most one active rule for a source
|
||||||
ownership.
|
tuple.
|
||||||
- `src/ffx/shifted_season_controller.py` implements create, update, lookup,
|
|
||||||
delete, sibling retrieval, and the runtime `shiftSeason(...)` mapping step.
|
|
||||||
- `src/ffx/show_details_screen.py`,
|
- `src/ffx/show_details_screen.py`,
|
||||||
`src/ffx/shifted_season_details_screen.py`, and
|
`src/ffx/shifted_season_details_screen.py`, and
|
||||||
`src/ffx/shifted_season_delete_screen.py` provide the current Textual CRUD
|
`src/ffx/shifted_season_delete_screen.py` provide reusable shifted-season
|
||||||
flow for managing show-scoped shifted-season rules.
|
editing dialogs, and `src/ffx/pattern_details_screen.py` now exposes the
|
||||||
- `src/ffx/cli.py` applies `shiftSeason(...)` during `convert` before TMDB
|
pattern-level override flow.
|
||||||
episode lookup and before output season and episode suffix generation.
|
- `src/ffx/cli.py` now resolves shifted numbering during `convert` from:
|
||||||
- The current `convert` implementation disables stored shifting whenever its
|
pattern-level match, then show-level match, then identity mapping.
|
||||||
TMDB override bucket is present, including cases such as `--show` without an
|
- `src/ffx/database.py` now migrates version-2 databases to version 3 by
|
||||||
explicit target season or episode override, so current behavior is broader
|
preserving existing show-level rows and extending the schema for pattern-level
|
||||||
than the minimum bypass contract stated above.
|
ownership.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
- The current CLI groups `--show`, `--season`, and `--episode` under one
|
- The current CLI groups `--show`, `--season`, and `--episode` under one
|
||||||
override bucket used for TMDB-related behavior. The exact source-domain versus
|
override bucket used for TMDB-related behavior. Source-domain versus
|
||||||
target-domain semantics of each override should stay documented clearly so
|
target-domain semantics of each override must stay documented clearly so
|
||||||
stored shifting is neither skipped nor double-applied unexpectedly.
|
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,
|
- Current modern automated test coverage for shifted-season behavior is light,
|
||||||
so validation and convert-time numbering behavior are not yet strongly locked
|
so precedence, migration, and convert-time numbering behavior need focused
|
||||||
down by focused tests.
|
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.
|
|
||||||
|
|||||||
@@ -1180,8 +1180,8 @@ def convert(ctx,
|
|||||||
|
|
||||||
|
|
||||||
ssc = ShiftedSeasonController(context)
|
ssc = ShiftedSeasonController(context)
|
||||||
|
|
||||||
showId = mediaFileProperties.getShowId()
|
matchedShowId = mediaFileProperties.getShowId()
|
||||||
|
|
||||||
#HINT: -1 if not set
|
#HINT: -1 if not set
|
||||||
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
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]
|
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||||
|
|
||||||
|
|
||||||
# Shift season and episode if defined for this show
|
showIdForShift = (
|
||||||
if ('tmdb' not in cliOverrides.keys() and showId != -1
|
cliOverrides['tmdb']['show']
|
||||||
and showSeason != -1 and showEpisode != -1):
|
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId,
|
else matchedShowId
|
||||||
season=showSeason,
|
)
|
||||||
episode=showEpisode)
|
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:
|
else:
|
||||||
shiftedShowSeason = showSeason
|
shiftedShowSeason = showSeason
|
||||||
shiftedShowEpisode = showEpisode
|
shiftedShowEpisode = showEpisode
|
||||||
|
|
||||||
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
||||||
#HINT: -1 if not set
|
#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:
|
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
VERSION='0.2.4'
|
VERSION='0.2.4'
|
||||||
DATABASE_VERSION = 2
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
DEFAULT_AV1_PRESET = 5
|
DEFAULT_AV1_PRESET = 5
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, click
|
import os, click
|
||||||
|
|
||||||
from sqlalchemy import create_engine, inspect
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
# Import the full model package so SQLAlchemy registers every mapped class
|
# Import the full model package so SQLAlchemy registers every mapped class
|
||||||
@@ -71,11 +71,110 @@ def bootstrapDatabaseIfNeeded(databaseContext):
|
|||||||
def ensureDatabaseVersion(databaseContext):
|
def ensureDatabaseVersion(databaseContext):
|
||||||
|
|
||||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||||
if currentDatabaseVersion:
|
if not currentDatabaseVersion:
|
||||||
if currentDatabaseVersion != DATABASE_VERSION:
|
|
||||||
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
|
|
||||||
else:
|
|
||||||
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
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):
|
def getDatabaseVersion(databaseContext):
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Pattern(Base):
|
|||||||
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||||
|
|
||||||
media_tags = relationship('MediaTag', 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)
|
quality = Column(Integer, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, ForeignKey
|
from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from .show import Base, Show
|
from .show import Base, Show
|
||||||
@@ -9,6 +9,14 @@ from .show import Base, Show
|
|||||||
class ShiftedSeason(Base):
|
class ShiftedSeason(Base):
|
||||||
|
|
||||||
__tablename__ = 'shifted_seasons'
|
__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
|
# v1.x
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
|
|||||||
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
# v1.x
|
# 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')
|
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
|
# v2.0
|
||||||
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
||||||
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
||||||
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
|
|||||||
def getId(self):
|
def getId(self):
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
|
def getShowId(self):
|
||||||
|
return self.show_id
|
||||||
|
|
||||||
|
def getPatternId(self):
|
||||||
|
return self.pattern_id
|
||||||
|
|
||||||
|
|
||||||
def getOriginalSeason(self):
|
def getOriginalSeason(self):
|
||||||
return self.original_season
|
return self.original_season
|
||||||
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
|
|||||||
|
|
||||||
shiftedSeasonObj = {}
|
shiftedSeasonObj = {}
|
||||||
|
|
||||||
|
shiftedSeasonObj['show_id'] = self.getShowId()
|
||||||
|
shiftedSeasonObj['pattern_id'] = self.getPatternId()
|
||||||
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
||||||
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
||||||
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
||||||
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
|
|||||||
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
||||||
|
|
||||||
return shiftedSeasonObj
|
return shiftedSeasonObj
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from ffx.model.pattern import Pattern
|
|||||||
|
|
||||||
from .track_details_screen import TrackDetailsScreen
|
from .track_details_screen import TrackDetailsScreen
|
||||||
from .track_delete_screen import TrackDeleteScreen
|
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_details_screen import TagDetailsScreen
|
||||||
from .tag_delete_screen import TagDeleteScreen
|
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.file_properties import FileProperties
|
||||||
from ffx.iso_language import IsoLanguage
|
from ffx.iso_language import IsoLanguage
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
|
from ffx.model.shifted_season import ShiftedSeason
|
||||||
|
|
||||||
from ffx.helper import formatRichColor, removeRichColor
|
from ffx.helper import formatRichColor, removeRichColor
|
||||||
|
|
||||||
@@ -34,8 +37,8 @@ class PatternDetailsScreen(Screen):
|
|||||||
CSS = """
|
CSS = """
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
grid-size: 7 17;
|
grid-size: 7 20;
|
||||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2;
|
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;
|
grid-columns: 25 25 25 25 25 25 25;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -115,11 +118,13 @@ class PatternDetailsScreen(Screen):
|
|||||||
show=True,
|
show=True,
|
||||||
track=True,
|
track=True,
|
||||||
tag=True,
|
tag=True,
|
||||||
|
shifted_season=True,
|
||||||
)
|
)
|
||||||
self.__pc = controllers['pattern']
|
self.__pc = controllers['pattern']
|
||||||
self.__sc = controllers['show']
|
self.__sc = controllers['show']
|
||||||
self.__tc = controllers['track']
|
self.__tc = controllers['track']
|
||||||
self.__tac = controllers['tag']
|
self.__tac = controllers['tag']
|
||||||
|
self.__ssc = controllers['shifted_season']
|
||||||
|
|
||||||
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
|
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
|
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))
|
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
||||||
self.tagsTable.add_row(*map(str, row))
|
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):
|
def on_mount(self):
|
||||||
|
|
||||||
@@ -276,6 +347,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.updateTags()
|
self.updateTags()
|
||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
|
|
||||||
@@ -304,6 +376,16 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.tracksTable.cursor_type = 'row'
|
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()
|
yield Header()
|
||||||
|
|
||||||
@@ -345,6 +427,27 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 9
|
# 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 Static("Media Tags")
|
||||||
yield Button("Add", id="button_add_tag")
|
yield Button("Add", id="button_add_tag")
|
||||||
yield Button("Edit", id="button_edit_tag")
|
yield Button("Edit", id="button_edit_tag")
|
||||||
@@ -354,13 +457,13 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# 10
|
# 13
|
||||||
yield self.tagsTable
|
yield self.tagsTable
|
||||||
|
|
||||||
# 11
|
# 14
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 12
|
# 15
|
||||||
yield Static("Streams")
|
yield Static("Streams")
|
||||||
yield Button("Add", id="button_add_track")
|
yield Button("Add", id="button_add_track")
|
||||||
yield Button("Edit", id="button_edit_track")
|
yield Button("Edit", id="button_edit_track")
|
||||||
@@ -370,21 +473,21 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Button("Up", id="button_track_up")
|
yield Button("Up", id="button_track_up")
|
||||||
yield Button("Down", id="button_track_down")
|
yield Button("Down", id="button_track_down")
|
||||||
|
|
||||||
# 13
|
# 16
|
||||||
yield self.tracksTable
|
yield self.tracksTable
|
||||||
|
|
||||||
# 14
|
# 17
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 15
|
# 18
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 16
|
# 19
|
||||||
yield Button("Save", id="save_button")
|
yield Button("Save", id="save_button")
|
||||||
yield Button("Cancel", id="cancel_button")
|
yield Button("Cancel", id="cancel_button")
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# 17
|
# 20
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -486,6 +589,35 @@ class PatternDetailsScreen(Screen):
|
|||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
self.app.pop_screen()
|
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())
|
numTracks = len(self.getCurrentTrackDescriptors())
|
||||||
|
|
||||||
@@ -654,3 +786,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
self.updateTags()
|
self.updateTags()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException('tag delete failed')
|
raise click.ClickException('tag delete failed')
|
||||||
|
|
||||||
|
def handle_update_shifted_season(self, screenResult):
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
|
def handle_delete_shifted_season(self, screenResult):
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|||||||
@@ -6,225 +6,431 @@ from ffx.model.shifted_season import ShiftedSeason
|
|||||||
class EpisodeOrderException(Exception):
|
class EpisodeOrderException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RangeOverlapException(Exception):
|
class RangeOverlapException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ShiftedSeasonController():
|
class ShiftedSeasonOwnerException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftedSeasonController:
|
||||||
|
|
||||||
def __init__(self, context):
|
def __init__(self, context):
|
||||||
|
|
||||||
self.context = 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
|
Check whether a shifted-season rule is valid within one owner scope.
|
||||||
|
|
||||||
shiftedSeasonId
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
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']
|
q = self._ordered_query(session, owner)
|
||||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
|
||||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
|
||||||
|
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
|
||||||
if shiftedSeasonId:
|
if shiftedSeasonId:
|
||||||
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
||||||
|
|
||||||
siblingShiftedSeason: ShiftedSeason
|
|
||||||
for siblingShiftedSeason in q.all():
|
for siblingShiftedSeason in q.all():
|
||||||
|
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
continue
|
||||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
|
||||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
|
||||||
|
|
||||||
if (originalSeason == siblingOriginalSeason
|
|
||||||
and lastEpisode >= siblingFirstEpisode
|
|
||||||
and siblingLastEpisode >= firstEpisode):
|
|
||||||
|
|
||||||
|
if self._ranges_overlap(
|
||||||
|
fields['first_episode'],
|
||||||
|
fields['last_episode'],
|
||||||
|
siblingShiftedSeason.getFirstEpisode(),
|
||||||
|
siblingShiftedSeason.getLastEpisode(),
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
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):
|
session = None
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
|
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||||
|
|
||||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
if not self.checkShiftedSeason(
|
||||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
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:
|
session = self.Session()
|
||||||
raise EpisodeOrderException()
|
shiftedSeason = ShiftedSeason(
|
||||||
|
show_id=owner['show_id'],
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
pattern_id=owner['pattern_id'],
|
||||||
|
original_season=fields['original_season'],
|
||||||
shiftedSeason = ShiftedSeason(show_id = int(showId),
|
first_episode=fields['first_episode'],
|
||||||
original_season = int(shiftedSeasonObj['original_season']),
|
last_episode=fields['last_episode'],
|
||||||
first_episode = firstEpisode,
|
season_offset=fields['season_offset'],
|
||||||
last_episode = lastEpisode,
|
episode_offset=fields['episode_offset'],
|
||||||
season_offset = int(shiftedSeasonObj['season_offset']),
|
)
|
||||||
episode_offset = int(shiftedSeasonObj['episode_offset']))
|
session.add(shiftedSeason)
|
||||||
s.add(shiftedSeason)
|
session.commit()
|
||||||
s.commit()
|
|
||||||
return shiftedSeason.getId()
|
return shiftedSeason.getId()
|
||||||
|
|
||||||
|
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not int:
|
if type(shiftedSeasonId) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
raise ValueError(
|
||||||
|
"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")
|
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
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:
|
if shiftedSeason is 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:
|
|
||||||
return False
|
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:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def findShiftedSeason(
|
||||||
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int):
|
self,
|
||||||
|
showId: int | None = None,
|
||||||
if type(showId) is not int:
|
originalSeason: int | None = None,
|
||||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
firstEpisode: int | None = None,
|
||||||
|
lastEpisode: int | None = None,
|
||||||
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
|
||||||
if type(originalSeason) is not int:
|
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:
|
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:
|
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:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
shiftedSeason = s.query(ShiftedSeason).filter(
|
session = self.Session()
|
||||||
ShiftedSeason.show_id == int(showId),
|
shiftedSeason = (
|
||||||
ShiftedSeason.original_season == int(originalSeason),
|
self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||||
ShiftedSeason.first_episode == int(firstEpisode),
|
.filter(
|
||||||
ShiftedSeason.last_episode == int(lastEpisode),
|
ShiftedSeason.original_season == int(originalSeason),
|
||||||
).first()
|
ShiftedSeason.first_episode == int(firstEpisode),
|
||||||
|
ShiftedSeason.last_episode == int(lastEpisode),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
return shiftedSeason.getId() if shiftedSeason is not None else None
|
return shiftedSeason.getId() if shiftedSeason is not None else None
|
||||||
|
|
||||||
|
except ShiftedSeasonOwnerException as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def getShiftedSeasonSiblings(self, showId: int):
|
def getShiftedSeasonSiblings(
|
||||||
|
self,
|
||||||
if type(showId) is not int:
|
showId: int | None = None,
|
||||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int")
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
session = self.Session()
|
||||||
|
return self._ordered_query(session, owner).all()
|
||||||
return q.all()
|
|
||||||
|
|
||||||
|
except ShiftedSeasonOwnerException as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def getShiftedSeason(self, shiftedSeasonId: int):
|
def getShiftedSeason(self, shiftedSeasonId: int):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not 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:
|
try:
|
||||||
s = self.Session()
|
session = self.Session()
|
||||||
return s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
return (
|
||||||
|
session.query(ShiftedSeason)
|
||||||
|
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def deleteShiftedSeason(self, shiftedSeasonId):
|
def deleteShiftedSeason(self, shiftedSeasonId):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not int:
|
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:
|
try:
|
||||||
s = self.Session()
|
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:
|
if shiftedSeason is not None:
|
||||||
|
session.delete(shiftedSeason)
|
||||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
session.commit()
|
||||||
# q.delete()
|
|
||||||
s.delete(shiftedSeason)
|
|
||||||
|
|
||||||
s.commit()
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
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
|
session = None
|
||||||
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId):
|
try:
|
||||||
|
session = self.Session()
|
||||||
|
activeShift = None
|
||||||
|
|
||||||
if (season == shiftedSeasonEntry.getOriginalSeason()
|
if patternId is not None:
|
||||||
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
|
activeShift = self._find_matching_rule(
|
||||||
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
|
session,
|
||||||
|
self._resolve_owner(patternId=patternId),
|
||||||
|
season=int(season),
|
||||||
|
episode=int(episode),
|
||||||
|
)
|
||||||
|
|
||||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
if activeShift is None and showId is not None and showId != -1:
|
||||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
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} "
|
if activeShift is None:
|
||||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
return season, episode
|
||||||
|
|
||||||
return shiftedSeason, shiftedEpisode
|
shiftedSeason = season + activeShift.getSeasonOffset()
|
||||||
|
shiftedEpisode = episode + activeShift.getEpisodeOffset()
|
||||||
return season, episode
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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__()
|
super().__init__()
|
||||||
|
|
||||||
self.context = self.app.getContext()
|
self.context = self.app.getContext()
|
||||||
@@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||||
|
|
||||||
self._showId = showId
|
self._showId = showId
|
||||||
|
self._patternId = patternId
|
||||||
self.__shiftedSeasonId = shiftedSeasonId
|
self.__shiftedSeasonId = shiftedSeasonId
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
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_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
||||||
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
||||||
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
||||||
@@ -77,8 +83,8 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
yield Static(" ", classes="two")
|
yield Static(" ", classes="two")
|
||||||
|
|
||||||
yield Static("from show")
|
yield Static("from")
|
||||||
yield Static(" ", id="static_show_id")
|
yield Static(" ", id="static_owner")
|
||||||
|
|
||||||
yield Static(" ", classes="two")
|
yield Static(" ", classes="two")
|
||||||
|
|
||||||
@@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|||||||
@@ -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__()
|
super().__init__()
|
||||||
|
|
||||||
self.context = self.app.getContext()
|
self.context = self.app.getContext()
|
||||||
@@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||||
|
|
||||||
self.__showId = showId
|
self.__showId = showId
|
||||||
|
self.__patternId = patternId
|
||||||
self.__shiftedSeasonId = shiftedSeasonId
|
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):
|
def on_mount(self):
|
||||||
|
|
||||||
if self.__shiftedSeasonId is not None:
|
if self.__shiftedSeasonId is not None:
|
||||||
@@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
|
|
||||||
if self.__shiftedSeasonId is not None:
|
if self.__shiftedSeasonId is not None:
|
||||||
|
|
||||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj,
|
if self.__ssc.checkShiftedSeason(
|
||||||
shiftedSeasonId = self.__shiftedSeasonId):
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
shiftedSeasonId=self.__shiftedSeasonId,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
):
|
||||||
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
||||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||||
else:
|
else:
|
||||||
@@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj):
|
if self.__ssc.checkShiftedSeason(
|
||||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj)
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
):
|
||||||
|
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
|
||||||
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
)
|
||||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -211,11 +211,17 @@ class ShowDetailsScreen(Screen):
|
|||||||
if row_key is not None:
|
if row_key is not None:
|
||||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
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['original_season'] = int(selected_row_data[0])
|
||||||
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1
|
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
|
||||||
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1
|
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
|
||||||
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0
|
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
|
||||||
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0
|
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
|
||||||
|
|
||||||
|
|
||||||
if self.__showDescriptor is not None:
|
if self.__showDescriptor is not None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -15,8 +16,17 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.constants import DATABASE_VERSION # noqa: E402
|
from ffx.constants import DATABASE_VERSION # noqa: E402
|
||||||
from ffx.database import DATABASE_VERSION_KEY, databaseContext, getDatabaseVersion # 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.property import Property # noqa: E402
|
||||||
from ffx.model.show import Base # 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):
|
class DatabaseContextTests(unittest.TestCase):
|
||||||
@@ -78,6 +88,106 @@ class DatabaseContextTests(unittest.TestCase):
|
|||||||
|
|
||||||
mocked_create_all.assert_not_called()
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
191
tests/unit/test_shifted_season_controller.py
Normal file
191
tests/unit/test_shifted_season_controller.py
Normal 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()
|
||||||
Reference in New Issue
Block a user