import time from rich.text import Text from textual import work from textual.app import ComposeResult from textual.binding import Binding from textual.widgets import DataTable import imdb import media from tui.cache_indicator import cache_state, cache_text from tui.dialogs import ConfirmDialog, FormDialog, FormField from tui.library_pane import LibraryPane REQUEST_DELAY_SECONDS = 0.5 STATUS_STYLES = {"watching": "blue", "watched": "green", "planning": "magenta"} def rating_text(item: dict) -> str: return f"{item['rating']}/10" if item.get("rating") is not None else "-" class MediaPane(LibraryPane): BINDINGS = [ Binding("a", "add", "Add"), Binding("e", "rate", "Rate"), Binding("s", "status", "Status"), Binding("g", "collection", "Group"), Binding("h", "dates", "Dates"), Binding("d", "delete", "Delete"), Binding("u", "refresh", "Refresh"), Binding("C", "cache_all", "Cache all"), Binding("r", "reload", "Reload"), ] def __init__(self, kind: str, **kwargs): super().__init__(**kwargs) self.kind = kind self.label = media.MEDIA_KINDS[kind]["label"] self.border_subtitle = media.MEDIA_KINDS[kind]["key"] self.items: dict[str, dict] = {} def compose(self) -> ComposeResult: yield DataTable(cursor_type="row", zebra_stripes=True) def on_mount(self): table = self.table() table.add_column("Status", key="status") table.add_column("Rating", key="rating") table.add_column("Title", key="title") table.add_column("Group", key="collection") table.add_column("Year", key="year") table.add_column("Finished", key="finished") table.add_column("Cache", key="cache") table.add_column("IMDb", key="imdb") self.reload() def reload(self, focus_key: str | None = None): table = self.table() table.clear() items = self.load_entries(lambda: media.list_items(self.kind)) self.items = {item["imdbId"]: item for item in items} for item in items: status = media.status_of(item) state = cache_state(media.has_details(item), media.has_stored_poster(item)) table.add_row( Text(status, style=STATUS_STYLES[status]), rating_text(item), item.get("name", ""), item.get("collection", ""), item["details"].get("year") or "-", item.get("finished") or "-", cache_text(state), item["imdbId"], key=item["imdbId"], ) self.restore_cursor(focus_key) def selected_item(self) -> dict | None: return self.items.get(self.selected_key()) def collection_hint(self) -> str: names = sorted({item["collection"] for item in self.items.values() if item.get("collection")}) return f"Existing: {', '.join(names)}" if names else "e.g. Star Wars" def action_add(self): fields = [ FormField("link", "IMDb link or ID", placeholder="https://www.imdb.com/title/tt0816692/", required=True), FormField("status", "Status", kind="select", options=media.STATUSES, value="watched"), FormField("rating", "Your rating (0 to 10, optional)", kind="rating"), FormField("collection", "Group (optional, same name groups titles together)", placeholder=self.collection_hint()), FormField("name", "Name (looked up on OMDb when empty)"), ] self.app.push_screen(FormDialog(f"Add a {self.label}", fields, "Add"), self.add_item) def add_item(self, values: dict | None): if not values: return identifier = imdb.imdb_id(values["link"]) def added(item: dict): self.reload(identifier) self.notify(f"Added {item['name']} ({media.status_of(item)})") self.run_in_background( f"Adding {identifier or values['link']}...", lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"], values["status"], values["collection"]), added, ) def action_rate(self): item = self.selected_item() if not item: return identifier = item["imdbId"] current = "" if item.get("rating") is None else str(item["rating"]) fields = [FormField("rating", f"Rating for {item['name']} (empty to clear)", kind="rating", value=current)] def rated(values: dict | None): if values is not None: self.run_now(lambda: media.rate_item(self.kind, identifier, values["rating"]), f"Updated {item['name']}", identifier) self.app.push_screen(FormDialog("Change rating", fields), rated) def action_status(self): item = self.selected_item() if not item: return identifier = item["imdbId"] fields = [FormField("status", f"Status for {item['name']}", kind="select", options=media.STATUSES, value=media.status_of(item))] def changed(values: dict | None): if values: status = values["status"] self.run_now(lambda: media.set_status(self.kind, identifier, status), f"{item['name']} is now {status}", identifier) self.app.push_screen(FormDialog("Change status", fields, "Save"), changed) def action_collection(self): item = self.selected_item() if not item: return identifier = item["imdbId"] fields = [FormField("collection", f"Group for {item['name']} (empty to remove)", value=item.get("collection", ""), placeholder=self.collection_hint())] def grouped(values: dict | None): if values is not None: collection = values["collection"] message = f"Added {item['name']} to {collection}" if collection else f"Removed {item['name']} from its group" self.run_now(lambda: media.set_collection(self.kind, identifier, collection), message, identifier) self.app.push_screen(FormDialog("Group titles", fields), grouped) def action_dates(self): item = self.selected_item() if not item: return identifier = item["imdbId"] self.edit_dates(item["name"], item, lambda started, finished: media.set_dates(self.kind, identifier, started, finished), identifier) def action_delete(self): item = self.selected_item() if not item: return identifier = item["imdbId"] def confirmed(should_delete: bool | None): if should_delete: self.run_now(lambda: media.remove_item(self.kind, identifier), f"Removed {item['name']}") self.app.push_screen(ConfirmDialog(f"Remove {item['name']}?"), confirmed) def action_refresh(self): item = self.selected_item() if not item: return identifier = item["imdbId"] def refreshed(_saved: dict): self.reload(identifier) self.notify(f"Saved details and poster for {item['name']}") self.run_in_background(f"Looking up {item['name']} on OMDb...", lambda: media.refresh_details(identifier), refreshed) def action_cache_all(self): missing = [identifier for identifier, item in self.items.items() if not media.has_details(item) or not media.has_stored_poster(item)] if not missing: self.notify(f"Every {self.label} already has saved details and a poster") return self.notify(f"Saving details for {len(missing)} titles...") self.cache_missing_details(missing) @work(thread=True, exclusive=True, group="media-cache") def cache_missing_details(self, missing: list[str]): saved = 0 for index, identifier in enumerate(missing): if index: time.sleep(REQUEST_DELAY_SECONDS) try: media.refresh_details(identifier) except (OSError, ValueError) as error: self.app.call_from_thread(self.notify, f"{identifier}: {error}", title="Skipped", severity="warning") continue saved += 1 self.app.call_from_thread(lambda: self.reload(self.selected_key())) self.app.call_from_thread(self.notify, f"Saved {saved} of {len(missing)} titles")