This commit is contained in:
Javanaut
2026-04-12 18:26:39 +02:00
parent 8361fc536b
commit a24b6dedaa
14 changed files with 346 additions and 163 deletions

View File

@@ -50,7 +50,7 @@
## Data And Interface Notes ## Data And Interface Notes
- Key entities or records: - Key entities or records:
- `Show`: canonical TV show metadata plus digit-formatting rules for generated filenames. - `Show`: canonical TV show metadata plus digit-formatting rules and an optional show-level encoding-quality fallback.
- `Pattern`: regex rule tying filenames to one show and one target media schema. - `Pattern`: regex rule tying filenames to one show and one target media schema.
- `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`. - `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`.
- `MediaTag`: persisted container-level metadata for a pattern. - `MediaTag`: persisted container-level metadata for a pattern.

View File

@@ -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 CLI command `ffx configure_workstation` shall act as a wrapper for the second-step preparation flow in `tools/configure_workstation.sh`.
- The system shall persist reusable normalization rules in SQLite for: - The system shall persist reusable normalization rules in SQLite for:
- shows and show formatting digits, - shows and show formatting digits,
- optional show-level quality defaults,
- regex-based filename patterns, - regex-based filename patterns,
- per-pattern media tags, - per-pattern media tags,
- per-pattern stream definitions, - per-pattern stream definitions,
@@ -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 support optional TMDB lookups to resolve show names, years, and episode titles when a show ID, season, and episode are available.
- The system shall generate output filenames from show metadata, season and episode indices, and episode names using the configured filename template. - The system shall generate output filenames from show metadata, season and episode indices, and episode names using the configured filename template.
- The system shall allow CLI overrides for stream languages, stream titles, default and forced tracks, stream order, TMDB show and episode data, output directory, label prefix, and processing resource limits. - The system shall allow CLI overrides for stream languages, stream titles, default and forced tracks, stream order, TMDB show and episode data, output directory, label prefix, and processing resource limits.
- The system shall resolve encoding quality by precedence `CLI override -> pattern -> show -> encoder default` and shall report the chosen value and source.
- The system shall resolve season shifting by precedence `pattern -> show -> identity default` and shall report the chosen mapping and source.
- Processing resource limit rules: - Processing resource limit rules:
- `--nice` shall accept niceness values from `-20` through `19`; omitting the option shall disable niceness adjustment. - `--nice` shall accept niceness values from `-20` through `19`; omitting the option shall disable niceness adjustment.
- `--cpu` shall accept either a positive absolute `cpulimit` value such as `200`, or a percentage suffixed with `%` such as `25%` to represent a share of present CPUs; omitting the option or using `0` shall disable CPU limiting. - `--cpu` shall accept either a positive absolute `cpulimit` value such as `200`, or a percentage suffixed with `%` such as `25%` to represent a share of present CPUs; omitting the option or using `0` shall disable CPU limiting.

View File

@@ -966,6 +966,7 @@ def convert(ctx,
from ffx.filter.quality_filter import QualityFilter from ffx.filter.quality_filter import QualityFilter
from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename from ffx.helper import filterFilename, getEpisodeFileBasename, substituteTmdbFilename
from ffx.shifted_season_controller import ShiftedSeasonController from ffx.shifted_season_controller import ShiftedSeasonController
from ffx.show_controller import ShowController
from ffx.show_descriptor import ShowDescriptor from ffx.show_descriptor import ShowDescriptor
from ffx.tmdb_controller import TmdbController from ffx.tmdb_controller import TmdbController
from ffx.track_codec import TrackCodec from ffx.track_codec import TrackCodec
@@ -1149,6 +1150,7 @@ def convert(ctx,
ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs") ctx.obj['logger'].info(f"\nRunning {len(existingSourcePaths) * len(chainYield)} jobs")
jobIndex = 0 jobIndex = 0
showController = ShowController(context)
for sourcePath in existingSourcePaths: for sourcePath in existingSourcePaths:
@@ -1278,6 +1280,14 @@ def convert(ctx,
fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor) fc = FfxController(context, targetMediaDescriptor, sourceMediaDescriptor)
qualityShowId = (
cliOverrides['tmdb']['show']
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
else matchedShowId
)
if currentShowDescriptor is None and qualityShowId != -1:
currentShowDescriptor = showController.getShowDescriptor(qualityShowId)
defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context) defaultDigitLengths = ShowDescriptor.getDefaultDigitLengths(context)
indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY] indexSeasonDigits = currentShowDescriptor.getIndexSeasonDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDEX_SEASON_DIGITS_KEY]
@@ -1408,7 +1418,8 @@ def convert(ctx,
targetFormat, targetFormat,
chainIteration, chainIteration,
cropArguments, cropArguments,
currentPattern) currentPattern,
currentShowDescriptor)

View File

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

View File

@@ -8,11 +8,13 @@ def applyMigration(databaseContext):
column['name'] column['name']
for column in inspector.get_columns('shifted_seasons') for column in inspector.get_columns('shifted_seasons')
} }
showColumns = {
if 'pattern_id' in shiftedSeasonColumns: column['name']
return for column in inspector.get_columns('shows')
}
with engine.begin() as connection: with engine.begin() as connection:
if 'pattern_id' not in shiftedSeasonColumns:
connection.execute(text("PRAGMA foreign_keys=OFF")) connection.execute(text("PRAGMA foreign_keys=OFF"))
connection.execute( connection.execute(
text( text(
@@ -72,3 +74,7 @@ def applyMigration(databaseContext):
) )
connection.execute(text("PRAGMA foreign_keys=ON")) connection.execute(text("PRAGMA foreign_keys=ON"))
if 'quality' not in showColumns:
connection.execute(
text("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
)

View File

@@ -45,6 +45,7 @@ class Show(Base):
index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS) index_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDEX_EPISODE_DIGITS)
indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS) indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS)
indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS) indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS)
quality = Column(Integer, default=0)
def getDescriptor(self, context): def getDescriptor(self, context):
@@ -58,5 +59,6 @@ class Show(Base):
kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits) kwargs[ShowDescriptor.INDEX_EPISODE_DIGITS_KEY] = int(self.index_episode_digits)
kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits) kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits)
kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits)
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.quality or 0)
return ShowDescriptor(**kwargs) return ShowDescriptor(**kwargs)

View File

@@ -409,18 +409,20 @@ class ShiftedSeasonController:
) )
if activeShift is None: if activeShift is None:
return season, episode shiftedSeason = season
shiftedEpisode = episode
sourceLabel = "default"
else:
shiftedSeason = season + activeShift.getSeasonOffset() shiftedSeason = season + activeShift.getSeasonOffset()
shiftedEpisode = episode + activeShift.getEpisodeOffset() shiftedEpisode = episode + activeShift.getEpisodeOffset()
sourceLabel = (
ownerLabel = ( "pattern"
f"pattern #{activeShift.getPatternId()}"
if activeShift.getPatternId() is not None if activeShift.getPatternId() is not None
else f"show #{activeShift.getShowId()}" else "show"
) )
self.context['logger'].info( 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 return shiftedSeason, shiftedEpisode

View File

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

View File

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

View File

@@ -150,6 +150,8 @@ class ShowDetailsScreen(Screen):
self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits()) self.query_one("#index_episode_digits_input", Input).value = str(self.__showDescriptor.getIndexEpisodeDigits())
self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits()) self.query_one("#indicator_season_digits_input", Input).value = str(self.__showDescriptor.getIndicatorSeasonDigits())
self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits()) self.query_one("#indicator_episode_digits_input", Input).value = str(self.__showDescriptor.getIndicatorEpisodeDigits())
if self.__showDescriptor.getQuality():
self.query_one("#quality_input", Input).value = str(self.__showDescriptor.getQuality())
#raise click.ClickException(f"show_id {showId}") #raise click.ClickException(f"show_id {showId}")
@@ -348,7 +350,8 @@ class ShowDetailsScreen(Screen):
yield Input(type="integer", id="year_input", classes="four") yield Input(type="integer", id="year_input", classes="four")
#5 #5
yield Static(" ", classes="five") yield Static("Quality")
yield Input(type="integer", id="quality_input", classes="four")
#6 #6
yield Static("Index Season Digits") 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) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.query_one("#indicator_episode_digits_input", Input).value)
except ValueError: except ValueError:
pass pass
try:
kwargs[ShowDescriptor.QUALITY_KEY] = int(self.query_one("#quality_input", Input).value)
except ValueError:
pass
return ShowDescriptor(**kwargs) return ShowDescriptor(**kwargs)

View File

@@ -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.database import DATABASE_VERSION_KEY, databaseContext, getDatabaseVersion # noqa: E402
from ffx.model.shifted_season import ShiftedSeason # noqa: E402 from ffx.model.shifted_season import ShiftedSeason # noqa: E402
from ffx.model.property import Property # noqa: E402 from ffx.model.property import Property # noqa: E402
from ffx.model.show import Show # noqa: E402
from ffx.model.show import Base # noqa: E402 from ffx.model.show import Base # noqa: E402
from ffx.show_controller import ShowController # noqa: E402 from ffx.show_controller import ShowController # noqa: E402
from ffx.show_descriptor import ShowDescriptor # noqa: E402 from ffx.show_descriptor import ShowDescriptor # noqa: E402
@@ -39,6 +40,115 @@ class DatabaseContextTests(unittest.TestCase):
def tearDown(self): def tearDown(self):
self.tempdir.cleanup() self.tempdir.cleanup()
def create_demo_show_with_shift(self):
database_context = databaseContext(str(self.database_path))
context = {
"database": database_context,
"config": StaticConfig(),
"logger": object(),
}
try:
ShowController(context).updateShow(
ShowDescriptor(id=1, name="Demo", year=2000)
)
shifted_season_id = ShiftedSeasonController(context).addShiftedSeason(
showId=1,
shiftedSeasonObj={
"original_season": 1,
"first_episode": 1,
"last_episode": 10,
"season_offset": 1,
"episode_offset": -10,
},
)
finally:
database_context["engine"].dispose()
return shifted_season_id
def rewrite_shows_table_without_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): def test_database_context_bootstraps_new_database_with_current_version(self):
with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all: with patch("ffx.database.Base.metadata.create_all", wraps=Base.metadata.create_all) as mocked_create_all:
context = databaseContext(str(self.database_path)) context = databaseContext(str(self.database_path))
@@ -91,74 +201,14 @@ class DatabaseContextTests(unittest.TestCase):
mocked_create_all.assert_not_called() mocked_create_all.assert_not_called()
def test_database_context_migrates_v2_shifted_seasons_schema_to_v3(self): def test_database_context_migrates_v2_shifted_seasons_schema_to_v3(self):
database_context = databaseContext(str(self.database_path)) shifted_season_id = self.create_demo_show_with_shift()
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()
connection = sqlite3.connect(self.database_path) connection = sqlite3.connect(self.database_path)
try: try:
cursor = connection.cursor() cursor = connection.cursor()
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_show_id") cursor.execute("PRAGMA foreign_keys=OFF")
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_pattern_id") self.rewrite_shifted_seasons_table_without_pattern_owner(cursor)
cursor.execute( self.rewrite_shows_table_without_quality(cursor)
"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( cursor.execute(
"UPDATE properties SET value = '2' WHERE key = ?", "UPDATE properties SET value = '2' WHERE key = ?",
(DATABASE_VERSION_KEY,), (DATABASE_VERSION_KEY,),
@@ -175,7 +225,7 @@ class DatabaseContextTests(unittest.TestCase):
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context)) self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
mocked_confirm.assert_called_once() 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()) self.assertTrue(backup_path.exists())
Session = reopened_context["session"] 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.getOriginalSeason())
self.assertEqual(1, migrated_shifted_season.getFirstEpisode()) self.assertEqual(1, migrated_shifted_season.getFirstEpisode())
self.assertEqual(10, migrated_shifted_season.getLastEpisode()) 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: finally:
session.close() session.close()
finally: finally:
@@ -231,7 +284,7 @@ class DatabaseContextTests(unittest.TestCase):
databaseContext(str(self.database_path)) databaseContext(str(self.database_path))
self.assertEqual("Database migration aborted by user.", str(raisedContext.exception)) 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__": if __name__ == "__main__":

View File

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

View File

@@ -5,6 +5,7 @@ from pathlib import Path
import sys import sys
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch
SRC_ROOT = Path(__file__).resolve().parents[2] / "src" SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
@@ -101,6 +102,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
}, },
) )
with patch.object(self.context["logger"], "info") as mocked_info:
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
showId=1, showId=1,
patternId=pattern_id, patternId=pattern_id,
@@ -109,6 +111,9 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
) )
self.assertEqual((3, 8), (shifted_season, shifted_episode)) 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): 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$") pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
@@ -133,6 +138,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
}, },
) )
with patch.object(self.context["logger"], "info") as mocked_info:
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
showId=1, showId=1,
patternId=pattern_id, patternId=pattern_id,
@@ -141,6 +147,9 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
) )
self.assertEqual((2, 1), (shifted_season, shifted_episode)) 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): 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$") pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
@@ -165,6 +174,7 @@ class ShiftedSeasonControllerTests(unittest.TestCase):
}, },
) )
with patch.object(self.context["logger"], "info") as mocked_info:
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
showId=1, showId=1,
patternId=pattern_id, patternId=pattern_id,
@@ -173,10 +183,14 @@ 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(
"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$")
with patch.object(self.context["logger"], "info") as mocked_info:
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason( shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
showId=1, showId=1,
patternId=pattern_id, patternId=pattern_id,
@@ -185,6 +199,9 @@ 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(
"Setting season shift 4/20 -> 4/20 from default"
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -56,6 +56,7 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
self.assertEqual(3, descriptor.getIndexEpisodeDigits()) self.assertEqual(3, descriptor.getIndexEpisodeDigits())
self.assertEqual(3, descriptor.getIndicatorSeasonDigits()) self.assertEqual(3, descriptor.getIndicatorSeasonDigits())
self.assertEqual(4, descriptor.getIndicatorEpisodeDigits()) self.assertEqual(4, descriptor.getIndicatorEpisodeDigits())
self.assertEqual(0, descriptor.getQuality())
def test_show_descriptor_without_context_uses_shared_constants(self): def test_show_descriptor_without_context_uses_shared_constants(self):
descriptor = ShowDescriptor(id=1, name="Default Show", year=2024) descriptor = ShowDescriptor(id=1, name="Default Show", year=2024)
@@ -70,6 +71,12 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS, DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
descriptor.getIndicatorEpisodeDigits(), 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): def test_episode_basename_uses_configured_digit_defaults_when_omitted(self):
basename = getEpisodeFileBasename( basename = getEpisodeFileBasename(