18 Commits

Author SHA1 Message Date
Javanaut
d9639561ce Fix TUI widgets color bleedthru 2026-04-13 12:00:38 +02:00
Javanaut
cbf43e5d6c adapt shift output 2026-04-12 20:41:31 +02:00
Javanaut
d6e885517d Adds inspect --shift option 2026-04-12 20:34:33 +02:00
Javanaut
8a8c43ecdf v0.2.5 2026-04-12 19:57:46 +02:00
Javanaut
6170ac641c ff 2026-04-12 19:35:03 +02:00
Javanaut
497c0e500b ff 2026-04-12 19:34:51 +02:00
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
Javanaut
0e4fae538b prep season shift 2026-04-12 16:52:12 +02:00
43 changed files with 2682 additions and 510 deletions

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ venv/
*.mkv *.mkv
*.webm *.webm
ffmpeg2pass-0.log ffmpeg2pass-0.log
*.sup

View File

@@ -99,6 +99,16 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
## Version History ## Version History
### 0.2.5
- show-level quality and notes fields
- pattern-over-show-over-default season-shift resolution with dynamic DB migration loading
- migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes
- `upgrade --branch <name>` now fetches remote-only branches before switching
- `unmux` now applies season shifting to subtitle output filenames
- convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists
- focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence
### 0.2.4 ### 0.2.4
- lightweight CLI commands now stay import-light via lazy runtime loading - lightweight CLI commands now stay import-light via lazy runtime loading

View File

@@ -4,18 +4,6 @@
- Capture a compact, project-wide list of optimization candidates after a broad scan of the current FFX codebase, tooling, and requirements. - Capture a compact, project-wide list of optimization candidates after a broad scan of the current FFX codebase, tooling, and requirements.
## Settled
- The biggest near-term wins are in startup cost, repeated subprocess work, repeated database query patterns, and general repo hygiene.
- This list is intentionally optimization-oriented rather than bug-oriented. Some items below also improve correctness or maintainability, but they were selected because they can reduce runtime cost, operator friction, or iteration overhead.
- A first modern integration slice now exists under [`tests/integration/subtrack_mapping`](/home/osgw/.local/src/codex/ffx/tests/integration/subtrack_mapping). Remaining test-suite cleanup is now mostly about migrating and shrinking the legacy harness surface under [`tests/legacy`](/home/osgw/.local/src/codex/ffx/tests/legacy).
- Shared CLI defaults for container/output tokens now live outside [`src/ffx/ffx_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/ffx_controller.py), and a focused unit test locks in the lazy-import contract.
- Helper filename and rich-text utilities now use compiled raw regexes plus translate-based filename filtering, with unit coverage for TMDB suffix rewriting and Rich color stripping.
- Process resource limiting now has explicit disabled/default states in the CLI and requirements, and combined CPU-plus-niceness wrapping now executes as `cpulimit -- nice -n ... <command>` instead of a less explicit prefix chain.
- FFX logger setup now reuses named handlers, and fallback logger access no longer mutates handlers in ordinary constructors and helpers.
- The process wrapper now uses `subprocess.run(...)` with centralized command formatting plus stable timeout and missing-command error mapping.
- Pattern matching now uses cached compiled regexes plus explicit duplicate-match errors, and pattern creation flows no longer persist zero-track patterns.
## Focused Snapshot ## Focused Snapshot
- Highest-leverage application optimizations: - Highest-leverage application optimizations:
@@ -59,6 +47,7 @@
## Open ## Open
- Durable shipped items have been moved into [`README.md`](/home/osgw/.local/src/codex/ffx/README.md) version history through `0.2.5`.
- Should optimization work focus first on operator-perceived latency, internal maintainability, or correctness-risk cleanup that also has performance upside? - Should optimization work focus first on operator-perceived latency, internal maintainability, or correctness-risk cleanup that also has performance upside?
- Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI? - Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI?

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "ffx" name = "ffx"
description = "FFX recoding and metadata managing tool" description = "FFX recoding and metadata managing tool"
version = "0.2.4" version = "0.2.5"
license = {file = "LICENSE.md"} license = {file = "LICENSE.md"}
dependencies = [ dependencies = [
"requests", "requests",

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.
@@ -91,7 +98,7 @@
- Intended for local execution, not server deployment. - Intended for local execution, not server deployment.
- Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`. - Stores default state in `~/.local/etc/ffx.json`, `~/.local/var/ffx/ffx.db`, and `~/.local/var/log/ffx.log`.
- Timeline constraints: - Timeline constraints:
- The current implemented scope reflects a compact alpha release stream up to version `0.2.4`. - The current implemented scope reflects a compact alpha release stream up to version `0.2.5`.
- Team capacity assumptions: - Team capacity assumptions:
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions. - Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
- Third-party dependencies: - Third-party dependencies:

View File

@@ -0,0 +1,177 @@
# Shifted Seasons Handling
This file defines the behavioral contract for mapping source season and episode
numbering to target season and episode numbering through stored shifted-season
rules.
Primary sources:
- `requirements/project.md`
- `requirements/architecture.md`
- actual tool code in `src/ffx/`
Secondary source:
- `SCRATCHPAD.md`, used only to clarify current hardening gaps and not as the
primary contract source.
## Scope
- Persisting shifted-season rules in SQLite.
- Allowing shifted-season rules to be attached either to a show or to a
specific pattern.
- Selecting at most one active shifted-season rule for one concrete source
season and episode tuple.
- Applying additive season and episode offsets to produce target numbering.
- Using shifted target numbering during `convert` for TMDB episode lookup and
generated season and episode filename tokens.
- Managing show-level default mappings and pattern-level override mappings from
the Textual editing workflows.
## Out Of Scope
- General filename parsing rules for detecting season and episode values.
- Standalone `rename` command behavior, which currently uses explicit rename
inputs rather than stored shifted-season rules.
- Stream or track mapping behavior unrelated to season and episode numbering.
## Terms
- `shifted-season rule`: one persisted row describing how one source-numbering
range maps to target numbering through additive offsets.
- `show-level shifted-season rule`: a rule attached directly to a show and used
as the fallback mapping layer for that show.
- `pattern-level shifted-season rule`: a rule attached directly to a pattern and
used as the override mapping layer for that pattern.
- `source numbering`: the season and episode values detected from the current
source file or supplied as source-side conversion inputs before shifting.
- `target numbering`: the season and episode values after one active
shifted-season rule has been applied.
- `original season`: the source-domain season number a shifted-season rule is
eligible to match.
- `episode range`: the optional source-domain episode interval covered by one
shifted-season rule.
- `open bound`: an unbounded start or end of the episode range. Current storage
uses `-1` as the internal sentinel for an open bound.
- `active shifted-season rule`: the single rule selected for one concrete input
after precedence resolution.
- `identity mapping`: the default `1:1` outcome where source numbering is used
unchanged.
## Rules
- `SHIFTED_SEASONS_HANDLING-0001`: The domain model shall allow a
shifted-season rule to be owned by exactly one of:
- one show
- one pattern
- `SHIFTED_SEASONS_HANDLING-0002`: A single shifted-season rule shall not
belong to both a show and a pattern at the same time.
- `SHIFTED_SEASONS_HANDLING-0003`: A shifted-season rule shall carry these
fields: `original_season`, `first_episode`, `last_episode`,
`season_offset`, and `episode_offset`.
- `SHIFTED_SEASONS_HANDLING-0004`: `season_offset` and `episode_offset` shall
be additive signed integers applied to matched source numbering to produce
target numbering.
- `SHIFTED_SEASONS_HANDLING-0005`: A shifted-season rule shall match a source
tuple only when:
- the source season equals `original_season`
- the source episode is greater than or equal to `first_episode` when the
lower bound is closed
- the source episode is less than or equal to `last_episode` when the upper
bound is closed
- `SHIFTED_SEASONS_HANDLING-0006`: An open lower or upper episode bound shall
represent an unbounded side of the covered source episode range.
- `SHIFTED_SEASONS_HANDLING-0007`: If one shifted-season rule matches, target
numbering shall be:
- `target season = source season + season_offset`
- `target episode = source episode + episode_offset`
- `SHIFTED_SEASONS_HANDLING-0008`: If no shifted-season rule matches, source
numbering shall pass through unchanged.
- `SHIFTED_SEASONS_HANDLING-0009`: Shifted-season handling shall operate in a
source-to-target numbering model. Stored rules map detected source numbering
to the target numbering used by conversion-facing metadata and output naming.
- `SHIFTED_SEASONS_HANDLING-0010`: Pattern matching identifies the owning show
and optionally a more specific owning pattern. Resolution of the active
shifted-season rule shall use this precedence order:
- matching pattern-level rule
- matching show-level rule
- identity mapping
- `SHIFTED_SEASONS_HANDLING-0011`: At most one shifted-season rule may be
active for one concrete source season and episode tuple. Shifted-season rules
shall never stack or compose.
- `SHIFTED_SEASONS_HANDLING-0012`: Within one owner scope, shifted-season rules
shall not overlap in their effective episode coverage for the same
`original_season`.
- `SHIFTED_SEASONS_HANDLING-0013`: If a shifted-season rule uses two closed
episode bounds, `last_episode` shall be greater than or equal to
`first_episode`.
- `SHIFTED_SEASONS_HANDLING-0014`: Shifted-season rule evaluation shall be
deterministic. Released behavior shall not depend on arbitrary database row
order when invalid overlapping rules exist.
- `SHIFTED_SEASONS_HANDLING-0015`: A pattern-level rule is permitted to map to
zero offsets. Such a rule is a valid explicit override that beats show-level
fallback and produces identity mapping for its covered source range.
- `SHIFTED_SEASONS_HANDLING-0016`: During `convert`, when show, season, and
episode values are available and stored shifting is active, the shifted target
numbering shall drive:
- TMDB episode lookup
- season and episode filename tokens such as `S01E02`
- generated episode basenames that include season and episode numbering
- `SHIFTED_SEASONS_HANDLING-0017`: When conversion is supplied explicit
target-domain season or episode values for TMDB naming, the system shall not
apply stored shifting on top of those already-targeted values.
- `SHIFTED_SEASONS_HANDLING-0018`: Operator-facing editing shall expose
shifted-season rule management in both of these places:
- show editing for show-level default mappings
- pattern editing for pattern-level override mappings
- `SHIFTED_SEASONS_HANDLING-0019`: User-facing shifted-season editing should
present open episode bounds as a natural empty-state input rather than forcing
operators to type the internal sentinel directly.
## Acceptance
- A show can exist with zero or more show-level shifted-season rules.
- A pattern can exist with zero or more pattern-level shifted-season rules.
- A shifted-season rule is stored against exactly one owner scope.
- A source tuple matching a pattern-level rule yields target numbering from that
rule even when a matching show-level rule also exists.
- A source tuple matching no pattern-level rule but matching a show-level rule
yields target numbering from the show-level rule.
- A source tuple matching neither scope yields identity mapping.
- A pattern-level zero-offset rule can explicitly override a nonzero show-level
rule for the same covered source range.
- Two shifted-season rules for the same owner scope and original season cannot
both be valid if they cover overlapping episode ranges.
- During `convert`, shifted numbering is what TMDB episode lookup and generated
season and episode tokens see when stored shifting is active.
- The TUI can display and maintain shifted-season rules from both the show and
pattern editing flows.
## Current Code Fit
- `src/ffx/model/show.py` and `src/ffx/model/pattern.py` now both expose
shifted-season relationships, and `src/ffx/model/shifted_season.py` stores
each rule against exactly one owner scope through `show_id` or `pattern_id`.
- `src/ffx/shifted_season_controller.py` now resolves mappings with
pattern-over-show precedence and applies at most one active rule for a source
tuple.
- `src/ffx/show_details_screen.py`,
`src/ffx/shifted_season_details_screen.py`, and
`src/ffx/shifted_season_delete_screen.py` provide reusable shifted-season
editing dialogs, and `src/ffx/pattern_details_screen.py` now exposes the
pattern-level override flow.
- `src/ffx/cli.py` now resolves shifted numbering during `convert` from:
pattern-level match, then show-level match, then identity mapping.
- `src/ffx/database.py` now migrates version-2 databases to version 3 by
preserving existing show-level rows and extending the schema for pattern-level
ownership.
## Risks
- The current CLI groups `--show`, `--season`, and `--episode` under one
override bucket used for TMDB-related behavior. Source-domain versus
target-domain semantics of each override must stay documented clearly so
stored shifting is neither skipped nor double-applied unexpectedly.
- Existing version-2 databases only contain show-owned shifted-season rows, so a
version-3 migration must preserve those rows as the show-level fallback layer.
- Current modern automated test coverage for shifted-season behavior is light,
so precedence, migration, and convert-time numbering behavior need focused
tests.

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', '.'],
] ]
@@ -457,13 +461,50 @@ def upgrade(ctx, branch):
@ffx.command() @ffx.command()
@click.pass_context @click.pass_context
@click.argument('filename', nargs=1) @click.option('--shift', is_flag=True, default=False, help='Print resolved season-shift mapping for each file instead of opening the TUI')
def inspect(ctx, filename): @click.argument('filenames', nargs=-1)
def inspect(ctx, shift, filenames):
if not filenames:
raise click.ClickException("At least one filename is required.")
if shift:
from ffx.file_properties import FileProperties
from ffx.shifted_season_controller import ShiftedSeasonController
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
for filename in filenames:
fileProperties = FileProperties(ctx.obj, filename)
season = fileProperties.getSeason()
episode = fileProperties.getEpisode()
if season == -1 or episode == -1:
click.echo(f"{filename}: no season/episode recognized")
continue
currentPattern = fileProperties.getPattern()
shiftedSeason, shiftedEpisode, sourceLabel = shiftedSeasonController.resolveShiftSeason(
fileProperties.getShowId(),
season=season,
episode=episode,
patternId=currentPattern.getId() if currentPattern is not None else None,
)
if shiftedSeason == season and shiftedEpisode == episode:
click.echo(f"{filename}: none")
else:
click.echo(
f"{filename}: {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
)
return
if len(filenames) != 1:
raise click.ClickException("Inspect without --shift requires exactly one filename.")
from ffx.ffx_app import FfxApp from ffx.ffx_app import FfxApp
ctx.obj['command'] = 'inspect' ctx.obj['command'] = 'inspect'
ctx.obj['arguments'] = {} ctx.obj['arguments'] = {}
ctx.obj['arguments']['filename'] = filename ctx.obj['arguments']['filename'] = filenames[0]
app = FfxApp(ctx.obj) app = FfxApp(ctx.obj)
app.run() app.run()
@@ -585,6 +626,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 +647,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 +665,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 +1014,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 +1198,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 +1231,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 +1313,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 +1329,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 +1345,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 +1467,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.5'
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

@@ -8,10 +8,9 @@ from ffx.audio_layout import AudioLayout
from .show_details_screen import ShowDetailsScreen from .show_details_screen import ShowDetailsScreen
from .pattern_details_screen import PatternDetailsScreen from .pattern_details_screen import PatternDetailsScreen
from .screen_support import build_screen_bootstrap, build_screen_controllers from .screen_support import build_screen_bootstrap, build_screen_controllers, populate_tag_table
from ffx.track_type import TrackType from ffx.track_type import TrackType
from ffx.track_codec import TrackCodec
from ffx.model.track import Track from ffx.model.track import Track
from ffx.track_disposition import TrackDisposition from ffx.track_disposition import TrackDisposition
@@ -25,7 +24,7 @@ from ffx.file_properties import FileProperties
from ffx.media_descriptor_change_set import MediaDescriptorChangeSet from ffx.media_descriptor_change_set import MediaDescriptorChangeSet
from ffx.helper import formatRichColor, DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY, DIFF_UNCHANGED_KEY from ffx.helper import DIFF_ADDED_KEY, DIFF_CHANGED_KEY, DIFF_REMOVED_KEY, DIFF_UNCHANGED_KEY
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
@@ -156,6 +155,9 @@ class MediaDetailsScreen(Screen):
raise click.ClickException(f"MediaDetailsScreen.__init__(): Argument 'filename' is required to be provided for command 'inspect'") raise click.ClickException(f"MediaDetailsScreen.__init__(): Argument 'filename' is required to be provided for command 'inspect'")
self.__mediaFilename = self.context['arguments']['filename'] self.__mediaFilename = self.context['arguments']['filename']
self.__showRowData: dict[object, ShowDescriptor | None] = {}
self.__trackRowData: dict[object, TrackDescriptor] = {}
self.__sourceMediaTagRowData: dict[object, tuple[str, str]] = {}
if not os.path.isfile(self.__mediaFilename): if not os.path.isfile(self.__mediaFilename):
raise click.ClickException(f"MediaDetailsScreen.__init__(): Media file {self.__mediaFilename} does not exist") raise click.ClickException(f"MediaDetailsScreen.__init__(): Media file {self.__mediaFilename} does not exist")
@@ -167,37 +169,49 @@ class MediaDetailsScreen(Screen):
"""Remove show entry from DataTable. """Remove show entry from DataTable.
Removes the <New show> entry if showId is not set""" Removes the <New show> entry if showId is not set"""
for rowKey, row in self.showsTable.rows.items(): # dict[RowKey, Row] for row_key, show_descriptor in list(self.__showRowData.items()):
if (
rowData = self.showsTable.get_row(rowKey) (showId == -1 and show_descriptor is None)
or (
try: show_descriptor is not None
if (showId == -1 and rowData[0] == ' ' and show_descriptor.getId() == showId
or showId == int(rowData[0])): )
self.showsTable.remove_row(rowKey) ):
self.showsTable.remove_row(row_key)
self.__showRowData.pop(row_key, None)
return return
except:
continue
def getRowIndexFromShowId(self, showId : int = -1) -> int: def getRowIndexFromShowId(self, showId : int = -1) -> int:
"""Find the index of the row where the value in the specified column matches the target_value.""" """Find the index of the row where the value in the specified column matches the target_value."""
for rowKey, row in self.showsTable.rows.items(): # dict[RowKey, Row] for row_key, show_descriptor in self.__showRowData.items():
if (
rowData = self.showsTable.get_row(rowKey) (showId == -1 and show_descriptor is None)
or (
try: show_descriptor is not None
if ((showId == -1 and rowData[0] == ' ') and show_descriptor.getId() == showId
or showId == int(rowData[0])): )
return int(self.showsTable.get_row_index(rowKey)) ):
except: return int(self.showsTable.get_row_index(row_key))
continue
return None return None
def _add_show_row(self, show_descriptor: ShowDescriptor | None):
if show_descriptor is None:
row_key = self.showsTable.add_row(' ', '<New show>', ' ')
else:
row_key = self.showsTable.add_row(
str(show_descriptor.getId()),
str(show_descriptor.getName()),
str(show_descriptor.getYear()),
)
self.__showRowData[row_key] = show_descriptor
return row_key
def loadProperties(self): def loadProperties(self):
self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename) self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename)
@@ -314,23 +328,17 @@ class MediaDetailsScreen(Screen):
def on_mount(self): def on_mount(self):
if self.__currentPattern is None: if self.__currentPattern is None:
row = (' ', '<New show>', ' ') # Convert each element to a string before adding self._add_show_row(None)
self.showsTable.add_row(*map(str, row))
for show in self.__sc.getAllShows(): for show in self.__sc.getAllShows():
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding self._add_show_row(show.getDescriptor(self.context))
self.showsTable.add_row(*map(str, row))
for mediaTagKey, mediaTagValue in self.__sourceMediaDescriptor.getTags().items(): self.__sourceMediaTagRowData = populate_tag_table(
self.mediaTagsTable,
textColor = None self.__sourceMediaDescriptor.getTags(),
if mediaTagKey in self.__ignoreGlobalKeys: ignore_keys=self.__ignoreGlobalKeys,
textColor = 'blue' remove_keys=self.__removeGlobalKeys,
if mediaTagKey in self.__removeGlobalKeys: )
textColor = 'red'
row = (formatRichColor(mediaTagKey, textColor), formatRichColor(mediaTagValue, textColor)) # Convert each element to a string before adding
self.mediaTagsTable.add_row(*map(str, row))
self.updateTracks() self.updateTracks()
@@ -362,6 +370,7 @@ class MediaDetailsScreen(Screen):
def updateTracks(self): def updateTracks(self):
self.tracksTable.clear() self.tracksTable.clear()
self.__trackRowData = {}
# trackDescriptorList = self.__sourceMediaDescriptor.getAllTrackDescriptors() # trackDescriptorList = self.__sourceMediaDescriptor.getAllTrackDescriptors()
trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors() trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors()
@@ -387,7 +396,8 @@ class MediaDetailsScreen(Screen):
'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No', 'Yes' if TrackDisposition.DEFAULT in dispoSet else 'No',
'Yes' if TrackDisposition.FORCED in dispoSet else 'No') 'Yes' if TrackDisposition.FORCED in dispoSet else 'No')
self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
self.__trackRowData[row_key] = td
typeCounter[trackType] += 1 typeCounter[trackType] += 1
@@ -529,17 +539,7 @@ class MediaDetailsScreen(Screen):
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_track_data = self.tracksTable.get_row(row_key) return self.__trackRowData.get(row_key)
kwargs = {}
kwargs[TrackDescriptor.CONTEXT_KEY] = self.context
kwargs[TrackDescriptor.INDEX_KEY] = int(selected_track_data[0])
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = TrackType.fromLabel(selected_track_data[1])
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(selected_track_data[2])
kwargs[TrackDescriptor.CODEC_KEY] = TrackCodec.fromLabel(selected_track_data[3])
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(selected_track_data[4])
return TrackDescriptor(**kwargs)
else: else:
return None return None
@@ -554,20 +554,7 @@ class MediaDetailsScreen(Screen):
row_key, col_key = self.showsTable.coordinate_to_cell_key(self.showsTable.cursor_coordinate) row_key, col_key = self.showsTable.coordinate_to_cell_key(self.showsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.showsTable.get_row(row_key) return self.__showRowData.get(row_key)
try:
kwargs = {}
kwargs[ShowDescriptor.CONTEXT_KEY] = self.context
kwargs[ShowDescriptor.ID_KEY] = int(selected_row_data[0])
kwargs[ShowDescriptor.NAME_KEY] = str(selected_row_data[1])
kwargs[ShowDescriptor.YEAR_KEY] = int(selected_row_data[2])
return ShowDescriptor(**kwargs)
except ValueError:
return None
except CellDoesNotExist: except CellDoesNotExist:
return None return None
@@ -583,8 +570,7 @@ class MediaDetailsScreen(Screen):
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
if showRowIndex is None: if showRowIndex is None:
show = (showDescriptor.getId(), showDescriptor.getName(), showDescriptor.getYear()) self._add_show_row(showDescriptor)
self.showsTable.add_row(*map(str, show))
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId()) showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
if showRowIndex is not None: if showRowIndex is not None:

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,10 +9,16 @@ 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
from .screen_support import build_screen_bootstrap, build_screen_controllers from .screen_support import (
build_screen_bootstrap,
build_screen_controllers,
populate_tag_table,
)
from ffx.track_type import TrackType from ffx.track_type import TrackType
@@ -24,8 +30,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
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
@@ -34,8 +39,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,21 +120,27 @@ 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
self.__draftTracks : List[TrackDescriptor] = [] self.__draftTracks : List[TrackDescriptor] = []
self.__draftTags : dict[str, str] = {} self.__draftTags : dict[str, str] = {}
self.__trackRowData: dict[object, TrackDescriptor] = {}
self.__tagRowData: dict[object, tuple[str, str]] = {}
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
def updateTracks(self): def updateTracks(self):
self.tracksTable.clear() self.tracksTable.clear()
self.__trackRowData = {}
tracks = self.getCurrentTrackDescriptors() tracks = self.getCurrentTrackDescriptors()
@@ -160,7 +171,8 @@ class PatternDetailsScreen(Screen):
'Yes' if TrackDisposition.FORCED in dispoSet else 'No', 'Yes' if TrackDisposition.FORCED in dispoSet else 'No',
td.getSourceIndex()) td.getSourceIndex())
self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
self.__trackRowData[row_key] = td
typeCounter[trackType] += 1 typeCounter[trackType] += 1
@@ -238,25 +250,65 @@ class PatternDetailsScreen(Screen):
def updateTags(self): def updateTags(self):
self.tagsTable.clear()
tags = ( tags = (
self.__tac.findAllMediaTags(self.__pattern.getId()) self.__tac.findAllMediaTags(self.__pattern.getId())
if self.__pattern is not None if self.__pattern is not None
else self.__draftTags else self.__draftTags
) )
for tagKey, tagValue in tags.items(): self.__tagRowData = populate_tag_table(
self.tagsTable,
tags,
ignore_keys=self.__ignoreGlobalKeys,
remove_keys=self.__removeGlobalKeys,
)
textColor = None def updateShiftedSeasons(self):
if tagKey in self.__ignoreGlobalKeys:
textColor = 'blue'
if tagKey in self.__removeGlobalKeys:
textColor = 'red'
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor)) self.shiftedSeasonsTable.clear()
self.tagsTable.add_row(*map(str, row)) self.__shiftedSeasonRowData = {}
if self.__pattern is None:
return
shiftedSeason: ShiftedSeason
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()):
shiftedSeasonObj = shiftedSeason.getObj()
shiftedSeasonObj['id'] = shiftedSeason.getId()
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'],
)
row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
self.__shiftedSeasonRowData[row_key] = shiftedSeasonObj
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:
shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
except CellDoesNotExist:
pass
return shiftedSeasonObj
def on_mount(self): def on_mount(self):
@@ -276,6 +328,7 @@ class PatternDetailsScreen(Screen):
self.updateTags() self.updateTags()
self.updateTracks() self.updateTracks()
self.updateShiftedSeasons()
def compose(self): def compose(self):
@@ -304,6 +357,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 +408,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 +438,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 +454,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()
@@ -410,15 +494,7 @@ class PatternDetailsScreen(Screen):
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_track_data = self.tracksTable.get_row(row_key) return self.__trackRowData.get(row_key)
trackIndex = int(selected_track_data[0])
trackSubIndex = int(selected_track_data[2])
for trackDescriptor in self.getCurrentTrackDescriptors():
if (trackDescriptor.getIndex() == trackIndex
and trackDescriptor.getSubIndex() == trackSubIndex):
return trackDescriptor
return None return None
@@ -436,12 +512,7 @@ class PatternDetailsScreen(Screen):
row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate) row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_tag_data = self.tagsTable.get_row(row_key) return self.__tagRowData.get(row_key)
tagKey = removeRichColor(selected_tag_data[0])
tagValue = removeRichColor(selected_tag_data[1])
return tagKey, tagValue
else: else:
return None return None
@@ -486,6 +557,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 +754,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

@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from .helper import formatRichColor
from .pattern_controller import PatternController from .pattern_controller import PatternController
from .show_controller import ShowController from .show_controller import ShowController
from .shifted_season_controller import ShiftedSeasonController from .shifted_season_controller import ShiftedSeasonController
@@ -63,3 +65,34 @@ def build_screen_controllers(
controllers['shifted_season'] = ShiftedSeasonController(context=context) controllers['shifted_season'] = ShiftedSeasonController(context=context)
return controllers return controllers
def populate_tag_table(
table,
tags: Mapping[str, object],
*,
ignore_keys: list[str],
remove_keys: list[str],
) -> dict[object, tuple[str, str]]:
"""Render display rows while keeping raw tag data addressable by row key."""
table.clear()
row_data: dict[object, tuple[str, str]] = {}
for tag_key, tag_value in tags.items():
raw_key = str(tag_key)
raw_value = str(tag_value)
text_color = None
if raw_key in ignore_keys:
text_color = "blue"
if raw_key in remove_keys:
text_color = "red"
row_key = table.add_row(
str(formatRichColor(raw_key, text_color)),
str(formatRichColor(raw_value, text_color)),
)
row_data[row_key] = (raw_key, raw_value)
return row_data

View File

@@ -6,225 +6,445 @@ 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):
if season == -1 or episode == -1:
return season, episode
def shiftSeason(self, showId, season, episode): shiftedSeason, shiftedEpisode, sourceLabel = self.resolveShiftSeason(
showId,
season,
episode,
patternId=patternId,
)
shiftedSeasonEntry: ShiftedSeason if shiftedSeason != season or shiftedEpisode != episode:
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId): self.context['logger'].info(
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
if (season == shiftedSeasonEntry.getOriginalSeason() )
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
return shiftedSeason, shiftedEpisode return shiftedSeason, shiftedEpisode
return season, episode def resolveShiftSeason(self, showId, season, episode, patternId=None):
if season == -1 or episode == -1:
return season, episode, "unrecognized"
session = None
try:
session = self.Session()
activeShift = None
if patternId is not None:
activeShift = self._find_matching_rule(
session,
self._resolve_owner(patternId=patternId),
season=int(season),
episode=int(episode),
)
if activeShift is None and showId is not None and showId != -1:
activeShift = self._find_matching_rule(
session,
self._resolve_owner(showId=showId),
season=int(season),
episode=int(episode),
)
if activeShift is None:
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"
)
return shiftedSeason, shiftedEpisode, sourceLabel
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 = [
@@ -104,12 +108,45 @@ class ShowDetailsScreen(Screen):
self.__ssc = controllers['shifted_season'] self.__ssc = controllers['shifted_season']
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
self.__patternRowData: dict[object, dict[str, object]] = {}
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
def _add_pattern_row(self, *, pattern_id: int | None, pattern_text: str):
row_key = self.patternTable.add_row(str(pattern_text))
self.__patternRowData[row_key] = {
'id': pattern_id,
'show_id': self.__showDescriptor.getId() if self.__showDescriptor is not None else None,
'pattern': str(pattern_text),
}
return row_key
def _add_shifted_season_row(self, shifted_season_obj: dict[str, int | None]):
firstEpisode = shifted_season_obj['first_episode']
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
lastEpisode = shifted_season_obj['last_episode']
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
row = (
shifted_season_obj['original_season'],
firstEpisodeStr,
lastEpisodeStr,
shifted_season_obj['season_offset'],
shifted_season_obj['episode_offset'],
)
row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
self.__shiftedSeasonRowData[row_key] = dict(shifted_season_obj)
return row_key
def updateShiftedSeasons(self): def updateShiftedSeasons(self):
self.shiftedSeasonsTable.clear() self.shiftedSeasonsTable.clear()
self.__shiftedSeasonRowData = {}
if not self.__showDescriptor is None: if not self.__showDescriptor is None:
@@ -119,20 +156,8 @@ class ShowDetailsScreen(Screen):
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId): for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId):
shiftedSeasonObj = shiftedSeason.getObj() shiftedSeasonObj = shiftedSeason.getObj()
shiftedSeasonObj['id'] = shiftedSeason.getId()
firstEpisode = shiftedSeasonObj['first_episode'] self._add_shifted_season_row(shiftedSeasonObj)
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))
@@ -150,12 +175,18 @@ 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}")
for pattern in self.__pc.getPatternsForShow(showId): for pattern in self.__pc.getPatternsForShow(showId):
row = (pattern.getPattern(),) self._add_pattern_row(
self.patternTable.add_row(*map(str, row)) pattern_id=pattern.getId(),
pattern_text=pattern.getPattern(),
)
self.updateShiftedSeasons() self.updateShiftedSeasons()
@@ -187,10 +218,7 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.patternTable.get_row(row_key) selectedPattern = dict(self.__patternRowData.get(row_key, {}))
selectedPattern['show_id'] = self.__showDescriptor.getId()
selectedPattern['pattern'] = str(selected_row_data[0])
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -209,25 +237,7 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate) row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.shiftedSeasonsTable.get_row(row_key) shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0
if self.__showDescriptor is not None:
showId = int(self.__showDescriptor.getId())
shiftedSeasonId = self.__ssc.findShiftedSeason(showId,
originalSeason=shiftedSeasonObj['original_season'],
firstEpisode=shiftedSeasonObj['first_episode'],
lastEpisode=shiftedSeasonObj['last_episode'])
if shiftedSeasonId is not None:
shiftedSeasonObj['id'] = shiftedSeasonId
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -241,9 +251,14 @@ class ShowDetailsScreen(Screen):
def handle_add_pattern(self, screenResult): def handle_add_pattern(self, screenResult):
if screenResult is None:
return
pattern = (screenResult['pattern'],) pattern_id = self.__pc.findPattern(screenResult)
self.patternTable.add_row(*map(str, pattern)) self._add_pattern_row(
pattern_id=pattern_id,
pattern_text=screenResult['pattern'],
)
def action_edit_pattern(self): def action_edit_pattern(self):
@@ -251,8 +266,7 @@ class ShowDetailsScreen(Screen):
selectedPatternDescriptor = self.getSelectedPatternDescriptor() selectedPatternDescriptor = self.getSelectedPatternDescriptor()
if selectedPatternDescriptor: if selectedPatternDescriptor:
selectedPatternId = selectedPatternDescriptor.get('id')
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
if selectedPatternId is None: if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to edit has no id") raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to edit has no id")
@@ -266,6 +280,8 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern']) self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern'])
if row_key in self.__patternRowData:
self.__patternRowData[row_key]['pattern'] = str(screenResult['pattern'])
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -277,7 +293,7 @@ class ShowDetailsScreen(Screen):
if selectedPatternDescriptor: if selectedPatternDescriptor:
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor) selectedPatternId = selectedPatternDescriptor.get('id')
if selectedPatternId is None: if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id") raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id")
@@ -290,6 +306,7 @@ class ShowDetailsScreen(Screen):
try: try:
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.remove_row(row_key) self.patternTable.remove_row(row_key)
self.__patternRowData.pop(row_key, None)
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -308,7 +325,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 +359,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 +400,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 +457,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)

View File

@@ -66,6 +66,17 @@ class ShowsScreen(Screen):
self.Session = self.context['database']['session'] # convenience self.Session = self.context['database']['session'] # convenience
self.__sc = ShowController(context = self.context) self.__sc = ShowController(context = self.context)
self.__showRowData: dict[object, ShowDescriptor] = {}
def _add_show_row(self, show_descriptor: ShowDescriptor):
row_key = self.table.add_row(
str(show_descriptor.getId()),
str(show_descriptor.getName()),
str(show_descriptor.getYear()),
)
self.__showRowData[row_key] = show_descriptor
return row_key
def getSelectedShowId(self): def getSelectedShowId(self):
@@ -76,9 +87,8 @@ class ShowsScreen(Screen):
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.table.get_row(row_key) selected_show = self.__showRowData.get(row_key)
return selected_show.getId() if selected_show is not None else None
return selected_row_data[0]
except CellDoesNotExist: except CellDoesNotExist:
return None return None
@@ -90,9 +100,8 @@ class ShowsScreen(Screen):
self.app.push_screen(ShowDetailsScreen(), self.handle_new_screen) self.app.push_screen(ShowDetailsScreen(), self.handle_new_screen)
def handle_new_screen(self, screenResult): def handle_new_screen(self, screenResult):
if isinstance(screenResult, ShowDescriptor):
show = (screenResult['id'], screenResult['name'], screenResult['year']) self._add_show_row(screenResult)
self.table.add_row(*map(str, show))
def action_edit_show(self): def action_edit_show(self):
@@ -111,6 +120,7 @@ class ShowsScreen(Screen):
self.table.update_cell(row_key, self.column_key_name, showDescriptor.getName()) self.table.update_cell(row_key, self.column_key_name, showDescriptor.getName())
self.table.update_cell(row_key, self.column_key_year, showDescriptor.getYear()) self.table.update_cell(row_key, self.column_key_year, showDescriptor.getYear())
self.__showRowData[row_key] = showDescriptor
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -131,6 +141,7 @@ class ShowsScreen(Screen):
try: try:
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate) row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
self.table.remove_row(row_key) self.table.remove_row(row_key)
self.__showRowData.pop(row_key, None)
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -138,8 +149,7 @@ class ShowsScreen(Screen):
def on_mount(self) -> None: def on_mount(self) -> None:
for show in self.__sc.getAllShows(): for show in self.__sc.getAllShows():
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding self._add_show_row(show.getDescriptor(self.context))
self.table.add_row(*map(str, row))
def compose(self): def compose(self):

View File

@@ -13,8 +13,7 @@ from .track_codec import TrackCodec
from .track_descriptor import TrackDescriptor from .track_descriptor import TrackDescriptor
from .track_disposition import TrackDisposition from .track_disposition import TrackDisposition
from .track_type import TrackType from .track_type import TrackType
from .screen_support import build_screen_bootstrap, populate_tag_table
from ffx.helper import formatRichColor, removeRichColor
class TrackDetailsScreen(Screen): class TrackDetailsScreen(Screen):
@@ -98,28 +97,12 @@ class TrackDetailsScreen(Screen):
): ):
super().__init__() super().__init__()
self.context = self.app.getContext() bootstrap = build_screen_bootstrap(self.app.getContext())
self.context = bootstrap.context
self.__configurationData = self.context["config"].getData() self.__removeTrackKeys = bootstrap.remove_track_keys
self.__ignoreTrackKeys = bootstrap.ignore_track_keys
metadataConfiguration = ( self.__tagRowData: dict[object, tuple[str, str]] = {}
self.__configurationData["metadata"]
if "metadata" in self.__configurationData.keys()
else {}
)
self.__removeTrackKeys = (
metadataConfiguration["streams"]["remove"]
if "streams" in metadataConfiguration.keys()
and "remove" in metadataConfiguration["streams"].keys()
else []
)
self.__ignoreTrackKeys = (
metadataConfiguration["streams"]["ignore"]
if "streams" in metadataConfiguration.keys()
and "ignore" in metadataConfiguration["streams"].keys()
else []
)
self.__isNew = trackDescriptor is None self.__isNew = trackDescriptor is None
self.__trackDescriptor = trackDescriptor self.__trackDescriptor = trackDescriptor
@@ -166,18 +149,12 @@ class TrackDetailsScreen(Screen):
) )
def updateTags(self): def updateTags(self):
self.__tagRowData = populate_tag_table(
self.trackTagsTable.clear() self.trackTagsTable,
self.__draftTrackTags,
for key, value in self.__draftTrackTags.items(): ignore_keys=self.__ignoreTrackKeys,
textColor = None remove_keys=self.__removeTrackKeys,
if key in self.__ignoreTrackKeys: )
textColor = "blue"
if key in self.__removeTrackKeys:
textColor = "red"
row = (formatRichColor(key, textColor), formatRichColor(value, textColor))
self.trackTagsTable.add_row(*map(str, row))
def on_mount(self): def on_mount(self):
@@ -190,9 +167,9 @@ class TrackDetailsScreen(Screen):
self.query_one("#pattern_label", Static).update(self.__patternLabel) self.query_one("#pattern_label", Static).update(self.__patternLabel)
if self.__trackType is not None: if self.__trackType is not None:
self.query_one("#type_select", Select).value = self.__trackType.label() self.query_one("#type_select", Select).value = self.__trackType
self.query_one("#audio_layout_select", Select).value = self.__audioLayout.label() self.query_one("#audio_layout_select", Select).value = self.__audioLayout
for disposition in TrackDisposition: for disposition in TrackDisposition:
@@ -211,9 +188,7 @@ class TrackDetailsScreen(Screen):
) )
if self.__trackDescriptor is not None: if self.__trackDescriptor is not None:
self.query_one("#language_select", Select).value = ( self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage()
self.__trackDescriptor.getLanguage().label()
)
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle() self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
self.updateTags() self.updateTags()
@@ -226,8 +201,6 @@ class TrackDetailsScreen(Screen):
self.trackTagsTable.cursor_type = "row" self.trackTagsTable.cursor_type = "row"
languages = [language.label() for language in IsoLanguage]
yield Header() yield Header()
with Grid(): with Grid():
@@ -250,15 +223,15 @@ class TrackDetailsScreen(Screen):
yield Static(" ", classes="five") yield Static(" ", classes="five")
yield Static("Type") yield Static("Type")
yield Select.from_values( yield Select(
[trackType.label() for trackType in TrackType], [(trackType.label(), trackType) for trackType in TrackType],
classes="four", classes="four",
id="type_select", id="type_select",
) )
yield Static("Audio Layout") yield Static("Audio Layout")
yield Select.from_values( yield Select(
[layout.label() for layout in AudioLayout], [(layout.label(), layout) for layout in AudioLayout],
classes="four", classes="four",
id="audio_layout_select", id="audio_layout_select",
) )
@@ -268,7 +241,11 @@ class TrackDetailsScreen(Screen):
yield Static(" ", classes="five") yield Static(" ", classes="five")
yield Static("Language") yield Static("Language")
yield Select.from_values(languages, classes="four", id="language_select") yield Select(
[(language.label(), language) for language in IsoLanguage],
classes="four",
id="language_select",
)
yield Static(" ", classes="five") yield Static(" ", classes="five")
@@ -328,15 +305,18 @@ class TrackDetailsScreen(Screen):
if self.__subIndex is not None and int(self.__subIndex) >= 0: if self.__subIndex is not None and int(self.__subIndex) >= 0:
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex) kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex)
selectedTrackType = TrackType.fromLabel( selectedTrackType = self.query_one("#type_select", Select).value
self.query_one("#type_select", Select).value if not isinstance(selectedTrackType, TrackType):
) selectedTrackType = TrackType.UNKNOWN
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
if selectedTrackType == TrackType.AUDIO: if selectedTrackType == TrackType.AUDIO:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel( selectedAudioLayout = self.query_one("#audio_layout_select", Select).value
self.query_one("#audio_layout_select", Select).value kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = (
selectedAudioLayout
if isinstance(selectedAudioLayout, AudioLayout)
else AudioLayout.LAYOUT_UNDEFINED
) )
else: else:
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
@@ -344,8 +324,8 @@ class TrackDetailsScreen(Screen):
trackTags = dict(self.__draftTrackTags) trackTags = dict(self.__draftTrackTags)
language = self.query_one("#language_select", Select).value language = self.query_one("#language_select", Select).value
if language: if isinstance(language, IsoLanguage):
trackTags["language"] = IsoLanguage.find(language).threeLetter() trackTags["language"] = language.threeLetter()
title = self.query_one("#title_input", Input).value title = self.query_one("#title_input", Input).value
if title: if title:
@@ -370,12 +350,7 @@ class TrackDetailsScreen(Screen):
) )
if row_key is not None: if row_key is not None:
selected_tag_data = self.trackTagsTable.get_row(row_key) return self.__tagRowData.get(row_key)
tagKey = removeRichColor(selected_tag_data[0])
tagValue = removeRichColor(selected_tag_data[1])
return tagKey, tagValue
return None return None

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

@@ -0,0 +1,142 @@
from __future__ import annotations
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
from click.testing import CliRunner
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 import cli # noqa: E402
class _FakePattern:
def __init__(self, pattern_id: int):
self._pattern_id = pattern_id
def getId(self):
return self._pattern_id
class _FakeFileProperties:
def __init__(self, context, source_path):
self.source_path = source_path
def getShowId(self):
return 42 if self.source_path.endswith("mapped.mkv") else -1
def getSeason(self):
if self.source_path.endswith("unknown.mkv"):
return -1
return 1
def getEpisode(self):
if self.source_path.endswith("unknown.mkv"):
return -1
return 3
def getPattern(self):
if self.source_path.endswith("mapped.mkv"):
return _FakePattern(7)
return None
class _FakeShiftedSeasonController:
def __init__(self, context):
self.context = context
def resolveShiftSeason(self, show_id, season, episode, patternId=None):
if patternId is not None:
return 2, 1, "pattern"
return season, episode, "default"
class InspectShiftCliTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.home_dir = Path(self.tempdir.name) / "home"
self.home_dir.mkdir()
self.database_path = Path(self.tempdir.name) / "test.db"
self.source_dir = Path(self.tempdir.name) / "source"
self.source_dir.mkdir()
self.mapped_path = self.source_dir / "mapped.mkv"
self.mapped_path.write_bytes(b"mapped")
self.identity_path = self.source_dir / "identity.mkv"
self.identity_path.write_bytes(b"identity")
self.unknown_path = self.source_dir / "unknown.mkv"
self.unknown_path.write_bytes(b"unknown")
def tearDown(self):
self.tempdir.cleanup()
def test_inspect_shift_prints_resolved_mapping_for_each_file(self):
runner = CliRunner()
with (
patch("ffx.file_properties.FileProperties", _FakeFileProperties),
patch(
"ffx.shifted_season_controller.ShiftedSeasonController",
_FakeShiftedSeasonController,
),
):
result = runner.invoke(
cli.ffx,
[
"--database-file",
str(self.database_path),
"inspect",
"--shift",
str(self.mapped_path),
str(self.identity_path),
str(self.unknown_path),
],
env={**os.environ, "HOME": str(self.home_dir)},
)
self.assertEqual(0, result.exit_code, result.output)
self.assertIn(
f"{self.mapped_path}: 1/3 -> 2/1 from pattern",
result.output,
)
self.assertIn(
f"{self.identity_path}: none",
result.output,
)
self.assertIn(
f"{self.unknown_path}: no season/episode recognized",
result.output,
)
def test_inspect_without_shift_requires_exactly_one_filename(self):
runner = CliRunner()
result = runner.invoke(
cli.ffx,
[
"--database-file",
str(self.database_path),
"inspect",
str(self.mapped_path),
str(self.unknown_path),
],
env={**os.environ, "HOME": str(self.home_dir)},
)
self.assertNotEqual(0, result.exit_code)
self.assertIn(
"Inspect without --shift requires exactly one filename.",
result.output,
)
if __name__ == "__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

@@ -23,6 +23,21 @@ class StaticConfig:
return self._data return self._data
class FakeTagTable:
def __init__(self):
self.rows = {}
self._next_index = 0
def clear(self):
self.rows.clear()
def add_row(self, *values):
row_key = f"row-{self._next_index}"
self._next_index += 1
self.rows[row_key] = tuple(values)
return row_key
class ScreenSupportTests(unittest.TestCase): class ScreenSupportTests(unittest.TestCase):
def make_context(self): def make_context(self):
return { return {
@@ -81,6 +96,32 @@ class ScreenSupportTests(unittest.TestCase):
controllers, controllers,
) )
def test_populate_tag_table_keeps_raw_values_outside_display_labels(self):
table = FakeTagTable()
row_data = screen_support.populate_tag_table(
table,
{"BPS": 4835, "KEEP": "plain"},
ignore_keys=["KEEP"],
remove_keys=["BPS"],
)
self.assertEqual(
{
"row-0": ("BPS", "4835"),
"row-1": ("KEEP", "plain"),
},
row_data,
)
self.assertEqual(
("[red]BPS[/red]", "[red]4835[/red]"),
table.rows["row-0"],
)
self.assertEqual(
("[blue]KEEP[/blue]", "[blue]plain[/blue]"),
table.rows["row-1"],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,204 @@
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_not_called()
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_not_called()
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(

View File

@@ -0,0 +1,325 @@
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.audio_layout import AudioLayout # noqa: E402
from ffx.iso_language import IsoLanguage # noqa: E402
from ffx.logging_utils import get_ffx_logger # noqa: E402
from ffx.media_details_screen import MediaDetailsScreen # noqa: E402
from ffx.pattern_details_screen import PatternDetailsScreen # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402
from ffx.show_details_screen import ShowDetailsScreen # noqa: E402
from ffx.shows_screen import ShowsScreen # noqa: E402
from ffx.track_codec import TrackCodec # noqa: E402
from ffx.track_descriptor import TrackDescriptor # noqa: E402
from ffx.track_details_screen import TrackDetailsScreen # noqa: E402
from ffx.track_type import TrackType # noqa: E402
class FakeTagTable:
def __init__(self):
self.rows = {}
self.cursor_coordinate = (0, 0)
self._selected_row_key = None
self._next_index = 0
self._row_order = []
def clear(self):
self.rows.clear()
self._selected_row_key = None
self._row_order.clear()
def add_row(self, *values):
row_key = f"row-{self._next_index}"
self._next_index += 1
self.rows[row_key] = tuple(values)
self._row_order.append(row_key)
if self._selected_row_key is None:
self._selected_row_key = row_key
return row_key
def coordinate_to_cell_key(self, _coordinate):
return self._selected_row_key, None
def select_row(self, row_key):
self._selected_row_key = row_key
def get_row_index(self, row_key):
return self._row_order.index(row_key)
def remove_row(self, row_key):
self.rows.pop(row_key, None)
if row_key in self._row_order:
self._row_order.remove(row_key)
if self._selected_row_key == row_key:
self._selected_row_key = self._row_order[0] if self._row_order else None
def update_cell(self, row_key, column_key, value):
row = list(self.rows[row_key])
row[int(column_key)] = value
self.rows[row_key] = tuple(row)
class FakeMediaDescriptor:
def __init__(self, track_descriptors):
self._track_descriptors = list(track_descriptors)
def getTrackDescriptors(self):
return list(self._track_descriptors)
class FakeValueWidget:
def __init__(self, value):
self.value = value
class FakeInputWidget:
def __init__(self, value):
self.value = value
class FakeSelectionListWidget:
def __init__(self, selected):
self.selected = selected
def make_track_descriptor(index, sub_index, track_type):
return TrackDescriptor(
index=index,
sub_index=sub_index,
track_type=track_type,
codec_name=TrackCodec.UNKNOWN,
audio_layout=AudioLayout.LAYOUT_UNDEFINED,
)
def make_show_descriptor(show_id, name="Show", year=2000):
return ShowDescriptor(
id=show_id,
name=name,
year=year,
)
class TagTableScreenStateTests(unittest.TestCase):
def test_track_details_screen_reads_selected_tag_from_raw_row_mapping(self):
screen = object.__new__(TrackDetailsScreen)
screen.trackTagsTable = FakeTagTable()
screen._TrackDetailsScreen__draftTrackTags = {
"BPS": "4835",
"KEEP_ME": "plain",
}
screen._TrackDetailsScreen__ignoreTrackKeys = ["KEEP_ME"]
screen._TrackDetailsScreen__removeTrackKeys = ["BPS"]
screen._TrackDetailsScreen__tagRowData = {}
screen.updateTags()
self.assertEqual(
("[red]BPS[/red]", "[red]4835[/red]"),
screen.trackTagsTable.rows["row-0"],
)
self.assertEqual(
("BPS", "4835"),
screen.getSelectedTag(),
)
def test_track_details_screen_reads_select_values_from_widget_state(self):
screen = object.__new__(TrackDetailsScreen)
screen.context = {"logger": get_ffx_logger()}
screen._TrackDetailsScreen__trackDescriptor = None
screen._TrackDetailsScreen__patternId = 5
screen._TrackDetailsScreen__index = 2
screen._TrackDetailsScreen__subIndex = 0
screen._TrackDetailsScreen__trackCodec = TrackCodec.UNKNOWN
screen._TrackDetailsScreen__draftTrackTags = {"KEEP": "value"}
widgets = {
"#type_select": FakeValueWidget(TrackType.AUDIO),
"#audio_layout_select": FakeValueWidget(AudioLayout.LAYOUT_STEREO),
"#language_select": FakeValueWidget(IsoLanguage.GERMAN),
"#title_input": FakeInputWidget("German Audio"),
"#dispositions_selection_list": FakeSelectionListWidget({0, 6}),
}
screen.query_one = lambda selector, _widget_type=None: widgets[selector]
descriptor = screen.getTrackDescriptorFromInput()
self.assertEqual(TrackType.AUDIO, descriptor.getType())
self.assertEqual(AudioLayout.LAYOUT_STEREO, descriptor.getAudioLayout())
self.assertEqual("deu", descriptor.getTags()["language"])
self.assertEqual("German Audio", descriptor.getTitle())
self.assertEqual("value", descriptor.getTags()["KEEP"])
def test_pattern_details_screen_reads_selected_track_from_row_mapping(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
screen = object.__new__(PatternDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._PatternDetailsScreen__draftTracks = [first_track, second_track]
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__trackRowData = {}
screen.updateTracks()
screen.tracksTable.select_row("row-1")
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
def test_pattern_details_screen_reads_selected_tag_from_raw_row_mapping(self):
screen = object.__new__(PatternDetailsScreen)
screen.tagsTable = FakeTagTable()
screen._PatternDetailsScreen__pattern = None
screen._PatternDetailsScreen__draftTags = {
"BPS": "4835",
"TITLE": "Deutsch [FN]",
}
screen._PatternDetailsScreen__ignoreGlobalKeys = ["TITLE"]
screen._PatternDetailsScreen__removeGlobalKeys = ["BPS"]
screen._PatternDetailsScreen__tagRowData = {}
screen.updateTags()
self.assertEqual(
("[red]BPS[/red]", "[red]4835[/red]"),
screen.tagsTable.rows["row-0"],
)
self.assertEqual(
("BPS", "4835"),
screen.getSelectedTag(),
)
def test_media_details_screen_reads_selected_track_from_row_mapping(self):
first_track = make_track_descriptor(0, 0, TrackType.VIDEO)
second_track = make_track_descriptor(1, 0, TrackType.SUBTITLE)
screen = object.__new__(MediaDetailsScreen)
screen.tracksTable = FakeTagTable()
screen._MediaDetailsScreen__sourceMediaDescriptor = FakeMediaDescriptor(
[first_track, second_track]
)
screen._MediaDetailsScreen__trackRowData = {}
screen.updateTracks()
screen.tracksTable.select_row("row-1")
self.assertIs(second_track, screen.getSelectedTrackDescriptor())
def test_pattern_details_screen_reads_selected_shifted_season_from_row_mapping(self):
screen = object.__new__(PatternDetailsScreen)
screen.shiftedSeasonsTable = FakeTagTable()
screen._PatternDetailsScreen__pattern = object()
screen._PatternDetailsScreen__shiftedSeasonRowData = {}
row_key = screen.shiftedSeasonsTable.add_row("9", "1", "3", "1", "0")
screen._PatternDetailsScreen__shiftedSeasonRowData[row_key] = {
"id": 44,
"original_season": 9,
"first_episode": 1,
"last_episode": 3,
"season_offset": 1,
"episode_offset": 0,
}
screen.shiftedSeasonsTable.rows[row_key] = ("broken", "ui", "values", "!", "?")
self.assertEqual(
{
"id": 44,
"original_season": 9,
"first_episode": 1,
"last_episode": 3,
"season_offset": 1,
"episode_offset": 0,
},
screen.getSelectedShiftedSeasonObjFromInput(),
)
def test_show_details_screen_reads_selected_pattern_from_row_mapping(self):
screen = object.__new__(ShowDetailsScreen)
screen.patternTable = FakeTagTable()
screen._ShowDetailsScreen__showDescriptor = make_show_descriptor(7, "Demo", 1999)
screen._ShowDetailsScreen__patternRowData = {}
row_key = screen._add_pattern_row(pattern_id=11, pattern_text=r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
screen.patternTable.rows[row_key] = ("display text changed",)
self.assertEqual(
{
"id": 11,
"show_id": 7,
"pattern": r"^demo_(s[0-9]+e[0-9]+)\.mkv$",
},
screen.getSelectedPatternDescriptor(),
)
def test_show_details_screen_reads_selected_shifted_season_from_row_mapping(self):
screen = object.__new__(ShowDetailsScreen)
screen.shiftedSeasonsTable = FakeTagTable()
screen._ShowDetailsScreen__shiftedSeasonRowData = {}
row_key = screen.shiftedSeasonsTable.add_row("1", "", "", "0", "0")
screen._ShowDetailsScreen__shiftedSeasonRowData[row_key] = {
"id": 3,
"original_season": 1,
"first_episode": -1,
"last_episode": -1,
"season_offset": 0,
"episode_offset": 0,
}
screen.shiftedSeasonsTable.rows[row_key] = ("bad", "visible", "data", "x", "y")
self.assertEqual(
{
"id": 3,
"original_season": 1,
"first_episode": -1,
"last_episode": -1,
"season_offset": 0,
"episode_offset": 0,
},
screen.getSelectedShiftedSeasonObjFromInput(),
)
def test_shows_screen_reads_selected_show_id_from_row_mapping(self):
screen = object.__new__(ShowsScreen)
screen.table = FakeTagTable()
screen._ShowsScreen__showRowData = {}
row_key = screen._add_show_row(make_show_descriptor(4, "Mapped", 2011))
screen.table.rows[row_key] = ("999", "Visible", "2099")
self.assertEqual(4, screen.getSelectedShowId())
def test_media_details_screen_reads_selected_show_from_row_mapping(self):
screen = object.__new__(MediaDetailsScreen)
screen.showsTable = FakeTagTable()
screen._MediaDetailsScreen__showRowData = {}
placeholder_key = screen._add_show_row(None)
show_key = screen._add_show_row(make_show_descriptor(8, "Real Show", 2020))
screen.showsTable.select_row(show_key)
screen.showsTable.rows[show_key] = ("oops", "display", "changed")
selected_show = screen.getSelectedShowDescriptor()
self.assertIsInstance(selected_show, ShowDescriptor)
self.assertEqual(8, selected_show.getId())
self.assertEqual(0, screen.getRowIndexFromShowId(-1))
self.assertEqual(1, screen.getRowIndexFromShowId(8))
screen.removeShow(-1)
self.assertNotIn(placeholder_key, screen._MediaDetailsScreen__showRowData)
self.assertEqual(0, screen.getRowIndexFromShowId(8))
if __name__ == "__main__":
unittest.main()