diff --git a/sachi/models.py b/sachi/models.py index ffcc274..abed9fa 100644 --- a/sachi/models.py +++ b/sachi/models.py @@ -1,12 +1,11 @@ import asyncio from dataclasses import asdict, dataclass from pathlib import Path -from typing import Self, assert_never, cast +from typing import Callable, Self, assert_never, cast import jinja2 from guessit import guessit from pymediainfo import MediaInfo -from textual.widgets.data_table import RowKey from sachi.config import BaseConfig, read_config from sachi.context import FileBotContext @@ -21,19 +20,24 @@ @dataclass class SachiMatch: parent: SachiParentModel - episode: SachiEpisodeModel | None + episode: SachiEpisodeModel class SachiFile: - def __init__(self, path: Path): + def __init__( + self, path: Path, base_dir: Path, set_rename_cell: Callable[[str | None], None] + ): self.path = path + self.base_dir = base_dir + self.set_rename_cell = set_rename_cell + self._match: SachiMatch | None = None self.ctx = FileBotContext() self.analyze_filename() self.media_analysis_done = asyncio.Event() - self.row_key: RowKey | None = None + self.new_path = asyncio.Future[Path]() @property def match(self) -> SachiMatch | None: @@ -42,10 +46,24 @@ def match(self) -> SachiMatch | None: @match.setter def match(self, value: SachiMatch | None): self._match = value + + self.set_rename_cell( + f"{value.parent.title} ({value.parent.year}) " + f"- {value.episode.season:02}x{value.episode.episode:02} " + f"- {value.episode.name}" + if value + else None + ) + self.analyze_match() - if not self.media_analysis_done.is_set(): + + if value is not None and not self.media_analysis_done.is_set(): asyncio.create_task(asyncio.to_thread(self.analyze_media)) + if self.new_path.done(): + self.new_path = asyncio.Future[Path]() + asyncio.create_task(self.template_new_path()) + def analyze_filename(self): guess = cast(dict, guessit(self.path.name)) self.ctx.source = guess.get("source", None) @@ -77,9 +95,9 @@ def analyze_match(self): self.ctx.s00e00 = f"S{episode.season:02}E{episode.episode:02}" self.ctx.t = episode.name - async def new_path(self, base: Path) -> Path | None: + async def template_new_path(self): if self.match is None: - return None + return await self.media_analysis_done.wait() @@ -99,7 +117,9 @@ async def new_path(self, base: Path) -> Path | None: new_segment = template.render(asdict(self.ctx)) new_segment = FS_SPECIAL_CHARS.sub("", new_segment) new_segment = new_segment.replace(FAKE_SLASH, "/") - return (base / new_segment).with_suffix(self.path.suffix) + new_path = (self.base_dir / new_segment).with_suffix(self.path.suffix) + self.set_rename_cell(str(new_path.relative_to(self.base_dir))) + self.new_path.set_result(new_path) def __eq__(self, other: object): return isinstance(other, SachiFile) and self.path == other.path diff --git a/sachi/screens/episodes.py b/sachi/screens/episodes.py index c1b5b06..0eec6da 100644 --- a/sachi/screens/episodes.py +++ b/sachi/screens/episodes.py @@ -2,10 +2,11 @@ from textual import on, work from textual.app import ComposeResult -from textual.containers import Container +from textual.containers import Horizontal, Vertical from textual.reactive import reactive from textual.screen import ModalScreen, Screen from textual.widgets import ( + DataTable, Footer, Header, Input, @@ -58,13 +59,14 @@ def selected_episodes(self) -> list[SachiEpisodeModel]: def compose(self) -> ComposeResult: yield Header() - with Container(id="search-header", classes="my-1"): - yield Input(placeholder="Search", id="search-input", restrict=r".+") - yield Select( - ((f"{s.service} ({s.media_type})", s) for s in SOURCE_CLASSES), - prompt="Source", - ) - yield SelectionList[int](classes="my-1") + with Vertical(): + with Horizontal(): + yield Input(placeholder="Search", id="search-input", restrict=r".+") + yield Select( + ((f"{s.service} ({s.media_type})", s) for s in SOURCE_CLASSES), + prompt="Source", + ) + yield SelectionList[int]() yield Footer() # Methods @@ -112,10 +114,12 @@ def action_append_selection(self): return parent = self.sachi_parent screen = cast(RenameScreen, self.app.get_screen("rename")) + table = screen.query_one(DataTable) j = 0 - for file in screen.files: + for row in table.ordered_rows: if j >= len(self.selected_episodes): break + file = screen.files[row.key] if file.match is None: file.match = SachiMatch( parent=parent, episode=self.selected_episodes[j] @@ -129,7 +133,8 @@ def action_replace_selection(self): return parent = self.sachi_parent screen = cast(RenameScreen, self.app.get_screen("rename")) - for file, ep in zip(screen.files, self.selected_episodes): - file.match = SachiMatch(parent=parent, episode=ep) + table = screen.query_one(DataTable) + for row, ep in zip(table.ordered_rows, self.selected_episodes): + screen.files[row.key].match = SachiMatch(parent=parent, episode=ep) self.app.switch_screen("rename") self.deselect_all() diff --git a/sachi/screens/episodes.tcss b/sachi/screens/episodes.tcss index 14d2d8a..805b57f 100644 --- a/sachi/screens/episodes.tcss +++ b/sachi/screens/episodes.tcss @@ -1,3 +1,19 @@ +Horizontal { + height: auto; + + Input { + width: 3fr; + } + + Select { + width: 1fr; + } +} + +SelectionList { + height: 100%; +} + ModalScreen { align: center middle; } @@ -11,17 +27,3 @@ ListView { Label { padding: 1 2; } - -.my-1 { - margin: 1 0; -} - -#search-header { - layout: grid; - grid-size: 4 1; - height: auto; -} - -#search-input { - column-span: 3; -} diff --git a/sachi/screens/rename.py b/sachi/screens/rename.py index 88af3cb..d7efca7 100644 --- a/sachi/screens/rename.py +++ b/sachi/screens/rename.py @@ -1,9 +1,12 @@ +from functools import partial from pathlib import Path +from typing import Generator from textual.app import ComposeResult from textual.reactive import reactive from textual.screen import Screen from textual.widgets import DataTable, Footer, Header +from textual.widgets.data_table import RowKey from sachi.models import SachiFile @@ -16,31 +19,28 @@ class RenameScreen(Screen): ("p", "apply_renames", "Apply"), ] - files: reactive[list[SachiFile]] = reactive([]) + files: reactive[dict[RowKey, SachiFile]] = reactive({}) def __init__(self, file_or_dir: Path, **kwargs): super().__init__(**kwargs) - self.load(file_or_dir) - self.files.sort() + self.file_or_dir = file_or_dir self.base_dir = file_or_dir.parent if file_or_dir.is_file() else file_or_dir def compose(self) -> ComposeResult: yield Header() - table = DataTable(fixed_rows=1, zebra_stripes=True) - table.add_columns("From", "To") - yield table + yield DataTable(fixed_rows=1, zebra_stripes=True) yield Footer() # Methods - def load(self, file_or_dir: Path): + def iter_files(self, file_or_dir: Path) -> Generator[Path, None, None]: if file_or_dir.name.startswith("."): return if file_or_dir.is_file(): - self.files.append(SachiFile(path=file_or_dir)) + yield file_or_dir elif file_or_dir.is_dir(): for file in file_or_dir.iterdir(): - self.load(file) + yield from self.iter_files(file) else: raise RuntimeError(f"Invalid file or directory: {file_or_dir}") @@ -48,48 +48,41 @@ def load(self, file_or_dir: Path): async def on_mount(self): table = self.query_one(DataTable) - for file in self.files: - new_path = await file.new_path(self.base_dir) - file.row_key = table.add_row( - str(file.path.relative_to(self.base_dir)), - str(new_path.relative_to(self.base_dir)) if new_path else None, + col_keys = table.add_columns("From", "To") + for path in self.iter_files(self.file_or_dir): + row_key = table.add_row( + str(path.relative_to(self.base_dir)), + None, ) - table.focus() - - async def on_screen_resume(self): - table = self.query_one(DataTable) - table.clear() - for file in self.files: - new_path = await file.new_path(self.base_dir) - file.row_key = table.add_row( - str(file.path.relative_to(self.base_dir)), - str(new_path.relative_to(self.base_dir)) if new_path else None, + self.files[row_key] = SachiFile( + path, + self.base_dir, + partial(table.update_cell, row_key, col_keys[1], update_width=True), ) + table.sort(col_keys[0]) table.focus() # Key bindings def action_remove_element(self): table = self.query_one(DataTable) - cood = table.cursor_coordinate - keys = table.coordinate_to_cell_key(cood) - if cood.column == 0: - del self.files[cood.row] - table.remove_row(keys.row_key) - if cood.column == 1: - self.files[cood.row].match = None - table.update_cell_at(cood, None) + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + col_i = table.cursor_column + if col_i == 0: + table.remove_row(cell_key.row_key) + del self.files[cell_key.row_key] + elif col_i == 1: + self.files[cell_key.row_key].match = None async def action_apply_renames(self): table = self.query_one(DataTable) - renamed = [] - for i, file in enumerate(self.files): - new_path = await file.new_path(self.base_dir) - if new_path is not None: + for row in table.ordered_rows: + file = self.files[row.key] + + if file.match is not None: + new_path = await file.new_path new_path.parent.mkdir(parents=True, exist_ok=True) file.path.rename(new_path) - renamed.append(i) - if file.row_key is not None: - table.remove_row(file.row_key) - for i in reversed(renamed): - del self.files[i] + + table.remove_row(row.key) + del self.files[row.key]