ff
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)})"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user