Add watching status and groups for shows and movies
This commit is contained in:
1 parent
7ef3a29ece
commit
90705bc3e2
11 files changed
+568
-244
No files matched your search
+60
-14
@@ -6,22 +6,39 @@ MEDIA_KINDS = {
|
||||
"show": {"key": "shows", "label": "TV show", "omdb_type": "series"},
|
||||
"movie": {"key": "movies", "label": "movie", "omdb_type": "movie"},
|
||||
}
|
||||
STATUSES = ["watched", "watching"]
|
||||
|
||||
|
||||
def check_rating(rating: int) -> int:
|
||||
if not 0 <= rating <= 10:
|
||||
def check_rating(rating: int | None) -> int | None:
|
||||
if rating is not None and not 0 <= rating <= 10:
|
||||
raise ValueError("Rating must be between 0 and 10")
|
||||
return rating
|
||||
|
||||
|
||||
def check_status(status: str) -> str:
|
||||
if status not in STATUSES:
|
||||
raise ValueError(f"Status must be one of {', '.join(STATUSES)}")
|
||||
return status
|
||||
|
||||
|
||||
def load_items(kind: str) -> tuple[dict, list[dict]]:
|
||||
data = jsonfile.load(ABOUT_FILE)
|
||||
return data, data.setdefault(MEDIA_KINDS[kind]["key"], [])
|
||||
|
||||
|
||||
def status_of(item: dict) -> str:
|
||||
return item.get("status", "watched")
|
||||
|
||||
|
||||
def list_items(kind: str) -> list[dict]:
|
||||
_, items = load_items(kind)
|
||||
return sorted(items, key=lambda item: -item.get("rating", 0))
|
||||
rating = lambda item: item["rating"] if item.get("rating") is not None else -1
|
||||
return sorted(items, key=lambda item: (status_of(item) != "watching", -rating(item)))
|
||||
|
||||
|
||||
def collection_names(kind: str) -> list[str]:
|
||||
_, items = load_items(kind)
|
||||
return sorted({item["collection"] for item in items if item.get("collection")})
|
||||
|
||||
|
||||
def find_index(items: list[dict], text: str) -> int | None:
|
||||
@@ -34,27 +51,53 @@ def find_index(items: list[dict], text: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def look_up_name(kind: str, identifier: str) -> str:
|
||||
def look_up_name(identifier: str) -> str:
|
||||
details = imdb.lookup(identifier)
|
||||
if not details:
|
||||
raise ValueError("Could not look up the title on OMDb. Enter a name, or set OMDB_API_KEY in .env")
|
||||
return details.get("Title") or identifier
|
||||
|
||||
|
||||
def add_item(kind: str, text: str, rating: int, name: str | None = None) -> dict:
|
||||
def with_optional(item: dict, key: str, value) -> dict:
|
||||
updated = {name: field for name, field in item.items() if name != key}
|
||||
if value is not None and value != "":
|
||||
updated[key] = value
|
||||
return updated
|
||||
|
||||
|
||||
def build_item(name: str, url: str, rating: int | None, status: str, collection: str | None) -> dict:
|
||||
item = {"name": name, "url": url}
|
||||
item = with_optional(item, "rating", rating)
|
||||
if status != "watched":
|
||||
item["status"] = status
|
||||
return with_optional(item, "collection", collection)
|
||||
|
||||
|
||||
def add_item(kind: str, text: str, rating: int | None, name: str | None = None, status: str = "watched", collection: str | None = None) -> dict:
|
||||
identifier = imdb.imdb_id(text)
|
||||
if not identifier:
|
||||
raise ValueError(f"{text} is not an IMDb link or ID")
|
||||
check_rating(rating)
|
||||
check_status(status)
|
||||
data, items = load_items(kind)
|
||||
if find_index(items, identifier) is not None:
|
||||
raise ValueError(f"{identifier} is already listed")
|
||||
item = {"name": name or look_up_name(kind, identifier), "rating": rating, "url": imdb.title_url(identifier)}
|
||||
item = build_item(name or look_up_name(identifier), imdb.title_url(identifier), rating, status, collection)
|
||||
items.append(item)
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return item
|
||||
|
||||
|
||||
def update_item(kind: str, text: str, change) -> dict:
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
if index is None:
|
||||
raise ValueError(f"{text} is not listed")
|
||||
items[index] = change(items[index])
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return items[index]
|
||||
|
||||
|
||||
def remove_item(kind: str, text: str) -> dict:
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
@@ -65,12 +108,15 @@ def remove_item(kind: str, text: str) -> dict:
|
||||
return removed
|
||||
|
||||
|
||||
def rate_item(kind: str, text: str, rating: int) -> dict:
|
||||
def rate_item(kind: str, text: str, rating: int | None) -> dict:
|
||||
check_rating(rating)
|
||||
data, items = load_items(kind)
|
||||
index = find_index(items, text)
|
||||
if index is None:
|
||||
raise ValueError(f"{text} is not listed")
|
||||
items[index]["rating"] = rating
|
||||
jsonfile.save(ABOUT_FILE, data)
|
||||
return items[index]
|
||||
return update_item(kind, text, lambda item: with_optional(item, "rating", rating))
|
||||
|
||||
|
||||
def set_status(kind: str, text: str, status: str) -> dict:
|
||||
check_status(status)
|
||||
return update_item(kind, text, lambda item: with_optional(item, "status", None if status == "watched" else status))
|
||||
|
||||
|
||||
def set_collection(kind: str, text: str, collection: str | None) -> dict:
|
||||
return update_item(kind, text, lambda item: with_optional(item, "collection", collection))
|
||||
@@ -1,3 +1,4 @@
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.widgets import DataTable
|
||||
@@ -7,11 +8,19 @@ import media
|
||||
from tui.content_pane import ContentPane
|
||||
from tui.dialogs import ConfirmDialog, FormDialog, FormField
|
||||
|
||||
STATUS_STYLES = {"watching": "blue", "watched": "green"}
|
||||
|
||||
|
||||
def rating_text(item: dict) -> str:
|
||||
return f"{item['rating']}/10" if item.get("rating") is not None else "-"
|
||||
|
||||
|
||||
class MediaPane(ContentPane):
|
||||
BINDINGS = [
|
||||
Binding("a", "add", "Add"),
|
||||
Binding("e", "rate", "Rate"),
|
||||
Binding("s", "status", "Watching"),
|
||||
Binding("g", "collection", "Group"),
|
||||
Binding("d", "delete", "Delete"),
|
||||
Binding("r", "reload", "Reload"),
|
||||
]
|
||||
@@ -29,8 +38,10 @@ class MediaPane(ContentPane):
|
||||
|
||||
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("IMDb", key="imdb")
|
||||
self.reload()
|
||||
|
||||
@@ -39,13 +50,31 @@ class MediaPane(ContentPane):
|
||||
table.clear()
|
||||
for item in media.list_items(self.kind):
|
||||
identifier = imdb.imdb_id(item.get("url", "")) or item.get("name", "")
|
||||
table.add_row(f"{item.get('rating', '?')}/10", item.get("name", ""), identifier, key=identifier)
|
||||
status = media.status_of(item)
|
||||
table.add_row(
|
||||
Text(status, style=STATUS_STYLES[status]),
|
||||
rating_text(item),
|
||||
item.get("name", ""),
|
||||
item.get("collection", ""),
|
||||
identifier,
|
||||
key=identifier,
|
||||
)
|
||||
self.restore_cursor(focus_key)
|
||||
|
||||
def selected_item(self) -> dict | None:
|
||||
identifier = self.selected_key()
|
||||
return next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), None)
|
||||
|
||||
def collection_hint(self) -> str:
|
||||
names = media.collection_names(self.kind)
|
||||
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("rating", "Your rating (0 to 10)", kind="rating", 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)
|
||||
@@ -57,31 +86,51 @@ class MediaPane(ContentPane):
|
||||
|
||||
def added(item: dict):
|
||||
self.reload(identifier)
|
||||
self.notify(f"Added {item['name']} ({item['rating']}/10)")
|
||||
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"]),
|
||||
lambda: media.add_item(self.kind, values["link"], values["rating"], values["name"], values["status"], values["collection"]),
|
||||
added,
|
||||
)
|
||||
|
||||
def action_rate(self):
|
||||
identifier = self.selected_key()
|
||||
if not identifier:
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
current = next((item for item in media.list_items(self.kind) if imdb.imdb_id(item.get("url", "")) == identifier), {})
|
||||
fields = [FormField("rating", f"Rating for {current.get('name', identifier)}", kind="rating", required=True, value=str(current.get("rating", "")))]
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
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:
|
||||
self.run_now(
|
||||
lambda: media.rate_item(self.kind, identifier, values["rating"]),
|
||||
lambda item: f"Rated {item['name']} {item['rating']}/10",
|
||||
identifier,
|
||||
)
|
||||
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 = imdb.imdb_id(item["url"])
|
||||
next_status = "watched" if media.status_of(item) == "watching" else "watching"
|
||||
self.run_now(lambda: media.set_status(self.kind, identifier, next_status), f"{item['name']} is now {next_status}", identifier)
|
||||
|
||||
def action_collection(self):
|
||||
item = self.selected_item()
|
||||
if not item:
|
||||
return
|
||||
identifier = imdb.imdb_id(item["url"])
|
||||
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_delete(self):
|
||||
identifier = self.selected_key()
|
||||
if not identifier:
|
||||
|
||||
Reference in new issue
Block a user