From a24b6dedaa37556a1a2f4b4197c131cba7d9d4f6 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sun, 12 Apr 2026 18:26:39 +0200 Subject: [PATCH] ff --- requirements/architecture.md | 2 +- requirements/project.md | 3 + src/ffx/cli.py | 13 +- src/ffx/ffx_controller.py | 9 +- src/ffx/model/migration/step_2_3.py | 118 ++++++------ src/ffx/model/show.py | 2 + src/ffx/shifted_season_controller.py | 22 ++- src/ffx/show_controller.py | 6 +- src/ffx/show_descriptor.py | 10 + src/ffx/show_details_screen.py | 9 +- tests/unit/test_database.py | 185 ++++++++++++------- tests/unit/test_ffx_controller.py | 58 ++++++ tests/unit/test_shifted_season_controller.py | 65 ++++--- tests/unit/test_show_descriptor_defaults.py | 7 + 14 files changed, 346 insertions(+), 163 deletions(-) diff --git a/requirements/architecture.md b/requirements/architecture.md index 6a6847d..2fe80b4 100644 --- a/requirements/architecture.md +++ b/requirements/architecture.md @@ -50,7 +50,7 @@ ## 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 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. diff --git a/requirements/project.md b/requirements/project.md index dd436dc..b477580 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -44,6 +44,7 @@ - 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 quality defaults, - regex-based filename patterns, - per-pattern media tags, - per-pattern stream definitions, @@ -67,6 +68,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. diff --git a/src/ffx/cli.py b/src/ffx/cli.py index a2f9d18..9e2b487 100755 --- a/src/ffx/cli.py +++ b/src/ffx/cli.py @@ -966,6 +966,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 +1150,7 @@ def convert(ctx, ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs") jobIndex = 0 + showController = ShowController(context) for sourcePath in existingSourcePaths: @@ -1278,6 +1280,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] @@ -1408,7 +1418,8 @@ def convert(ctx, targetFormat, chainIteration, cropArguments, - currentPattern) + currentPattern, + currentShowDescriptor) diff --git a/src/ffx/ffx_controller.py b/src/ffx/ffx_controller.py index 52ec099..9ec9600 100644 --- a/src/ffx/ffx_controller.py +++ b/src/ffx/ffx_controller.py @@ -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) diff --git a/src/ffx/model/migration/step_2_3.py b/src/ffx/model/migration/step_2_3.py index 5eacdb0..528d166 100644 --- a/src/ffx/model/migration/step_2_3.py +++ b/src/ffx/model/migration/step_2_3.py @@ -8,67 +8,73 @@ def applyMigration(databaseContext): column['name'] for column in inspector.get_columns('shifted_seasons') } - - if 'pattern_id' in shiftedSeasonColumns: - return + showColumns = { + column['name'] + for column in inspector.get_columns('shows') + } with engine.begin() as connection: - connection.execute(text("PRAGMA foreign_keys=OFF")) - connection.execute( - text( - """ - CREATE TABLE shifted_seasons_v3 ( - id INTEGER PRIMARY KEY, - show_id INTEGER, - pattern_id INTEGER, - original_season INTEGER, - first_episode INTEGER DEFAULT -1, - last_episode INTEGER DEFAULT -1, - season_offset INTEGER DEFAULT 0, - episode_offset INTEGER DEFAULT 0, - FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE, - FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE, - CHECK ( - (show_id IS NOT NULL AND pattern_id IS NULL) - OR (show_id IS NULL AND pattern_id IS NOT NULL) + 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 + 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 + """ ) - 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")) + 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") + ) diff --git a/src/ffx/model/show.py b/src/ffx/model/show.py index af157f3..8e3f757 100644 --- a/src/ffx/model/show.py +++ b/src/ffx/model/show.py @@ -45,6 +45,7 @@ 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) def getDescriptor(self, context): @@ -58,5 +59,6 @@ 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) return ShowDescriptor(**kwargs) diff --git a/src/ffx/shifted_season_controller.py b/src/ffx/shifted_season_controller.py index a5c1a8a..98d8ab3 100644 --- a/src/ffx/shifted_season_controller.py +++ b/src/ffx/shifted_season_controller.py @@ -409,18 +409,20 @@ class ShiftedSeasonController: ) if activeShift is None: - return season, episode + 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" + ) - shiftedSeason = season + activeShift.getSeasonOffset() - shiftedEpisode = episode + activeShift.getEpisodeOffset() - - ownerLabel = ( - f"pattern #{activeShift.getPatternId()}" - if activeShift.getPatternId() is not None - else f"show #{activeShift.getShowId()}" - ) self.context['logger'].info( - f"Shifting season via {ownerLabel}: {season}/{episode} -> {shiftedSeason}/{shiftedEpisode}" + f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}" ) return shiftedSeason, shiftedEpisode diff --git a/src/ffx/show_controller.py b/src/ffx/show_controller.py index 7407a19..3ed2fe0 100644 --- a/src/ffx/show_controller.py +++ b/src/ffx/show_controller.py @@ -62,7 +62,8 @@ 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()) s.add(show) s.commit() @@ -88,6 +89,9 @@ 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 changed: s.commit() diff --git a/src/ffx/show_descriptor.py b/src/ffx/show_descriptor.py index e1b3398..5ae6dfe 100644 --- a/src/ffx/show_descriptor.py +++ b/src/ffx/show_descriptor.py @@ -21,6 +21,7 @@ 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' DEFAULT_INDEX_SEASON_DIGITS = DEFAULT_SHOW_INDEX_SEASON_DIGITS DEFAULT_INDEX_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS @@ -124,6 +125,13 @@ 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 + def getId(self): return self.__showId @@ -140,6 +148,8 @@ class ShowDescriptor(): return self.__indicatorSeasonDigits def getIndicatorEpisodeDigits(self): return self.__indicatorEpisodeDigits + def getQuality(self): + return self.__quality def getFilenamePrefix(self): return f"{self.__showName} ({str(self.__showYear)})" diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index b343423..7458ee8 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -150,6 +150,8 @@ 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()) #raise click.ClickException(f"show_id {showId}") @@ -348,7 +350,8 @@ 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("Index Season Digits") @@ -438,6 +441,10 @@ 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 return ShowDescriptor(**kwargs) diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index f800038..f5fa194 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -20,6 +20,7 @@ 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 @@ -39,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_quality(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)) @@ -91,74 +201,14 @@ class DatabaseContextTests(unittest.TestCase): mocked_create_all.assert_not_called() def test_database_context_migrates_v2_shifted_seasons_schema_to_v3(self): - database_context = databaseContext(str(self.database_path)) - context = { - "database": database_context, - "config": StaticConfig(), - "logger": object(), - } - try: - ShowController(context).updateShow( - ShowDescriptor(id=1, name="Demo", year=2000) - ) - shifted_season_id = ShiftedSeasonController(context).addShiftedSeason( - showId=1, - shiftedSeasonObj={ - "original_season": 1, - "first_episode": 1, - "last_episode": 10, - "season_offset": 1, - "episode_offset": -10, - }, - ) - finally: - database_context["engine"].dispose() + shifted_season_id = self.create_demo_show_with_shift() connection = sqlite3.connect(self.database_path) try: cursor = connection.cursor() - cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_show_id") - cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_pattern_id") - cursor.execute( - "ALTER TABLE shifted_seasons RENAME TO shifted_seasons_v3_current" - ) - cursor.execute( - """ - CREATE TABLE shifted_seasons ( - id INTEGER PRIMARY KEY, - show_id INTEGER, - original_season INTEGER, - first_episode INTEGER DEFAULT -1, - last_episode INTEGER DEFAULT -1, - season_offset INTEGER DEFAULT 0, - episode_offset INTEGER DEFAULT 0, - FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE - ) - """ - ) - cursor.execute( - """ - INSERT INTO shifted_seasons ( - id, - show_id, - original_season, - first_episode, - last_episode, - season_offset, - episode_offset - ) - SELECT - id, - show_id, - original_season, - first_episode, - last_episode, - season_offset, - episode_offset - FROM shifted_seasons_v3_current - """ - ) - cursor.execute("DROP TABLE shifted_seasons_v3_current") + cursor.execute("PRAGMA foreign_keys=OFF") + self.rewrite_shifted_seasons_table_without_pattern_owner(cursor) + self.rewrite_shows_table_without_quality(cursor) cursor.execute( "UPDATE properties SET value = '2' WHERE key = ?", (DATABASE_VERSION_KEY,), @@ -175,7 +225,7 @@ class DatabaseContextTests(unittest.TestCase): self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context)) mocked_confirm.assert_called_once() - backup_path = Path(f"{self.database_path}.v2-to-v3.bak") + backup_path = Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak") self.assertTrue(backup_path.exists()) Session = reopened_context["session"] @@ -192,6 +242,9 @@ class DatabaseContextTests(unittest.TestCase): 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)) finally: session.close() finally: @@ -231,7 +284,7 @@ class DatabaseContextTests(unittest.TestCase): databaseContext(str(self.database_path)) self.assertEqual("Database migration aborted by user.", str(raisedContext.exception)) - self.assertFalse(Path(f"{self.database_path}.v2-to-v3.bak").exists()) + self.assertFalse(Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak").exists()) if __name__ == "__main__": diff --git a/tests/unit/test_ffx_controller.py b/tests/unit/test_ffx_controller.py index 197d818..0102113 100644 --- a/tests/unit/test_ffx_controller.py +++ b/tests/unit/test_ffx_controller.py @@ -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() diff --git a/tests/unit/test_shifted_season_controller.py b/tests/unit/test_shifted_season_controller.py index c32f16e..b8abde2 100644 --- a/tests/unit/test_shifted_season_controller.py +++ b/tests/unit/test_shifted_season_controller.py @@ -5,6 +5,7 @@ from pathlib import Path import sys import tempfile import unittest +from unittest.mock import patch SRC_ROOT = Path(__file__).resolve().parents[2] / "src" @@ -101,14 +102,18 @@ class ShiftedSeasonControllerTests(unittest.TestCase): }, ) - shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( - showId=1, - patternId=pattern_id, - season=1, - episode=3, - ) + 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$") @@ -133,14 +138,18 @@ class ShiftedSeasonControllerTests(unittest.TestCase): }, ) - shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( - showId=1, - patternId=pattern_id, - season=1, - episode=3, - ) + 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$") @@ -165,26 +174,34 @@ class ShiftedSeasonControllerTests(unittest.TestCase): }, ) - shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( - showId=1, - patternId=pattern_id, - season=1, - episode=3, - ) + with patch.object(self.context["logger"], "info") as mocked_info: + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=1, + episode=3, + ) self.assertEqual((1, 3), (shifted_season, shifted_episode)) + mocked_info.assert_called_once_with( + "Setting season shift 1/3 -> 1/3 from pattern" + ) def test_shift_season_falls_back_to_identity_when_no_rule_matches(self): pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$") - shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( - showId=1, - patternId=pattern_id, - season=4, - episode=20, - ) + with patch.object(self.context["logger"], "info") as mocked_info: + shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( + showId=1, + patternId=pattern_id, + season=4, + episode=20, + ) self.assertEqual((4, 20), (shifted_season, shifted_episode)) + mocked_info.assert_called_once_with( + "Setting season shift 4/20 -> 4/20 from default" + ) if __name__ == "__main__": diff --git a/tests/unit/test_show_descriptor_defaults.py b/tests/unit/test_show_descriptor_defaults.py index 159931c..f1df5bf 100644 --- a/tests/unit/test_show_descriptor_defaults.py +++ b/tests/unit/test_show_descriptor_defaults.py @@ -56,6 +56,7 @@ class ShowDescriptorDefaultTests(unittest.TestCase): self.assertEqual(3, descriptor.getIndexEpisodeDigits()) self.assertEqual(3, descriptor.getIndicatorSeasonDigits()) self.assertEqual(4, descriptor.getIndicatorEpisodeDigits()) + self.assertEqual(0, descriptor.getQuality()) def test_show_descriptor_without_context_uses_shared_constants(self): descriptor = ShowDescriptor(id=1, name="Default Show", year=2024) @@ -70,6 +71,12 @@ class ShowDescriptorDefaultTests(unittest.TestCase): DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, descriptor.getIndicatorEpisodeDigits(), ) + self.assertEqual(0, descriptor.getQuality()) + + 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_episode_basename_uses_configured_digit_defaults_when_omitted(self): basename = getEpisodeFileBasename(