Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hyperwall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from __future__ import annotations

__version__ = "10.13.1"
__version__ = "10.13.2"
# Short "major.minor" form, derived — used for User-Agent / Emby auth Version /
# window titles so a version bump touches exactly ONE line (this file).
VERSION_SHORT = ".".join(__version__.split(".")[:2])
Expand Down
25 changes: 11 additions & 14 deletions hyperwall/cell.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
)
from . import theme
from .perftrace import traced
from .urls import tag_names

logger = logging.getLogger("HyperWall")

Expand Down Expand Up @@ -625,14 +626,10 @@ def _begin_track(self, item: dict[str, Any]) -> None:

self.lbl_title.setText(item.get("Name", "Unknown"))

# Update tag/fav buttons
raw = item.get("Tags", [])
tag_names = (
[t.get("Name", "") for t in raw]
if raw and isinstance(raw[0], dict)
else list(raw)
)
self.btn_tag.setChecked("ToDelete" in tag_names)
# Update tag/fav buttons. Emby serves applied tags under TagItems and
# leaves Tags null, so read via the shared helper (item["Tags"] alone
# shows an already-tagged clip as untagged).
self.btn_tag.setChecked("ToDelete" in tag_names(item))
self.btn_fav.setChecked(
item.get("UserData", {}).get("IsFavorite", False)
)
Expand Down Expand Up @@ -1203,17 +1200,17 @@ def _toggle_tag(self) -> None:
if not self.current_item:
return
self._nudge_pill()
raw = self.current_item.setdefault("Tags", [])
tags = (
[t.get("Name", "") for t in raw]
if raw and isinstance(raw[0], dict)
else list(raw)
)
# Read via the helper (Emby puts tags in TagItems, leaves Tags null —
# the old item["Tags"] read hit list(None) on a library-loaded clip).
tags = tag_names(self.current_item)
if "ToDelete" in tags:
tags.remove("ToDelete")
else:
tags.append("ToDelete")
# Keep BOTH shapes in the local dict in sync so the helper (which
# prefers TagItems) reflects the new state on the next read.
self.current_item["Tags"] = tags
self.current_item["TagItems"] = [{"Name": t} for t in tags]
self.btn_tag.setChecked("ToDelete" in tags) # :checked tints the glyph red
self.controller.update_tags(self.current_item)

Expand Down
20 changes: 20 additions & 0 deletions hyperwall/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ def needs_transcode(
)


def tag_names(item: dict[str, Any]) -> list[str]:
"""Tag names for an Emby item, tolerant of Emby's tag shapes.

Emby returns applied tags under ``TagItems`` (a list of ``{Name, Id}``)
and leaves the legacy ``Tags`` string list **null** on both the item
list and detail endpoints — so reading ``item["Tags"]`` alone sees an
already-tagged item as untagged (the wall's ToDelete indicator was wrong,
and toggling a library-loaded tagged clip hit ``list(None)``). Prefer
``TagItems``; fall back to ``Tags`` (which may itself be a string list or,
on some shapes, a list of dicts). Always returns a fresh list of str.
"""
ti = item.get("TagItems")
if ti:
return [t.get("Name", "") for t in ti if isinstance(t, dict)]
raw = item.get("Tags") or []
if raw and isinstance(raw[0], dict):
return [t.get("Name", "") for t in raw]
return list(raw)


def build_stream_url(
*,
base: str,
Expand Down
10 changes: 3 additions & 7 deletions hyperwall/wall.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
from .emby import EmbyClient, ContentLoader
from .urls import needs_transcode as _needs_transcode_pure
from .reliability import is_systemic_outage
from .urls import build_stream_url
from .urls import build_stream_url, tag_names
from .playlist import PlaylistManager, DEFAULT_GROUP

logger = logging.getLogger("HyperWall")
Expand Down Expand Up @@ -525,12 +525,8 @@ def _set_filter(self, mode: str) -> None:
def update_tags(self, item: dict[str, Any]) -> None:
iid = item["Id"]
name = item.get("Name", "Unknown")
raw = item.get("Tags", [])
tags = (
[t.get("Name", "") for t in raw]
if raw and isinstance(raw[0], dict)
else list(raw)
)
# Read via the helper (Emby serves tags under TagItems, Tags is null).
tags = tag_names(item)

def _worker() -> None:
try:
Expand Down
4 changes: 2 additions & 2 deletions tests/run_repo_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ def test_01_entry_point_imports():
def test_02_package_identity():
"""Package has version and banner."""
from hyperwall import __version__, runtime_banner
assert __version__ == "10.13.1"
assert __version__ == "10.13.2"
banner = runtime_banner()
assert "Hyperwall" in banner
assert "10.13.1" in banner
assert "10.13.2" in banner


def test_03_config_loads():
Expand Down
38 changes: 38 additions & 0 deletions tests/test_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
exceeds_1080p,
exceeds_direct_budget,
needs_transcode,
tag_names,
)


Expand Down Expand Up @@ -203,6 +204,43 @@ def test_urls_carry_item_and_key():
assert "api_key=tok" in url


# ── tag_names (Emby TagItems vs Tags shape) ───────────────────────────────────

def test_tag_names_reads_tagitems_when_tags_null():
# The exact shape greg's Emby returns for a tagged item (probed 2026-07-15):
# Tags is null, the applied tag lives in TagItems. The old item["Tags"]
# read saw this as untagged → wrong ToDelete indicator + list(None) crash.
item = {"Id": "x", "Tags": None,
"TagItems": [{"Name": "ToDelete", "Id": 21516}]}
assert tag_names(item) == ["ToDelete"]


def test_tag_names_tagitems_takes_precedence_over_tags():
item = {"TagItems": [{"Name": "ToDelete"}], "Tags": ["stale"]}
assert tag_names(item) == ["ToDelete"]


def test_tag_names_falls_back_to_string_list():
assert tag_names({"Tags": ["ToDelete", "keep"]}) == ["ToDelete", "keep"]


def test_tag_names_falls_back_to_dict_list_tags():
assert tag_names({"Tags": [{"Name": "ToDelete"}]}) == ["ToDelete"]


def test_tag_names_empty_and_missing_safe():
assert tag_names({}) == []
assert tag_names({"Tags": None, "TagItems": None}) == []
assert tag_names({"Tags": [], "TagItems": []}) == []


def test_tag_names_untagged_item_not_checked():
# The bug's user-visible symptom: an untagged item must compute False, a
# ToDelete-tagged one True — regardless of which field Emby populates.
assert "ToDelete" not in tag_names({"Tags": None, "TagItems": []})
assert "ToDelete" in tag_names({"TagItems": [{"Name": "ToDelete"}]})


def run_all() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
passed = failed = 0
Expand Down
Loading