Compare commits
16 Commits
12509cd4e2
...
2593c95b5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2593c95b5c | ||
|
|
8a8c43ecdf | ||
|
|
6170ac641c | ||
|
|
497c0e500b | ||
|
|
008c643272 | ||
|
|
c302b30e63 | ||
|
|
7926407534 | ||
|
|
0894ac2fab | ||
|
|
353759b983 | ||
|
|
454f5f0656 | ||
|
|
0e51d6337f | ||
|
|
a24b6dedaa | ||
|
|
8361fc536b | ||
|
|
4d4272e5e8 | ||
|
|
559869ca68 | ||
|
|
0e4fae538b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ venv/
|
|||||||
*.mkv
|
*.mkv
|
||||||
*.webm
|
*.webm
|
||||||
ffmpeg2pass-0.log
|
ffmpeg2pass-0.log
|
||||||
|
*.sup
|
||||||
10
README.md
10
README.md
@@ -99,6 +99,16 @@ TMDB-backed metadata enrichment requires `TMDB_API_KEY` to be set in the environ
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
### 0.2.5
|
||||||
|
|
||||||
|
- show-level quality and notes fields
|
||||||
|
- pattern-over-show-over-default season-shift resolution with dynamic DB migration loading
|
||||||
|
- migration prompt now reports the upgrade path and creates an in-place DB backup before applying schema changes
|
||||||
|
- `upgrade --branch <name>` now fetches remote-only branches before switching
|
||||||
|
- `unmux` now applies season shifting to subtitle output filenames
|
||||||
|
- convert now keeps DB-defined target subtitle dispositions authoritative over sidecar filename disposition flags when a pattern definition exists
|
||||||
|
- focused modern tests added around migrations, unmux, upgrade, and subtitle-disposition import precedence
|
||||||
|
|
||||||
### 0.2.4
|
### 0.2.4
|
||||||
|
|
||||||
- lightweight CLI commands now stay import-light via lazy runtime loading
|
- lightweight CLI commands now stay import-light via lazy runtime loading
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ffx"
|
name = "ffx"
|
||||||
description = "FFX recoding and metadata managing tool"
|
description = "FFX recoding and metadata managing tool"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
license = {file = "LICENSE.md"}
|
license = {file = "LICENSE.md"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"requests",
|
"requests",
|
||||||
|
|||||||
@@ -436,10 +436,14 @@ def upgrade(ctx, branch):
|
|||||||
commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
|
commandSequences.append(['git', 'reset', '--hard', 'HEAD'])
|
||||||
|
|
||||||
if branch:
|
if branch:
|
||||||
commandSequences.append(['git', 'checkout', branch])
|
commandSequences += [
|
||||||
|
['git', 'fetch', 'origin', branch],
|
||||||
|
['git', 'checkout', '-B', branch, 'FETCH_HEAD'],
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
commandSequences.append(['git', 'pull'])
|
||||||
|
|
||||||
commandSequences += [
|
commandSequences += [
|
||||||
['git', 'pull'],
|
|
||||||
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
[bundlePipPath, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||||
[bundlePipPath, 'install', '--editable', '.'],
|
[bundlePipPath, 'install', '--editable', '.'],
|
||||||
]
|
]
|
||||||
@@ -585,6 +589,7 @@ def unmux(ctx,
|
|||||||
cpu):
|
cpu):
|
||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
from ffx.process import executeProcess
|
from ffx.process import executeProcess
|
||||||
|
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||||
from ffx.track_disposition import TrackDisposition
|
from ffx.track_disposition import TrackDisposition
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
|
|
||||||
@@ -605,6 +610,8 @@ def unmux(ctx,
|
|||||||
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
if create_output_directory and existingSourcePaths and not ctx.obj.get('dry_run', False):
|
||||||
os.makedirs(output_directory, exist_ok=True)
|
os.makedirs(output_directory, exist_ok=True)
|
||||||
|
|
||||||
|
shiftedSeasonController = ShiftedSeasonController(ctx.obj)
|
||||||
|
|
||||||
for sourcePath in existingSourcePaths:
|
for sourcePath in existingSourcePaths:
|
||||||
|
|
||||||
fp = FileProperties(ctx.obj, sourcePath)
|
fp = FileProperties(ctx.obj, sourcePath)
|
||||||
@@ -621,8 +628,12 @@ def unmux(ctx,
|
|||||||
currentShowDescriptor,
|
currentShowDescriptor,
|
||||||
)
|
)
|
||||||
|
|
||||||
season = fp.getSeason()
|
season, episode = shiftedSeasonController.shiftSeason(
|
||||||
episode = fp.getEpisode()
|
fp.getShowId(),
|
||||||
|
season=fp.getSeason(),
|
||||||
|
episode=fp.getEpisode(),
|
||||||
|
patternId=currentPattern.getId() if currentPattern is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
#TODO: Recognition für alle Formate anpassen
|
#TODO: Recognition für alle Formate anpassen
|
||||||
targetLabel = label if label else fp.getFileBasename()
|
targetLabel = label if label else fp.getFileBasename()
|
||||||
@@ -966,6 +977,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 +1161,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:
|
||||||
|
|
||||||
@@ -1181,7 +1194,7 @@ def convert(ctx,
|
|||||||
|
|
||||||
ssc = ShiftedSeasonController(context)
|
ssc = ShiftedSeasonController(context)
|
||||||
|
|
||||||
showId = mediaFileProperties.getShowId()
|
matchedShowId = mediaFileProperties.getShowId()
|
||||||
|
|
||||||
#HINT: -1 if not set
|
#HINT: -1 if not set
|
||||||
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
if 'tmdb' in cliOverrides.keys() and 'season' in cliOverrides['tmdb']:
|
||||||
@@ -1263,7 +1276,8 @@ def convert(ctx,
|
|||||||
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
targetMediaDescriptor.importSubtitles(context['subtitle_directory'],
|
||||||
context['subtitle_prefix'],
|
context['subtitle_prefix'],
|
||||||
showSeason,
|
showSeason,
|
||||||
showEpisode)
|
showEpisode,
|
||||||
|
preserve_dispositions=True)
|
||||||
|
|
||||||
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
# ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getAllTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getAllTrackDescriptors()]}")
|
||||||
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
ctx.obj['logger'].debug(f"tmd subindices: {[t.getIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getSubIndex() for t in targetMediaDescriptor.getTrackDescriptors()]} {[t.getDispositionFlag(TrackDisposition.DEFAULT) for t in targetMediaDescriptor.getTrackDescriptors()]}")
|
||||||
@@ -1278,6 +1292,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]
|
||||||
@@ -1286,19 +1308,43 @@ def convert(ctx,
|
|||||||
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
indicatorEpisodeDigits = currentShowDescriptor.getIndicatorEpisodeDigits() if not currentPattern is None else defaultDigitLengths[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY]
|
||||||
|
|
||||||
|
|
||||||
# Shift season and episode if defined for this show
|
showIdForShift = (
|
||||||
if ('tmdb' not in cliOverrides.keys() and showId != -1
|
cliOverrides['tmdb']['show']
|
||||||
and showSeason != -1 and showEpisode != -1):
|
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||||
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(showId,
|
else matchedShowId
|
||||||
|
)
|
||||||
|
patternIdForShift = currentPattern.getId() if currentPattern is not None else None
|
||||||
|
hasExplicitTargetSeasonOrEpisode = (
|
||||||
|
'tmdb' in cliOverrides.keys()
|
||||||
|
and (
|
||||||
|
'season' in cliOverrides['tmdb']
|
||||||
|
or 'episode' in cliOverrides['tmdb']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shift season and episode if defined for the matched pattern or show
|
||||||
|
if (
|
||||||
|
not hasExplicitTargetSeasonOrEpisode
|
||||||
|
and showSeason != -1
|
||||||
|
and showEpisode != -1
|
||||||
|
):
|
||||||
|
shiftedShowSeason, shiftedShowEpisode = ssc.shiftSeason(
|
||||||
|
showIdForShift,
|
||||||
season=showSeason,
|
season=showSeason,
|
||||||
episode=showEpisode)
|
episode=showEpisode,
|
||||||
|
patternId=patternIdForShift,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
shiftedShowSeason = showSeason
|
shiftedShowSeason = showSeason
|
||||||
shiftedShowEpisode = showEpisode
|
shiftedShowEpisode = showEpisode
|
||||||
|
|
||||||
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
# Assemble target filename accordingly depending on TMDB lookup is enabled
|
||||||
#HINT: -1 if not set
|
#HINT: -1 if not set
|
||||||
showId = cliOverrides['tmdb']['show'] if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb'] else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
showId = (
|
||||||
|
cliOverrides['tmdb']['show']
|
||||||
|
if 'tmdb' in cliOverrides.keys() and 'show' in cliOverrides['tmdb']
|
||||||
|
else (-1 if currentShowDescriptor is None else currentShowDescriptor.getId())
|
||||||
|
)
|
||||||
|
|
||||||
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
if context['use_tmdb'] and showId != -1 and shiftedShowSeason != -1 and shiftedShowEpisode != -1:
|
||||||
|
|
||||||
@@ -1384,7 +1430,8 @@ def convert(ctx,
|
|||||||
targetFormat,
|
targetFormat,
|
||||||
chainIteration,
|
chainIteration,
|
||||||
cropArguments,
|
cropArguments,
|
||||||
currentPattern)
|
currentPattern,
|
||||||
|
currentShowDescriptor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
VERSION='0.2.4'
|
VERSION='0.2.5'
|
||||||
DATABASE_VERSION = 2
|
DATABASE_VERSION = 3
|
||||||
|
|
||||||
DEFAULT_QUALITY = 32
|
DEFAULT_QUALITY = 32
|
||||||
DEFAULT_AV1_PRESET = 5
|
DEFAULT_AV1_PRESET = 5
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os, click
|
import os, shutil, click
|
||||||
|
|
||||||
from sqlalchemy import create_engine, inspect
|
from sqlalchemy import create_engine, inspect, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
# Import the full model package so SQLAlchemy registers every mapped class
|
# Import the full model package so SQLAlchemy registers every mapped class
|
||||||
@@ -9,6 +9,11 @@ import ffx.model
|
|||||||
from ffx.model.show import Base
|
from ffx.model.show import Base
|
||||||
|
|
||||||
from ffx.model.property import Property
|
from ffx.model.property import Property
|
||||||
|
from ffx.model.migration import (
|
||||||
|
DatabaseVersionException,
|
||||||
|
getMigrationPlan,
|
||||||
|
migrateDatabase,
|
||||||
|
)
|
||||||
|
|
||||||
from ffx.constants import DATABASE_VERSION
|
from ffx.constants import DATABASE_VERSION
|
||||||
|
|
||||||
@@ -16,10 +21,6 @@ from ffx.constants import DATABASE_VERSION
|
|||||||
DATABASE_VERSION_KEY = 'database_version'
|
DATABASE_VERSION_KEY = 'database_version'
|
||||||
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
|
EXPECTED_TABLE_NAMES = set(Base.metadata.tables.keys())
|
||||||
|
|
||||||
class DatabaseVersionException(Exception):
|
|
||||||
def __init__(self, errorMessage):
|
|
||||||
super().__init__(errorMessage)
|
|
||||||
|
|
||||||
def databaseContext(databasePath: str = ''):
|
def databaseContext(databasePath: str = ''):
|
||||||
|
|
||||||
databaseContext = {}
|
databaseContext = {}
|
||||||
@@ -33,7 +34,13 @@ def databaseContext(databasePath: str = ''):
|
|||||||
if not os.path.exists(ffxVarDir):
|
if not os.path.exists(ffxVarDir):
|
||||||
os.makedirs(ffxVarDir)
|
os.makedirs(ffxVarDir)
|
||||||
databasePath = os.path.join(ffxVarDir, 'ffx.db')
|
databasePath = os.path.join(ffxVarDir, 'ffx.db')
|
||||||
|
else:
|
||||||
|
databasePath = os.path.expanduser(databasePath)
|
||||||
|
|
||||||
|
if databasePath != ':memory:':
|
||||||
|
databasePath = os.path.abspath(databasePath)
|
||||||
|
|
||||||
|
databaseContext['path'] = databasePath
|
||||||
databaseContext['url'] = f"sqlite:///{databasePath}"
|
databaseContext['url'] = f"sqlite:///{databasePath}"
|
||||||
databaseContext['engine'] = create_engine(databaseContext['url'])
|
databaseContext['engine'] = create_engine(databaseContext['url'])
|
||||||
databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
|
databaseContext['session'] = sessionmaker(bind=databaseContext['engine'])
|
||||||
@@ -68,14 +75,113 @@ def bootstrapDatabaseIfNeeded(databaseContext):
|
|||||||
|
|
||||||
Base.metadata.create_all(databaseContext['engine'])
|
Base.metadata.create_all(databaseContext['engine'])
|
||||||
|
|
||||||
|
|
||||||
def ensureDatabaseVersion(databaseContext):
|
def ensureDatabaseVersion(databaseContext):
|
||||||
|
|
||||||
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||||
if currentDatabaseVersion:
|
if not currentDatabaseVersion:
|
||||||
if currentDatabaseVersion != DATABASE_VERSION:
|
|
||||||
raise DatabaseVersionException(f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})")
|
|
||||||
else:
|
|
||||||
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
setDatabaseVersion(databaseContext, DATABASE_VERSION)
|
||||||
|
return
|
||||||
|
|
||||||
|
if currentDatabaseVersion > DATABASE_VERSION:
|
||||||
|
raise DatabaseVersionException(
|
||||||
|
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if currentDatabaseVersion < DATABASE_VERSION:
|
||||||
|
promptForDatabaseMigration(databaseContext, currentDatabaseVersion, DATABASE_VERSION)
|
||||||
|
migrateDatabase(databaseContext, currentDatabaseVersion, DATABASE_VERSION, setDatabaseVersion)
|
||||||
|
currentDatabaseVersion = getDatabaseVersion(databaseContext)
|
||||||
|
|
||||||
|
if currentDatabaseVersion != DATABASE_VERSION:
|
||||||
|
raise DatabaseVersionException(
|
||||||
|
f"Current database version ({currentDatabaseVersion}) does not match required ({DATABASE_VERSION})"
|
||||||
|
)
|
||||||
|
|
||||||
|
ensureCurrentSchemaCompatibility(databaseContext)
|
||||||
|
|
||||||
|
|
||||||
|
def ensureCurrentSchemaCompatibility(databaseContext):
|
||||||
|
engine = databaseContext['engine']
|
||||||
|
inspector = inspect(engine)
|
||||||
|
showColumns = {
|
||||||
|
column['name']
|
||||||
|
for column in inspector.get_columns('shows')
|
||||||
|
}
|
||||||
|
|
||||||
|
alterStatements = []
|
||||||
|
if 'quality' not in showColumns:
|
||||||
|
alterStatements.append("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||||
|
if 'notes' not in showColumns:
|
||||||
|
alterStatements.append("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||||
|
|
||||||
|
if not alterStatements:
|
||||||
|
return
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
for alterStatement in alterStatements:
|
||||||
|
connection.execute(text(alterStatement))
|
||||||
|
|
||||||
|
|
||||||
|
def promptForDatabaseMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int):
|
||||||
|
migrationPlan = getMigrationPlan(currentDatabaseVersion, targetDatabaseVersion)
|
||||||
|
|
||||||
|
click.echo("Database migration required.")
|
||||||
|
click.echo(f"Current version: {currentDatabaseVersion}")
|
||||||
|
click.echo(f"Target version: {targetDatabaseVersion}")
|
||||||
|
click.echo("Steps required:")
|
||||||
|
|
||||||
|
missingSteps = []
|
||||||
|
for migrationStep in migrationPlan:
|
||||||
|
moduleStatus = "present" if migrationStep.modulePresent else "missing"
|
||||||
|
click.echo(
|
||||||
|
f" {migrationStep.versionFrom} -> {migrationStep.versionTo}: "
|
||||||
|
+ f"{migrationStep.moduleName} [{moduleStatus}]"
|
||||||
|
)
|
||||||
|
if not migrationStep.modulePresent:
|
||||||
|
missingSteps.append(migrationStep)
|
||||||
|
|
||||||
|
if missingSteps:
|
||||||
|
firstMissingStep = missingSteps[0]
|
||||||
|
raise DatabaseVersionException(
|
||||||
|
f"No migration path from database version "
|
||||||
|
+ f"{firstMissingStep.versionFrom} to {firstMissingStep.versionTo}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not click.confirm(
|
||||||
|
"Create a backup and continue with database migration?",
|
||||||
|
default=True,
|
||||||
|
):
|
||||||
|
raise click.ClickException("Database migration aborted by user.")
|
||||||
|
|
||||||
|
backupPath = backupDatabaseBeforeMigration(
|
||||||
|
databaseContext,
|
||||||
|
currentDatabaseVersion,
|
||||||
|
targetDatabaseVersion,
|
||||||
|
)
|
||||||
|
click.echo(f"Database backup created: {backupPath}")
|
||||||
|
|
||||||
|
|
||||||
|
def backupDatabaseBeforeMigration(databaseContext, currentDatabaseVersion: int, targetDatabaseVersion: int) -> str:
|
||||||
|
databasePath = databaseContext.get('path', '')
|
||||||
|
if not databasePath or databasePath == ':memory:':
|
||||||
|
raise click.ClickException("Database migration backup requires a file-backed SQLite database.")
|
||||||
|
|
||||||
|
if not os.path.isfile(databasePath):
|
||||||
|
raise click.ClickException(f"Database file not found for backup: {databasePath}")
|
||||||
|
|
||||||
|
backupPath = f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.bak"
|
||||||
|
backupIndex = 1
|
||||||
|
while os.path.exists(backupPath):
|
||||||
|
backupPath = (
|
||||||
|
f"{databasePath}.v{currentDatabaseVersion}-to-v{targetDatabaseVersion}.{backupIndex}.bak"
|
||||||
|
)
|
||||||
|
backupIndex += 1
|
||||||
|
|
||||||
|
databaseContext['engine'].dispose()
|
||||||
|
shutil.copy2(databasePath, backupPath)
|
||||||
|
|
||||||
|
return backupPath
|
||||||
|
|
||||||
|
|
||||||
def getDatabaseVersion(databaseContext):
|
def getDatabaseVersion(databaseContext):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -500,7 +500,14 @@ class MediaDescriptor:
|
|||||||
return subtitleFileDescriptors
|
return subtitleFileDescriptors
|
||||||
|
|
||||||
|
|
||||||
def importSubtitles(self, searchDirectory, prefix, season: int = -1, episode: int = -1):
|
def importSubtitles(
|
||||||
|
self,
|
||||||
|
searchDirectory,
|
||||||
|
prefix,
|
||||||
|
season: int = -1,
|
||||||
|
episode: int = -1,
|
||||||
|
preserve_dispositions: bool = False,
|
||||||
|
):
|
||||||
|
|
||||||
# click.echo(f"Season: {season} Episode: {episode}")
|
# click.echo(f"Season: {season} Episode: {episode}")
|
||||||
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
self.__logger.debug(f"importSubtitles(): Season: {season} Episode: {episode}")
|
||||||
@@ -543,7 +550,7 @@ class MediaDescriptor:
|
|||||||
# Prefer metadata coming from the external single-track source when
|
# Prefer metadata coming from the external single-track source when
|
||||||
# it is provided explicitly by the filename contract.
|
# it is provided explicitly by the filename contract.
|
||||||
matchingTrack.getTags()["language"] = msfd["language"]
|
matchingTrack.getTags()["language"] = msfd["language"]
|
||||||
if msfd["disposition_set"]:
|
if msfd["disposition_set"] and not preserve_dispositions:
|
||||||
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
matchingTrack.setDispositionSet(msfd["disposition_set"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import os, sys, importlib, inspect, glob, re
|
|
||||||
|
|
||||||
from ffx.configuration_controller import ConfigurationController
|
|
||||||
from ffx.database import databaseContext
|
|
||||||
|
|
||||||
from sqlalchemy import Engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
|
|
||||||
class Conversion():
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
|
|
||||||
self._context = {}
|
|
||||||
self._context['config'] = ConfigurationController()
|
|
||||||
|
|
||||||
self._context['database'] = databaseContext(databasePath=self._context['config'].getDatabaseFilePath())
|
|
||||||
|
|
||||||
self.__databaseSession: sessionmaker = self._context['database']['session']
|
|
||||||
self.__databaseEngine: Engine = self._context['database']['engine']
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def list():
|
|
||||||
|
|
||||||
basePath = os.path.dirname(__file__)
|
|
||||||
|
|
||||||
filenamePattern = re.compile("conversion_([0-9]+)_([0-9]+)\\.py")
|
|
||||||
|
|
||||||
filenameList = [os.path.basename(fp) for fp in glob.glob(f"{ basePath }/*.py") if fp != __file__]
|
|
||||||
|
|
||||||
versionTupleList = [(fm.group(1), fm.group(2)) for fn in filenameList if (fm := filenamePattern.search(fn))]
|
|
||||||
|
|
||||||
return versionTupleList
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def getClassReference(versionFrom, versionTo):
|
|
||||||
importlib.import_module(f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }")
|
|
||||||
for name, obj in inspect.getmembers(sys.modules[f"ffx.model.conversions.conversion_{ versionFrom }_{ versionTo }"]):
|
|
||||||
#HINT: Excluding DispositionCombination as it seems to be included by import (?)
|
|
||||||
if inspect.isclass(obj) and name != 'Conversion' and name.startswith('Conversion'):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def getAllClassReferences():
|
|
||||||
return [Conversion.getClassReference(verFrom, verTo) for verFrom, verTo in Conversion.list()]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import os, sys, importlib, inspect, glob, re
|
|
||||||
|
|
||||||
from .conversion import Conversion
|
|
||||||
|
|
||||||
|
|
||||||
class Conversion_2_3(Conversion):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def applyConversion(self):
|
|
||||||
|
|
||||||
s = self.__databaseSession()
|
|
||||||
e = self.__databaseEngine
|
|
||||||
|
|
||||||
with e.connect() as c:
|
|
||||||
c.execute("ALTER TABLE user ADD COLUMN email VARCHAR(255)")
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import os, sys, importlib, inspect, glob, re
|
|
||||||
|
|
||||||
from .conversion import Conversion
|
|
||||||
|
|
||||||
|
|
||||||
class Conversion_3_4(Conversion):
|
|
||||||
pass
|
|
||||||
82
src/ffx/model/migration/__init__.py
Normal file
82
src/ffx/model/migration/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import importlib
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseVersionException(Exception):
|
||||||
|
def __init__(self, errorMessage):
|
||||||
|
super().__init__(errorMessage)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MigrationStep:
|
||||||
|
versionFrom: int
|
||||||
|
versionTo: int
|
||||||
|
moduleName: str
|
||||||
|
modulePresent: bool
|
||||||
|
|
||||||
|
|
||||||
|
def getMigrationStepModuleName(versionFrom: int, versionTo: int) -> str:
|
||||||
|
return f"ffx.model.migration.step_{int(versionFrom)}_{int(versionTo)}"
|
||||||
|
|
||||||
|
|
||||||
|
def migrationStepModuleExists(versionFrom: int, versionTo: int) -> bool:
|
||||||
|
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return importlib.util.find_spec(moduleName) is not None
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def getMigrationPlan(currentVersion: int, targetVersion: int) -> list[MigrationStep]:
|
||||||
|
version = int(currentVersion)
|
||||||
|
target = int(targetVersion)
|
||||||
|
migrationPlan = []
|
||||||
|
|
||||||
|
while version < target:
|
||||||
|
nextVersion = version + 1
|
||||||
|
migrationPlan.append(
|
||||||
|
MigrationStep(
|
||||||
|
versionFrom=version,
|
||||||
|
versionTo=nextVersion,
|
||||||
|
moduleName=getMigrationStepModuleName(version, nextVersion),
|
||||||
|
modulePresent=migrationStepModuleExists(version, nextVersion),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
version = nextVersion
|
||||||
|
|
||||||
|
return migrationPlan
|
||||||
|
|
||||||
|
|
||||||
|
def loadMigrationStep(versionFrom: int, versionTo: int):
|
||||||
|
moduleName = getMigrationStepModuleName(versionFrom, versionTo)
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(moduleName)
|
||||||
|
except ModuleNotFoundError as ex:
|
||||||
|
if ex.name == moduleName:
|
||||||
|
raise DatabaseVersionException(
|
||||||
|
f"No migration path from database version {versionFrom} to {versionTo}"
|
||||||
|
) from ex
|
||||||
|
raise
|
||||||
|
|
||||||
|
migrationStep = getattr(module, "applyMigration", None)
|
||||||
|
if migrationStep is None:
|
||||||
|
raise DatabaseVersionException(
|
||||||
|
f"Migration module {moduleName} does not define applyMigration()"
|
||||||
|
)
|
||||||
|
|
||||||
|
return migrationStep
|
||||||
|
|
||||||
|
|
||||||
|
def migrateDatabase(databaseContext, currentVersion: int, targetVersion: int, setDatabaseVersion):
|
||||||
|
for migrationStepInfo in getMigrationPlan(currentVersion, targetVersion):
|
||||||
|
migrationStep = loadMigrationStep(
|
||||||
|
migrationStepInfo.versionFrom,
|
||||||
|
migrationStepInfo.versionTo,
|
||||||
|
)
|
||||||
|
migrationStep(databaseContext)
|
||||||
|
setDatabaseVersion(databaseContext, migrationStepInfo.versionTo)
|
||||||
84
src/ffx/model/migration/step_2_3.py
Normal file
84
src/ffx/model/migration/step_2_3.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
|
||||||
|
def applyMigration(databaseContext):
|
||||||
|
engine = databaseContext['engine']
|
||||||
|
inspector = inspect(engine)
|
||||||
|
shiftedSeasonColumns = {
|
||||||
|
column['name']
|
||||||
|
for column in inspector.get_columns('shifted_seasons')
|
||||||
|
}
|
||||||
|
showColumns = {
|
||||||
|
column['name']
|
||||||
|
for column in inspector.get_columns('shows')
|
||||||
|
}
|
||||||
|
|
||||||
|
with engine.begin() as connection:
|
||||||
|
if 'pattern_id' not in shiftedSeasonColumns:
|
||||||
|
connection.execute(text("PRAGMA foreign_keys=OFF"))
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TABLE shifted_seasons_v3 (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
show_id INTEGER,
|
||||||
|
pattern_id INTEGER,
|
||||||
|
original_season INTEGER,
|
||||||
|
first_episode INTEGER DEFAULT -1,
|
||||||
|
last_episode INTEGER DEFAULT -1,
|
||||||
|
season_offset INTEGER DEFAULT 0,
|
||||||
|
episode_offset INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(pattern_id) REFERENCES patterns(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
(show_id IS NOT NULL AND pattern_id IS NULL)
|
||||||
|
OR (show_id IS NULL AND pattern_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO shifted_seasons_v3 (
|
||||||
|
id,
|
||||||
|
show_id,
|
||||||
|
pattern_id,
|
||||||
|
original_season,
|
||||||
|
first_episode,
|
||||||
|
last_episode,
|
||||||
|
season_offset,
|
||||||
|
episode_offset
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
show_id,
|
||||||
|
NULL,
|
||||||
|
original_season,
|
||||||
|
first_episode,
|
||||||
|
last_episode,
|
||||||
|
season_offset,
|
||||||
|
episode_offset
|
||||||
|
FROM shifted_seasons
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection.execute(text("DROP TABLE shifted_seasons"))
|
||||||
|
connection.execute(text("ALTER TABLE shifted_seasons_v3 RENAME TO shifted_seasons"))
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX ix_shifted_seasons_show_id ON shifted_seasons(show_id)")
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
text("CREATE INDEX ix_shifted_seasons_pattern_id ON shifted_seasons(pattern_id)")
|
||||||
|
)
|
||||||
|
connection.execute(text("PRAGMA foreign_keys=ON"))
|
||||||
|
|
||||||
|
if 'quality' not in showColumns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE shows ADD COLUMN quality INTEGER DEFAULT 0")
|
||||||
|
)
|
||||||
|
if 'notes' not in showColumns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE shows ADD COLUMN notes TEXT DEFAULT ''")
|
||||||
|
)
|
||||||
@@ -35,6 +35,7 @@ class Pattern(Base):
|
|||||||
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
tracks = relationship('Track', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||||
|
|
||||||
media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined')
|
media_tags = relationship('MediaTag', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||||
|
shifted_seasons = relationship('ShiftedSeason', back_populates='pattern', cascade="all, delete", lazy='joined')
|
||||||
|
|
||||||
quality = Column(Integer, default=0)
|
quality = Column(Integer, default=0)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, ForeignKey
|
from sqlalchemy import CheckConstraint, Column, ForeignKey, Index, Integer
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from .show import Base, Show
|
from .show import Base, Show
|
||||||
@@ -9,6 +9,14 @@ from .show import Base, Show
|
|||||||
class ShiftedSeason(Base):
|
class ShiftedSeason(Base):
|
||||||
|
|
||||||
__tablename__ = 'shifted_seasons'
|
__tablename__ = 'shifted_seasons'
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(show_id IS NOT NULL AND pattern_id IS NULL) OR (show_id IS NULL AND pattern_id IS NOT NULL)",
|
||||||
|
name="ck_shifted_seasons_single_owner",
|
||||||
|
),
|
||||||
|
Index("ix_shifted_seasons_show_id", "show_id"),
|
||||||
|
Index("ix_shifted_seasons_pattern_id", "pattern_id"),
|
||||||
|
)
|
||||||
|
|
||||||
# v1.x
|
# v1.x
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
@@ -19,9 +27,12 @@ class ShiftedSeason(Base):
|
|||||||
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
# pattern: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
# v1.x
|
# v1.x
|
||||||
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"))
|
show_id = Column(Integer, ForeignKey('shows.id', ondelete="CASCADE"), nullable=True)
|
||||||
show = relationship(Show, back_populates='shifted_seasons', lazy='joined')
|
show = relationship(Show, back_populates='shifted_seasons', lazy='joined')
|
||||||
|
|
||||||
|
pattern_id = Column(Integer, ForeignKey('patterns.id', ondelete="CASCADE"), nullable=True)
|
||||||
|
pattern = relationship('Pattern', back_populates='shifted_seasons', lazy='joined')
|
||||||
|
|
||||||
# v2.0
|
# v2.0
|
||||||
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
# show_id: Mapped[int] = mapped_column(ForeignKey("shows.id", ondelete="CASCADE"))
|
||||||
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
# show: Mapped["Show"] = relationship(back_populates="patterns")
|
||||||
@@ -39,6 +50,12 @@ class ShiftedSeason(Base):
|
|||||||
def getId(self):
|
def getId(self):
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
|
def getShowId(self):
|
||||||
|
return self.show_id
|
||||||
|
|
||||||
|
def getPatternId(self):
|
||||||
|
return self.pattern_id
|
||||||
|
|
||||||
|
|
||||||
def getOriginalSeason(self):
|
def getOriginalSeason(self):
|
||||||
return self.original_season
|
return self.original_season
|
||||||
@@ -61,6 +78,8 @@ class ShiftedSeason(Base):
|
|||||||
|
|
||||||
shiftedSeasonObj = {}
|
shiftedSeasonObj = {}
|
||||||
|
|
||||||
|
shiftedSeasonObj['show_id'] = self.getShowId()
|
||||||
|
shiftedSeasonObj['pattern_id'] = self.getPatternId()
|
||||||
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
shiftedSeasonObj['original_season'] = self.getOriginalSeason()
|
||||||
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
shiftedSeasonObj['first_episode'] = self.getFirstEpisode()
|
||||||
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
shiftedSeasonObj['last_episode'] = self.getLastEpisode()
|
||||||
@@ -68,4 +87,3 @@ class ShiftedSeason(Base):
|
|||||||
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
shiftedSeasonObj['episode_offset'] = self.getEpisodeOffset()
|
||||||
|
|
||||||
return shiftedSeasonObj
|
return shiftedSeasonObj
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# from typing import List
|
# from typing import List
|
||||||
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
|
from sqlalchemy import create_engine, Column, Integer, String, Text, ForeignKey
|
||||||
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
from sqlalchemy.orm import relationship, declarative_base, sessionmaker
|
||||||
|
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
@@ -45,6 +45,8 @@ 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)
|
||||||
|
notes = Column(Text, default='')
|
||||||
|
|
||||||
|
|
||||||
def getDescriptor(self, context):
|
def getDescriptor(self, context):
|
||||||
@@ -58,5 +60,7 @@ 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)
|
||||||
|
kwargs[ShowDescriptor.NOTES_KEY] = str(self.notes or '')
|
||||||
|
|
||||||
return ShowDescriptor(**kwargs)
|
return ShowDescriptor(**kwargs)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from ffx.model.pattern import Pattern
|
|||||||
|
|
||||||
from .track_details_screen import TrackDetailsScreen
|
from .track_details_screen import TrackDetailsScreen
|
||||||
from .track_delete_screen import TrackDeleteScreen
|
from .track_delete_screen import TrackDeleteScreen
|
||||||
|
from .shifted_season_delete_screen import ShiftedSeasonDeleteScreen
|
||||||
|
from .shifted_season_details_screen import ShiftedSeasonDetailsScreen
|
||||||
|
|
||||||
from .tag_details_screen import TagDetailsScreen
|
from .tag_details_screen import TagDetailsScreen
|
||||||
from .tag_delete_screen import TagDeleteScreen
|
from .tag_delete_screen import TagDeleteScreen
|
||||||
@@ -24,6 +26,7 @@ from textual.widgets._data_table import CellDoesNotExist
|
|||||||
from ffx.file_properties import FileProperties
|
from ffx.file_properties import FileProperties
|
||||||
from ffx.iso_language import IsoLanguage
|
from ffx.iso_language import IsoLanguage
|
||||||
from ffx.audio_layout import AudioLayout
|
from ffx.audio_layout import AudioLayout
|
||||||
|
from ffx.model.shifted_season import ShiftedSeason
|
||||||
|
|
||||||
from ffx.helper import formatRichColor, removeRichColor
|
from ffx.helper import formatRichColor, removeRichColor
|
||||||
|
|
||||||
@@ -34,8 +37,8 @@ class PatternDetailsScreen(Screen):
|
|||||||
CSS = """
|
CSS = """
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
grid-size: 7 17;
|
grid-size: 7 20;
|
||||||
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 2 2;
|
grid-rows: 2 2 2 2 2 2 6 2 2 8 2 2 8 2 2 8 2 2 2 2;
|
||||||
grid-columns: 25 25 25 25 25 25 25;
|
grid-columns: 25 25 25 25 25 25 25;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -115,11 +118,13 @@ class PatternDetailsScreen(Screen):
|
|||||||
show=True,
|
show=True,
|
||||||
track=True,
|
track=True,
|
||||||
tag=True,
|
tag=True,
|
||||||
|
shifted_season=True,
|
||||||
)
|
)
|
||||||
self.__pc = controllers['pattern']
|
self.__pc = controllers['pattern']
|
||||||
self.__sc = controllers['show']
|
self.__sc = controllers['show']
|
||||||
self.__tc = controllers['track']
|
self.__tc = controllers['track']
|
||||||
self.__tac = controllers['tag']
|
self.__tac = controllers['tag']
|
||||||
|
self.__ssc = controllers['shifted_season']
|
||||||
|
|
||||||
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
|
self.__pattern : Pattern = self.__pc.getPattern(patternId) if patternId is not None else None
|
||||||
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
self.__showDescriptor = self.__sc.getShowDescriptor(showId) if showId is not None else None
|
||||||
@@ -258,6 +263,72 @@ class PatternDetailsScreen(Screen):
|
|||||||
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
row = (formatRichColor(tagKey, textColor), formatRichColor(tagValue, textColor))
|
||||||
self.tagsTable.add_row(*map(str, row))
|
self.tagsTable.add_row(*map(str, row))
|
||||||
|
|
||||||
|
def updateShiftedSeasons(self):
|
||||||
|
|
||||||
|
self.shiftedSeasonsTable.clear()
|
||||||
|
|
||||||
|
if self.__pattern is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
shiftedSeason: ShiftedSeason
|
||||||
|
for shiftedSeason in self.__ssc.getShiftedSeasonSiblings(patternId=self.__pattern.getId()):
|
||||||
|
shiftedSeasonObj = shiftedSeason.getObj()
|
||||||
|
|
||||||
|
firstEpisode = shiftedSeasonObj['first_episode']
|
||||||
|
firstEpisodeStr = str(firstEpisode) if firstEpisode != -1 else ''
|
||||||
|
|
||||||
|
lastEpisode = shiftedSeasonObj['last_episode']
|
||||||
|
lastEpisodeStr = str(lastEpisode) if lastEpisode != -1 else ''
|
||||||
|
|
||||||
|
row = (
|
||||||
|
shiftedSeasonObj['original_season'],
|
||||||
|
firstEpisodeStr,
|
||||||
|
lastEpisodeStr,
|
||||||
|
shiftedSeasonObj['season_offset'],
|
||||||
|
shiftedSeasonObj['episode_offset'],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.shiftedSeasonsTable.add_row(*map(str, row))
|
||||||
|
|
||||||
|
def getSelectedShiftedSeasonObjFromInput(self):
|
||||||
|
|
||||||
|
shiftedSeasonObj = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
row_key, col_key = self.shiftedSeasonsTable.coordinate_to_cell_key(
|
||||||
|
self.shiftedSeasonsTable.cursor_coordinate
|
||||||
|
)
|
||||||
|
|
||||||
|
if row_key is not None:
|
||||||
|
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||||
|
|
||||||
|
def parse_int_or_default(value: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
||||||
|
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
|
||||||
|
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
|
||||||
|
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
|
||||||
|
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
|
||||||
|
|
||||||
|
if self.__pattern is not None:
|
||||||
|
shiftedSeasonId = self.__ssc.findShiftedSeason(
|
||||||
|
patternId=self.__pattern.getId(),
|
||||||
|
originalSeason=shiftedSeasonObj['original_season'],
|
||||||
|
firstEpisode=shiftedSeasonObj['first_episode'],
|
||||||
|
lastEpisode=shiftedSeasonObj['last_episode'],
|
||||||
|
)
|
||||||
|
if shiftedSeasonId is not None:
|
||||||
|
shiftedSeasonObj['id'] = shiftedSeasonId
|
||||||
|
|
||||||
|
except CellDoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return shiftedSeasonObj
|
||||||
|
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
@@ -276,6 +347,7 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.updateTags()
|
self.updateTags()
|
||||||
self.updateTracks()
|
self.updateTracks()
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
def compose(self):
|
def compose(self):
|
||||||
|
|
||||||
@@ -304,6 +376,16 @@ class PatternDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.tracksTable.cursor_type = 'row'
|
self.tracksTable.cursor_type = 'row'
|
||||||
|
|
||||||
|
self.shiftedSeasonsTable = DataTable(classes="seven")
|
||||||
|
|
||||||
|
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Source Season", width=18)
|
||||||
|
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=18)
|
||||||
|
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=18)
|
||||||
|
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=18)
|
||||||
|
self.column_key_episode_offset = self.shiftedSeasonsTable.add_column("Episode Offset", width=18)
|
||||||
|
|
||||||
|
self.shiftedSeasonsTable.cursor_type = 'row'
|
||||||
|
|
||||||
|
|
||||||
yield Header()
|
yield Header()
|
||||||
|
|
||||||
@@ -345,6 +427,27 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 9
|
# 9
|
||||||
|
yield Static("Shifted Seasons")
|
||||||
|
if self.__pattern is not None:
|
||||||
|
yield Button("Add", id="button_add_shifted_season")
|
||||||
|
yield Button("Edit", id="button_edit_shifted_season")
|
||||||
|
yield Button("Delete", id="button_delete_shifted_season")
|
||||||
|
else:
|
||||||
|
yield Static(" ")
|
||||||
|
yield Static(" ")
|
||||||
|
yield Static(" ")
|
||||||
|
|
||||||
|
yield Static(" ")
|
||||||
|
yield Static(" ")
|
||||||
|
yield Static(" ")
|
||||||
|
|
||||||
|
# 10
|
||||||
|
yield self.shiftedSeasonsTable
|
||||||
|
|
||||||
|
# 11
|
||||||
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
|
# 12
|
||||||
yield Static("Media Tags")
|
yield Static("Media Tags")
|
||||||
yield Button("Add", id="button_add_tag")
|
yield Button("Add", id="button_add_tag")
|
||||||
yield Button("Edit", id="button_edit_tag")
|
yield Button("Edit", id="button_edit_tag")
|
||||||
@@ -354,13 +457,13 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# 10
|
# 13
|
||||||
yield self.tagsTable
|
yield self.tagsTable
|
||||||
|
|
||||||
# 11
|
# 14
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 12
|
# 15
|
||||||
yield Static("Streams")
|
yield Static("Streams")
|
||||||
yield Button("Add", id="button_add_track")
|
yield Button("Add", id="button_add_track")
|
||||||
yield Button("Edit", id="button_edit_track")
|
yield Button("Edit", id="button_edit_track")
|
||||||
@@ -370,21 +473,21 @@ class PatternDetailsScreen(Screen):
|
|||||||
yield Button("Up", id="button_track_up")
|
yield Button("Up", id="button_track_up")
|
||||||
yield Button("Down", id="button_track_down")
|
yield Button("Down", id="button_track_down")
|
||||||
|
|
||||||
# 13
|
# 16
|
||||||
yield self.tracksTable
|
yield self.tracksTable
|
||||||
|
|
||||||
# 14
|
# 17
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 15
|
# 18
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
# 16
|
# 19
|
||||||
yield Button("Save", id="save_button")
|
yield Button("Save", id="save_button")
|
||||||
yield Button("Cancel", id="cancel_button")
|
yield Button("Cancel", id="cancel_button")
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# 17
|
# 20
|
||||||
yield Static(" ", classes="seven")
|
yield Static(" ", classes="seven")
|
||||||
|
|
||||||
yield Footer()
|
yield Footer()
|
||||||
@@ -486,6 +589,35 @@ class PatternDetailsScreen(Screen):
|
|||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
if event.button.id == "button_add_shifted_season":
|
||||||
|
if self.__pattern is not None:
|
||||||
|
self.app.push_screen(
|
||||||
|
ShiftedSeasonDetailsScreen(patternId=self.__pattern.getId()),
|
||||||
|
self.handle_update_shifted_season,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.button.id == "button_edit_shifted_season":
|
||||||
|
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||||
|
if 'id' in selectedShiftedSeasonObj.keys():
|
||||||
|
self.app.push_screen(
|
||||||
|
ShiftedSeasonDetailsScreen(
|
||||||
|
patternId=self.__pattern.getId(),
|
||||||
|
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||||
|
),
|
||||||
|
self.handle_update_shifted_season,
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.button.id == "button_delete_shifted_season":
|
||||||
|
selectedShiftedSeasonObj = self.getSelectedShiftedSeasonObjFromInput()
|
||||||
|
if 'id' in selectedShiftedSeasonObj.keys():
|
||||||
|
self.app.push_screen(
|
||||||
|
ShiftedSeasonDeleteScreen(
|
||||||
|
patternId=self.__pattern.getId(),
|
||||||
|
shiftedSeasonId=selectedShiftedSeasonObj['id'],
|
||||||
|
),
|
||||||
|
self.handle_delete_shifted_season,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
numTracks = len(self.getCurrentTrackDescriptors())
|
numTracks = len(self.getCurrentTrackDescriptors())
|
||||||
|
|
||||||
@@ -654,3 +786,9 @@ class PatternDetailsScreen(Screen):
|
|||||||
self.updateTags()
|
self.updateTags()
|
||||||
else:
|
else:
|
||||||
raise click.ClickException('tag delete failed')
|
raise click.ClickException('tag delete failed')
|
||||||
|
|
||||||
|
def handle_update_shifted_season(self, screenResult):
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|
||||||
|
def handle_delete_shifted_season(self, screenResult):
|
||||||
|
self.updateShiftedSeasons()
|
||||||
|
|||||||
@@ -6,225 +6,433 @@ from ffx.model.shifted_season import ShiftedSeason
|
|||||||
class EpisodeOrderException(Exception):
|
class EpisodeOrderException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RangeOverlapException(Exception):
|
class RangeOverlapException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ShiftedSeasonController():
|
class ShiftedSeasonOwnerException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftedSeasonController:
|
||||||
|
|
||||||
def __init__(self, context):
|
def __init__(self, context):
|
||||||
|
|
||||||
self.context = context
|
self.context = context
|
||||||
self.Session = self.context['database']['session'] # convenience
|
self.Session = self.context['database']['session'] # convenience
|
||||||
|
|
||||||
def checkShiftedSeason(self, showId: int, shiftedSeasonObj: dict, shiftedSeasonId: int = 0):
|
def _resolve_owner(self, showId=None, patternId=None):
|
||||||
"""
|
hasShow = showId is not None
|
||||||
Check if for a particula season
|
hasPattern = patternId is not None
|
||||||
|
|
||||||
shiftedSeasonId
|
if hasShow == hasPattern:
|
||||||
|
raise ShiftedSeasonOwnerException(
|
||||||
|
"ShiftedSeason rules require exactly one owner: either showId or patternId."
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasShow:
|
||||||
|
if type(showId) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController: Argument showId is required to be of type int"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'show_id': int(showId),
|
||||||
|
'pattern_id': None,
|
||||||
|
'label': f"show #{int(showId)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
if type(patternId) is not int:
|
||||||
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController: Argument patternId is required to be of type int"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'show_id': None,
|
||||||
|
'pattern_id': int(patternId),
|
||||||
|
'label': f"pattern #{int(patternId)}",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_owner_filter(self, query, owner):
|
||||||
|
if owner['pattern_id'] is not None:
|
||||||
|
return query.filter(ShiftedSeason.pattern_id == owner['pattern_id'])
|
||||||
|
return query.filter(ShiftedSeason.show_id == owner['show_id'])
|
||||||
|
|
||||||
|
def _normalize_shifted_season_fields(self, shiftedSeasonObj: dict):
|
||||||
|
if type(shiftedSeasonObj) is not dict:
|
||||||
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController: Argument shiftedSeasonObj is required to be of type dict"
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = {
|
||||||
|
'original_season': int(shiftedSeasonObj['original_season']),
|
||||||
|
'first_episode': int(shiftedSeasonObj['first_episode']),
|
||||||
|
'last_episode': int(shiftedSeasonObj['last_episode']),
|
||||||
|
'season_offset': int(shiftedSeasonObj['season_offset']),
|
||||||
|
'episode_offset': int(shiftedSeasonObj['episode_offset']),
|
||||||
|
}
|
||||||
|
|
||||||
|
firstEpisode = fields['first_episode']
|
||||||
|
lastEpisode = fields['last_episode']
|
||||||
|
if firstEpisode != -1 and lastEpisode != -1 and lastEpisode < firstEpisode:
|
||||||
|
raise EpisodeOrderException(
|
||||||
|
"ShiftedSeason last_episode must be greater than or equal to first_episode."
|
||||||
|
)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
def _ranges_overlap(self, firstEpisodeA, lastEpisodeA, firstEpisodeB, lastEpisodeB):
|
||||||
|
startA = float('-inf') if int(firstEpisodeA) == -1 else int(firstEpisodeA)
|
||||||
|
endA = float('inf') if int(lastEpisodeA) == -1 else int(lastEpisodeA)
|
||||||
|
startB = float('-inf') if int(firstEpisodeB) == -1 else int(firstEpisodeB)
|
||||||
|
endB = float('inf') if int(lastEpisodeB) == -1 else int(lastEpisodeB)
|
||||||
|
return startA <= endB and startB <= endA
|
||||||
|
|
||||||
|
def _ordered_query(self, session, owner):
|
||||||
|
q = self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||||
|
return q.order_by(
|
||||||
|
ShiftedSeason.original_season.asc(),
|
||||||
|
ShiftedSeason.first_episode.asc(),
|
||||||
|
ShiftedSeason.last_episode.asc(),
|
||||||
|
ShiftedSeason.id.asc(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_matching_rule(self, session, owner, season: int, episode: int):
|
||||||
|
for shiftedSeasonEntry in self._ordered_query(session, owner).all():
|
||||||
|
if (
|
||||||
|
season == shiftedSeasonEntry.getOriginalSeason()
|
||||||
|
and (
|
||||||
|
shiftedSeasonEntry.getFirstEpisode() == -1
|
||||||
|
or episode >= shiftedSeasonEntry.getFirstEpisode()
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
shiftedSeasonEntry.getLastEpisode() == -1
|
||||||
|
or episode <= shiftedSeasonEntry.getLastEpisode()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return shiftedSeasonEntry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def checkShiftedSeason(
|
||||||
|
self,
|
||||||
|
showId: int | None = None,
|
||||||
|
shiftedSeasonObj: dict | None = None,
|
||||||
|
shiftedSeasonId: int = 0,
|
||||||
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Check whether a shifted-season rule is valid within one owner scope.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
|
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||||
|
session = self.Session()
|
||||||
|
|
||||||
originalSeason = shiftedSeasonObj['original_season']
|
q = self._ordered_query(session, owner)
|
||||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
|
||||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
|
||||||
|
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
|
||||||
if shiftedSeasonId:
|
if shiftedSeasonId:
|
||||||
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
q = q.filter(ShiftedSeason.id != int(shiftedSeasonId))
|
||||||
|
|
||||||
siblingShiftedSeason: ShiftedSeason
|
|
||||||
for siblingShiftedSeason in q.all():
|
for siblingShiftedSeason in q.all():
|
||||||
|
if fields['original_season'] != siblingShiftedSeason.getOriginalSeason():
|
||||||
|
continue
|
||||||
|
|
||||||
siblingOriginalSeason = siblingShiftedSeason.getOriginalSeason
|
if self._ranges_overlap(
|
||||||
siblingFirstEpisode = siblingShiftedSeason.getFirstEpisode()
|
fields['first_episode'],
|
||||||
siblingLastEpisode = siblingShiftedSeason.getLastEpisode()
|
fields['last_episode'],
|
||||||
|
siblingShiftedSeason.getFirstEpisode(),
|
||||||
if (originalSeason == siblingOriginalSeason
|
siblingShiftedSeason.getLastEpisode(),
|
||||||
and lastEpisode >= siblingFirstEpisode
|
):
|
||||||
and siblingLastEpisode >= firstEpisode):
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
except (EpisodeOrderException, ShiftedSeasonOwnerException) as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.checkShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def addShiftedSeason(
|
||||||
|
self,
|
||||||
|
showId: int | None = None,
|
||||||
|
shiftedSeasonObj: dict | None = None,
|
||||||
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
|
||||||
def addShiftedSeason(self, showId: int, shiftedSeasonObj: dict):
|
session = None
|
||||||
|
|
||||||
if type(showId) is not int:
|
|
||||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument showId is required to be of type int")
|
|
||||||
|
|
||||||
if type(shiftedSeasonObj) is not dict:
|
|
||||||
raise ValueError(f"ShiftedSeasonController.addShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
|
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||||
|
|
||||||
firstEpisode = int(shiftedSeasonObj['first_episode'])
|
if not self.checkShiftedSeason(
|
||||||
lastEpisode = int(shiftedSeasonObj['last_episode'])
|
showId=owner['show_id'],
|
||||||
|
patternId=owner['pattern_id'],
|
||||||
|
shiftedSeasonObj=fields,
|
||||||
|
):
|
||||||
|
raise RangeOverlapException(
|
||||||
|
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||||
|
)
|
||||||
|
|
||||||
if lastEpisode < firstEpisode:
|
session = self.Session()
|
||||||
raise EpisodeOrderException()
|
shiftedSeason = ShiftedSeason(
|
||||||
|
show_id=owner['show_id'],
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
pattern_id=owner['pattern_id'],
|
||||||
|
original_season=fields['original_season'],
|
||||||
shiftedSeason = ShiftedSeason(show_id = int(showId),
|
first_episode=fields['first_episode'],
|
||||||
original_season = int(shiftedSeasonObj['original_season']),
|
last_episode=fields['last_episode'],
|
||||||
first_episode = firstEpisode,
|
season_offset=fields['season_offset'],
|
||||||
last_episode = lastEpisode,
|
episode_offset=fields['episode_offset'],
|
||||||
season_offset = int(shiftedSeasonObj['season_offset']),
|
)
|
||||||
episode_offset = int(shiftedSeasonObj['episode_offset']))
|
session.add(shiftedSeason)
|
||||||
s.add(shiftedSeason)
|
session.commit()
|
||||||
s.commit()
|
|
||||||
return shiftedSeason.getId()
|
return shiftedSeason.getId()
|
||||||
|
|
||||||
|
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.addShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
def updateShiftedSeason(self, shiftedSeasonId: int, shiftedSeasonObj: dict):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not int:
|
if type(shiftedSeasonId) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||||
if type(shiftedSeasonObj) is not dict:
|
)
|
||||||
raise ValueError(f"ShiftedSeasonController.updateShiftedSeason(): Argument shiftedSeasonObj is required to be of type dict")
|
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
fields = self._normalize_shifted_season_fields(shiftedSeasonObj)
|
||||||
|
session = self.Session()
|
||||||
|
|
||||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
shiftedSeason = (
|
||||||
|
session.query(ShiftedSeason)
|
||||||
|
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if shiftedSeason is not None:
|
if shiftedSeason is None:
|
||||||
|
|
||||||
shiftedSeason.original_season = int(shiftedSeasonObj['original_season'])
|
|
||||||
shiftedSeason.first_episode = int(shiftedSeasonObj['first_episode'])
|
|
||||||
shiftedSeason.last_episode = int(shiftedSeasonObj['last_episode'])
|
|
||||||
shiftedSeason.season_offset = int(shiftedSeasonObj['season_offset'])
|
|
||||||
shiftedSeason.episode_offset = int(shiftedSeasonObj['episode_offset'])
|
|
||||||
|
|
||||||
s.commit()
|
|
||||||
return True
|
|
||||||
|
|
||||||
else:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
owner = self._resolve_owner(
|
||||||
|
showId=shiftedSeason.getShowId(),
|
||||||
|
patternId=shiftedSeason.getPatternId(),
|
||||||
|
)
|
||||||
|
if not self.checkShiftedSeason(
|
||||||
|
showId=owner['show_id'],
|
||||||
|
patternId=owner['pattern_id'],
|
||||||
|
shiftedSeasonObj=fields,
|
||||||
|
shiftedSeasonId=shiftedSeasonId,
|
||||||
|
):
|
||||||
|
raise RangeOverlapException(
|
||||||
|
f"ShiftedSeason rule overlaps with an existing rule for {owner['label']}."
|
||||||
|
)
|
||||||
|
|
||||||
|
shiftedSeason.original_season = fields['original_season']
|
||||||
|
shiftedSeason.first_episode = fields['first_episode']
|
||||||
|
shiftedSeason.last_episode = fields['last_episode']
|
||||||
|
shiftedSeason.season_offset = fields['season_offset']
|
||||||
|
shiftedSeason.episode_offset = fields['episode_offset']
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (EpisodeOrderException, RangeOverlapException, ShiftedSeasonOwnerException) as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.updateShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def findShiftedSeason(
|
||||||
def findShiftedSeason(self, showId: int, originalSeason: int, firstEpisode: int, lastEpisode: int):
|
self,
|
||||||
|
showId: int | None = None,
|
||||||
if type(showId) is not int:
|
originalSeason: int | None = None,
|
||||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
firstEpisode: int | None = None,
|
||||||
|
lastEpisode: int | None = None,
|
||||||
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
|
||||||
if type(originalSeason) is not int:
|
if type(originalSeason) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.findShiftedSeason(): Argument originalSeason is required to be of type int"
|
||||||
|
)
|
||||||
|
|
||||||
if type(firstEpisode) is not int:
|
if type(firstEpisode) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.findShiftedSeason(): Argument firstEpisode is required to be of type int"
|
||||||
|
)
|
||||||
|
|
||||||
if type(lastEpisode) is not int:
|
if type(lastEpisode) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.findShiftedSeason(): Argument lastEpisode is required to be of type int"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
shiftedSeason = s.query(ShiftedSeason).filter(
|
session = self.Session()
|
||||||
ShiftedSeason.show_id == int(showId),
|
shiftedSeason = (
|
||||||
|
self._apply_owner_filter(session.query(ShiftedSeason), owner)
|
||||||
|
.filter(
|
||||||
ShiftedSeason.original_season == int(originalSeason),
|
ShiftedSeason.original_season == int(originalSeason),
|
||||||
ShiftedSeason.first_episode == int(firstEpisode),
|
ShiftedSeason.first_episode == int(firstEpisode),
|
||||||
ShiftedSeason.last_episode == int(lastEpisode),
|
ShiftedSeason.last_episode == int(lastEpisode),
|
||||||
).first()
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
return shiftedSeason.getId() if shiftedSeason is not None else None
|
return shiftedSeason.getId() if shiftedSeason is not None else None
|
||||||
|
|
||||||
|
except ShiftedSeasonOwnerException as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"PatternController.findShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.findShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def getShiftedSeasonSiblings(self, showId: int):
|
def getShiftedSeasonSiblings(
|
||||||
|
self,
|
||||||
if type(showId) is not int:
|
showId: int | None = None,
|
||||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeasonSiblings(): Argument shiftedSeasonId is required to be of type int")
|
patternId: int | None = None,
|
||||||
|
):
|
||||||
|
session = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
owner = self._resolve_owner(showId=showId, patternId=patternId)
|
||||||
q = s.query(ShiftedSeason).filter(ShiftedSeason.show_id == int(showId))
|
session = self.Session()
|
||||||
|
return self._ordered_query(session, owner).all()
|
||||||
return q.all()
|
|
||||||
|
|
||||||
|
except ShiftedSeasonOwnerException as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"PatternController.getShiftedSeasonSiblings(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.getShiftedSeasonSiblings(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def getShiftedSeason(self, shiftedSeasonId: int):
|
def getShiftedSeason(self, shiftedSeasonId: int):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not int:
|
if type(shiftedSeasonId) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.getShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
session = self.Session()
|
||||||
return s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
return (
|
||||||
|
session.query(ShiftedSeason)
|
||||||
|
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.getShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
def deleteShiftedSeason(self, shiftedSeasonId):
|
def deleteShiftedSeason(self, shiftedSeasonId):
|
||||||
|
|
||||||
if type(shiftedSeasonId) is not int:
|
if type(shiftedSeasonId) is not int:
|
||||||
raise ValueError(f"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int")
|
raise ValueError(
|
||||||
|
"ShiftedSeasonController.deleteShiftedSeason(): Argument shiftedSeasonId is required to be of type int"
|
||||||
|
)
|
||||||
|
|
||||||
|
session = None
|
||||||
try:
|
try:
|
||||||
s = self.Session()
|
session = self.Session()
|
||||||
shiftedSeason = s.query(ShiftedSeason).filter(ShiftedSeason.id == int(shiftedSeasonId)).first()
|
shiftedSeason = (
|
||||||
|
session.query(ShiftedSeason)
|
||||||
|
.filter(ShiftedSeason.id == int(shiftedSeasonId))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if shiftedSeason is not None:
|
if shiftedSeason is not None:
|
||||||
|
session.delete(shiftedSeason)
|
||||||
#DAFUQ: https://stackoverflow.com/a/19245058
|
session.commit()
|
||||||
# q.delete()
|
|
||||||
s.delete(shiftedSeason)
|
|
||||||
|
|
||||||
s.commit()
|
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise click.ClickException(f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}")
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.deleteShiftedSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def shiftSeason(self, showId, season, episode, patternId=None):
|
||||||
|
|
||||||
def shiftSeason(self, showId, season, episode):
|
if season == -1 or episode == -1:
|
||||||
|
return season, episode
|
||||||
|
|
||||||
shiftedSeasonEntry: ShiftedSeason
|
session = None
|
||||||
for shiftedSeasonEntry in self.getShiftedSeasonSiblings(showId):
|
try:
|
||||||
|
session = self.Session()
|
||||||
|
activeShift = None
|
||||||
|
|
||||||
if (season == shiftedSeasonEntry.getOriginalSeason()
|
if patternId is not None:
|
||||||
and (shiftedSeasonEntry.getFirstEpisode() == -1 or episode >= shiftedSeasonEntry.getFirstEpisode())
|
activeShift = self._find_matching_rule(
|
||||||
and (shiftedSeasonEntry.getLastEpisode() == -1 or episode <= shiftedSeasonEntry.getLastEpisode())):
|
session,
|
||||||
|
self._resolve_owner(patternId=patternId),
|
||||||
|
season=int(season),
|
||||||
|
episode=int(episode),
|
||||||
|
)
|
||||||
|
|
||||||
shiftedSeason = season + shiftedSeasonEntry.getSeasonOffset()
|
if activeShift is None and showId is not None and showId != -1:
|
||||||
shiftedEpisode = episode + shiftedSeasonEntry.getEpisodeOffset()
|
activeShift = self._find_matching_rule(
|
||||||
|
session,
|
||||||
|
self._resolve_owner(showId=showId),
|
||||||
|
season=int(season),
|
||||||
|
episode=int(episode),
|
||||||
|
)
|
||||||
|
|
||||||
self.context['logger'].info(f"Shifting season: {season} episode: {episode} "
|
if activeShift is None:
|
||||||
+f"-> season: {shiftedSeason} episode: {shiftedEpisode}")
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.context['logger'].info(
|
||||||
|
f"Setting season shift {season}/{episode} -> {shiftedSeason}/{shiftedEpisode} from {sourceLabel}"
|
||||||
|
)
|
||||||
|
|
||||||
return shiftedSeason, shiftedEpisode
|
return shiftedSeason, shiftedEpisode
|
||||||
|
|
||||||
return season, episode
|
except ShiftedSeasonOwnerException as ex:
|
||||||
|
raise click.ClickException(str(ex))
|
||||||
|
except Exception as ex:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"ShiftedSeasonController.shiftSeason(): {repr(ex)}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if session is not None:
|
||||||
|
session.close()
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.context = self.app.getContext()
|
self.context = self.app.getContext()
|
||||||
@@ -52,6 +52,7 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||||
|
|
||||||
self._showId = showId
|
self._showId = showId
|
||||||
|
self._patternId = patternId
|
||||||
self.__shiftedSeasonId = shiftedSeasonId
|
self.__shiftedSeasonId = shiftedSeasonId
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +60,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
shiftedSeason: ShiftedSeason = self.__ssc.getShiftedSeason(self.__shiftedSeasonId)
|
||||||
|
|
||||||
self.query_one("#static_show_id", Static).update(str(self._showId))
|
ownerLabel = (
|
||||||
|
f"pattern #{self._patternId}"
|
||||||
|
if self._patternId is not None
|
||||||
|
else f"show #{self._showId}"
|
||||||
|
)
|
||||||
|
self.query_one("#static_owner", Static).update(ownerLabel)
|
||||||
self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
self.query_one("#static_original_season", Static).update(str(shiftedSeason.getOriginalSeason()))
|
||||||
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
self.query_one("#static_first_episode", Static).update(str(shiftedSeason.getFirstEpisode()))
|
||||||
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
self.query_one("#static_last_episode", Static).update(str(shiftedSeason.getLastEpisode()))
|
||||||
@@ -77,12 +83,12 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
yield Static(" ", classes="two")
|
yield Static(" ", classes="two")
|
||||||
|
|
||||||
yield Static("from show")
|
yield Static("from")
|
||||||
yield Static(" ", id="static_show_id")
|
yield Static(" ", id="static_owner")
|
||||||
|
|
||||||
yield Static(" ", classes="two")
|
yield Static(" ", classes="two")
|
||||||
|
|
||||||
yield Static("Original season")
|
yield Static("Source season")
|
||||||
yield Static(" ", id="static_original_season")
|
yield Static(" ", id="static_original_season")
|
||||||
|
|
||||||
yield Static("First episode")
|
yield Static("First episode")
|
||||||
@@ -122,4 +128,3 @@ class ShiftedSeasonDeleteScreen(Screen):
|
|||||||
|
|
||||||
if event.button.id == "cancel_button":
|
if event.button.id == "cancel_button":
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, showId = None, shiftedSeasonId = None):
|
def __init__(self, showId = None, patternId = None, shiftedSeasonId = None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
self.context = self.app.getContext()
|
self.context = self.app.getContext()
|
||||||
@@ -90,8 +90,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
self.__ssc = ShiftedSeasonController(context = self.context)
|
self.__ssc = ShiftedSeasonController(context = self.context)
|
||||||
|
|
||||||
self.__showId = showId
|
self.__showId = showId
|
||||||
|
self.__patternId = patternId
|
||||||
self.__shiftedSeasonId = shiftedSeasonId
|
self.__shiftedSeasonId = shiftedSeasonId
|
||||||
|
|
||||||
|
def _owner_kwargs(self):
|
||||||
|
if self.__patternId is not None:
|
||||||
|
return {'patternId': self.__patternId}
|
||||||
|
return {'showId': self.__showId}
|
||||||
|
|
||||||
def on_mount(self):
|
def on_mount(self):
|
||||||
|
|
||||||
if self.__shiftedSeasonId is not None:
|
if self.__shiftedSeasonId is not None:
|
||||||
@@ -126,7 +132,7 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
yield Static(" ", classes="three")
|
yield Static(" ", classes="three")
|
||||||
|
|
||||||
# 3
|
# 3
|
||||||
yield Static("Original season")
|
yield Static("Source season")
|
||||||
yield Input(id="input_original_season", classes="two")
|
yield Input(id="input_original_season", classes="two")
|
||||||
|
|
||||||
# 4
|
# 4
|
||||||
@@ -203,8 +209,11 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
|
|
||||||
if self.__shiftedSeasonId is not None:
|
if self.__shiftedSeasonId is not None:
|
||||||
|
|
||||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj,
|
if self.__ssc.checkShiftedSeason(
|
||||||
shiftedSeasonId = self.__shiftedSeasonId):
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
shiftedSeasonId=self.__shiftedSeasonId,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
):
|
||||||
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
if self.__ssc.updateShiftedSeason(self.__shiftedSeasonId, shiftedSeasonObj):
|
||||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||||
else:
|
else:
|
||||||
@@ -212,8 +221,14 @@ class ShiftedSeasonDetailsScreen(Screen):
|
|||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|
||||||
else:
|
else:
|
||||||
if self.__ssc.checkShiftedSeason(self.__showId, shiftedSeasonObj):
|
if self.__ssc.checkShiftedSeason(
|
||||||
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(self.__showId, shiftedSeasonObj)
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
):
|
||||||
|
self.__shiftedSeasonId = self.__ssc.addShiftedSeason(
|
||||||
|
shiftedSeasonObj=shiftedSeasonObj,
|
||||||
|
**self._owner_kwargs(),
|
||||||
|
)
|
||||||
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
self.dismiss((self.__shiftedSeasonId, shiftedSeasonObj))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ 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(),
|
||||||
|
notes = showDescriptor.getNotes())
|
||||||
|
|
||||||
s.add(show)
|
s.add(show)
|
||||||
s.commit()
|
s.commit()
|
||||||
@@ -88,6 +90,12 @@ 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 str(currentShow.notes or '') != str(showDescriptor.getNotes()):
|
||||||
|
currentShow.notes = str(showDescriptor.getNotes())
|
||||||
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
s.commit()
|
s.commit()
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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'
|
||||||
|
NOTES_KEY = 'notes'
|
||||||
|
|
||||||
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 +126,20 @@ 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
|
||||||
|
|
||||||
|
if ShowDescriptor.NOTES_KEY in kwargs.keys():
|
||||||
|
if type(kwargs[ShowDescriptor.NOTES_KEY]) is not str:
|
||||||
|
raise TypeError(f"ShowDescriptor.__init__(): Argument {ShowDescriptor.NOTES_KEY} is required to be of type str")
|
||||||
|
self.__notes = kwargs[ShowDescriptor.NOTES_KEY]
|
||||||
|
else:
|
||||||
|
self.__notes = ''
|
||||||
|
|
||||||
|
|
||||||
def getId(self):
|
def getId(self):
|
||||||
return self.__showId
|
return self.__showId
|
||||||
@@ -140,6 +156,10 @@ 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 getNotes(self):
|
||||||
|
return self.__notes
|
||||||
|
|
||||||
def getFilenamePrefix(self):
|
def getFilenamePrefix(self):
|
||||||
return f"{self.__showName} ({str(self.__showYear)})"
|
return f"{self.__showName} ({str(self.__showYear)})"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
from textual.widgets import Header, Footer, Static, Button, DataTable, Input
|
from textual.widgets import Header, Footer, Static, Button, DataTable, Input, TextArea
|
||||||
from textual.containers import Grid
|
from textual.containers import Grid
|
||||||
from textual.widgets._data_table import CellDoesNotExist
|
from textual.widgets._data_table import CellDoesNotExist
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ class ShowDetailsScreen(Screen):
|
|||||||
CSS = """
|
CSS = """
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
grid-size: 5 16;
|
grid-size: 5 18;
|
||||||
grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2;
|
grid-rows: 2 2 2 2 2 2 6 2 2 2 2 2 2 9 2 9 2 2;
|
||||||
grid-columns: 30 30 30 30 30;
|
grid-columns: 30 30 30 30 30;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -77,6 +77,10 @@ class ShowDetailsScreen(Screen):
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border: solid green;
|
border: solid green;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note_box {
|
||||||
|
min-height: 6;
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -150,6 +154,10 @@ 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())
|
||||||
|
if self.__showDescriptor.getNotes():
|
||||||
|
self.query_one("#notes_textarea", TextArea).text = str(self.__showDescriptor.getNotes())
|
||||||
|
|
||||||
|
|
||||||
#raise click.ClickException(f"show_id {showId}")
|
#raise click.ClickException(f"show_id {showId}")
|
||||||
@@ -211,11 +219,17 @@ class ShowDetailsScreen(Screen):
|
|||||||
if row_key is not None:
|
if row_key is not None:
|
||||||
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
selected_row_data = self.shiftedSeasonsTable.get_row(row_key)
|
||||||
|
|
||||||
|
def parse_int_or_default(value: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
shiftedSeasonObj['original_season'] = int(selected_row_data[0])
|
||||||
shiftedSeasonObj['first_episode'] = int(selected_row_data[1]) if selected_row_data[1].isnumeric() else -1
|
shiftedSeasonObj['first_episode'] = parse_int_or_default(selected_row_data[1], -1)
|
||||||
shiftedSeasonObj['last_episode'] = int(selected_row_data[2]) if selected_row_data[2].isnumeric() else -1
|
shiftedSeasonObj['last_episode'] = parse_int_or_default(selected_row_data[2], -1)
|
||||||
shiftedSeasonObj['season_offset'] = int(selected_row_data[3]) if selected_row_data[3].isnumeric() else 0
|
shiftedSeasonObj['season_offset'] = parse_int_or_default(selected_row_data[3], 0)
|
||||||
shiftedSeasonObj['episode_offset'] = int(selected_row_data[4]) if selected_row_data[4].isnumeric() else 0
|
shiftedSeasonObj['episode_offset'] = parse_int_or_default(selected_row_data[4], 0)
|
||||||
|
|
||||||
|
|
||||||
if self.__showDescriptor is not None:
|
if self.__showDescriptor is not None:
|
||||||
@@ -308,7 +322,7 @@ class ShowDetailsScreen(Screen):
|
|||||||
|
|
||||||
self.shiftedSeasonsTable = DataTable(classes="five")
|
self.shiftedSeasonsTable = DataTable(classes="five")
|
||||||
|
|
||||||
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Original Season", width=30)
|
self.column_key_original_season = self.shiftedSeasonsTable.add_column("Source Season", width=30)
|
||||||
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30)
|
self.column_key_first_episode = self.shiftedSeasonsTable.add_column("First Episode", width=30)
|
||||||
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
|
self.column_key_last_episode = self.shiftedSeasonsTable.add_column("Last Episode", width=30)
|
||||||
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30)
|
self.column_key_season_offset = self.shiftedSeasonsTable.add_column("Season Offset", width=30)
|
||||||
@@ -342,28 +356,36 @@ 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("Notes")
|
||||||
|
yield Static(" ", classes="four")
|
||||||
|
|
||||||
|
#7
|
||||||
|
yield TextArea(id="notes_textarea", classes="five note_box")
|
||||||
|
|
||||||
|
#8
|
||||||
yield Static("Index Season Digits")
|
yield Static("Index Season Digits")
|
||||||
yield Input(type="integer", id="index_season_digits_input", classes="four")
|
yield Input(type="integer", id="index_season_digits_input", classes="four")
|
||||||
|
|
||||||
#7
|
#9
|
||||||
yield Static("Index Episode Digits")
|
yield Static("Index Episode Digits")
|
||||||
yield Input(type="integer", id="index_episode_digits_input", classes="four")
|
yield Input(type="integer", id="index_episode_digits_input", classes="four")
|
||||||
|
|
||||||
#8
|
#10
|
||||||
yield Static("Indicator Season Digits")
|
yield Static("Indicator Season Digits")
|
||||||
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
|
yield Input(type="integer", id="indicator_season_digits_input", classes="four")
|
||||||
|
|
||||||
#9
|
#11
|
||||||
yield Static("Indicator Edisode Digits")
|
yield Static("Indicator Edisode Digits")
|
||||||
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
|
yield Input(type="integer", id="indicator_episode_digits_input", classes="four")
|
||||||
|
|
||||||
# 10
|
# 12
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# 11
|
# 13
|
||||||
yield Static("Shifted seasons", classes="two")
|
yield Static("Shifted seasons", classes="two")
|
||||||
|
|
||||||
if self.__showDescriptor is not None:
|
if self.__showDescriptor is not None:
|
||||||
@@ -375,18 +397,18 @@ class ShowDetailsScreen(Screen):
|
|||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
yield Static(" ")
|
yield Static(" ")
|
||||||
|
|
||||||
# 12
|
# 14
|
||||||
yield self.shiftedSeasonsTable
|
yield self.shiftedSeasonsTable
|
||||||
|
|
||||||
# 13
|
# 15
|
||||||
yield Static("File patterns", classes="five")
|
yield Static("File patterns", classes="five")
|
||||||
# 14
|
# 16
|
||||||
yield self.patternTable
|
yield self.patternTable
|
||||||
|
|
||||||
# 15
|
# 17
|
||||||
yield Static(" ", classes="five")
|
yield Static(" ", classes="five")
|
||||||
|
|
||||||
# 16
|
# 18
|
||||||
yield Button("Save", id="save_button")
|
yield Button("Save", id="save_button")
|
||||||
yield Button("Cancel", id="cancel_button")
|
yield Button("Cancel", id="cancel_button")
|
||||||
|
|
||||||
@@ -432,6 +454,11 @@ 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
|
||||||
|
kwargs[ShowDescriptor.NOTES_KEY] = str(self.query_one("#notes_textarea", TextArea).text)
|
||||||
|
|
||||||
return ShowDescriptor(**kwargs)
|
return ShowDescriptor(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from tests.support.ffx_bundle import (
|
|||||||
from ffx.pattern_controller import PatternController
|
from ffx.pattern_controller import PatternController
|
||||||
from ffx.show_controller import ShowController
|
from ffx.show_controller import ShowController
|
||||||
from ffx.show_descriptor import ShowDescriptor
|
from ffx.show_descriptor import ShowDescriptor
|
||||||
|
from ffx.shifted_season_controller import ShiftedSeasonController
|
||||||
from ffx.track_codec import TrackCodec
|
from ffx.track_codec import TrackCodec
|
||||||
from ffx.track_descriptor import TrackDescriptor
|
from ffx.track_descriptor import TrackDescriptor
|
||||||
from ffx.track_type import TrackType
|
from ffx.track_type import TrackType
|
||||||
@@ -109,6 +110,31 @@ class UnmuxCliTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
dispose_controller_context(context)
|
dispose_controller_context(context)
|
||||||
|
|
||||||
|
def add_show_shift(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
show_id: int,
|
||||||
|
original_season: int,
|
||||||
|
first_episode: int,
|
||||||
|
last_episode: int,
|
||||||
|
season_offset: int,
|
||||||
|
episode_offset: int,
|
||||||
|
) -> None:
|
||||||
|
context = build_controller_context(self.database_path)
|
||||||
|
try:
|
||||||
|
ShiftedSeasonController(context).addShiftedSeason(
|
||||||
|
showId=show_id,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": original_season,
|
||||||
|
"first_episode": first_episode,
|
||||||
|
"last_episode": last_episode,
|
||||||
|
"season_offset": season_offset,
|
||||||
|
"episode_offset": episode_offset,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dispose_controller_context(context)
|
||||||
|
|
||||||
def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self):
|
def test_subtitles_only_without_output_directory_uses_configured_base_plus_label(self):
|
||||||
self.write_config(
|
self.write_config(
|
||||||
{
|
{
|
||||||
@@ -223,6 +249,55 @@ class UnmuxCliTests(unittest.TestCase):
|
|||||||
output_filenames,
|
output_filenames,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_unmux_applies_shifted_season_mapping_to_output_filenames(self):
|
||||||
|
self.seed_matching_show(
|
||||||
|
r"^unmux_([sS][0-9]+[eE][0-9]+)\.mkv$",
|
||||||
|
indicator_season_digits=2,
|
||||||
|
indicator_episode_digits=2,
|
||||||
|
)
|
||||||
|
self.add_show_shift(
|
||||||
|
show_id=1,
|
||||||
|
original_season=1,
|
||||||
|
first_episode=1,
|
||||||
|
last_episode=99,
|
||||||
|
season_offset=1,
|
||||||
|
episode_offset=-88,
|
||||||
|
)
|
||||||
|
source_filename = "unmux_s01e89.mkv"
|
||||||
|
output_directory = self.workdir / "unmux-output"
|
||||||
|
output_directory.mkdir()
|
||||||
|
source_path = create_source_fixture(
|
||||||
|
self.workdir,
|
||||||
|
source_filename,
|
||||||
|
[
|
||||||
|
SourceTrackSpec(TrackType.VIDEO, identity="video-0"),
|
||||||
|
SourceTrackSpec(
|
||||||
|
TrackType.SUBTITLE,
|
||||||
|
identity="subtitle-1",
|
||||||
|
language="eng",
|
||||||
|
subtitle_lines=("subtitle payload",),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
completed = run_ffx_unmux(
|
||||||
|
self.workdir,
|
||||||
|
self.home_dir,
|
||||||
|
self.database_path,
|
||||||
|
"--label",
|
||||||
|
"dball",
|
||||||
|
"--output-directory",
|
||||||
|
str(output_directory),
|
||||||
|
"--subtitles-only",
|
||||||
|
str(source_path),
|
||||||
|
)
|
||||||
|
self.assertCompleted(completed)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"Unmuxing stream 1 into file dball_S02E01_1_eng",
|
||||||
|
completed.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
self.assertTrue(subprocess_calls[0][1]["capture_output"])
|
self.assertTrue(subprocess_calls[0][1]["capture_output"])
|
||||||
self.assertTrue(subprocess_calls[0][1]["text"])
|
self.assertTrue(subprocess_calls[0][1]["text"])
|
||||||
|
|
||||||
def test_upgrade_resets_before_checkout_and_pull_when_user_confirms(self):
|
def test_upgrade_resets_then_fetches_and_checks_out_requested_branch_when_user_confirms(self):
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
repo_path = "/tmp/ffx-repo"
|
repo_path = "/tmp/ffx-repo"
|
||||||
pip_path = "/tmp/ffx-venv/bin/pip"
|
pip_path = "/tmp/ffx-venv/bin/pip"
|
||||||
@@ -85,8 +85,8 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
[
|
[
|
||||||
['git', 'status', '--porcelain', '--untracked-files=no'],
|
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||||
['git', 'reset', '--hard', 'HEAD'],
|
['git', 'reset', '--hard', 'HEAD'],
|
||||||
['git', 'checkout', 'main'],
|
['git', 'fetch', 'origin', 'main'],
|
||||||
['git', 'pull'],
|
['git', 'checkout', '-B', 'main', 'FETCH_HEAD'],
|
||||||
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||||
[pip_path, 'install', '--editable', '.'],
|
[pip_path, 'install', '--editable', '.'],
|
||||||
],
|
],
|
||||||
@@ -95,6 +95,39 @@ class UpgradeCommandTests(unittest.TestCase):
|
|||||||
for args, kwargs in subprocess_calls[1:]:
|
for args, kwargs in subprocess_calls[1:]:
|
||||||
self.assertEqual(repo_path, kwargs["cwd"], args)
|
self.assertEqual(repo_path, kwargs["cwd"], args)
|
||||||
|
|
||||||
|
def test_upgrade_pulls_current_branch_when_no_branch_is_requested(self):
|
||||||
|
runner = CliRunner()
|
||||||
|
repo_path = "/tmp/ffx-repo"
|
||||||
|
pip_path = "/tmp/ffx-venv/bin/pip"
|
||||||
|
|
||||||
|
subprocess_calls = []
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
subprocess_calls.append((args, kwargs))
|
||||||
|
if args == ['git', 'status', '--porcelain', '--untracked-files=no']:
|
||||||
|
return self.make_completed(args, stdout="")
|
||||||
|
return self.make_completed(args)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(cli, "getBundleRepoPath", return_value=repo_path),
|
||||||
|
patch.object(cli, "getBundlePipPath", return_value=pip_path),
|
||||||
|
patch.object(cli.os.path, "isdir", return_value=True),
|
||||||
|
patch.object(cli.os.path, "isfile", return_value=True),
|
||||||
|
patch.object(cli.subprocess, "run", side_effect=fake_run),
|
||||||
|
):
|
||||||
|
result = runner.invoke(cli.ffx, ["upgrade"])
|
||||||
|
|
||||||
|
self.assertEqual(0, result.exit_code, result.output)
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
['git', 'status', '--porcelain', '--untracked-files=no'],
|
||||||
|
['git', 'pull'],
|
||||||
|
[pip_path, 'install', '--upgrade', 'pip', 'setuptools', 'wheel'],
|
||||||
|
[pip_path, 'install', '--editable', '.'],
|
||||||
|
],
|
||||||
|
[call[0] for call in subprocess_calls],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
|
||||||
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
@@ -15,8 +18,18 @@ if str(SRC_ROOT) not in sys.path:
|
|||||||
|
|
||||||
from ffx.constants import DATABASE_VERSION # noqa: E402
|
from ffx.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.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_descriptor import ShowDescriptor # noqa: E402
|
||||||
|
from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class StaticConfig:
|
||||||
|
def getData(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class DatabaseContextTests(unittest.TestCase):
|
class DatabaseContextTests(unittest.TestCase):
|
||||||
@@ -27,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_show_fields(self, cursor):
|
||||||
|
cursor.execute("ALTER TABLE shows RENAME TO shows_current")
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE shows (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name VARCHAR,
|
||||||
|
year INTEGER,
|
||||||
|
index_season_digits INTEGER,
|
||||||
|
index_episode_digits INTEGER,
|
||||||
|
indicator_season_digits INTEGER,
|
||||||
|
indicator_episode_digits INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO shows (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
year,
|
||||||
|
index_season_digits,
|
||||||
|
index_episode_digits,
|
||||||
|
indicator_season_digits,
|
||||||
|
indicator_episode_digits
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
year,
|
||||||
|
index_season_digits,
|
||||||
|
index_episode_digits,
|
||||||
|
indicator_season_digits,
|
||||||
|
indicator_episode_digits
|
||||||
|
FROM shows_current
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("DROP TABLE shows_current")
|
||||||
|
|
||||||
|
def rewrite_shifted_seasons_table_without_pattern_owner(self, cursor):
|
||||||
|
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_show_id")
|
||||||
|
cursor.execute("DROP INDEX IF EXISTS ix_shifted_seasons_pattern_id")
|
||||||
|
cursor.execute(
|
||||||
|
"ALTER TABLE shifted_seasons RENAME TO shifted_seasons_current"
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE shifted_seasons (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
show_id INTEGER,
|
||||||
|
original_season INTEGER,
|
||||||
|
first_episode INTEGER DEFAULT -1,
|
||||||
|
last_episode INTEGER DEFAULT -1,
|
||||||
|
season_offset INTEGER DEFAULT 0,
|
||||||
|
episode_offset INTEGER DEFAULT 0,
|
||||||
|
FOREIGN KEY(show_id) REFERENCES shows(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO shifted_seasons (
|
||||||
|
id,
|
||||||
|
show_id,
|
||||||
|
original_season,
|
||||||
|
first_episode,
|
||||||
|
last_episode,
|
||||||
|
season_offset,
|
||||||
|
episode_offset
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
show_id,
|
||||||
|
original_season,
|
||||||
|
first_episode,
|
||||||
|
last_episode,
|
||||||
|
season_offset,
|
||||||
|
episode_offset
|
||||||
|
FROM shifted_seasons_current
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cursor.execute("DROP TABLE shifted_seasons_current")
|
||||||
|
|
||||||
def test_database_context_bootstraps_new_database_with_current_version(self):
|
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))
|
||||||
@@ -78,6 +200,127 @@ 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):
|
||||||
|
shifted_season_id = self.create_demo_show_with_shift()
|
||||||
|
|
||||||
|
connection = sqlite3.connect(self.database_path)
|
||||||
|
try:
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
self.rewrite_shifted_seasons_table_without_pattern_owner(cursor)
|
||||||
|
self.rewrite_shows_table_without_show_fields(cursor)
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE properties SET value = '2' WHERE key = ?",
|
||||||
|
(DATABASE_VERSION_KEY,),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with patch("ffx.database.click.confirm", return_value=True) as mocked_confirm, patch(
|
||||||
|
"ffx.database.click.echo"
|
||||||
|
) as mocked_echo:
|
||||||
|
reopened_context = databaseContext(str(self.database_path))
|
||||||
|
try:
|
||||||
|
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
|
||||||
|
mocked_confirm.assert_called_once()
|
||||||
|
|
||||||
|
backup_path = Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak")
|
||||||
|
self.assertTrue(backup_path.exists())
|
||||||
|
|
||||||
|
Session = reopened_context["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
migrated_shifted_season = (
|
||||||
|
session.query(ShiftedSeason)
|
||||||
|
.filter(ShiftedSeason.id == shifted_season_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(migrated_shifted_season)
|
||||||
|
self.assertEqual(1, migrated_shifted_season.getShowId())
|
||||||
|
self.assertIsNone(migrated_shifted_season.getPatternId())
|
||||||
|
self.assertEqual(1, migrated_shifted_season.getOriginalSeason())
|
||||||
|
self.assertEqual(1, migrated_shifted_season.getFirstEpisode())
|
||||||
|
self.assertEqual(10, migrated_shifted_season.getLastEpisode())
|
||||||
|
migrated_show = session.query(Show).filter(Show.id == 1).first()
|
||||||
|
self.assertIsNotNone(migrated_show)
|
||||||
|
self.assertEqual(0, int(migrated_show.quality or 0))
|
||||||
|
self.assertEqual('', str(migrated_show.notes or ''))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
finally:
|
||||||
|
reopened_context["engine"].dispose()
|
||||||
|
|
||||||
|
echoedLines = [call.args[0] for call in mocked_echo.call_args_list]
|
||||||
|
self.assertIn("Database migration required.", echoedLines)
|
||||||
|
self.assertIn("Current version: 2", echoedLines)
|
||||||
|
self.assertIn(f"Target version: {DATABASE_VERSION}", echoedLines)
|
||||||
|
self.assertIn(
|
||||||
|
" 2 -> 3: ffx.model.migration.step_2_3 [present]",
|
||||||
|
echoedLines,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_database_context_aborts_migration_when_confirmation_is_declined(self):
|
||||||
|
context = databaseContext(str(self.database_path))
|
||||||
|
try:
|
||||||
|
Session = context["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
version_row = (
|
||||||
|
session.query(Property)
|
||||||
|
.filter(Property.key == DATABASE_VERSION_KEY)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
version_row.value = "2"
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
finally:
|
||||||
|
context["engine"].dispose()
|
||||||
|
|
||||||
|
with patch("ffx.database.click.confirm", return_value=False), patch(
|
||||||
|
"ffx.database.click.echo"
|
||||||
|
):
|
||||||
|
with self.assertRaises(click.ClickException) as raisedContext:
|
||||||
|
databaseContext(str(self.database_path))
|
||||||
|
|
||||||
|
self.assertEqual("Database migration aborted by user.", str(raisedContext.exception))
|
||||||
|
self.assertFalse(Path(f"{self.database_path}.v2-to-v{DATABASE_VERSION}.bak").exists())
|
||||||
|
|
||||||
|
def test_database_context_repairs_current_show_schema_without_version_bump(self):
|
||||||
|
self.create_demo_show_with_shift()
|
||||||
|
|
||||||
|
connection = sqlite3.connect(self.database_path)
|
||||||
|
try:
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("PRAGMA foreign_keys=OFF")
|
||||||
|
self.rewrite_shows_table_without_show_fields(cursor)
|
||||||
|
connection.commit()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
with patch("ffx.database.click.confirm") as mocked_confirm, patch(
|
||||||
|
"ffx.database.click.echo"
|
||||||
|
) as mocked_echo:
|
||||||
|
reopened_context = databaseContext(str(self.database_path))
|
||||||
|
try:
|
||||||
|
self.assertEqual(DATABASE_VERSION, getDatabaseVersion(reopened_context))
|
||||||
|
|
||||||
|
Session = reopened_context["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
repaired_show = session.query(Show).filter(Show.id == 1).first()
|
||||||
|
self.assertIsNotNone(repaired_show)
|
||||||
|
self.assertEqual(0, int(repaired_show.quality or 0))
|
||||||
|
self.assertEqual('', str(repaired_show.notes or ''))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
finally:
|
||||||
|
reopened_context["engine"].dispose()
|
||||||
|
|
||||||
|
mocked_confirm.assert_not_called()
|
||||||
|
mocked_echo.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.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()
|
||||||
|
|||||||
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal file
79
tests/unit/test_media_descriptor_import_subtitles.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.logging_utils import get_ffx_logger # noqa: E402
|
||||||
|
from ffx.media_descriptor import MediaDescriptor # noqa: E402
|
||||||
|
from ffx.track_descriptor import TrackDescriptor # noqa: E402
|
||||||
|
from ffx.track_disposition import TrackDisposition # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class MediaDescriptorImportSubtitlesTests(unittest.TestCase):
|
||||||
|
def make_descriptor(self) -> MediaDescriptor:
|
||||||
|
return MediaDescriptor(
|
||||||
|
context={"logger": get_ffx_logger()},
|
||||||
|
track_descriptors=[
|
||||||
|
TrackDescriptor(
|
||||||
|
index=3,
|
||||||
|
source_index=3,
|
||||||
|
sub_index=0,
|
||||||
|
track_type=TrackType.SUBTITLE,
|
||||||
|
tags={"language": "eng", "title": "DB Subtitle"},
|
||||||
|
disposition_set={TrackDisposition.DEFAULT},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_import_subtitles_preserves_target_dispositions_when_requested(self):
|
||||||
|
descriptor = self.make_descriptor()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
|
||||||
|
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"dball",
|
||||||
|
season=1,
|
||||||
|
episode=1,
|
||||||
|
preserve_dispositions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
track = descriptor.getSubtitleTracks()[0]
|
||||||
|
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
|
||||||
|
self.assertEqual("deu", track.getTags()["language"])
|
||||||
|
self.assertEqual({TrackDisposition.DEFAULT}, track.getDispositionSet())
|
||||||
|
|
||||||
|
def test_import_subtitles_uses_sidecar_dispositions_by_default(self):
|
||||||
|
descriptor = self.make_descriptor()
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
sidecar_path = Path(tmpdir) / "dball_S01E01_3_deu_FOR.vtt"
|
||||||
|
sidecar_path.write_text("WEBVTT\n\n", encoding="utf-8")
|
||||||
|
|
||||||
|
descriptor.importSubtitles(
|
||||||
|
tmpdir,
|
||||||
|
"dball",
|
||||||
|
season=1,
|
||||||
|
episode=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
track = descriptor.getSubtitleTracks()[0]
|
||||||
|
self.assertEqual(str(sidecar_path), track.getExternalSourceFilePath())
|
||||||
|
self.assertEqual("deu", track.getTags()["language"])
|
||||||
|
self.assertEqual({TrackDisposition.FORCED}, track.getDispositionSet())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
47
tests/unit/test_migration.py
Normal file
47
tests/unit/test_migration.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.model.migration import ( # noqa: E402
|
||||||
|
DatabaseVersionException,
|
||||||
|
getMigrationPlan,
|
||||||
|
loadMigrationStep,
|
||||||
|
migrateDatabase,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MigrationTests(unittest.TestCase):
|
||||||
|
def test_get_migration_plan_lists_known_step_with_module_presence(self):
|
||||||
|
migrationPlan = getMigrationPlan(2, 3)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(migrationPlan))
|
||||||
|
self.assertEqual(2, migrationPlan[0].versionFrom)
|
||||||
|
self.assertEqual(3, migrationPlan[0].versionTo)
|
||||||
|
self.assertEqual("ffx.model.migration.step_2_3", migrationPlan[0].moduleName)
|
||||||
|
self.assertTrue(migrationPlan[0].modulePresent)
|
||||||
|
|
||||||
|
def test_load_migration_step_returns_known_step(self):
|
||||||
|
migrationStep = loadMigrationStep(2, 3)
|
||||||
|
|
||||||
|
self.assertTrue(callable(migrationStep))
|
||||||
|
|
||||||
|
def test_migrate_database_raises_when_step_module_is_missing(self):
|
||||||
|
updatedVersions = []
|
||||||
|
|
||||||
|
with self.assertRaises(DatabaseVersionException):
|
||||||
|
migrateDatabase({}, 1, 2, lambda context, version: updatedVersions.append(version))
|
||||||
|
|
||||||
|
self.assertEqual([], updatedVersions)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
208
tests/unit/test_shifted_season_controller.py
Normal file
208
tests/unit/test_shifted_season_controller.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
SRC_ROOT = Path(__file__).resolve().parents[2] / "src"
|
||||||
|
|
||||||
|
if str(SRC_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(SRC_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
from ffx.database import databaseContext # noqa: E402
|
||||||
|
from ffx.model.pattern import Pattern # noqa: E402
|
||||||
|
from ffx.model.track import Track # noqa: E402
|
||||||
|
from ffx.show_controller import ShowController # noqa: E402
|
||||||
|
from ffx.show_descriptor import ShowDescriptor # noqa: E402
|
||||||
|
from ffx.shifted_season_controller import ShiftedSeasonController # noqa: E402
|
||||||
|
from ffx.track_type import TrackType # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class StaticConfig:
|
||||||
|
def __init__(self, data: dict | None = None):
|
||||||
|
self._data = data or {}
|
||||||
|
|
||||||
|
def getData(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
def make_logger(name: str) -> logging.Logger:
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.handlers = []
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
logger.propagate = False
|
||||||
|
logger.addHandler(logging.NullHandler())
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def make_context(database_path: Path) -> dict:
|
||||||
|
return {
|
||||||
|
"logger": make_logger(f"ffx-test-shifted-{database_path.stem}"),
|
||||||
|
"config": StaticConfig(),
|
||||||
|
"database": databaseContext(str(database_path)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftedSeasonControllerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tempdir = tempfile.TemporaryDirectory()
|
||||||
|
self.database_path = Path(self.tempdir.name) / "shifted-season-test.db"
|
||||||
|
self.context = make_context(self.database_path)
|
||||||
|
self.show_controller = ShowController(self.context)
|
||||||
|
self.shifted_season_controller = ShiftedSeasonController(self.context)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.context["database"]["engine"].dispose()
|
||||||
|
self.tempdir.cleanup()
|
||||||
|
|
||||||
|
def add_show(self, show_id: int, name: str = "Demo Show"):
|
||||||
|
self.show_controller.updateShow(
|
||||||
|
ShowDescriptor(id=show_id, name=name, year=2000 + show_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_pattern(self, show_id: int, expression: str) -> int:
|
||||||
|
self.add_show(show_id)
|
||||||
|
Session = self.context["database"]["session"]
|
||||||
|
session = Session()
|
||||||
|
try:
|
||||||
|
pattern = Pattern(show_id=show_id, pattern=expression)
|
||||||
|
session.add(pattern)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
Track(
|
||||||
|
pattern_id=pattern.getId(),
|
||||||
|
track_type=TrackType.VIDEO.index(),
|
||||||
|
codec_name="h264",
|
||||||
|
index=0,
|
||||||
|
source_index=0,
|
||||||
|
disposition_flags=0,
|
||||||
|
audio_layout=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return pattern.getId()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
def test_shift_season_uses_show_mapping_when_no_pattern_mapping_exists(self):
|
||||||
|
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
self.shifted_season_controller.addShiftedSeason(
|
||||||
|
showId=1,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": 1,
|
||||||
|
"first_episode": 1,
|
||||||
|
"last_episode": 10,
|
||||||
|
"season_offset": 2,
|
||||||
|
"episode_offset": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||||
|
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||||
|
showId=1,
|
||||||
|
patternId=pattern_id,
|
||||||
|
season=1,
|
||||||
|
episode=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((3, 8), (shifted_season, shifted_episode))
|
||||||
|
mocked_info.assert_called_once_with(
|
||||||
|
"Setting season shift 1/3 -> 3/8 from show"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shift_season_prefers_pattern_mapping_over_show_mapping(self):
|
||||||
|
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
self.shifted_season_controller.addShiftedSeason(
|
||||||
|
showId=1,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": 1,
|
||||||
|
"first_episode": 1,
|
||||||
|
"last_episode": 10,
|
||||||
|
"season_offset": 2,
|
||||||
|
"episode_offset": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.shifted_season_controller.addShiftedSeason(
|
||||||
|
patternId=pattern_id,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": 1,
|
||||||
|
"first_episode": 1,
|
||||||
|
"last_episode": 10,
|
||||||
|
"season_offset": 1,
|
||||||
|
"episode_offset": -2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||||
|
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||||
|
showId=1,
|
||||||
|
patternId=pattern_id,
|
||||||
|
season=1,
|
||||||
|
episode=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((2, 1), (shifted_season, shifted_episode))
|
||||||
|
mocked_info.assert_called_once_with(
|
||||||
|
"Setting season shift 1/3 -> 2/1 from pattern"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_shift_season_pattern_zero_offsets_override_show_mapping_to_identity(self):
|
||||||
|
pattern_id = self.add_pattern(1, r"^demo_(s[0-9]+e[0-9]+)\.mkv$")
|
||||||
|
self.shifted_season_controller.addShiftedSeason(
|
||||||
|
showId=1,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": 1,
|
||||||
|
"first_episode": 1,
|
||||||
|
"last_episode": 10,
|
||||||
|
"season_offset": 2,
|
||||||
|
"episode_offset": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.shifted_season_controller.addShiftedSeason(
|
||||||
|
patternId=pattern_id,
|
||||||
|
shiftedSeasonObj={
|
||||||
|
"original_season": 1,
|
||||||
|
"first_episode": 1,
|
||||||
|
"last_episode": 10,
|
||||||
|
"season_offset": 0,
|
||||||
|
"episode_offset": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(self.context["logger"], "info") as mocked_info:
|
||||||
|
shifted_season, shifted_episode = self.shifted_season_controller.shiftSeason(
|
||||||
|
showId=1,
|
||||||
|
patternId=pattern_id,
|
||||||
|
season=1,
|
||||||
|
episode=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((1, 3), (shifted_season, shifted_episode))
|
||||||
|
mocked_info.assert_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$")
|
||||||
|
|
||||||
|
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__":
|
||||||
|
unittest.main()
|
||||||
@@ -56,6 +56,8 @@ 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())
|
||||||
|
self.assertEqual("", descriptor.getNotes())
|
||||||
|
|
||||||
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 +72,18 @@ class ShowDescriptorDefaultTests(unittest.TestCase):
|
|||||||
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
DEFAULT_SHOW_INDICATOR_EPISODE_DIGITS,
|
||||||
descriptor.getIndicatorEpisodeDigits(),
|
descriptor.getIndicatorEpisodeDigits(),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(0, descriptor.getQuality())
|
||||||
|
self.assertEqual("", descriptor.getNotes())
|
||||||
|
|
||||||
|
def test_show_descriptor_preserves_explicit_quality(self):
|
||||||
|
descriptor = ShowDescriptor(id=1, name="Quality Show", year=2024, quality=23)
|
||||||
|
|
||||||
|
self.assertEqual(23, descriptor.getQuality())
|
||||||
|
|
||||||
|
def test_show_descriptor_preserves_explicit_notes(self):
|
||||||
|
descriptor = ShowDescriptor(id=1, name="Notes Show", year=2024, notes="show notes")
|
||||||
|
|
||||||
|
self.assertEqual("show notes", descriptor.getNotes())
|
||||||
|
|
||||||
def test_episode_basename_uses_configured_digit_defaults_when_omitted(self):
|
def test_episode_basename_uses_configured_digit_defaults_when_omitted(self):
|
||||||
basename = getEpisodeFileBasename(
|
basename = getEpisodeFileBasename(
|
||||||
|
|||||||
Reference in New Issue
Block a user