From 0e51d6337ffb83b5183ac19a4538d8a8cee1dd57 Mon Sep 17 00:00:00 2001 From: Javanaut Date: Sun, 12 Apr 2026 18:35:13 +0200 Subject: [PATCH] ff --- requirements/architecture.md | 2 +- requirements/project.md | 1 + src/ffx/database.py | 26 +++++++++++++- src/ffx/model/migration/step_2_3.py | 4 +++ src/ffx/model/show.py | 4 ++- src/ffx/show_controller.py | 6 +++- src/ffx/show_descriptor.py | 10 ++++++ src/ffx/show_details_screen.py | 40 ++++++++++++++------- tests/unit/test_database.py | 39 ++++++++++++++++++-- tests/unit/test_show_descriptor_defaults.py | 7 ++++ 10 files changed, 120 insertions(+), 19 deletions(-) diff --git a/requirements/architecture.md b/requirements/architecture.md index 2fe80b4..82c3910 100644 --- a/requirements/architecture.md +++ b/requirements/architecture.md @@ -50,7 +50,7 @@ ## Data And Interface Notes - Key entities or records: - - `Show`: canonical TV show metadata plus digit-formatting rules and an optional show-level encoding-quality fallback. + - `Show`: canonical TV show metadata plus digit-formatting rules, optional show-level notes, and an optional show-level encoding-quality fallback. - `Pattern`: regex rule tying filenames to one show and one target media schema. - `Track` and `TrackTag`: persisted target stream records, codec, dispositions, audio layout, and stream-level tags. Detailed source-to-target mapping rules live in `requirements/subtrack_mapping.md`. - `MediaTag`: persisted container-level metadata for a pattern. diff --git a/requirements/project.md b/requirements/project.md index b477580..616bbe3 100644 --- a/requirements/project.md +++ b/requirements/project.md @@ -44,6 +44,7 @@ - The CLI command `ffx configure_workstation` shall act as a wrapper for the second-step preparation flow in `tools/configure_workstation.sh`. - The system shall persist reusable normalization rules in SQLite for: - shows and show formatting digits, + - optional show-level notes, - optional show-level quality defaults, - regex-based filename patterns, - per-pattern media tags, diff --git a/src/ffx/database.py b/src/ffx/database.py index 8665ee3..3918d29 100644 --- a/src/ffx/database.py +++ b/src/ffx/database.py @@ -1,6 +1,6 @@ import os, shutil, click -from sqlalchemy import create_engine, inspect +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import sessionmaker # Import the full model package so SQLAlchemy registers every mapped class @@ -98,6 +98,30 @@ def ensureDatabaseVersion(databaseContext): 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) diff --git a/src/ffx/model/migration/step_2_3.py b/src/ffx/model/migration/step_2_3.py index 528d166..ebae497 100644 --- a/src/ffx/model/migration/step_2_3.py +++ b/src/ffx/model/migration/step_2_3.py @@ -78,3 +78,7 @@ def applyMigration(databaseContext): 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 ''") + ) diff --git a/src/ffx/model/show.py b/src/ffx/model/show.py index 8e3f757..91f98c3 100644 --- a/src/ffx/model/show.py +++ b/src/ffx/model/show.py @@ -1,5 +1,5 @@ # 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 ffx.show_descriptor import ShowDescriptor @@ -46,6 +46,7 @@ class Show(Base): indicator_season_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_SEASON_DIGITS) indicator_episode_digits = Column(Integer, default=ShowDescriptor.DEFAULT_INDICATOR_EPISODE_DIGITS) quality = Column(Integer, default=0) + notes = Column(Text, default='') def getDescriptor(self, context): @@ -60,5 +61,6 @@ class Show(Base): kwargs[ShowDescriptor.INDICATOR_SEASON_DIGITS_KEY] = int(self.indicator_season_digits) kwargs[ShowDescriptor.INDICATOR_EPISODE_DIGITS_KEY] = int(self.indicator_episode_digits) kwargs[ShowDescriptor.QUALITY_KEY] = int(self.quality or 0) + kwargs[ShowDescriptor.NOTES_KEY] = str(self.notes or '') return ShowDescriptor(**kwargs) diff --git a/src/ffx/show_controller.py b/src/ffx/show_controller.py index 3ed2fe0..307c873 100644 --- a/src/ffx/show_controller.py +++ b/src/ffx/show_controller.py @@ -63,7 +63,8 @@ class ShowController(): index_episode_digits = showDescriptor.getIndexEpisodeDigits(), indicator_season_digits = showDescriptor.getIndicatorSeasonDigits(), indicator_episode_digits = showDescriptor.getIndicatorEpisodeDigits(), - quality = showDescriptor.getQuality()) + quality = showDescriptor.getQuality(), + notes = showDescriptor.getNotes()) s.add(show) s.commit() @@ -92,6 +93,9 @@ class ShowController(): 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: s.commit() diff --git a/src/ffx/show_descriptor.py b/src/ffx/show_descriptor.py index 5ae6dfe..8c0d21b 100644 --- a/src/ffx/show_descriptor.py +++ b/src/ffx/show_descriptor.py @@ -22,6 +22,7 @@ class ShowDescriptor(): INDICATOR_SEASON_DIGITS_KEY = 'indicator_season_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_EPISODE_DIGITS = DEFAULT_SHOW_INDEX_EPISODE_DIGITS @@ -132,6 +133,13 @@ class ShowDescriptor(): 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): return self.__showId @@ -150,6 +158,8 @@ class ShowDescriptor(): return self.__indicatorEpisodeDigits def getQuality(self): return self.__quality + def getNotes(self): + return self.__notes def getFilenamePrefix(self): return f"{self.__showName} ({str(self.__showYear)})" diff --git a/src/ffx/show_details_screen.py b/src/ffx/show_details_screen.py index 7458ee8..1ed3e10 100644 --- a/src/ffx/show_details_screen.py +++ b/src/ffx/show_details_screen.py @@ -1,7 +1,7 @@ import click 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.widgets._data_table import CellDoesNotExist @@ -25,8 +25,8 @@ class ShowDetailsScreen(Screen): CSS = """ Grid { - grid-size: 5 16; - grid-rows: 2 2 2 2 2 2 2 2 2 2 2 9 2 9 2 2; + grid-size: 5 18; + grid-rows: 2 2 2 2 2 2 6 2 2 2 2 2 9 2 9 2 2 2; grid-columns: 30 30 30 30 30; height: 100%; width: 100%; @@ -77,6 +77,10 @@ class ShowDetailsScreen(Screen): height: 100%; border: solid green; } + + .note_box { + min-height: 6; + } """ BINDINGS = [ @@ -152,6 +156,8 @@ class ShowDetailsScreen(Screen): 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}") @@ -354,25 +360,32 @@ class ShowDetailsScreen(Screen): yield Input(type="integer", id="quality_input", classes="four") #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 Input(type="integer", id="index_season_digits_input", classes="four") - #7 + #9 yield Static("Index Episode Digits") yield Input(type="integer", id="index_episode_digits_input", classes="four") - #8 + #10 yield Static("Indicator Season Digits") yield Input(type="integer", id="indicator_season_digits_input", classes="four") - #9 + #11 yield Static("Indicator Edisode Digits") yield Input(type="integer", id="indicator_episode_digits_input", classes="four") - # 10 + # 12 yield Static(" ", classes="five") - # 11 + # 13 yield Static("Shifted seasons", classes="two") if self.__showDescriptor is not None: @@ -384,18 +397,18 @@ class ShowDetailsScreen(Screen): yield Static(" ") yield Static(" ") - # 12 + # 14 yield self.shiftedSeasonsTable - # 13 + # 15 yield Static("File patterns", classes="five") - # 14 + # 16 yield self.patternTable - # 15 + # 17 yield Static(" ", classes="five") - # 16 + # 18 yield Button("Save", id="save_button") yield Button("Cancel", id="cancel_button") @@ -445,6 +458,7 @@ class ShowDetailsScreen(Screen): 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) diff --git a/tests/unit/test_database.py b/tests/unit/test_database.py index f5fa194..a6fef87 100644 --- a/tests/unit/test_database.py +++ b/tests/unit/test_database.py @@ -66,7 +66,7 @@ class DatabaseContextTests(unittest.TestCase): return shifted_season_id - def rewrite_shows_table_without_quality(self, cursor): + def rewrite_shows_table_without_show_fields(self, cursor): cursor.execute("ALTER TABLE shows RENAME TO shows_current") cursor.execute( """ @@ -208,7 +208,7 @@ class DatabaseContextTests(unittest.TestCase): cursor = connection.cursor() cursor.execute("PRAGMA foreign_keys=OFF") self.rewrite_shifted_seasons_table_without_pattern_owner(cursor) - self.rewrite_shows_table_without_quality(cursor) + self.rewrite_shows_table_without_show_fields(cursor) cursor.execute( "UPDATE properties SET value = '2' WHERE key = ?", (DATABASE_VERSION_KEY,), @@ -245,6 +245,7 @@ class DatabaseContextTests(unittest.TestCase): 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: @@ -286,6 +287,40 @@ class DatabaseContextTests(unittest.TestCase): 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__": unittest.main() diff --git a/tests/unit/test_show_descriptor_defaults.py b/tests/unit/test_show_descriptor_defaults.py index f1df5bf..1c95a58 100644 --- a/tests/unit/test_show_descriptor_defaults.py +++ b/tests/unit/test_show_descriptor_defaults.py @@ -57,6 +57,7 @@ class ShowDescriptorDefaultTests(unittest.TestCase): self.assertEqual(3, descriptor.getIndicatorSeasonDigits()) self.assertEqual(4, descriptor.getIndicatorEpisodeDigits()) self.assertEqual(0, descriptor.getQuality()) + self.assertEqual("", descriptor.getNotes()) def test_show_descriptor_without_context_uses_shared_constants(self): descriptor = ShowDescriptor(id=1, name="Default Show", year=2024) @@ -72,12 +73,18 @@ class ShowDescriptorDefaultTests(unittest.TestCase): 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): basename = getEpisodeFileBasename( "Configured Show",