6 Commits

Author SHA1 Message Date
Javanaut
d9639561ce Fix TUI widgets color bleedthru 2026-04-13 12:00:38 +02:00
Javanaut
cbf43e5d6c adapt shift output 2026-04-12 20:41:31 +02:00
Javanaut
d6e885517d Adds inspect --shift option 2026-04-12 20:34:33 +02:00
Javanaut
8a8c43ecdf v0.2.5 2026-04-12 19:57:46 +02:00
Javanaut
6170ac641c ff 2026-04-12 19:35:03 +02:00
Javanaut
497c0e500b ff 2026-04-12 19:34:51 +02:00
20 changed files with 800 additions and 318 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@@ -4,18 +4,6 @@
- Capture a compact, project-wide list of optimization candidates after a broad scan of the current FFX codebase, tooling, and requirements. - Capture a compact, project-wide list of optimization candidates after a broad scan of the current FFX codebase, tooling, and requirements.
## Settled
- The biggest near-term wins are in startup cost, repeated subprocess work, repeated database query patterns, and general repo hygiene.
- This list is intentionally optimization-oriented rather than bug-oriented. Some items below also improve correctness or maintainability, but they were selected because they can reduce runtime cost, operator friction, or iteration overhead.
- A first modern integration slice now exists under [`tests/integration/subtrack_mapping`](/home/osgw/.local/src/codex/ffx/tests/integration/subtrack_mapping). Remaining test-suite cleanup is now mostly about migrating and shrinking the legacy harness surface under [`tests/legacy`](/home/osgw/.local/src/codex/ffx/tests/legacy).
- Shared CLI defaults for container/output tokens now live outside [`src/ffx/ffx_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/ffx_controller.py), and a focused unit test locks in the lazy-import contract.
- Helper filename and rich-text utilities now use compiled raw regexes plus translate-based filename filtering, with unit coverage for TMDB suffix rewriting and Rich color stripping.
- Process resource limiting now has explicit disabled/default states in the CLI and requirements, and combined CPU-plus-niceness wrapping now executes as `cpulimit -- nice -n ... <command>` instead of a less explicit prefix chain.
- FFX logger setup now reuses named handlers, and fallback logger access no longer mutates handlers in ordinary constructors and helpers.
- The process wrapper now uses `subprocess.run(...)` with centralized command formatting plus stable timeout and missing-command error mapping.
- Pattern matching now uses cached compiled regexes plus explicit duplicate-match errors, and pattern creation flows no longer persist zero-track patterns.
## Focused Snapshot ## Focused Snapshot
- Highest-leverage application optimizations: - Highest-leverage application optimizations:
@@ -59,6 +47,7 @@
## Open ## Open
- Durable shipped items have been moved into [`README.md`](/home/osgw/.local/src/codex/ffx/README.md) version history through `0.2.5`.
- Should optimization work focus first on operator-perceived latency, internal maintainability, or correctness-risk cleanup that also has performance upside? - Should optimization work focus first on operator-perceived latency, internal maintainability, or correctness-risk cleanup that also has performance upside?
- Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI? - Is the long-term supported model still “local Linux workstation plus Textual UI,” or should optimization decisions bias toward a more scriptable/headless CLI?
@@ -77,52 +66,6 @@
3. Continue replacing oversized legacy test matrices with focused modern integration and unit coverage. 3. Continue replacing oversized legacy test matrices with focused modern integration and unit coverage.
4. Triage the legacy `Scenario 4` pattern/track failure and decide whether to fix the harness, adapt it to the zero-track guard, or retire that path during the ongoing test-suite migration. 4. Triage the legacy `Scenario 4` pattern/track failure and decide whether to fix the harness, adapt it to the zero-track guard, or retire that path during the ongoing test-suite migration.
## Shifted Season Status (2026-04-12)
- Current assessment:
- The shifted-season subsystem is present end to end and looks feature-complete in shape, but it is not yet hardened.
- The storage, TUI CRUD surface, and CLI/TMDB filename application path all exist, so this is no longer a stubbed or half-started area.
- The main gap is correctness and direct verification rather than missing surface area.
- Implemented surface confirmed:
- Requirements still treat shifted seasons as part of the accepted product surface in [`requirements/project.md`](/home/osgw/.local/src/codex/ffx/requirements/project.md) and [`requirements/architecture.md`](/home/osgw/.local/src/codex/ffx/requirements/architecture.md).
- Persistence exists via [`src/ffx/model/shifted_season.py`](/home/osgw/.local/src/codex/ffx/src/ffx/model/shifted_season.py) plus the `Show.shifted_seasons` relationship in [`src/ffx/model/show.py`](/home/osgw/.local/src/codex/ffx/src/ffx/model/show.py).
- CRUD logic exists in [`src/ffx/shifted_season_controller.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py).
- Textual add/edit/delete flows are wired through [`src/ffx/shifted_season_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_details_screen.py), [`src/ffx/shifted_season_delete_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_delete_screen.py), and the show details table in [`src/ffx/show_details_screen.py`](/home/osgw/.local/src/codex/ffx/src/ffx/show_details_screen.py).
- CLI conversion applies season shifts before TMDB lookup and output suffix generation in [`src/ffx/cli.py`](/home/osgw/.local/src/codex/ffx/src/ffx/cli.py).
- Verified current behavior:
- `~/.local/share/ffx.venv/bin/python -m unittest discover -s tests/unit -p 'test_*.py'` passed on 2026-04-12: `75` tests in `0.795s`.
- That run emitted `ResourceWarning` messages for unclosed SQLite connections, so the suite is green but not perfectly clean.
- There is almost no direct shifted-season coverage in the modern tests:
- [`tests/unit/test_cli_rename_only.py`](/home/osgw/.local/src/codex/ffx/tests/unit/test_cli_rename_only.py) stubs `ShiftedSeasonController` rather than exercising it.
- [`tests/unit/test_screen_support.py`](/home/osgw/.local/src/codex/ffx/tests/unit/test_screen_support.py) only verifies controller bootstrap wiring.
- Net effect: the subsystem is integrated, but its core rules are effectively untested by the current modern suite.
- Reproduced correctness gaps:
- Overlap validation is broken in [`src/ffx/shifted_season_controller.py:41`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:41) because `getOriginalSeason` is compared as a method object instead of being called.
- Reproduction on 2026-04-12 with a temp SQLite DB:
- Added `S1 E1-E10`.
- `checkShiftedSeason(...)` incorrectly returned `True` for overlapping `S1 E5-E15`.
- `addShiftedSeason(...)` then stored the overlapping row successfully.
- `updateShiftedSeason(...)` in [`src/ffx/shifted_season_controller.py:93`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:93) does not enforce episode ordering, so an invalid range like `first_episode=20`, `last_episode=10` was accepted in the same reproduction.
- Because [`src/ffx/shifted_season_controller.py:213`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:213) returns the first matching sibling and [`src/ffx/shifted_season_controller.py:163`](/home/osgw/.local/src/codex/ffx/src/ffx/shifted_season_controller.py:163) applies no explicit ordering, overlapping rows would also make runtime shifting ambiguous.
- Progress summary:
- Good progress:
- The subsystem exists across requirements, schema, UI, and conversion flow.
- It appears fully integrated into the show-editing workflow rather than parked as dead code.
- Incomplete progress:
- Validation logic is not trustworthy yet.
- Modern tests do not currently protect the subsystem's real behavior.
- User-facing error feedback in the shifted-season screens still has placeholder `#TODO: Meldung` branches.
- Recommended next slice:
1. Add direct controller tests for overlap rejection, episode-order validation, and `shiftSeason(...)` selection behavior.
2. Fix `checkShiftedSeason(...)` and add the same range/order validation to `updateShiftedSeason(...)`.
3. Make sibling selection deterministic or enforce non-overlap strongly enough that ordering no longer matters in practice.
4. Add at least one focused integration test that proves a stored shifted season changes TMDB lookup and/or generated filename numbering during conversion.
## Delete When ## Delete When
- Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance. - Delete this scratchpad once the optimization backlog is either converted into issues/work items or distilled into durable project guidance.

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
VERSION='0.2.4' VERSION='0.2.5'
DATABASE_VERSION = 3 DATABASE_VERSION = 3
DEFAULT_QUALITY = 32 DEFAULT_QUALITY = 32

View File

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

View File

@@ -14,7 +14,11 @@ from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
from .tag_details_screen import TagDetailsScreen from .tag_details_screen import TagDetailsScreen
from .tag_delete_screen import TagDeleteScreen from .tag_delete_screen import TagDeleteScreen
from .screen_support import build_screen_bootstrap, build_screen_controllers from .screen_support import (
build_screen_bootstrap,
build_screen_controllers,
populate_tag_table,
)
from ffx.track_type import TrackType from ffx.track_type import TrackType
@@ -28,8 +32,6 @@ from ffx.iso_language import IsoLanguage
from ffx.audio_layout import AudioLayout from ffx.audio_layout import AudioLayout
from ffx.model.shifted_season import ShiftedSeason from ffx.model.shifted_season import ShiftedSeason
from ffx.helper import formatRichColor, removeRichColor
# Screen[dict[int, str, int]] # Screen[dict[int, str, int]]
class PatternDetailsScreen(Screen): class PatternDetailsScreen(Screen):
@@ -130,11 +132,15 @@ class PatternDetailsScreen(Screen):
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
self.__draftTracks : List[TrackDescriptor] = [] self.__draftTracks : List[TrackDescriptor] = []
self.__draftTags : dict[str, str] = {} self.__draftTags : dict[str, str] = {}
self.__trackRowData: dict[object, TrackDescriptor] = {}
self.__tagRowData: dict[object, tuple[str, str]] = {}
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
def updateTracks(self): def updateTracks(self):
self.tracksTable.clear() self.tracksTable.clear()
self.__trackRowData = {}
tracks = self.getCurrentTrackDescriptors() tracks = self.getCurrentTrackDescriptors()
@@ -165,7 +171,8 @@ class PatternDetailsScreen(Screen):
'Yes' if TrackDisposition.FORCED in dispoSet else 'No', 'Yes' if TrackDisposition.FORCED in dispoSet else 'No',
td.getSourceIndex()) td.getSourceIndex())
self.tracksTable.add_row(*map(str, row)) row_key = self.tracksTable.add_row(*map(str, row))
self.__trackRowData[row_key] = td
typeCounter[trackType] += 1 typeCounter[trackType] += 1
@@ -243,29 +250,23 @@ class PatternDetailsScreen(Screen):
def updateTags(self): def updateTags(self):
self.tagsTable.clear()
tags = ( tags = (
self.__tac.findAllMediaTags(self.__pattern.getId()) self.__tac.findAllMediaTags(self.__pattern.getId())
if self.__pattern is not None if self.__pattern is not None
else self.__draftTags else self.__draftTags
) )
for tagKey, tagValue in tags.items(): self.__tagRowData = populate_tag_table(
self.tagsTable,
textColor = None tags,
if tagKey in self.__ignoreGlobalKeys: ignore_keys=self.__ignoreGlobalKeys,
textColor = 'blue' remove_keys=self.__removeGlobalKeys,
if tagKey in self.__removeGlobalKeys: )
textColor = 'red'
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
self.tagsTable.add_row(*map(str, row))
def updateShiftedSeasons(self): def updateShiftedSeasons(self):
self.shiftedSeasonsTable.clear() self.shiftedSeasonsTable.clear()
self.__shiftedSeasonRowData = {}
if self.__pattern is None: if self.__pattern is None:
return return
@@ -273,6 +274,7 @@ class PatternDetailsScreen(Screen):
shiftedSeason: ShiftedSeason shiftedSeason: ShiftedSeason
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()): for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()):
shiftedSeasonObj = shiftedSeason.getObj() shiftedSeasonObj = shiftedSeason.getObj()
shiftedSeasonObj['id'] = shiftedSeason.getId()
firstEpisode = shiftedSeasonObj['first_episode'] firstEpisode = shiftedSeasonObj['first_episode']
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else '' firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
@@ -288,7 +290,8 @@ class PatternDetailsScreen(Screen):
shiftedSeasonObj['episode_offset'], shiftedSeasonObj['episode_offset'],
) )
self.shiftedSeasonsTable.add_row(*map(str, row)) row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
self.__shiftedSeasonRowData[row_key] = shiftedSeasonObj
def getSelectedShiftedSeasonObjFromInput(self): def getSelectedShiftedSeasonObjFromInput(self):
@@ -300,29 +303,7 @@ class PatternDetailsScreen(Screen):
) )
if row_key is not None: if row_key is not None:
selected_row_data = self.shiftedSeasonsTable.get_row(row_key) shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
def parse_int_or_default(value: str, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
if self.__pattern is not None:
shiftedSeasonId = self.__ssc.findShiftedSeason(
patternId=self.__pattern.getId(),
originalSeason=shiftedSeasonObj['original_season'],
firstEpisode=shiftedSeasonObj['first_episode'],
lastEpisode=shiftedSeasonObj['last_episode'],
)
if shiftedSeasonId is not None:
shiftedSeasonObj['id'] = shiftedSeasonId
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -513,15 +494,7 @@ class PatternDetailsScreen(Screen):
row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate) row_key, col_key = self.tracksTable.coordinate_to_cell_key(self.tracksTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_track_data = self.tracksTable.get_row(row_key) return self.__trackRowData.get(row_key)
trackIndex = int(selected_track_data[0])
trackSubIndex = int(selected_track_data[2])
for trackDescriptor in self.getCurrentTrackDescriptors():
if (trackDescriptor.getIndex() == trackIndex
and trackDescriptor.getSubIndex() == trackSubIndex):
return trackDescriptor
return None return None
@@ -539,12 +512,7 @@ class PatternDetailsScreen(Screen):
row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate) row_key, col_key = self.tagsTable.coordinate_to_cell_key(self.tagsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_tag_data = self.tagsTable.get_row(row_key) return self.__tagRowData.get(row_key)
tagKey = removeRichColor(selected_tag_data[0])
tagValue = removeRichColor(selected_tag_data[1])
return tagKey, tagValue
else: else:
return None return None

View File

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

View File

@@ -383,10 +383,27 @@ class ShiftedSeasonController:
session.close() session.close()
def shiftSeason(self, showId, season, episode, patternId=None): def shiftSeason(self, showId, season, episode, patternId=None):
if season == -1 or episode == -1: if season == -1 or episode == -1:
return season, episode return season, episode
shiftedSeason, shiftedEpisode, sourceLabel = self.resolveShiftSeason(
showId,
season,
episode,
patternId=patternId,
)
if shiftedSeason != season or shiftedEpisode != episode:
self.context['logger'].info(
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
)
return shiftedSeason, shiftedEpisode
def resolveShiftSeason(self, showId, season, episode, patternId=None):
if season == -1 or episode == -1:
return season, episode, "unrecognized"
session = None session = None
try: try:
session = self.Session() session = self.Session()
@@ -420,12 +437,7 @@ class ShiftedSeasonController:
if activeShift.getPatternId() is not None if activeShift.getPatternId() is not None
else "show" else "show"
) )
return shiftedSeason, shiftedEpisode, sourceLabel
self.context['logger'].info(
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
)
return shiftedSeason, shiftedEpisode
except ShiftedSeasonOwnerException as ex: except ShiftedSeasonOwnerException as ex:
raise click.ClickException(str(ex)) raise click.ClickException(str(ex))

View File

@@ -108,12 +108,45 @@ class ShowDetailsScreen(Screen):
self.__ssc = controllers['shifted_season'] self.__ssc = controllers['shifted_season']
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
self.__patternRowData: dict[object, dict[str, object]] = {}
self.__shiftedSeasonRowData: dict[object, dict[str, int | None]] = {}
def _add_pattern_row(self, *, pattern_id: int | None, pattern_text: str):
row_key = self.patternTable.add_row(str(pattern_text))
self.__patternRowData[row_key] = {
'id': pattern_id,
'show_id': self.__showDescriptor.getId() if self.__showDescriptor is not None else None,
'pattern': str(pattern_text),
}
return row_key
def _add_shifted_season_row(self, shifted_season_obj: dict[str, int | None]):
firstEpisode = shifted_season_obj['first_episode']
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
lastEpisode = shifted_season_obj['last_episode']
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
row = (
shifted_season_obj['original_season'],
firstEpisodeStr,
lastEpisodeStr,
shifted_season_obj['season_offset'],
shifted_season_obj['episode_offset'],
)
row_key = self.shiftedSeasonsTable.add_row(*map(str, row))
self.__shiftedSeasonRowData[row_key] = dict(shifted_season_obj)
return row_key
def updateShiftedSeasons(self): def updateShiftedSeasons(self):
self.shiftedSeasonsTable.clear() self.shiftedSeasonsTable.clear()
self.__shiftedSeasonRowData = {}
if not self.__showDescriptor is None: if not self.__showDescriptor is None:
@@ -123,20 +156,8 @@ class ShowDetailsScreen(Screen):
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId): for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(showId=showId):
shiftedSeasonObj = shiftedSeason.getObj() shiftedSeasonObj = shiftedSeason.getObj()
shiftedSeasonObj['id'] = shiftedSeason.getId()
firstEpisode = shiftedSeasonObj['first_episode'] self._add_shifted_season_row(shiftedSeasonObj)
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
lastEpisode = shiftedSeasonObj['last_episode']
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
row = (shiftedSeasonObj['original_season'],
firstEpisodeStr,
lastEpisodeStr,
shiftedSeasonObj['season_offset'],
shiftedSeasonObj['episode_offset'])
self.shiftedSeasonsTable.add_row(*map(str, row))
@@ -162,8 +183,10 @@ class ShowDetailsScreen(Screen):
#raise click.ClickException(f"show_id {showId}") #raise click.ClickException(f"show_id {showId}")
for pattern in self.__pc.getPatternsForShow(showId): for pattern in self.__pc.getPatternsForShow(showId):
row = (pattern.getPattern(),) self._add_pattern_row(
self.patternTable.add_row(*map(str, row)) pattern_id=pattern.getId(),
pattern_text=pattern.getPattern(),
)
self.updateShiftedSeasons() self.updateShiftedSeasons()
@@ -195,10 +218,7 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.patternTable.get_row(row_key) selectedPattern = dict(self.__patternRowData.get(row_key, {}))
selectedPattern['show_id'] = self.__showDescriptor.getId()
selectedPattern['pattern'] = str(selected_row_data[0])
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -217,31 +237,7 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate) row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(self.shiftedSeasonsTable.cursor_coordinate)
if row_key is not None: if row_key is not None:
selected_row_data = self.shiftedSeasonsTable.get_row(row_key) shiftedSeasonObj = dict(self.__shiftedSeasonRowData.get(row_key, {}))
def parse_int_or_default(value: str, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
if self.__showDescriptor is not None:
showId = int(self.__showDescriptor.getId())
shiftedSeasonId = self.__ssc.findShiftedSeason(showId,
originalSeason=shiftedSeasonObj['original_season'],
firstEpisode=shiftedSeasonObj['first_episode'],
lastEpisode=shiftedSeasonObj['last_episode'])
if shiftedSeasonId is not None:
shiftedSeasonObj['id'] = shiftedSeasonId
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -255,9 +251,14 @@ class ShowDetailsScreen(Screen):
def handle_add_pattern(self, screenResult): def handle_add_pattern(self, screenResult):
if screenResult is None:
return
pattern = (screenResult['pattern'],) pattern_id = self.__pc.findPattern(screenResult)
self.patternTable.add_row(*map(str, pattern)) self._add_pattern_row(
pattern_id=pattern_id,
pattern_text=screenResult['pattern'],
)
def action_edit_pattern(self): def action_edit_pattern(self):
@@ -265,8 +266,7 @@ class ShowDetailsScreen(Screen):
selectedPatternDescriptor = self.getSelectedPatternDescriptor() selectedPatternDescriptor = self.getSelectedPatternDescriptor()
if selectedPatternDescriptor: if selectedPatternDescriptor:
selectedPatternId = selectedPatternDescriptor.get('id')
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor)
if selectedPatternId is None: if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to edit has no id") raise click.ClickException(f"ShowDetailsScreen.action_edit_pattern(): Pattern to edit has no id")
@@ -280,6 +280,8 @@ class ShowDetailsScreen(Screen):
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern']) self.patternTable.update_cell(row_key, self.column_key_pattern, screenResult['pattern'])
if row_key in self.__patternRowData:
self.__patternRowData[row_key]['pattern'] = str(screenResult['pattern'])
except CellDoesNotExist: except CellDoesNotExist:
pass pass
@@ -291,7 +293,7 @@ class ShowDetailsScreen(Screen):
if selectedPatternDescriptor: if selectedPatternDescriptor:
selectedPatternId = self.__pc.findPattern(selectedPatternDescriptor) selectedPatternId = selectedPatternDescriptor.get('id')
if selectedPatternId is None: if selectedPatternId is None:
raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id") raise click.ClickException(f"ShowDetailsScreen.action_remove_pattern(): Pattern to remove has no id")
@@ -304,6 +306,7 @@ class ShowDetailsScreen(Screen):
try: try:
row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate) row_key, col_key = self.patternTable.coordinate_to_cell_key(self.patternTable.cursor_coordinate)
self.patternTable.remove_row(row_key) self.patternTable.remove_row(row_key)
self.__patternRowData.pop(row_key, None)
except CellDoesNotExist: except CellDoesNotExist:
pass pass

View File

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

View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@@ -183,9 +183,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
) )
self.assertEqual((1, 3), (shifted_season, shifted_episode)) self.assertEqual((1, 3), (shifted_season, shifted_episode))
mocked_info.assert_called_once_with( mocked_info.assert_not_called()
"Setting season shift 1/3 -> 1/3 from pattern"
)
def test_shift_season_falls_back_to_identity_when_no_rule_matches(self): 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$") pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
@@ -199,9 +197,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
) )
self.assertEqual((4, 20), (shifted_season, shifted_episode)) self.assertEqual((4, 20), (shifted_season, shifted_episode))
mocked_info.assert_called_once_with( mocked_info.assert_not_called()
"Setting season shift 4/20 -> 4/20 from default"
)
if __name__ == "__main__": if __name__ == "__main__":

View File

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