11 Commits

Author SHA1 Message Date
Javanaut
008c643272 change disposition order for sidecar files 2026-04-12 19:31:49 +02:00
Javanaut
c302b30e63 ff 2026-04-12 19:19:08 +02:00
Javanaut
7926407534 ff 2026-04-12 19:09:26 +02:00
Javanaut
0894ac2fab ff 2026-04-12 18:50:41 +02:00
Javanaut
353759b983 ff 2026-04-12 18:47:54 +02:00
Javanaut
454f5f0656 ff 2026-04-12 18:46:54 +02:00
Javanaut
0e51d6337f ff 2026-04-12 18:35:13 +02:00
Javanaut
a24b6dedaa ff 2026-04-12 18:26:39 +02:00
Javanaut
8361fc536b ff 2026-04-12 17:53:56 +02:00
Javanaut
4d4272e5e8 ff 2026-04-12 17:47:06 +02:00
Javanaut
559869ca68 iteration1 2026-04-12 17:12:32 +02:00
34 changed files with 1839 additions and 361 deletions

View File

@@ -41,6 +41,7 @@
- File inspection caches combined `ffprobe` data and crop-detection results per source and sampling window within one process to avoid repeated subprocess work. - File inspection caches combined `ffprobe` data and crop-detection results per source and sampling window within one process to avoid repeated subprocess work.
- Storage: - Storage:
- SQLite via SQLAlchemy ORM, with schema rooted in shows, patterns, tracks, media tags, track tags, shifted seasons, and generic properties. - SQLite via SQLAlchemy ORM, with schema rooted in shows, patterns, tracks, media tags, track tags, shifted seasons, and generic properties.
- Ordered schema migrations are loaded dynamically from per-version-step modules under [`src/ffx/model/migration/`](/home/osgw/.local/src/codex/ffx/src/ffx/model/migration/).
- A configuration JSON file supplies optional path, metadata-filtering, and filename-template settings. - A configuration JSON file supplies optional path, metadata-filtering, and filename-template settings.
- Integration adapters: - Integration adapters:
- Process execution wrapper for `ffmpeg`, `ffprobe`, `nice`, and `cpulimit`, with explicit disabled states for niceness and CPU limiting, support for both absolute `cpulimit` values and machine-wide percent input, and a combined `cpulimit -- nice -n ... <command>` execution shape when both limits are configured. - Process execution wrapper for `ffmpeg`, `ffprobe`, `nice`, and `cpulimit`, with explicit disabled states for niceness and CPU limiting, support for both absolute `cpulimit` values and machine-wide percent input, and a combined `cpulimit -- nice -n ... <command>` execution shape when both limits are configured.
@@ -49,11 +50,11 @@
## Data And Interface Notes ## Data And Interface Notes
- Key entities or records: - Key entities or records:
- `Show`: canonical TV show metadata plus digit-formatting rules for generated filenames. - `Show`: canonical TV show metadata plus digit-formatting rules, optional show-level notes, and an optional show-level encoding-quality fallback.
- `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.
@@ -62,9 +63,9 @@
- Config keys `databasePath`, `logDirectory`, and `outputFilenameTemplate`, plus optional metadata-filter rules. - Config keys `databasePath`, `logDirectory`, and `outputFilenameTemplate`, plus optional metadata-filter rules.
- Validation rules: - Validation rules:
- 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 either match the runtime-required version already or have a supported sequential migration path to it.
- 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.

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 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
@@ -44,11 +44,16 @@
- The CLI command `ffx configure_workstation` shall act as a wrapper for the second-step preparation flow in `tools/configure_workstation.sh`. - The CLI command `ffx configure_workstation` shall act as a wrapper for the second-step preparation flow in `tools/configure_workstation.sh`.
- The system shall persist reusable normalization rules in SQLite for: - The system shall persist reusable normalization rules in SQLite for:
- shows and show formatting digits, - shows and show formatting digits,
- optional show-level notes,
- optional show-level quality defaults,
- 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.
- The system shall apply supported ordered database migrations automatically when opening an older local database file and shall fail fast when no supported path exists.
- Before applying a required database migration, the system shall show the current version, target version, required sequential steps, and whether each corresponding migration module is present, then require user confirmation.
- Before applying a confirmed file-backed database migration, the system shall create an in-place backup copy whose filename includes the covered version range.
- 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.
- The system shall optionally open a Textual UI to browse shows, inspect files, and create, edit, or delete shows, patterns, stream definitions, tags, and shifted-season rules. - The system shall optionally open a Textual UI to browse shows, inspect files, and create, edit, or delete shows, patterns, stream definitions, tags, and shifted-season rules.
@@ -64,6 +69,8 @@
- The system shall support optional TMDB lookups to resolve show names, years, and episode titles when a show ID, season, and episode are available. - The system shall support optional TMDB lookups to resolve show names, years, and episode titles when a show ID, season, and episode are available.
- The system shall generate output filenames from show metadata, season and episode indices, and episode names using the configured filename template. - The system shall generate output filenames from show metadata, season and episode indices, and episode names using the configured filename template.
- The system shall allow CLI overrides for stream languages, stream titles, default and forced tracks, stream order, TMDB show and episode data, output directory, label prefix, and processing resource limits. - The system shall allow CLI overrides for stream languages, stream titles, default and forced tracks, stream order, TMDB show and episode data, output directory, label prefix, and processing resource limits.
- The system shall resolve encoding quality by precedence `CLI override -> pattern -> show -> encoder default` and shall report the chosen value and source.
- The system shall resolve season shifting by precedence `pattern -> show -> identity default` and shall report the chosen mapping and source.
- Processing resource limit rules: - Processing resource limit rules:
- `--nice` shall accept niceness values from `-20` through `19`; omitting the option shall disable niceness adjustment. - `--nice` shall accept niceness values from `-20` through `19`; omitting the option shall disable niceness adjustment.
- `--cpu` shall accept either a positive absolute `cpulimit` value such as `200`, or a percentage suffixed with `%` such as `25%` to represent a share of present CPUs; omitting the option or using `0` shall disable CPU limiting. - `--cpu` shall accept either a positive absolute `cpulimit` value such as `200`, or a percentage suffixed with `%` such as `25%` to represent a share of present CPUs; omitting the option or using `0` shall disable CPU limiting.

View File

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

View File

@@ -436,10 +436,14 @@ def upgrade(ctx, branch):
commandSequences.append(['git', 'reset', '--hard', 'HEAD']) commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
if branch: if branch:
commandSequences.append(['git', 'checkout', branch]) commandSequences += [
['git', 'fetch', 'origin', branch],
['git', 'checkout', '-B', branch, 'FETCH_HEAD'],
]
else:
commandSequences.append(['git', 'pull'])
commandSequences += [ commandSequences += [
['git', 'pull'],
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], [bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
[bundlePipPath, 'install', '--editable', '.'], [bundlePipPath, 'install', '--editable', '.'],
] ]
@@ -585,6 +589,7 @@ def unmux(ctx,
cpu): cpu):
from ffx.file_properties import FileProperties from ffx.file_properties import FileProperties
from ffx.process import executeProcess from ffx.process import executeProcess
from ffx.shifted_season_controller import ShiftedSeasonController
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
from ffx.track_type import TrackType from ffx.track_type import TrackType
@@ -605,6 +610,8 @@ def unmux(ctx,
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False): if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
os.makedirs(output_directory, exist_ok=True) os.makedirs(output_directory, exist_ok=True)
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
for sourcePath in existingSourcePaths: for sourcePath in existingSourcePaths:
fp = FileProperties(ctx.obj, sourcePath) fp = FileProperties(ctx.obj, sourcePath)
@@ -621,8 +628,12 @@ def unmux(ctx,
currentShowDescriptor, currentShowDescriptor,
) )
season = fp.getSeason() season, episode = shiftedSeasonController.shiftSeason(
episode = fp.getEpisode() fp.getShowId(),
season=fp.getSeason(),
episode=fp.getEpisode(),
patternId=currentPattern.getId() if currentPattern is not None else None,
)
#TODO: Recognition für alle Formate anpassen #TODO: Recognition für alle Formate anpassen
targetLabel = label if label else fp.getFileBasename() targetLabel = label if label else fp.getFileBasename()
@@ -966,6 +977,7 @@ def convert(ctx,
from ffx.filter.quality_filter import QualityFilter from ffx.filter.quality_filter import QualityFilter
from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename
from ffx.shifted_season_controller import ShiftedSeasonController from ffx.shifted_season_controller import ShiftedSeasonController
from ffx.show_controller import ShowController
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.tmdb_controller import TmdbController from ffx.tmdb_controller import TmdbController
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
@@ -1149,6 +1161,7 @@ def convert(ctx,
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs") ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
jobIndex = 0 jobIndex = 0
showController = ShowController(context)
for sourcePath in existingSourcePaths: for sourcePath in existingSourcePaths:
@@ -1181,7 +1194,7 @@ 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']:
@@ -1263,7 +1276,8 @@ def convert(ctx,
targetMediaDescriptor.importSubtitles(context['subtitle_directory'], targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
context['subtitle_prefix'], context['subtitle_prefix'],
showSeason, showSeason,
showEpisode) showEpisode,
preserve_dispositions=True)
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}") # ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}") ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
@@ -1278,6 +1292,14 @@ def convert(ctx,
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
qualityShowId = (
cliOverrides['tmdb']['show']
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
else matchedShowId
)
if currentShowDescriptor is None and qualityShowId != -1:
currentShowDescriptor = showController.getShowDescriptor(qualityShowId)
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context) defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
@@ -1286,19 +1308,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
)
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, season=showSeason,
episode=showEpisode) 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:
@@ -1384,7 +1430,8 @@ def convert(ctx,
targetFormat, targetFormat,
chainIteration, chainIteration,
cropArguments, cropArguments,
currentPattern) currentPattern,
currentShowDescriptor)

View File

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

View File

@@ -1,6 +1,6 @@
import os, click import os, shutil, 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
@@ -9,6 +9,11 @@ import ffx.model
from ffx.model.show import Base from ffx.model.show import Base
from ffx.model.property import Property from ffx.model.property import Property
from ffx.model.migration import (
DatabaseVersionException,
getMigrationPlan,
migrateDatabase,
)
from ffx.constants import DATABASE_VERSION from ffx.constants import DATABASE_VERSION
@@ -16,10 +21,6 @@ from ffx.constants import DATABASE_VERSION
DATABASE_VERSION_KEY = 'database_version' DATABASE_VERSION_KEY = 'database_version'
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys()) EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
class DatabaseVersionException(Exception):
def __init__(self, errorMessage):
super().__init__(errorMessage)
def databaseContext(databasePath: str = ''): def databaseContext(databasePath: str = ''):
databaseContext = {} databaseContext = {}
@@ -33,7 +34,13 @@ def databaseContext(databasePath: str = ''):
if not os.path.exists(ffxVarDir): if not os.path.exists(ffxVarDir):
os.makedirs(ffxVarDir) os.makedirs(ffxVarDir)
databasePath = os.path.join(ffxVarDir, 'ffx.db') databasePath = os.path.join(ffxVarDir, 'ffx.db')
else:
databasePath = os.path.expanduser(databasePath)
if databasePath != ':memory:':
databasePath = os.path.abspath(databasePath)
databaseContext['path'] = databasePath
databaseContext['url'] = f"sqlite:///{databasePath}" databaseContext['url'] = f"sqlite:///{databasePath}"
databaseContext['engine'] = create_engine(databaseContext['url']) databaseContext['engine'] = create_engine(databaseContext['url'])
databaseContext['session'] = sessionmaker(bind=databaseContext['engine']) databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
@@ -68,14 +75,113 @@ def bootstrapDatabaseIfNeeded(databaseContext):
Base.metadata.create_all(databaseContext['engine']) Base.metadata.create_all(databaseContext['engine'])
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:
promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION)
migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion)
currentDatabaseVersion = getDatabaseVersion(databaseContext)
if currentDatabaseVersion != DATABASE_VERSION:
raise DatabaseVersionException(
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
)
ensureCurrentSchemaCompatibility(databaseContext)
def ensureCurrentSchemaCompatibility(databaseContext):
engine = databaseContext['engine']
inspector = inspect(engine)
showColumns = {
column['name']
for column in inspector.get_columns('shows')
}
alterStatements = []
if 'quality' not in showColumns:
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
if 'notes' not in showColumns:
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
if not alterStatements:
return
with engine.begin() as connection:
for alterStatement in alterStatements:
connection.execute(text(alterStatement))
def promptForDatabaseMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int):
migrationPlan = getMigrationPlan(currentDatabaseVersion, targetDatabaseVersion)
click.echo("Database migration required.")
click.echo(f"Current version: {currentDatabaseVersion}")
click.echo(f"Target version: {targetDatabaseVersion}")
click.echo("Steps required:")
missingSteps = []
for migrationStep in migrationPlan:
moduleStatus = "present" if migrationStep.modulePresent else "missing"
click.echo(
f" {migrationStep.versionFrom} -> {migrationStep.versionTo}: "
+ f"{migrationStep.moduleName} [{moduleStatus}]"
)
if not migrationStep.modulePresent:
missingSteps.append(migrationStep)
if missingSteps:
firstMissingStep = missingSteps[0]
raise DatabaseVersionException(
f"No migration path from database version "
+ f"{firstMissingStep.versionFrom} to {firstMissingStep.versionTo}"
)
if not click.confirm(
"Create a backup and continue with database migration?",
default=True,
):
raise click.ClickException("Database migration aborted by user.")
backupPath = backupDatabaseBeforeMigration(
databaseContext,
currentDatabaseVersion,
targetDatabaseVersion,
)
click.echo(f"Database backup created: {backupPath}")
def backupDatabaseBeforeMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int) -> str:
databasePath = databaseContext.get('path', '')
if not databasePath or databasePath == ':memory:':
raise click.ClickException("Database migration backup requires a file-backed SQLite database.")
if not os.path.isfile(databasePath):
raise click.ClickException(f"Database file not found for backup: {databasePath}")
backupPath = f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.bak"
backupIndex = 1
while os.path.exists(backupPath):
backupPath = (
f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.{backupIndex}.bak"
)
backupIndex += 1
databaseContext['engine'].dispose()
shutil.copy2(databasePath, backupPath)
return backupPath
def getDatabaseVersion(databaseContext): def getDatabaseVersion(databaseContext):

View File

@@ -245,7 +245,8 @@ class FfxController():
targetFormat: str = '', targetFormat: str = '',
chainIteration: list = [], chainIteration: list = [],
cropArguments: dict = {}, cropArguments: dict = {},
currentPattern: Pattern = None): currentPattern: Pattern = None,
currentShowDescriptor = None):
# quality: int = DEFAULT_QUALITY, # quality: int = DEFAULT_QUALITY,
# preset: int = DEFAULT_AV1_PRESET): # preset: int = DEFAULT_AV1_PRESET):
@@ -262,9 +263,11 @@ class FfxController():
if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']): if qualityFilters and (quality := qualityFilters[0]['parameters']['quality']):
self.__logger.info(f"Setting quality {quality} from command line parameter") self.__logger.info(f"Setting quality {quality} from command line")
elif currentPattern is not None and (quality := currentPattern.quality): elif currentPattern is not None and (quality := currentPattern.quality):
self.__logger.info(f"Setting quality {quality} from pattern default") self.__logger.info(f"Setting quality {quality} from pattern")
elif currentShowDescriptor is not None and (quality := currentShowDescriptor.getQuality()):
self.__logger.info(f"Setting quality {quality} from show")
else: else:
quality = (QualityFilter.DEFAULT_H264_QUALITY quality = (QualityFilter.DEFAULT_H264_QUALITY
if (videoEncoder == VideoEncoder.H264) if (videoEncoder == VideoEncoder.H264)

View File

@@ -500,7 +500,14 @@ class MediaDescriptor:
return subtitleFileDescriptors return subtitleFileDescriptors
def importSubtitles(self, searchDirectory, prefix, season: int = -1, episode: int = -1): def importSubtitles(
self,
searchDirectory,
prefix,
season: int = -1,
episode: int = -1,
preserve_dispositions: bool = False,
):
# click.echo(f"Season: {season} Episode: {episode}") # click.echo(f"Season: {season} Episode: {episode}")
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}") self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
@@ -543,7 +550,7 @@ class MediaDescriptor:
# Prefer metadata coming from the external single-track source when # Prefer metadata coming from the external single-track source when
# it is provided explicitly by the filename contract. # it is provided explicitly by the filename contract.
matchingTrack.getTags()["language"] = msfd["language"] matchingTrack.getTags()["language"] = msfd["language"]
if msfd["disposition_set"]: if msfd["disposition_set"] and not preserve_dispositions:
matchingTrack.setDispositionSet(msfd["disposition_set"]) matchingTrack.setDispositionSet(msfd["disposition_set"])

View File

@@ -1,47 +0,0 @@
import os, sys, importlib, inspect, glob, re
from ffx.configuration_controller import ConfigurationController
from ffx.database import databaseContext
from sqlalchemy import Engine
from sqlalchemy.orm import sessionmaker
class Conversion():
def __init__(self):
self._context = {}
self._context['config'] = ConfigurationController()
self._context['database'] = databaseContext(databasePath=self._context['config'].getDatabaseFilePath())
self.__databaseSession: sessionmaker = self._context['database']['session']
self.__databaseEngine: Engine = self._context['database']['engine']
@staticmethod
def list():
basePath = os.path.dirname(__file__)
filenamePattern = re.compile("conversion_([0-9]+)_([0-9]+)\\.py")
filenameList = [os.path.basename(fp) for fp in glob.glob(f"{ basePath }/*.py") if fp != __file__]
versionTupleList = [(fm.group(1), fm.group(2)) for fn in filenameList if (fm := filenamePattern.search(fn))]
return versionTupleList
@staticmethod
def getClassReference(versionFrom, versionTo):
importlib.import_module(f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }")
for name, obj in inspect.getmembers(sys.modules[f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }"]):
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
if inspect.isclass(obj) and name != 'Conversion' and name.startswith('Conversion'):
return obj
@staticmethod
def getAllClassReferences():
return [Conversion.getClassReference(verFrom, verTo) for verFrom, verTo in Conversion.list()]

View File

@@ -1,17 +0,0 @@
import os, sys, importlib, inspect, glob, re
from .conversion import Conversion
class Conversion_2_3(Conversion):
def __init__(self):
super().__init__()
def applyConversion(self):
s = self.__databaseSession()
e = self.__databaseEngine
with e.connect() as c:
c.execute("ALTER TABLE user ADD COLUMN email VARCHAR(255)")

View File

@@ -1,7 +0,0 @@
import os, sys, importlib, inspect, glob, re
from .conversion import Conversion
class Conversion_3_4(Conversion):
pass

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
import importlib
import importlib.util
class DatabaseVersionException(Exception):
def __init__(self, errorMessage):
super().__init__(errorMessage)
@dataclass(frozen=True)
class MigrationStep:
versionFrom: int
versionTo: int
moduleName: str
modulePresent: bool
def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str:
return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}"
def migrationStepModuleExists(versionFrom: int, versionTo: int) -> bool:
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
try:
return importlib.util.find_spec(moduleName) is not None
except ModuleNotFoundError:
return False
def getMigrationPlan(currentVersion: int, targetVersion: int) -> list[MigrationStep]:
version = int(currentVersion)
target = int(targetVersion)
migrationPlan = []
while version < target:
nextVersion = version + 1
migrationPlan.append(
MigrationStep(
versionFrom=version,
versionTo=nextVersion,
moduleName=getMigrationStepModuleName(version, nextVersion),
modulePresent=migrationStepModuleExists(version, nextVersion),
)
)
version = nextVersion
return migrationPlan
def loadMigrationStep(versionFrom: int, versionTo: int):
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
try:
module = importlib.import_module(moduleName)
except ModuleNotFoundError as ex:
if ex.name == moduleName:
raise DatabaseVersionException(
f"No migration path from database version {versionFrom} to {versionTo}"
) from ex
raise
migrationStep = getattr(module, "applyMigration", None)
if migrationStep is None:
raise DatabaseVersionException(
f"Migration module {moduleName} does not define applyMigration()"
)
return migrationStep
def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion):
for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion):
migrationStep = loadMigrationStep(
migrationStepInfo.versionFrom,
migrationStepInfo.versionTo,
)
migrationStep(databaseContext)
setDatabaseVersion(databaseContext, migrationStepInfo.versionTo)

View File

@@ -0,0 +1,84 @@
from sqlalchemy import inspect, text
def applyMigration(databaseContext):
engine = databaseContext['engine']
inspector = inspect(engine)
shiftedSeasonColumns = {
column['name']
for column in inspector.get_columns('shifted_seasons')
}
showColumns = {
column['name']
for column in inspector.get_columns('shows')
}
with engine.begin() as connection:
if 'pattern_id' not in shiftedSeasonColumns:
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"))
if 'quality' not in showColumns:
connection.execute(
text("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
)
if 'notes' not in showColumns:
connection.execute(
text("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
)

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# from typing import List # from typing import List
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey
from sqlalchemy.orm import relationship, declarative_base, sessionmaker from sqlalchemy.orm import relationship, declarative_base, sessionmaker
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
@@ -45,6 +45,8 @@ class Show(Base):
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS) index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS) indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS) indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
quality = Column(Integer, default=0)
notes = Column(Text, default='')
def getDescriptor(self, context): def getDescriptor(self, context):
@@ -58,5 +60,7 @@ class Show(Base):
kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits) kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits)
kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits) kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits)
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits)
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.quality or 0)
kwargs[ShowDescriptor.NOTES_KEY] = str(self.notes or '')
return ShowDescriptor(**kwargs) return ShowDescriptor(**kwargs)

View File

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

View File

@@ -6,225 +6,433 @@ 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
Check if for a particula season hasPattern = patternId is not None
shiftedSeasonId 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 whether a shifted-season rule is valid within one owner scope.
""" """
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():
continue
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason if self._ranges_overlap(
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode() fields['first_episode'],
siblingLastEpisode = siblingShiftedSeason.getLastEpisode() fields['last_episode'],
siblingShiftedSeason.getFirstEpisode(),
if (originalSeason == siblingOriginalSeason siblingShiftedSeason.getLastEpisode(),
and lastEpisode >= siblingFirstEpisode ):
and siblingLastEpisode >= firstEpisode):
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 = (
self._apply_owner_filter(session.query(ShiftedSeason), owner)
.filter(
ShiftedSeason.original_season == int(originalSeason), ShiftedSeason.original_season == int(originalSeason),
ShiftedSeason.first_episode == int(firstEpisode), ShiftedSeason.first_episode == int(firstEpisode),
ShiftedSeason.last_episode == int(lastEpisode), ShiftedSeason.last_episode == int(lastEpisode),
).first() )
.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}") shiftedSeason = season
shiftedEpisode = episode
sourceLabel = "default"
else:
shiftedSeason = season + activeShift.getSeasonOffset()
shiftedEpisode = episode + activeShift.getEpisodeOffset()
sourceLabel = (
"pattern"
if activeShift.getPatternId() is not None
else "show"
)
self.context['logger'].info(
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
)
return shiftedSeason, shiftedEpisode return shiftedSeason, shiftedEpisode
return season, episode 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__() 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,12 +83,12 @@ 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")
yield Static("Original season") yield Static("Source season")
yield Static(" ", id="static_original_season") yield Static(" ", id="static_original_season")
yield Static("First episode") yield Static("First episode")
@@ -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()

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__() 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:
@@ -126,7 +132,7 @@ class ShiftedSeasonDetailsScreen(Screen):
yield Static(" ", classes="three") yield Static(" ", classes="three")
# 3 # 3
yield Static("Original season") yield Static("Source season")
yield Input(id="input_original_season", classes="two") yield Input(id="input_original_season", classes="two")
# 4 # 4
@@ -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))

View File

@@ -62,7 +62,9 @@ class ShowController():
index_season_digits = showDescriptor.getIndexSeasonDigits(), index_season_digits = showDescriptor.getIndexSeasonDigits(),
index_episode_digits = showDescriptor.getIndexEpisodeDigits(), index_episode_digits = showDescriptor.getIndexEpisodeDigits(),
indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(), indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(),
indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits()) indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits(),
quality = showDescriptor.getQuality(),
notes = showDescriptor.getNotes())
s.add(show) s.add(show)
s.commit() s.commit()
@@ -88,6 +90,12 @@ class ShowController():
if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()): if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()):
currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits()) currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits())
changed = True changed = True
if int(currentShow.quality or 0) != int(showDescriptor.getQuality()):
currentShow.quality = int(showDescriptor.getQuality())
changed = True
if str(currentShow.notes or '') != str(showDescriptor.getNotes()):
currentShow.notes = str(showDescriptor.getNotes())
changed = True
if changed: if changed:
s.commit() s.commit()

View File

@@ -21,6 +21,8 @@ class ShowDescriptor():
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits' INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits' INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_digits'
INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits' INDICATOR_EPISODE_DIGITS_KEY = 'indicator_episode_digits'
QUALITY_KEY = 'quality'
NOTES_KEY = 'notes'
DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS
DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS
@@ -124,6 +126,20 @@ class ShowDescriptor():
else: else:
self.__indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] self.__indicatorEpisodeDigits = defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
if ShowDescriptor.QUALITY_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.QUALITY_KEY]) is not int:
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.QUALITY_KEY} is required to be of type int")
self.__quality = kwargs[ShowDescriptor.QUALITY_KEY]
else:
self.__quality = 0
if ShowDescriptor.NOTES_KEY in kwargs.keys():
if type(kwargs[ShowDescriptor.NOTES_KEY]) is not str:
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.NOTES_KEY} is required to be of type str")
self.__notes = kwargs[ShowDescriptor.NOTES_KEY]
else:
self.__notes = ''
def getId(self): def getId(self):
return self.__showId return self.__showId
@@ -140,6 +156,10 @@ class ShowDescriptor():
return self.__indicatorSeasonDigits return self.__indicatorSeasonDigits
def getIndicatorEpisodeDigits(self): def getIndicatorEpisodeDigits(self):
return self.__indicatorEpisodeDigits return self.__indicatorEpisodeDigits
def getQuality(self):
return self.__quality
def getNotes(self):
return self.__notes
def getFilenamePrefix(self): def getFilenamePrefix(self):
return f"{self.__showName} ({str(self.__showYear)})" return f"{self.__showName} ({str(self.__showYear)})"

View File

@@ -1,7 +1,7 @@
import click import click
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, DataTable, Input from textual.widgets import Header, Footer, Static, Button, DataTable, Input, TextArea
from textual.containers import Grid from textual.containers import Grid
from textual.widgets._data_table import CellDoesNotExist from textual.widgets._data_table import CellDoesNotExist
@@ -25,8 +25,8 @@ class ShowDetailsScreen(Screen):
CSS = """ CSS = """
Grid { Grid {
grid-size: 5 16; grid-size: 5 18;
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2; grid-rows: 2 2 2 2 2 2 6 2 2 2 2 2 2 9 2 9 2 2;
grid-columns: 30 30 30 30 30; grid-columns: 30 30 30 30 30;
height: 100%; height: 100%;
width: 100%; width: 100%;
@@ -77,6 +77,10 @@ class ShowDetailsScreen(Screen):
height: 100%; height: 100%;
border: solid green; border: solid green;
} }
.note_box {
min-height: 6;
}
""" """
BINDINGS = [ BINDINGS = [
@@ -150,6 +154,10 @@ class ShowDetailsScreen(Screen):
self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits()) self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits())
self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits()) self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits())
self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits()) self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits())
if self.__showDescriptor.getQuality():
self.query_one("#quality_input", Input).value = str(self.__showDescriptor.getQuality())
if self.__showDescriptor.getNotes():
self.query_one("#notes_textarea", TextArea).text = str(self.__showDescriptor.getNotes())
#raise click.ClickException(f"show_id {showId}") #raise click.ClickException(f"show_id {showId}")
@@ -211,11 +219,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:
@@ -308,7 +322,7 @@ class ShowDetailsScreen(Screen):
self.shiftedSeasonsTable = DataTable(classes="five") self.shiftedSeasonsTable = DataTable(classes="five")
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Original Season", width=30) self.column_key_original_season = self.shiftedSeasonsTable.add_column("Source Season", width=30)
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30) self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30)
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30) self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30) self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30)
@@ -342,28 +356,36 @@ class ShowDetailsScreen(Screen):
yield Input(type="integer", id="year_input", classes="four") yield Input(type="integer", id="year_input", classes="four")
#5 #5
yield Static(" ", classes="five") yield Static("Quality")
yield Input(type="integer", id="quality_input", classes="four")
#6 #6
yield Static("Notes")
yield Static(" ", classes="four")
#7
yield TextArea(id="notes_textarea", classes="five note_box")
#8
yield Static("Index Season Digits") yield Static("Index Season Digits")
yield Input(type="integer", id="index_season_digits_input", classes="four") yield Input(type="integer", id="index_season_digits_input", classes="four")
#7 #9
yield Static("Index Episode Digits") yield Static("Index Episode Digits")
yield Input(type="integer", id="index_episode_digits_input", classes="four") yield Input(type="integer", id="index_episode_digits_input", classes="four")
#8 #10
yield Static("Indicator Season Digits") yield Static("Indicator Season Digits")
yield Input(type="integer", id="indicator_season_digits_input", classes="four") yield Input(type="integer", id="indicator_season_digits_input", classes="four")
#9 #11
yield Static("Indicator Edisode Digits") yield Static("Indicator Edisode Digits")
yield Input(type="integer", id="indicator_episode_digits_input", classes="four") yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
# 10 # 12
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 11 # 13
yield Static("Shifted seasons", classes="two") yield Static("Shifted seasons", classes="two")
if self.__showDescriptor is not None: if self.__showDescriptor is not None:
@@ -375,18 +397,18 @@ class ShowDetailsScreen(Screen):
yield Static(" ") yield Static(" ")
yield Static(" ") yield Static(" ")
# 12 # 14
yield self.shiftedSeasonsTable yield self.shiftedSeasonsTable
# 13 # 15
yield Static("File patterns", classes="five") yield Static("File patterns", classes="five")
# 14 # 16
yield self.patternTable yield self.patternTable
# 15 # 17
yield Static(" ", classes="five") yield Static(" ", classes="five")
# 16 # 18
yield Button("Save", id="save_button") yield Button("Save", id="save_button")
yield Button("Cancel", id="cancel_button") yield Button("Cancel", id="cancel_button")
@@ -432,6 +454,11 @@ class ShowDetailsScreen(Screen):
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value)
except ValueError: except ValueError:
pass pass
try:
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.query_one("#quality_input", Input).value)
except ValueError:
pass
kwargs[ShowDescriptor.NOTES_KEY] = str(self.query_one("#notes_textarea", TextArea).text)
return ShowDescriptor(**kwargs) return ShowDescriptor(**kwargs)

Binary file not shown.

Binary file not shown.

View File

@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
from ffx.pattern_controller import PatternController from ffx.pattern_controller import PatternController
from ffx.show_controller import ShowController from ffx.show_controller import ShowController
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.shifted_season_controller import ShiftedSeasonController
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
from ffx.track_descriptor import TrackDescriptor from ffx.track_descriptor import TrackDescriptor
from ffx.track_type import TrackType from ffx.track_type import TrackType
@@ -109,6 +110,31 @@ class UnmuxCliTests(unittest.TestCase):
finally: finally:
dispose_controller_context(context) dispose_controller_context(context)
def add_show_shift(
self,
*,
show_id: int,
original_season: int,
first_episode: int,
last_episode: int,
season_offset: int,
episode_offset: int,
) -> None:
context = build_controller_context(self.database_path)
try:
ShiftedSeasonController(context).addShiftedSeason(
showId=show_id,
shiftedSeasonObj={
"original_season": original_season,
"first_episode": first_episode,
"last_episode": last_episode,
"season_offset": season_offset,
"episode_offset": episode_offset,
},
)
finally:
dispose_controller_context(context)
def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self): def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self):
self.write_config( self.write_config(
{ {
@@ -223,6 +249,55 @@ class UnmuxCliTests(unittest.TestCase):
output_filenames, output_filenames,
) )
def test_unmux_applies_shifted_season_mapping_to_output_filenames(self):
self.seed_matching_show(
r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$",
indicator_season_digits=2,
indicator_episode_digits=2,
)
self.add_show_shift(
show_id=1,
original_season=1,
first_episode=1,
last_episode=99,
season_offset=1,
episode_offset=-88,
)
source_filename = "unmux_s01e89.mkv"
output_directory = self.workdir / "unmux-output"
output_directory.mkdir()
source_path = create_source_fixture(
self.workdir,
source_filename,
[
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
SourceTrackSpec(
TrackType.SUBTITLE,
identity="subtitle-1",
language="eng",
subtitle_lines=("subtitle payload",),
),
],
)
completed = run_ffx_unmux(
self.workdir,
self.home_dir,
self.database_path,
"--label",
"dball",
"--output-directory",
str(output_directory),
"--subtitles-only",
str(source_path),
)
self.assertCompleted(completed)
self.assertIn(
"Unmuxing stream 1 into file dball_S02E01_1_eng",
completed.stderr,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -57,7 +57,7 @@ class UpgradeCommandTests(unittest.TestCase):
self.assertTrue(subprocess_calls[0][1]["capture_output"]) self.assertTrue(subprocess_calls[0][1]["capture_output"])
self.assertTrue(subprocess_calls[0][1]["text"]) self.assertTrue(subprocess_calls[0][1]["text"])
def test_upgrade_resets_before_checkout_and_pull_when_user_confirms(self): def test_upgrade_resets_then_fetches_and_checks_out_requested_branch_when_user_confirms(self):
runner = CliRunner() runner = CliRunner()
repo_path = "/tmp/ffx-repo" repo_path = "/tmp/ffx-repo"
pip_path = "/tmp/ffx-venv/bin/pip" pip_path = "/tmp/ffx-venv/bin/pip"
@@ -85,8 +85,8 @@ class UpgradeCommandTests(unittest.TestCase):
[ [
['git', 'status', '--porcelain', '--untracked-files=no'], ['git', 'status', '--porcelain', '--untracked-files=no'],
['git', 'reset', '--hard', 'HEAD'], ['git', 'reset', '--hard', 'HEAD'],
['git', 'checkout', 'main'], ['git', 'fetch', 'origin', 'main'],
['git', 'pull'], ['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'], [pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
[pip_path, 'install', '--editable', '.'], [pip_path, 'install', '--editable', '.'],
], ],
@@ -95,6 +95,39 @@ class UpgradeCommandTests(unittest.TestCase):
for args, kwargs in subprocess_calls[1:]: for args, kwargs in subprocess_calls[1:]:
self.assertEqual(repo_path, kwargs["cwd"], args) self.assertEqual(repo_path, kwargs["cwd"], args)
def test_upgrade_pulls_current_branch_when_no_branch_is_requested(self):
runner = CliRunner()
repo_path = "/tmp/ffx-repo"
pip_path = "/tmp/ffx-venv/bin/pip"
subprocess_calls = []
def fake_run(args, **kwargs):
subprocess_calls.append((args, kwargs))
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
return self.make_completed(args, stdout="")
return self.make_completed(args)
with (
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
patch.object(cli, "getBundlePipPath", return_value=pip_path),
patch.object(cli.os.path, "isdir", return_value=True),
patch.object(cli.os.path, "isfile", return_value=True),
patch.object(cli.subprocess, "run", side_effect=fake_run),
):
result = runner.invoke(cli.ffx, ["upgrade"])
self.assertEqual(0, result.exit_code, result.output)
self.assertEqual(
[
['git', 'status', '--porcelain', '--untracked-files=no'],
['git', 'pull'],
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
[pip_path, 'install', '--editable', '.'],
],
[call[0] for call in subprocess_calls],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -1,11 +1,14 @@
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
from unittest.mock import patch from unittest.mock import patch
import click
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
@@ -15,8 +18,18 @@ 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 Show # 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):
@@ -27,6 +40,115 @@ class DatabaseContextTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.tempdir.cleanup() self.tempdir.cleanup()
def create_demo_show_with_shift(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()
return shifted_season_id
def rewrite_shows_table_without_show_fields(self, cursor):
cursor.execute("ALTER TABLE shows RENAME TO shows_current")
cursor.execute(
"""
CREATE TABLE shows (
id INTEGER PRIMARY KEY,
name VARCHAR,
year INTEGER,
index_season_digits INTEGER,
index_episode_digits INTEGER,
indicator_season_digits INTEGER,
indicator_episode_digits INTEGER
)
"""
)
cursor.execute(
"""
INSERT INTO shows (
id,
name,
year,
index_season_digits,
index_episode_digits,
indicator_season_digits,
indicator_episode_digits
)
SELECT
id,
name,
year,
index_season_digits,
index_episode_digits,
indicator_season_digits,
indicator_episode_digits
FROM shows_current
"""
)
cursor.execute("DROP TABLE shows_current")
def rewrite_shifted_seasons_table_without_pattern_owner(self, 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_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_current
"""
)
cursor.execute("DROP TABLE shifted_seasons_current")
def test_database_context_bootstraps_new_database_with_current_version(self): def test_database_context_bootstraps_new_database_with_current_version(self):
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all: with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
context = databaseContext(str(self.database_path)) context = databaseContext(str(self.database_path))
@@ -78,6 +200,127 @@ 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):
shifted_season_id = self.create_demo_show_with_shift()
connection = sqlite3.connect(self.database_path)
try:
cursor = connection.cursor()
cursor.execute("PRAGMA foreign_keys=OFF")
self.rewrite_shifted_seasons_table_without_pattern_owner(cursor)
self.rewrite_shows_table_without_show_fields(cursor)
cursor.execute(
"UPDATE properties SET value = '2' WHERE key = ?",
(DATABASE_VERSION_KEY,),
)
connection.commit()
finally:
connection.close()
with patch("ffx.database.click.confirm", return_value=True) as mocked_confirm, patch(
"ffx.database.click.echo"
) as mocked_echo:
reopened_context = databaseContext(str(self.database_path))
try:
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
mocked_confirm.assert_called_once()
backup_path = Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak")
self.assertTrue(backup_path.exists())
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())
migrated_show = session.query(Show).filter(Show.id == 1).first()
self.assertIsNotNone(migrated_show)
self.assertEqual(0, int(migrated_show.quality or 0))
self.assertEqual('', str(migrated_show.notes or ''))
finally:
session.close()
finally:
reopened_context["engine"].dispose()
echoedLines = [call.args[0] for call in mocked_echo.call_args_list]
self.assertIn("Database migration required.", echoedLines)
self.assertIn("Current version: 2", echoedLines)
self.assertIn(f"Target version: {DATABASE_VERSION}", echoedLines)
self.assertIn(
" 2 -> 3: ffx.model.migration.step_2_3 [present]",
echoedLines,
)
def test_database_context_aborts_migration_when_confirmation_is_declined(self):
context = databaseContext(str(self.database_path))
try:
Session = context["session"]
session = Session()
try:
version_row = (
session.query(Property)
.filter(Property.key == DATABASE_VERSION_KEY)
.first()
)
version_row.value = "2"
session.commit()
finally:
session.close()
finally:
context["engine"].dispose()
with patch("ffx.database.click.confirm", return_value=False), patch(
"ffx.database.click.echo"
):
with self.assertRaises(click.ClickException) as raisedContext:
databaseContext(str(self.database_path))
self.assertEqual("Database migration aborted by user.", str(raisedContext.exception))
self.assertFalse(Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak").exists())
def test_database_context_repairs_current_show_schema_without_version_bump(self):
self.create_demo_show_with_shift()
connection = sqlite3.connect(self.database_path)
try:
cursor = connection.cursor()
cursor.execute("PRAGMA foreign_keys=OFF")
self.rewrite_shows_table_without_show_fields(cursor)
connection.commit()
finally:
connection.close()
with patch("ffx.database.click.confirm") as mocked_confirm, patch(
"ffx.database.click.echo"
) as mocked_echo:
reopened_context = databaseContext(str(self.database_path))
try:
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
Session = reopened_context["session"]
session = Session()
try:
repaired_show = session.query(Show).filter(Show.id == 1).first()
self.assertIsNotNone(repaired_show)
self.assertEqual(0, int(repaired_show.quality or 0))
self.assertEqual('', str(repaired_show.notes or ''))
finally:
session.close()
finally:
reopened_context["engine"].dispose()
mocked_confirm.assert_not_called()
mocked_echo.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -4,6 +4,7 @@ from pathlib import Path
import sys import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from types import SimpleNamespace
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
@@ -15,6 +16,7 @@ if str(SRC_ROOT) not in sys.path:
from ffx.ffx_controller import FfxController # noqa: E402 from ffx.ffx_controller import FfxController # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402 from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402 from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402
from ffx.track_codec import TrackCodec # noqa: E402 from ffx.track_codec import TrackCodec # noqa: E402
from ffx.track_descriptor import TrackDescriptor # noqa: E402 from ffx.track_descriptor import TrackDescriptor # noqa: E402
from ffx.track_type import TrackType # noqa: E402 from ffx.track_type import TrackType # noqa: E402
@@ -134,6 +136,62 @@ class FfxControllerTests(unittest.TestCase):
self.assertIn("ENCODING_QUALITY=29", commands[0]) self.assertIn("ENCODING_QUALITY=29", commands[0])
self.assertIn("ENCODING_PRESET=7", commands[0]) self.assertIn("ENCODING_PRESET=7", commands[0])
def test_run_job_uses_show_quality_when_pattern_quality_is_unset(self):
context = self.make_context(VideoEncoder.H264)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
commands = []
show_descriptor = ShowDescriptor(id=1, name="Show", year=2024, quality=23)
pattern = SimpleNamespace(quality=0)
with (
patch.object(
controller,
"executeCommandSequence",
side_effect=lambda command: commands.append(command) or ("", "", 0),
),
patch.object(context["logger"], "info") as mocked_info,
):
controller.runJob(
"input.mkv",
"output.mkv",
chainIteration=[],
currentPattern=pattern,
currentShowDescriptor=show_descriptor,
)
self.assertEqual(1, len(commands))
self.assertIn("ENCODING_QUALITY=23", commands[0])
mocked_info.assert_any_call("Setting quality 23 from show")
def test_run_job_prefers_pattern_quality_over_show_quality(self):
context = self.make_context(VideoEncoder.H264)
target_descriptor, source_descriptor = self.make_media_descriptors()
controller = FfxController(context, target_descriptor, source_descriptor)
commands = []
show_descriptor = ShowDescriptor(id=1, name="Show", year=2024, quality=23)
pattern = SimpleNamespace(quality=19)
with (
patch.object(
controller,
"executeCommandSequence",
side_effect=lambda command: commands.append(command) or ("", "", 0),
),
patch.object(context["logger"], "info") as mocked_info,
):
controller.runJob(
"input.mkv",
"output.mkv",
chainIteration=[],
currentPattern=pattern,
currentShowDescriptor=show_descriptor,
)
self.assertEqual(1, len(commands))
self.assertIn("ENCODING_QUALITY=19", commands[0])
mocked_info.assert_any_call("Setting quality 19 from pattern")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
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.logging_utils import get_ffx_logger # noqa: E402
from ffx.media_descriptor import MediaDescriptor # noqa: E402
from ffx.track_descriptor import TrackDescriptor # noqa: E402
from ffx.track_disposition import TrackDisposition # noqa: E402
from ffx.track_type import TrackType # noqa: E402
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
def make_descriptor(self) -> MediaDescriptor:
return MediaDescriptor(
context={"logger": get_ffx_logger()},
track_descriptors=[
TrackDescriptor(
index=3,
source_index=3,
sub_index=0,
track_type=TrackType.SUBTITLE,
tags={"language": "eng", "title": "DB Subtitle"},
disposition_set={TrackDisposition.DEFAULT},
)
],
)
def test_import_subtitles_preserves_target_dispositions_when_requested(self):
descriptor = self.make_descriptor()
with tempfile.TemporaryDirectory() as tmpdir:
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
descriptor.importSubtitles(
tmpdir,
"dball",
season=1,
episode=1,
preserve_dispositions=True,
)
track = descriptor.getSubtitleTracks()[0]
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
self.assertEqual("deu", track.getTags()["language"])
self.assertEqual({TrackDisposition.DEFAULT}, track.getDispositionSet())
def test_import_subtitles_uses_sidecar_dispositions_by_default(self):
descriptor = self.make_descriptor()
with tempfile.TemporaryDirectory() as tmpdir:
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
descriptor.importSubtitles(
tmpdir,
"dball",
season=1,
episode=1,
)
track = descriptor.getSubtitleTracks()[0]
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
self.assertEqual("deu", track.getTags()["language"])
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from pathlib import Path
import sys
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.model.migration import ( # noqa: E402
DatabaseVersionException,
getMigrationPlan,
loadMigrationStep,
migrateDatabase,
)
class MigrationTests(unittest.TestCase):
def test_get_migration_plan_lists_known_step_with_module_presence(self):
migrationPlan = getMigrationPlan(2, 3)
self.assertEqual(1, len(migrationPlan))
self.assertEqual(2, migrationPlan[0].versionFrom)
self.assertEqual(3, migrationPlan[0].versionTo)
self.assertEqual("ffx.model.migration.step_2_3", migrationPlan[0].moduleName)
self.assertTrue(migrationPlan[0].modulePresent)
def test_load_migration_step_returns_known_step(self):
migrationStep = loadMigrationStep(2, 3)
self.assertTrue(callable(migrationStep))
def test_migrate_database_raises_when_step_module_is_missing(self):
updatedVersions = []
with self.assertRaises(DatabaseVersionException):
migrateDatabase({}, 1, 2, lambda context, version: updatedVersions.append(version))
self.assertEqual([], updatedVersions)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
import logging
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
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,
},
)
with patch.object(self.context["logger"], "info") as mocked_info:
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))
mocked_info.assert_called_once_with(
"Setting season shift 1/3 -> 3/8 from show"
)
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,
},
)
with patch.object(self.context["logger"], "info") as mocked_info:
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))
mocked_info.assert_called_once_with(
"Setting season shift 1/3 -> 2/1 from pattern"
)
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,
},
)
with patch.object(self.context["logger"], "info") as mocked_info:
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))
mocked_info.assert_called_once_with(
"Setting season shift 1/3 -> 1/3 from pattern"
)
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$")
with patch.object(self.context["logger"], "info") as mocked_info:
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))
mocked_info.assert_called_once_with(
"Setting season shift 4/20 -> 4/20 from default"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -56,6 +56,8 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
self.assertEqual(3, descriptor.getIndexEpisodeDigits()) self.assertEqual(3, descriptor.getIndexEpisodeDigits())
self.assertEqual(3, descriptor.getIndicatorSeasonDigits()) self.assertEqual(3, descriptor.getIndicatorSeasonDigits())
self.assertEqual(4, descriptor.getIndicatorEpisodeDigits()) self.assertEqual(4, descriptor.getIndicatorEpisodeDigits())
self.assertEqual(0, descriptor.getQuality())
self.assertEqual("", descriptor.getNotes())
def test_show_descriptor_without_context_uses_shared_constants(self): def test_show_descriptor_without_context_uses_shared_constants(self):
descriptor = ShowDescriptor(id=1, name="Default Show", year=2024) descriptor = ShowDescriptor(id=1, name="Default Show", year=2024)
@@ -70,6 +72,18 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
descriptor.getIndicatorEpisodeDigits(), descriptor.getIndicatorEpisodeDigits(),
) )
self.assertEqual(0, descriptor.getQuality())
self.assertEqual("", descriptor.getNotes())
def test_show_descriptor_preserves_explicit_quality(self):
descriptor = ShowDescriptor(id=1, name="Quality Show", year=2024, quality=23)
self.assertEqual(23, descriptor.getQuality())
def test_show_descriptor_preserves_explicit_notes(self):
descriptor = ShowDescriptor(id=1, name="Notes Show", year=2024, notes="show notes")
self.assertEqual("show notes", descriptor.getNotes())
def test_episode_basename_uses_configured_digit_defaults_when_omitted(self): def test_episode_basename_uses_configured_digit_defaults_when_omitted(self):
basename = getEpisodeFileBasename( basename = getEpisodeFileBasename(