Compare commits
18 Commits
v0.2.4
...
d9639561ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9639561ce | ||
|
|
cbf43e5d6c | ||
|
|
d6e885517d | ||
|
|
8a8c43ecdf | ||
|
|
6170ac641c | ||
|
|
497c0e500b | ||
|
|
008c643272 | ||
|
|
c302b30e63 | ||
|
|
7926407534 | ||
|
|
0894ac2fab | ||
|
|
353759b983 | ||
|
|
454f5f0656 | ||
|
|
0e51d6337f | ||
|
|
a24b6dedaa | ||
|
|
8361fc536b | ||
|
|
4d4272e5e8 | ||
|
|
559869ca68 | ||
|
|
0e4fae538b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ venv/
|
||||
*.mkv
|
||||
*.webm
|
||||
ffmpeg2pass-0.log
|
||||
*.sup
|
||||
10
README.md
10
README.md
@@ -99,6 +99,16 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
||||
|
||||
## 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
|
||||
|
||||
- lightweight CLI commands now stay import-light via lazy runtime loading
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- Highest-leverage application optimizations:
|
||||
@@ -59,6 +47,7 @@
|
||||
|
||||
## 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?
|
||||
- Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI?
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "ffx"
|
||||
description = "FFX recoding and metadata managing tool"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
license = {file = "LICENSE.md"}
|
||||
dependencies = [
|
||||
"requests",
|
||||
|
||||
@@ -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.
|
||||
- Storage:
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -49,11 +50,11 @@
|
||||
## Data And Interface Notes
|
||||
|
||||
- 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.
|
||||
- `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`.
|
||||
- `MediaTag`: persisted container-level metadata for a pattern.
|
||||
- `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers.
|
||||
- `ShiftedSeason`: mapping from source numbering ranges to adjusted season and episode numbers, owned either by a show as fallback or by a pattern as override.
|
||||
- `Property`: internal key-value storage currently used for database versioning.
|
||||
- External interfaces:
|
||||
- CLI commands for conversion, inspection, extraction, and crop detection.
|
||||
@@ -62,9 +63,9 @@
|
||||
- Config keys `databasePath`, `logDirectory`, and `outputFilenameTemplate`, plus optional metadata-filter rules.
|
||||
- Validation rules:
|
||||
- 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.
|
||||
- Shifted-season ranges are intended not to overlap for the same show and season.
|
||||
- Shifted-season ranges are intended not to overlap within the same owner scope and season, and runtime resolution prefers pattern-owned matches over show-owned matches.
|
||||
- TMDB lookups require a show ID and season and episode numbers.
|
||||
- Error-handling approach:
|
||||
- User-facing operational failures are raised as `click.ClickException` or warnings.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
- As an operator, I want to inspect a file before conversion so that I can compare its actual streams and tags against the stored target schema.
|
||||
- As a user preparing web-playback files, I want to recode video and audio with a small set of predictable options so that results are compatible and consistently named.
|
||||
- As a user dealing with nonstandard releases, I want CLI overrides for language, title, stream order, default and forced tracks, and season and episode data so that one-off fixes do not require database edits first.
|
||||
- As a user importing anime or other shifted numbering schemes, I want season and episode offsets per show so that generated filenames align with TMDB and media-library expectations.
|
||||
- As a user importing anime or other shifted numbering schemes, I want season and episode offsets at the show level with optional pattern-specific overrides so that generated filenames align with TMDB and media-library expectations.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
@@ -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 system shall persist reusable normalization rules in SQLite for:
|
||||
- shows and show formatting digits,
|
||||
- optional show-level notes,
|
||||
- optional show-level quality defaults,
|
||||
- regex-based filename patterns,
|
||||
- per-pattern media tags,
|
||||
- per-pattern stream definitions,
|
||||
- shifted-season mappings,
|
||||
- show-level and pattern-level shifted-season mappings,
|
||||
- internal database version properties.
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -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 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 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:
|
||||
- `--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.
|
||||
@@ -91,7 +98,7 @@
|
||||
- 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`.
|
||||
- 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:
|
||||
- Maintained as a small codebase where simple patterns and direct controller logic are preferred over framework-heavy abstractions.
|
||||
- Third-party dependencies:
|
||||
|
||||
177
requirements/shifted_seasons_handling.md
Normal file
177
requirements/shifted_seasons_handling.md
Normal 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.
|
||||
118
src/ffx/cli.py
118
src/ffx/cli.py
@@ -436,10 +436,14 @@ def upgrade(ctx, branch):
|
||||
commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
|
||||
|
||||
if branch:
|
||||
commandSequences.append(['git', 'checkout', branch])
|
||||
commandSequences += [
|
||||
['git', 'fetch', 'origin', branch],
|
||||
['git', 'checkout', '-B', branch, 'FETCH_HEAD'],
|
||||
]
|
||||
else:
|
||||
commandSequences.append(['git', 'pull'])
|
||||
|
||||
commandSequences += [
|
||||
['git', 'pull'],
|
||||
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[bundlePipPath, 'install', '--editable', '.'],
|
||||
]
|
||||
@@ -457,13 +461,50 @@ def upgrade(ctx, branch):
|
||||
|
||||
@ffx.command()
|
||||
@click.pass_context
|
||||
@click.argument('filename', nargs=1)
|
||||
def inspect(ctx, filename):
|
||||
@click.option('--shift', is_flag=True, default=False, help='Print resolved season-shift mapping for each file instead of opening the TUI')
|
||||
@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
|
||||
|
||||
ctx.obj['command'] = 'inspect'
|
||||
ctx.obj['arguments'] = {}
|
||||
ctx.obj['arguments']['filename'] = filename
|
||||
ctx.obj['arguments']['filename'] = filenames[0]
|
||||
|
||||
app = FfxApp(ctx.obj)
|
||||
app.run()
|
||||
@@ -585,6 +626,7 @@ def unmux(ctx,
|
||||
cpu):
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.process import executeProcess
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_disposition import TrackDisposition
|
||||
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):
|
||||
os.makedirs(output_directory, exist_ok=True)
|
||||
|
||||
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
fp = FileProperties(ctx.obj, sourcePath)
|
||||
@@ -621,8 +665,12 @@ def unmux(ctx,
|
||||
currentShowDescriptor,
|
||||
)
|
||||
|
||||
season = fp.getSeason()
|
||||
episode = fp.getEpisode()
|
||||
season, episode = shiftedSeasonController.shiftSeason(
|
||||
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
|
||||
targetLabel = label if label else fp.getFileBasename()
|
||||
@@ -966,6 +1014,7 @@ def convert(ctx,
|
||||
from ffx.filter.quality_filter import QualityFilter
|
||||
from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.tmdb_controller import TmdbController
|
||||
from ffx.track_codec import TrackCodec
|
||||
@@ -1149,6 +1198,7 @@ def convert(ctx,
|
||||
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
|
||||
|
||||
jobIndex = 0
|
||||
showController = ShowController(context)
|
||||
|
||||
for sourcePath in existingSourcePaths:
|
||||
|
||||
@@ -1181,7 +1231,7 @@ def convert(ctx,
|
||||
|
||||
ssc = ShiftedSeasonController(context)
|
||||
|
||||
showId = mediaFileProperties.getShowId()
|
||||
matchedShowId = mediaFileProperties.getShowId()
|
||||
|
||||
#HINT: -1 if not set
|
||||
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
||||
@@ -1263,7 +1313,8 @@ def convert(ctx,
|
||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
||||
context['subtitle_prefix'],
|
||||
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.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)
|
||||
|
||||
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)
|
||||
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]
|
||||
|
||||
|
||||
# Shift season and episode if defined for this show
|
||||
if ('tmdb' not in cliOverrides.keys() and showId != -1
|
||||
and showSeason != -1 and showEpisode != -1):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId,
|
||||
season=showSeason,
|
||||
episode=showEpisode)
|
||||
showIdForShift = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else matchedShowId
|
||||
)
|
||||
patternIdForShift = currentPattern.getId() if currentPattern is not None else None
|
||||
hasExplicitTargetSeasonOrEpisode = (
|
||||
'tmdb' in cliOverrides.keys()
|
||||
and (
|
||||
'season' in cliOverrides['tmdb']
|
||||
or 'episode' in cliOverrides['tmdb']
|
||||
)
|
||||
)
|
||||
|
||||
# Shift season and episode if defined for the matched pattern or show
|
||||
if (
|
||||
not hasExplicitTargetSeasonOrEpisode
|
||||
and showSeason != -1
|
||||
and showEpisode != -1
|
||||
):
|
||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(
|
||||
showIdForShift,
|
||||
season=showSeason,
|
||||
episode=showEpisode,
|
||||
patternId=patternIdForShift,
|
||||
)
|
||||
else:
|
||||
shiftedShowSeason = showSeason
|
||||
shiftedShowEpisode = showEpisode
|
||||
|
||||
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
||||
#HINT: -1 if not set
|
||||
showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
showId = (
|
||||
cliOverrides['tmdb']['show']
|
||||
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||
else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||
)
|
||||
|
||||
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
||||
|
||||
@@ -1384,7 +1467,8 @@ def convert(ctx,
|
||||
targetFormat,
|
||||
chainIteration,
|
||||
cropArguments,
|
||||
currentPattern)
|
||||
currentPattern,
|
||||
currentShowDescriptor)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
VERSION='0.2.4'
|
||||
DATABASE_VERSION = 2
|
||||
VERSION='0.2.5'
|
||||
DATABASE_VERSION = 3
|
||||
|
||||
DEFAULT_QUALITY = 32
|
||||
DEFAULT_AV1_PRESET = 5
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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.property import Property
|
||||
from ffx.model.migration import (
|
||||
DatabaseVersionException,
|
||||
getMigrationPlan,
|
||||
migrateDatabase,
|
||||
)
|
||||
|
||||
from ffx.constants import DATABASE_VERSION
|
||||
|
||||
@@ -16,10 +21,6 @@ from ffx.constants import DATABASE_VERSION
|
||||
DATABASE_VERSION_KEY = 'database_version'
|
||||
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
|
||||
|
||||
class DatabaseVersionException(Exception):
|
||||
def __init__(self, errorMessage):
|
||||
super().__init__(errorMessage)
|
||||
|
||||
def databaseContext(databasePath: str = ''):
|
||||
|
||||
databaseContext = {}
|
||||
@@ -33,7 +34,13 @@ def databaseContext(databasePath: str = ''):
|
||||
if not os.path.exists(ffxVarDir):
|
||||
os.makedirs(ffxVarDir)
|
||||
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['engine'] = create_engine(databaseContext['url'])
|
||||
databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
|
||||
@@ -68,14 +75,113 @@ def bootstrapDatabaseIfNeeded(databaseContext):
|
||||
|
||||
Base.metadata.create_all(databaseContext['engine'])
|
||||
|
||||
|
||||
def ensureDatabaseVersion(databaseContext):
|
||||
|
||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||
if currentDatabaseVersion:
|
||||
if currentDatabaseVersion != DATABASE_VERSION:
|
||||
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
|
||||
else:
|
||||
if not currentDatabaseVersion:
|
||||
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
||||
return
|
||||
|
||||
if currentDatabaseVersion > DATABASE_VERSION:
|
||||
raise DatabaseVersionException(
|
||||
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||
)
|
||||
|
||||
if currentDatabaseVersion < DATABASE_VERSION:
|
||||
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):
|
||||
|
||||
@@ -245,7 +245,8 @@ class FfxController():
|
||||
targetFormat: str = '',
|
||||
chainIteration: list = [],
|
||||
cropArguments: dict = {},
|
||||
currentPattern: Pattern = None):
|
||||
currentPattern: Pattern = None,
|
||||
currentShowDescriptor = None):
|
||||
# quality: int = DEFAULT_QUALITY,
|
||||
# preset: int = DEFAULT_AV1_PRESET):
|
||||
|
||||
@@ -262,9 +263,11 @@ class FfxController():
|
||||
|
||||
|
||||
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):
|
||||
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:
|
||||
quality = (QualityFilter.DEFAULT_H264_QUALITY
|
||||
if (videoEncoder == VideoEncoder.H264)
|
||||
|
||||
@@ -500,7 +500,14 @@ class MediaDescriptor:
|
||||
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}")
|
||||
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
|
||||
# it is provided explicitly by the filename contract.
|
||||
matchingTrack.getTags()["language"] = msfd["language"]
|
||||
if msfd["disposition_set"]:
|
||||
if msfd["disposition_set"] and not preserve_dispositions:
|
||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||
|
||||
|
||||
|
||||
@@ -8,10 +8,9 @@ from ffx.audio_layout import AudioLayout
|
||||
|
||||
from .show_details_screen import ShowDetailsScreen
|
||||
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_codec import TrackCodec
|
||||
from ffx.model.track import Track
|
||||
|
||||
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.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]]
|
||||
@@ -156,6 +155,9 @@ class MediaDetailsScreen(Screen):
|
||||
raise click.ClickException(f"MediaDetailsScreen.__init__(): Argument 'filename' is required to be provided for command 'inspect'")
|
||||
|
||||
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):
|
||||
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.
|
||||
Removes the <New show> entry if showId is not set"""
|
||||
|
||||
for rowKey, row in self.showsTable.rows.items(): # dict[RowKey, Row]
|
||||
|
||||
rowData = self.showsTable.get_row(rowKey)
|
||||
|
||||
try:
|
||||
if (showId == -1 and rowData[0] == ' '
|
||||
or showId == int(rowData[0])):
|
||||
self.showsTable.remove_row(rowKey)
|
||||
return
|
||||
except:
|
||||
continue
|
||||
for row_key, show_descriptor in list(self.__showRowData.items()):
|
||||
if (
|
||||
(showId == -1 and show_descriptor is None)
|
||||
or (
|
||||
show_descriptor is not None
|
||||
and show_descriptor.getId() == showId
|
||||
)
|
||||
):
|
||||
self.showsTable.remove_row(row_key)
|
||||
self.__showRowData.pop(row_key, None)
|
||||
return
|
||||
|
||||
|
||||
|
||||
def getRowIndexFromShowId(self, showId : int = -1) -> int:
|
||||
"""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]
|
||||
|
||||
rowData = self.showsTable.get_row(rowKey)
|
||||
|
||||
try:
|
||||
if ((showId == -1 and rowData[0] == ' ')
|
||||
or showId == int(rowData[0])):
|
||||
return int(self.showsTable.get_row_index(rowKey))
|
||||
except:
|
||||
continue
|
||||
for row_key, show_descriptor in self.__showRowData.items():
|
||||
if (
|
||||
(showId == -1 and show_descriptor is None)
|
||||
or (
|
||||
show_descriptor is not None
|
||||
and show_descriptor.getId() == showId
|
||||
)
|
||||
):
|
||||
return int(self.showsTable.get_row_index(row_key))
|
||||
|
||||
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):
|
||||
|
||||
self.__mediaFileProperties = FileProperties(self.context, self.__mediaFilename)
|
||||
@@ -314,23 +328,17 @@ class MediaDetailsScreen(Screen):
|
||||
def on_mount(self):
|
||||
|
||||
if self.__currentPattern is None:
|
||||
row = (' ', '<New show>', ' ') # Convert each element to a string before adding
|
||||
self.showsTable.add_row(*map(str, row))
|
||||
self._add_show_row(None)
|
||||
|
||||
for show in self.__sc.getAllShows():
|
||||
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding
|
||||
self.showsTable.add_row(*map(str, row))
|
||||
self._add_show_row(show.getDescriptor(self.context))
|
||||
|
||||
for mediaTagKey, mediaTagValue in self.__sourceMediaDescriptor.getTags().items():
|
||||
|
||||
textColor = None
|
||||
if mediaTagKey in self.__ignoreGlobalKeys:
|
||||
textColor = 'blue'
|
||||
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.__sourceMediaTagRowData = populate_tag_table(
|
||||
self.mediaTagsTable,
|
||||
self.__sourceMediaDescriptor.getTags(),
|
||||
ignore_keys=self.__ignoreGlobalKeys,
|
||||
remove_keys=self.__removeGlobalKeys,
|
||||
)
|
||||
|
||||
self.updateTracks()
|
||||
|
||||
@@ -362,6 +370,7 @@ class MediaDetailsScreen(Screen):
|
||||
def updateTracks(self):
|
||||
|
||||
self.tracksTable.clear()
|
||||
self.__trackRowData = {}
|
||||
|
||||
# trackDescriptorList = self.__sourceMediaDescriptor.getAllTrackDescriptors()
|
||||
trackDescriptorList = self.__sourceMediaDescriptor.getTrackDescriptors()
|
||||
@@ -387,7 +396,8 @@ class MediaDetailsScreen(Screen):
|
||||
'Yes' if TrackDisposition.DEFAULT 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
|
||||
|
||||
@@ -529,17 +539,7 @@ class MediaDetailsScreen(Screen):
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_track_data = self.tracksTable.get_row(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)
|
||||
return self.__trackRowData.get(row_key)
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -554,20 +554,7 @@ class MediaDetailsScreen(Screen):
|
||||
row_key, col_key = self.showsTable.coordinate_to_cell_key(self.showsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.showsTable.get_row(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
|
||||
return self.__showRowData.get(row_key)
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
@@ -583,8 +570,7 @@ class MediaDetailsScreen(Screen):
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is None:
|
||||
show = (showDescriptor.getId(), showDescriptor.getName(), showDescriptor.getYear())
|
||||
self.showsTable.add_row(*map(str, show))
|
||||
self._add_show_row(showDescriptor)
|
||||
|
||||
showRowIndex = self.getRowIndexFromShowId(showDescriptor.getId())
|
||||
if showRowIndex is not None:
|
||||
|
||||
@@ -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()]
|
||||
@@ -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)")
|
||||
@@ -1,7 +0,0 @@
|
||||
import os, sys, importlib, inspect, glob, re
|
||||
|
||||
from .conversion import Conversion
|
||||
|
||||
|
||||
class Conversion_3_4(Conversion):
|
||||
pass
|
||||
82
src/ffx/model/migration/__init__.py
Normal file
82
src/ffx/model/migration/__init__.py
Normal 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)
|
||||
84
src/ffx/model/migration/step_2_3.py
Normal file
84
src/ffx/model/migration/step_2_3.py
Normal 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 ''")
|
||||
)
|
||||
@@ -35,6 +35,7 @@ class Pattern(Base):
|
||||
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
shifted_seasons = relationship('ShiftedSeason', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||
|
||||
quality = Column(Integer, default=0)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import click
|
||||
|
||||
from sqlalchemy import Column, Integer, ForeignKey
|
||||
from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .show import Base, Show
|
||||
@@ -9,6 +9,14 @@ from .show import Base, Show
|
||||
class ShiftedSeason(Base):
|
||||
|
||||
__tablename__ = 'shifted_seasons'
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(show_id IS NOT NULL AND pattern_id IS NULL) OR (show_id IS NULL AND pattern_id IS NOT NULL)",
|
||||
name="ck_shifted_seasons_single_owner",
|
||||
),
|
||||
Index("ix_shifted_seasons_show_id", "show_id"),
|
||||
Index("ix_shifted_seasons_pattern_id", "pattern_id"),
|
||||
)
|
||||
|
||||
# v1.x
|
||||
id = Column(Integer, primary_key=True)
|
||||
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
|
||||
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
# v1.x
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"))
|
||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"), nullable=True)
|
||||
show = relationship(Show, back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"), nullable=True)
|
||||
pattern = relationship('Pattern', back_populates='shifted_seasons', lazy='joined')
|
||||
|
||||
# v2.0
|
||||
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
||||
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
||||
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
|
||||
def getId(self):
|
||||
return self.id
|
||||
|
||||
def getShowId(self):
|
||||
return self.show_id
|
||||
|
||||
def getPatternId(self):
|
||||
return self.pattern_id
|
||||
|
||||
|
||||
def getOriginalSeason(self):
|
||||
return self.original_season
|
||||
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
|
||||
|
||||
shiftedSeasonObj = {}
|
||||
|
||||
shiftedSeasonObj['show_id'] = self.getShowId()
|
||||
shiftedSeasonObj['pattern_id'] = self.getPatternId()
|
||||
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
||||
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
||||
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
||||
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
|
||||
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
||||
|
||||
return shiftedSeasonObj
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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 ffx.show_descriptor import ShowDescriptor
|
||||
@@ -45,6 +45,8 @@ class Show(Base):
|
||||
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
|
||||
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_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):
|
||||
@@ -58,5 +60,7 @@ class Show(Base):
|
||||
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_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)
|
||||
|
||||
@@ -9,10 +9,16 @@ from ffx.model.pattern import Pattern
|
||||
|
||||
from .track_details_screen import TrackDetailsScreen
|
||||
from .track_delete_screen import TrackDeleteScreen
|
||||
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||
|
||||
from .tag_details_screen import TagDetailsScreen
|
||||
from .tag_delete_screen import TagDeleteScreen
|
||||
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
|
||||
|
||||
@@ -24,8 +30,7 @@ from textual.widgets._data_table import CellDoesNotExist
|
||||
from ffx.file_properties import FileProperties
|
||||
from ffx.iso_language import IsoLanguage
|
||||
from ffx.audio_layout import AudioLayout
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
from ffx.model.shifted_season import ShiftedSeason
|
||||
|
||||
|
||||
# Screen[dict[int, str, int]]
|
||||
@@ -34,8 +39,8 @@ class PatternDetailsScreen(Screen):
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 7 17;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-size: 7 20;
|
||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 8 2 2 2 2;
|
||||
grid-columns: 25 25 25 25 25 25 25;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -115,21 +120,27 @@ class PatternDetailsScreen(Screen):
|
||||
show=True,
|
||||
track=True,
|
||||
tag=True,
|
||||
shifted_season=True,
|
||||
)
|
||||
self.__pc = controllers['pattern']
|
||||
self.__sc = controllers['show']
|
||||
self.__tc = controllers['track']
|
||||
self.__tac = controllers['tag']
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
|
||||
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
||||
self.__draftTracks : List[TrackDescriptor] = []
|
||||
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):
|
||||
|
||||
self.tracksTable.clear()
|
||||
self.__trackRowData = {}
|
||||
|
||||
tracks = self.getCurrentTrackDescriptors()
|
||||
|
||||
@@ -160,7 +171,8 @@ class PatternDetailsScreen(Screen):
|
||||
'Yes' if TrackDisposition.FORCED in dispoSet else 'No',
|
||||
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
|
||||
|
||||
@@ -238,25 +250,65 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
|
||||
def updateTags(self):
|
||||
|
||||
self.tagsTable.clear()
|
||||
|
||||
tags = (
|
||||
self.__tac.findAllMediaTags(self.__pattern.getId())
|
||||
if self.__pattern is not None
|
||||
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
|
||||
if tagKey in self.__ignoreGlobalKeys:
|
||||
textColor = 'blue'
|
||||
if tagKey in self.__removeGlobalKeys:
|
||||
textColor = 'red'
|
||||
def updateShiftedSeasons(self):
|
||||
|
||||
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
||||
self.tagsTable.add_row(*map(str, row))
|
||||
self.shiftedSeasonsTable.clear()
|
||||
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):
|
||||
@@ -276,6 +328,7 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
self.updateTags()
|
||||
self.updateTracks()
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def compose(self):
|
||||
|
||||
@@ -304,6 +357,16 @@ class PatternDetailsScreen(Screen):
|
||||
|
||||
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()
|
||||
|
||||
@@ -345,6 +408,27 @@ class PatternDetailsScreen(Screen):
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 9
|
||||
yield Static("Shifted Seasons")
|
||||
if self.__pattern is not None:
|
||||
yield Button("Add", id="button_add_shifted_season")
|
||||
yield Button("Edit", id="button_edit_shifted_season")
|
||||
yield Button("Delete", id="button_delete_shifted_season")
|
||||
else:
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 10
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# 11
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 12
|
||||
yield Static("Media Tags")
|
||||
yield Button("Add", id="button_add_tag")
|
||||
yield Button("Edit", id="button_edit_tag")
|
||||
@@ -354,13 +438,13 @@ class PatternDetailsScreen(Screen):
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 10
|
||||
# 13
|
||||
yield self.tagsTable
|
||||
|
||||
# 11
|
||||
# 14
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 12
|
||||
# 15
|
||||
yield Static("Streams")
|
||||
yield Button("Add", id="button_add_track")
|
||||
yield Button("Edit", id="button_edit_track")
|
||||
@@ -370,21 +454,21 @@ class PatternDetailsScreen(Screen):
|
||||
yield Button("Up", id="button_track_up")
|
||||
yield Button("Down", id="button_track_down")
|
||||
|
||||
# 13
|
||||
# 16
|
||||
yield self.tracksTable
|
||||
|
||||
# 14
|
||||
# 17
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 15
|
||||
# 18
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
# 16
|
||||
# 19
|
||||
yield Button("Save", id="save_button")
|
||||
yield Button("Cancel", id="cancel_button")
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 17
|
||||
# 20
|
||||
yield Static(" ", classes="seven")
|
||||
|
||||
yield Footer()
|
||||
@@ -410,15 +494,7 @@ class PatternDetailsScreen(Screen):
|
||||
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_track_data = self.tracksTable.get_row(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 self.__trackRowData.get(row_key)
|
||||
|
||||
return None
|
||||
|
||||
@@ -436,12 +512,7 @@ class PatternDetailsScreen(Screen):
|
||||
row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_tag_data = self.tagsTable.get_row(row_key)
|
||||
|
||||
tagKey = removeRichColor(selected_tag_data[0])
|
||||
tagValue = removeRichColor(selected_tag_data[1])
|
||||
|
||||
return tagKey, tagValue
|
||||
return self.__tagRowData.get(row_key)
|
||||
|
||||
else:
|
||||
return None
|
||||
@@ -486,6 +557,35 @@ class PatternDetailsScreen(Screen):
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
if event.button.id == "button_add_shifted_season":
|
||||
if self.__pattern is not None:
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(patternId=self.__pattern.getId()),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
if event.button.id == "button_edit_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDetailsScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_update_shifted_season,
|
||||
)
|
||||
|
||||
if event.button.id == "button_delete_shifted_season":
|
||||
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||
if 'id' in selectedShiftedSeasonObj.keys():
|
||||
self.app.push_screen(
|
||||
ShiftedSeasonDeleteScreen(
|
||||
patternId=self.__pattern.getId(),
|
||||
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||
),
|
||||
self.handle_delete_shifted_season,
|
||||
)
|
||||
|
||||
|
||||
numTracks = len(self.getCurrentTrackDescriptors())
|
||||
|
||||
@@ -654,3 +754,9 @@ class PatternDetailsScreen(Screen):
|
||||
self.updateTags()
|
||||
else:
|
||||
raise click.ClickException('tag delete failed')
|
||||
|
||||
def handle_update_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
def handle_delete_shifted_season(self, screenResult):
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .helper import formatRichColor
|
||||
from .pattern_controller import PatternController
|
||||
from .show_controller import ShowController
|
||||
from .shifted_season_controller import ShiftedSeasonController
|
||||
@@ -63,3 +65,34 @@ def build_screen_controllers(
|
||||
controllers['shifted_season'] = ShiftedSeasonController(context=context)
|
||||
|
||||
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
|
||||
|
||||
@@ -6,225 +6,445 @@ from ffx.model.shifted_season import ShiftedSeason
|
||||
class EpisodeOrderException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RangeOverlapException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController():
|
||||
class ShiftedSeasonOwnerException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ShiftedSeasonController:
|
||||
|
||||
def __init__(self, context):
|
||||
|
||||
self.context = context
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0):
|
||||
def _resolve_owner(self, showId=None, patternId=None):
|
||||
hasShow = showId is not None
|
||||
hasPattern = patternId is not None
|
||||
|
||||
if hasShow == hasPattern:
|
||||
raise ShiftedSeasonOwnerException(
|
||||
"ShiftedSeason rules require exactly one owner: either showId or patternId."
|
||||
)
|
||||
|
||||
if hasShow:
|
||||
if type(showId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument showId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': int(showId),
|
||||
'pattern_id': None,
|
||||
'label': f"show #{int(showId)}",
|
||||
}
|
||||
|
||||
if type(patternId) is not int:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument patternId is required to be of type int"
|
||||
)
|
||||
return {
|
||||
'show_id': None,
|
||||
'pattern_id': int(patternId),
|
||||
'label': f"pattern #{int(patternId)}",
|
||||
}
|
||||
|
||||
def _apply_owner_filter(self, query, owner):
|
||||
if owner['pattern_id'] is not None:
|
||||
return query.filter(ShiftedSeason.pattern_id == owner['pattern_id'])
|
||||
return query.filter(ShiftedSeason.show_id == owner['show_id'])
|
||||
|
||||
def _normalize_shifted_season_fields(self, shiftedSeasonObj: dict):
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController: Argument shiftedSeasonObj is required to be of type dict"
|
||||
)
|
||||
|
||||
fields = {
|
||||
'original_season': int(shiftedSeasonObj['original_season']),
|
||||
'first_episode': int(shiftedSeasonObj['first_episode']),
|
||||
'last_episode': int(shiftedSeasonObj['last_episode']),
|
||||
'season_offset': int(shiftedSeasonObj['season_offset']),
|
||||
'episode_offset': int(shiftedSeasonObj['episode_offset']),
|
||||
}
|
||||
|
||||
firstEpisode = fields['first_episode']
|
||||
lastEpisode = fields['last_episode']
|
||||
if firstEpisode != -1 and lastEpisode != -1 and lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException(
|
||||
"ShiftedSeason last_episode must be greater than or equal to first_episode."
|
||||
)
|
||||
|
||||
return fields
|
||||
|
||||
def _ranges_overlap(self, firstEpisodeA, lastEpisodeA, firstEpisodeB, lastEpisodeB):
|
||||
startA = float('-inf') if int(firstEpisodeA) == -1 else int(firstEpisodeA)
|
||||
endA = float('inf') if int(lastEpisodeA) == -1 else int(lastEpisodeA)
|
||||
startB = float('-inf') if int(firstEpisodeB) == -1 else int(firstEpisodeB)
|
||||
endB = float('inf') if int(lastEpisodeB) == -1 else int(lastEpisodeB)
|
||||
return startA <= endB and startB <= endA
|
||||
|
||||
def _ordered_query(self, session, owner):
|
||||
q = self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
return q.order_by(
|
||||
ShiftedSeason.original_season.asc(),
|
||||
ShiftedSeason.first_episode.asc(),
|
||||
ShiftedSeason.last_episode.asc(),
|
||||
ShiftedSeason.id.asc(),
|
||||
)
|
||||
|
||||
def _find_matching_rule(self, session, owner, season: int, episode: int):
|
||||
for shiftedSeasonEntry in self._ordered_query(session, owner).all():
|
||||
if (
|
||||
season == shiftedSeasonEntry.getOriginalSeason()
|
||||
and (
|
||||
shiftedSeasonEntry.getFirstEpisode() == -1
|
||||
or episode >= shiftedSeasonEntry.getFirstEpisode()
|
||||
)
|
||||
and (
|
||||
shiftedSeasonEntry.getLastEpisode() == -1
|
||||
or episode <= shiftedSeasonEntry.getLastEpisode()
|
||||
)
|
||||
):
|
||||
return shiftedSeasonEntry
|
||||
return None
|
||||
|
||||
def checkShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
shiftedSeasonId: int = 0,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
"""
|
||||
Check if for a particula season
|
||||
|
||||
shiftedSeasonId
|
||||
Check whether a shifted-season rule is valid within one owner scope.
|
||||
"""
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
originalSeason = shiftedSeasonObj['original_season']
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
q = self._ordered_query(session, owner)
|
||||
if shiftedSeasonId:
|
||||
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
||||
|
||||
siblingShiftedSeason: ShiftedSeason
|
||||
for siblingShiftedSeason in q.all():
|
||||
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||
continue
|
||||
|
||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
||||
|
||||
if (originalSeason == siblingOriginalSeason
|
||||
and lastEpisode >= siblingFirstEpisode
|
||||
and siblingLastEpisode >= firstEpisode):
|
||||
|
||||
if self._ranges_overlap(
|
||||
fields['first_episode'],
|
||||
fields['last_episode'],
|
||||
siblingShiftedSeason.getFirstEpisode(),
|
||||
siblingShiftedSeason.getLastEpisode(),
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def addShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
shiftedSeasonObj: dict | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
def addShiftedSeason(self, showId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument showId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
|
||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
if lastEpisode < firstEpisode:
|
||||
raise EpisodeOrderException()
|
||||
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
shiftedSeason = ShiftedSeason(show_id = int(showId),
|
||||
original_season = int(shiftedSeasonObj['original_season']),
|
||||
first_episode = firstEpisode,
|
||||
last_episode = lastEpisode,
|
||||
season_offset = int(shiftedSeasonObj['season_offset']),
|
||||
episode_offset = int(shiftedSeasonObj['episode_offset']))
|
||||
s.add(shiftedSeason)
|
||||
s.commit()
|
||||
session = self.Session()
|
||||
shiftedSeason = ShiftedSeason(
|
||||
show_id=owner['show_id'],
|
||||
pattern_id=owner['pattern_id'],
|
||||
original_season=fields['original_season'],
|
||||
first_episode=fields['first_episode'],
|
||||
last_episode=fields['last_episode'],
|
||||
season_offset=fields['season_offset'],
|
||||
episode_offset=fields['episode_offset'],
|
||||
)
|
||||
session.add(shiftedSeason)
|
||||
session.commit()
|
||||
return shiftedSeason.getId()
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
|
||||
if type(shiftedSeasonObj) is not dict:
|
||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||
session = self.Session()
|
||||
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if shiftedSeason is not None:
|
||||
|
||||
shiftedSeason.original_season = int(shiftedSeasonObj['original_season'])
|
||||
shiftedSeason.first_episode = int(shiftedSeasonObj['first_episode'])
|
||||
shiftedSeason.last_episode = int(shiftedSeasonObj['last_episode'])
|
||||
shiftedSeason.season_offset = int(shiftedSeasonObj['season_offset'])
|
||||
shiftedSeason.episode_offset = int(shiftedSeasonObj['episode_offset'])
|
||||
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
else:
|
||||
if shiftedSeason is None:
|
||||
return False
|
||||
|
||||
owner = self._resolve_owner(
|
||||
showId=shiftedSeason.getShowId(),
|
||||
patternId=shiftedSeason.getPatternId(),
|
||||
)
|
||||
if not self.checkShiftedSeason(
|
||||
showId=owner['show_id'],
|
||||
patternId=owner['pattern_id'],
|
||||
shiftedSeasonObj=fields,
|
||||
shiftedSeasonId=shiftedSeasonId,
|
||||
):
|
||||
raise RangeOverlapException(
|
||||
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||
)
|
||||
|
||||
shiftedSeason.original_season = fields['original_season']
|
||||
shiftedSeason.first_episode = fields['first_episode']
|
||||
shiftedSeason.last_episode = fields['last_episode']
|
||||
shiftedSeason.season_offset = fields['season_offset']
|
||||
shiftedSeason.episode_offset = fields['episode_offset']
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
|
||||
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
def findShiftedSeason(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
originalSeason: int | None = None,
|
||||
firstEpisode: int | None = None,
|
||||
lastEpisode: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
|
||||
if type(originalSeason) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int"
|
||||
)
|
||||
|
||||
if type(firstEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
if type(lastEpisode) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(
|
||||
ShiftedSeason.show_id == int(showId),
|
||||
ShiftedSeason.original_season == int(originalSeason),
|
||||
ShiftedSeason.first_episode == int(firstEpisode),
|
||||
ShiftedSeason.last_episode == int(lastEpisode),
|
||||
).first()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||
.filter(
|
||||
ShiftedSeason.original_season == int(originalSeason),
|
||||
ShiftedSeason.first_episode == int(firstEpisode),
|
||||
ShiftedSeason.last_episode == int(lastEpisode),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return shiftedSeason.getId() if shiftedSeason is not None else None
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeasonSiblings(self, showId: int):
|
||||
|
||||
if type(showId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int")
|
||||
def getShiftedSeasonSiblings(
|
||||
self,
|
||||
showId: int | None = None,
|
||||
patternId: int | None = None,
|
||||
):
|
||||
session = None
|
||||
|
||||
try:
|
||||
s = self.Session()
|
||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
||||
|
||||
return q.all()
|
||||
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||
session = self.Session()
|
||||
return self._ordered_query(session, owner).all()
|
||||
|
||||
except ShiftedSeasonOwnerException as ex:
|
||||
raise click.ClickException(str(ex))
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def getShiftedSeason(self, shiftedSeasonId: int):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
return s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
session = self.Session()
|
||||
return (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def deleteShiftedSeason(self, shiftedSeasonId):
|
||||
|
||||
if type(shiftedSeasonId) is not int:
|
||||
raise ValueError(f"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
||||
raise ValueError(
|
||||
"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||
)
|
||||
|
||||
session = None
|
||||
try:
|
||||
s = self.Session()
|
||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
||||
session = self.Session()
|
||||
shiftedSeason = (
|
||||
session.query(ShiftedSeason)
|
||||
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||
.first()
|
||||
)
|
||||
|
||||
if shiftedSeason is not None:
|
||||
|
||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
||||
# q.delete()
|
||||
s.delete(shiftedSeason)
|
||||
|
||||
s.commit()
|
||||
session.delete(shiftedSeason)
|
||||
session.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
except Exception as ex:
|
||||
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}")
|
||||
raise click.ClickException(
|
||||
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
|
||||
)
|
||||
finally:
|
||||
s.close()
|
||||
if session is not None:
|
||||
session.close()
|
||||
|
||||
def shiftSeason(self, showId, season, episode, patternId=None):
|
||||
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
|
||||
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId):
|
||||
if shiftedSeason != season or shiftedEpisode != episode:
|
||||
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())):
|
||||
return shiftedSeason, shiftedEpisode
|
||||
|
||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
||||
def resolveShiftSeason(self, showId, season, episode, patternId=None):
|
||||
if season == -1 or episode == -1:
|
||||
return season, episode, "unrecognized"
|
||||
|
||||
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
|
||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
||||
session = None
|
||||
try:
|
||||
session = self.Session()
|
||||
activeShift = None
|
||||
|
||||
return shiftedSeason, shiftedEpisode
|
||||
if patternId is not None:
|
||||
activeShift = self._find_matching_rule(
|
||||
session,
|
||||
self._resolve_owner(patternId=patternId),
|
||||
season=int(season),
|
||||
episode=int(episode),
|
||||
)
|
||||
|
||||
return season, 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()
|
||||
|
||||
@@ -43,7 +43,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self._showId = showId
|
||||
self._patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
|
||||
@@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||
|
||||
self.query_one("#static_show_id", Static).update(str(self._showId))
|
||||
ownerLabel = (
|
||||
f"pattern #{self._patternId}"
|
||||
if self._patternId is not None
|
||||
else f"show #{self._showId}"
|
||||
)
|
||||
self.query_one("#static_owner", Static).update(ownerLabel)
|
||||
self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
||||
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
||||
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
||||
@@ -77,12 +83,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("from show")
|
||||
yield Static(" ", id="static_show_id")
|
||||
yield Static("from")
|
||||
yield Static(" ", id="static_owner")
|
||||
|
||||
yield Static(" ", classes="two")
|
||||
|
||||
yield Static("Original season")
|
||||
yield Static("Source season")
|
||||
yield Static(" ", id="static_original_season")
|
||||
|
||||
yield Static("First episode")
|
||||
@@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen):
|
||||
|
||||
if event.button.id == "cancel_button":
|
||||
self.app.pop_screen()
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
||||
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
@@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||
|
||||
self.__showId = showId
|
||||
self.__patternId = patternId
|
||||
self.__shiftedSeasonId = shiftedSeasonId
|
||||
|
||||
def _owner_kwargs(self):
|
||||
if self.__patternId is not None:
|
||||
return {'patternId': self.__patternId}
|
||||
return {'showId': self.__showId}
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
@@ -126,7 +132,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
yield Static(" ", classes="three")
|
||||
|
||||
# 3
|
||||
yield Static("Original season")
|
||||
yield Static("Source season")
|
||||
yield Input(id="input_original_season", classes="two")
|
||||
|
||||
# 4
|
||||
@@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
|
||||
if self.__shiftedSeasonId is not None:
|
||||
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj,
|
||||
shiftedSeasonId = self.__shiftedSeasonId):
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
shiftedSeasonId=self.__shiftedSeasonId,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
else:
|
||||
@@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
||||
self.app.pop_screen()
|
||||
|
||||
else:
|
||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj)
|
||||
if self.__ssc.checkShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
):
|
||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
|
||||
shiftedSeasonObj=shiftedSeasonObj,
|
||||
**self._owner_kwargs(),
|
||||
)
|
||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,9 @@ class ShowController():
|
||||
index_season_digits = showDescriptor.getIndexSeasonDigits(),
|
||||
index_episode_digits = showDescriptor.getIndexEpisodeDigits(),
|
||||
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.commit()
|
||||
@@ -88,6 +90,12 @@ class ShowController():
|
||||
if currentShow.indicator_episode_digits != int(showDescriptor.getIndicatorEpisodeDigits()):
|
||||
currentShow.indicator_episode_digits = int(showDescriptor.getIndicatorEpisodeDigits())
|
||||
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:
|
||||
s.commit()
|
||||
|
||||
@@ -21,6 +21,8 @@ class ShowDescriptor():
|
||||
INDEX_EPISODE_DIGITS_KEY = 'index_episode_digits'
|
||||
INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_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_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS
|
||||
@@ -124,6 +126,20 @@ class ShowDescriptor():
|
||||
else:
|
||||
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):
|
||||
return self.__showId
|
||||
@@ -140,6 +156,10 @@ class ShowDescriptor():
|
||||
return self.__indicatorSeasonDigits
|
||||
def getIndicatorEpisodeDigits(self):
|
||||
return self.__indicatorEpisodeDigits
|
||||
def getQuality(self):
|
||||
return self.__quality
|
||||
def getNotes(self):
|
||||
return self.__notes
|
||||
|
||||
def getFilenamePrefix(self):
|
||||
return f"{self.__showName} ({str(self.__showYear)})"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import click
|
||||
|
||||
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.widgets._data_table import CellDoesNotExist
|
||||
|
||||
@@ -25,8 +25,8 @@ class ShowDetailsScreen(Screen):
|
||||
CSS = """
|
||||
|
||||
Grid {
|
||||
grid-size: 5 16;
|
||||
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2;
|
||||
grid-size: 5 18;
|
||||
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;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
@@ -77,6 +77,10 @@ class ShowDetailsScreen(Screen):
|
||||
height: 100%;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
.note_box {
|
||||
min-height: 6;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
@@ -104,12 +108,45 @@ class ShowDetailsScreen(Screen):
|
||||
self.__ssc = controllers['shifted_season']
|
||||
|
||||
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):
|
||||
|
||||
self.shiftedSeasonsTable.clear()
|
||||
self.__shiftedSeasonRowData = {}
|
||||
|
||||
if not self.__showDescriptor is None:
|
||||
|
||||
@@ -119,20 +156,8 @@ class ShowDetailsScreen(Screen):
|
||||
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId):
|
||||
|
||||
shiftedSeasonObj = shiftedSeason.getObj()
|
||||
|
||||
firstEpisode = shiftedSeasonObj['first_episode']
|
||||
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||
|
||||
lastEpisode = shiftedSeasonObj['last_episode']
|
||||
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||
|
||||
row = (shiftedSeasonObj['original_season'],
|
||||
firstEpisodeStr,
|
||||
lastEpisodeStr,
|
||||
shiftedSeasonObj['season_offset'],
|
||||
shiftedSeasonObj['episode_offset'])
|
||||
|
||||
self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||
shiftedSeasonObj['id'] = shiftedSeason.getId()
|
||||
self._add_shifted_season_row(shiftedSeasonObj)
|
||||
|
||||
|
||||
|
||||
@@ -150,12 +175,18 @@ class ShowDetailsScreen(Screen):
|
||||
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_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}")
|
||||
for pattern in self.__pc.getPatternsForShow(showId):
|
||||
row = (pattern.getPattern(),)
|
||||
self.patternTable.add_row(*map(str, row))
|
||||
self._add_pattern_row(
|
||||
pattern_id=pattern.getId(),
|
||||
pattern_text=pattern.getPattern(),
|
||||
)
|
||||
|
||||
self.updateShiftedSeasons()
|
||||
|
||||
@@ -187,10 +218,7 @@ class ShowDetailsScreen(Screen):
|
||||
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.patternTable.get_row(row_key)
|
||||
|
||||
selectedPattern['show_id'] = self.__showDescriptor.getId()
|
||||
selectedPattern['pattern'] = str(selected_row_data[0])
|
||||
selectedPattern = dict(self.__patternRowData.get(row_key, {}))
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -209,25 +237,7 @@ class ShowDetailsScreen(Screen):
|
||||
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||
|
||||
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
|
||||
shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -241,9 +251,14 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
|
||||
def handle_add_pattern(self, screenResult):
|
||||
if screenResult is None:
|
||||
return
|
||||
|
||||
pattern = (screenResult['pattern'],)
|
||||
self.patternTable.add_row(*map(str, pattern))
|
||||
pattern_id = self.__pc.findPattern(screenResult)
|
||||
self._add_pattern_row(
|
||||
pattern_id=pattern_id,
|
||||
pattern_text=screenResult['pattern'],
|
||||
)
|
||||
|
||||
|
||||
def action_edit_pattern(self):
|
||||
@@ -251,8 +266,7 @@ class ShowDetailsScreen(Screen):
|
||||
selectedPatternDescriptor = self.getSelectedPatternDescriptor()
|
||||
|
||||
if selectedPatternDescriptor:
|
||||
|
||||
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
|
||||
selectedPatternId = selectedPatternDescriptor.get('id')
|
||||
|
||||
if selectedPatternId is None:
|
||||
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)
|
||||
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:
|
||||
pass
|
||||
@@ -277,7 +293,7 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
if selectedPatternDescriptor:
|
||||
|
||||
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
|
||||
selectedPatternId = selectedPatternDescriptor.get('id')
|
||||
|
||||
if selectedPatternId is None:
|
||||
raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id")
|
||||
@@ -290,6 +306,7 @@ class ShowDetailsScreen(Screen):
|
||||
try:
|
||||
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
|
||||
self.patternTable.remove_row(row_key)
|
||||
self.__patternRowData.pop(row_key, None)
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -308,7 +325,7 @@ class ShowDetailsScreen(Screen):
|
||||
|
||||
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_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", 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")
|
||||
|
||||
#5
|
||||
yield Static(" ", classes="five")
|
||||
yield Static("Quality")
|
||||
yield Input(type="integer", id="quality_input", classes="four")
|
||||
|
||||
#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 Input(type="integer", id="index_season_digits_input", classes="four")
|
||||
|
||||
#7
|
||||
#9
|
||||
yield Static("Index Episode Digits")
|
||||
yield Input(type="integer", id="index_episode_digits_input", classes="four")
|
||||
|
||||
#8
|
||||
#10
|
||||
yield Static("Indicator Season Digits")
|
||||
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
|
||||
|
||||
#9
|
||||
#11
|
||||
yield Static("Indicator Edisode Digits")
|
||||
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
|
||||
|
||||
# 10
|
||||
# 12
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 11
|
||||
# 13
|
||||
yield Static("Shifted seasons", classes="two")
|
||||
|
||||
if self.__showDescriptor is not None:
|
||||
@@ -375,18 +400,18 @@ class ShowDetailsScreen(Screen):
|
||||
yield Static(" ")
|
||||
yield Static(" ")
|
||||
|
||||
# 12
|
||||
# 14
|
||||
yield self.shiftedSeasonsTable
|
||||
|
||||
# 13
|
||||
# 15
|
||||
yield Static("File patterns", classes="five")
|
||||
# 14
|
||||
# 16
|
||||
yield self.patternTable
|
||||
|
||||
# 15
|
||||
# 17
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
# 16
|
||||
# 18
|
||||
yield Button("Save", id="save_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)
|
||||
except ValueError:
|
||||
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)
|
||||
|
||||
|
||||
@@ -66,6 +66,17 @@ class ShowsScreen(Screen):
|
||||
self.Session = self.context['database']['session'] # convenience
|
||||
|
||||
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):
|
||||
@@ -76,9 +87,8 @@ class ShowsScreen(Screen):
|
||||
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
|
||||
if row_key is not None:
|
||||
selected_row_data = self.table.get_row(row_key)
|
||||
|
||||
return selected_row_data[0]
|
||||
selected_show = self.__showRowData.get(row_key)
|
||||
return selected_show.getId() if selected_show is not None else None
|
||||
|
||||
except CellDoesNotExist:
|
||||
return None
|
||||
@@ -90,9 +100,8 @@ class ShowsScreen(Screen):
|
||||
self.app.push_screen(ShowDetailsScreen(), self.handle_new_screen)
|
||||
|
||||
def handle_new_screen(self, screenResult):
|
||||
|
||||
show = (screenResult['id'], screenResult['name'], screenResult['year'])
|
||||
self.table.add_row(*map(str, show))
|
||||
if isinstance(screenResult, ShowDescriptor):
|
||||
self._add_show_row(screenResult)
|
||||
|
||||
|
||||
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_year, showDescriptor.getYear())
|
||||
self.__showRowData[row_key] = showDescriptor
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -131,6 +141,7 @@ class ShowsScreen(Screen):
|
||||
try:
|
||||
row_key, col_key = self.table.coordinate_to_cell_key(self.table.cursor_coordinate)
|
||||
self.table.remove_row(row_key)
|
||||
self.__showRowData.pop(row_key, None)
|
||||
|
||||
except CellDoesNotExist:
|
||||
pass
|
||||
@@ -138,8 +149,7 @@ class ShowsScreen(Screen):
|
||||
|
||||
def on_mount(self) -> None:
|
||||
for show in self.__sc.getAllShows():
|
||||
row = (int(show.id), show.name, show.year) # Convert each element to a string before adding
|
||||
self.table.add_row(*map(str, row))
|
||||
self._add_show_row(show.getDescriptor(self.context))
|
||||
|
||||
|
||||
def compose(self):
|
||||
|
||||
@@ -13,8 +13,7 @@ from .track_codec import TrackCodec
|
||||
from .track_descriptor import TrackDescriptor
|
||||
from .track_disposition import TrackDisposition
|
||||
from .track_type import TrackType
|
||||
|
||||
from ffx.helper import formatRichColor, removeRichColor
|
||||
from .screen_support import build_screen_bootstrap, populate_tag_table
|
||||
|
||||
|
||||
class TrackDetailsScreen(Screen):
|
||||
@@ -98,28 +97,12 @@ class TrackDetailsScreen(Screen):
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.context = self.app.getContext()
|
||||
bootstrap = build_screen_bootstrap(self.app.getContext())
|
||||
self.context = bootstrap.context
|
||||
|
||||
self.__configurationData = self.context["config"].getData()
|
||||
|
||||
metadataConfiguration = (
|
||||
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.__removeTrackKeys = bootstrap.remove_track_keys
|
||||
self.__ignoreTrackKeys = bootstrap.ignore_track_keys
|
||||
self.__tagRowData: dict[object, tuple[str, str]] = {}
|
||||
|
||||
self.__isNew = trackDescriptor is None
|
||||
self.__trackDescriptor = trackDescriptor
|
||||
@@ -166,18 +149,12 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
def updateTags(self):
|
||||
|
||||
self.trackTagsTable.clear()
|
||||
|
||||
for key, value in self.__draftTrackTags.items():
|
||||
textColor = None
|
||||
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))
|
||||
self.__tagRowData = populate_tag_table(
|
||||
self.trackTagsTable,
|
||||
self.__draftTrackTags,
|
||||
ignore_keys=self.__ignoreTrackKeys,
|
||||
remove_keys=self.__removeTrackKeys,
|
||||
)
|
||||
|
||||
def on_mount(self):
|
||||
|
||||
@@ -190,9 +167,9 @@ class TrackDetailsScreen(Screen):
|
||||
self.query_one("#pattern_label", Static).update(self.__patternLabel)
|
||||
|
||||
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:
|
||||
|
||||
@@ -211,9 +188,7 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
if self.__trackDescriptor is not None:
|
||||
self.query_one("#language_select", Select).value = (
|
||||
self.__trackDescriptor.getLanguage().label()
|
||||
)
|
||||
self.query_one("#language_select", Select).value = self.__trackDescriptor.getLanguage()
|
||||
self.query_one("#title_input", Input).value = self.__trackDescriptor.getTitle()
|
||||
self.updateTags()
|
||||
|
||||
@@ -226,8 +201,6 @@ class TrackDetailsScreen(Screen):
|
||||
|
||||
self.trackTagsTable.cursor_type = "row"
|
||||
|
||||
languages = [language.label() for language in IsoLanguage]
|
||||
|
||||
yield Header()
|
||||
|
||||
with Grid():
|
||||
@@ -250,15 +223,15 @@ class TrackDetailsScreen(Screen):
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
yield Static("Type")
|
||||
yield Select.from_values(
|
||||
[trackType.label() for trackType in TrackType],
|
||||
yield Select(
|
||||
[(trackType.label(), trackType) for trackType in TrackType],
|
||||
classes="four",
|
||||
id="type_select",
|
||||
)
|
||||
|
||||
yield Static("Audio Layout")
|
||||
yield Select.from_values(
|
||||
[layout.label() for layout in AudioLayout],
|
||||
yield Select(
|
||||
[(layout.label(), layout) for layout in AudioLayout],
|
||||
classes="four",
|
||||
id="audio_layout_select",
|
||||
)
|
||||
@@ -268,7 +241,11 @@ class TrackDetailsScreen(Screen):
|
||||
yield Static(" ", classes="five")
|
||||
|
||||
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")
|
||||
|
||||
@@ -328,15 +305,18 @@ class TrackDetailsScreen(Screen):
|
||||
if self.__subIndex is not None and int(self.__subIndex) >= 0:
|
||||
kwargs[TrackDescriptor.SUB_INDEX_KEY] = int(self.__subIndex)
|
||||
|
||||
selectedTrackType = TrackType.fromLabel(
|
||||
self.query_one("#type_select", Select).value
|
||||
)
|
||||
selectedTrackType = self.query_one("#type_select", Select).value
|
||||
if not isinstance(selectedTrackType, TrackType):
|
||||
selectedTrackType = TrackType.UNKNOWN
|
||||
kwargs[TrackDescriptor.TRACK_TYPE_KEY] = selectedTrackType
|
||||
kwargs[TrackDescriptor.CODEC_KEY] = self.__trackCodec
|
||||
|
||||
if selectedTrackType == TrackType.AUDIO:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.fromLabel(
|
||||
self.query_one("#audio_layout_select", Select).value
|
||||
selectedAudioLayout = self.query_one("#audio_layout_select", Select).value
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = (
|
||||
selectedAudioLayout
|
||||
if isinstance(selectedAudioLayout, AudioLayout)
|
||||
else AudioLayout.LAYOUT_UNDEFINED
|
||||
)
|
||||
else:
|
||||
kwargs[TrackDescriptor.AUDIO_LAYOUT_KEY] = AudioLayout.LAYOUT_UNDEFINED
|
||||
@@ -344,8 +324,8 @@ class TrackDetailsScreen(Screen):
|
||||
trackTags = dict(self.__draftTrackTags)
|
||||
|
||||
language = self.query_one("#language_select", Select).value
|
||||
if language:
|
||||
trackTags["language"] = IsoLanguage.find(language).threeLetter()
|
||||
if isinstance(language, IsoLanguage):
|
||||
trackTags["language"] = language.threeLetter()
|
||||
|
||||
title = self.query_one("#title_input", Input).value
|
||||
if title:
|
||||
@@ -370,12 +350,7 @@ class TrackDetailsScreen(Screen):
|
||||
)
|
||||
|
||||
if row_key is not None:
|
||||
selected_tag_data = self.trackTagsTable.get_row(row_key)
|
||||
|
||||
tagKey = removeRichColor(selected_tag_data[0])
|
||||
tagValue = removeRichColor(selected_tag_data[1])
|
||||
|
||||
return tagKey, tagValue
|
||||
return self.__tagRowData.get(row_key)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
||||
from ffx.pattern_controller import PatternController
|
||||
from ffx.show_controller import ShowController
|
||||
from ffx.show_descriptor import ShowDescriptor
|
||||
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||
from ffx.track_codec import TrackCodec
|
||||
from ffx.track_descriptor import TrackDescriptor
|
||||
from ffx.track_type import TrackType
|
||||
@@ -109,6 +110,31 @@ class UnmuxCliTests(unittest.TestCase):
|
||||
finally:
|
||||
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):
|
||||
self.write_config(
|
||||
{
|
||||
@@ -223,6 +249,55 @@ class UnmuxCliTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
142
tests/unit/test_cli_inspect_shift.py
Normal file
142
tests/unit/test_cli_inspect_shift.py
Normal 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()
|
||||
@@ -57,7 +57,7 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
self.assertTrue(subprocess_calls[0][1]["capture_output"])
|
||||
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()
|
||||
repo_path = "/tmp/ffx-repo"
|
||||
pip_path = "/tmp/ffx-venv/bin/pip"
|
||||
@@ -85,8 +85,8 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
[
|
||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||
['git', 'reset', '--hard', 'HEAD'],
|
||||
['git', 'checkout', 'main'],
|
||||
['git', 'pull'],
|
||||
['git', 'fetch', 'origin', 'main'],
|
||||
['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
|
||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||
[pip_path, 'install', '--editable', '.'],
|
||||
],
|
||||
@@ -95,6 +95,39 @@ class UpgradeCommandTests(unittest.TestCase):
|
||||
for args, kwargs in subprocess_calls[1:]:
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import click
|
||||
|
||||
|
||||
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.database import DATABASE_VERSION_KEY, databaseContext, getDatabaseVersion # noqa: E402
|
||||
from ffx.model.shifted_season import ShiftedSeason # noqa: E402
|
||||
from ffx.model.property import Property # noqa: E402
|
||||
from ffx.model.show import Show # 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):
|
||||
@@ -27,6 +40,115 @@ class DatabaseContextTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
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):
|
||||
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
|
||||
context = databaseContext(str(self.database_path))
|
||||
@@ -78,6 +200,127 @@ class DatabaseContextTests(unittest.TestCase):
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
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.logging_utils import get_ffx_logger # 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_descriptor import TrackDescriptor # 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_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__":
|
||||
unittest.main()
|
||||
|
||||
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal file
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal 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()
|
||||
47
tests/unit/test_migration.py
Normal file
47
tests/unit/test_migration.py
Normal 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()
|
||||
@@ -23,6 +23,21 @@ class StaticConfig:
|
||||
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):
|
||||
def make_context(self):
|
||||
return {
|
||||
@@ -81,6 +96,32 @@ class ScreenSupportTests(unittest.TestCase):
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
204
tests/unit/test_shifted_season_controller.py
Normal file
204
tests/unit/test_shifted_season_controller.py
Normal 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()
|
||||
@@ -56,6 +56,8 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
|
||||
self.assertEqual(3, descriptor.getIndexEpisodeDigits())
|
||||
self.assertEqual(3, descriptor.getIndicatorSeasonDigits())
|
||||
self.assertEqual(4, descriptor.getIndicatorEpisodeDigits())
|
||||
self.assertEqual(0, descriptor.getQuality())
|
||||
self.assertEqual("", descriptor.getNotes())
|
||||
|
||||
def test_show_descriptor_without_context_uses_shared_constants(self):
|
||||
descriptor = ShowDescriptor(id=1, name="Default Show", year=2024)
|
||||
@@ -70,6 +72,18 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
|
||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||
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):
|
||||
basename = getEpisodeFileBasename(
|
||||
|
||||
325
tests/unit/test_tag_table_screen_state.py
Normal file
325
tests/unit/test_tag_table_screen_state.py
Normal 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()
|
||||
Reference in New Issue
Block a user