fix(plex): re-probe on file replace, HU parity, public class const, playback-class test gaps
Deep-review round 1 on feature/plex-playback-class-filter: - Sticky watermark (medium): _apply_item reset the bit-depth probe watermark only on a codec-string change, so a same-codec re-encode (8-bit->10-bit HEVC) kept a stale video_bit_depth/transcode_hw forever (the backfill only re-probes media_probed_at IS NULL rows). Also reset when the Plex Part key changes (the file was replaced), not just the codec. - i18n parity (medium): the HU playback-class labels were mixed-language (only 'direct' translated). Translated remux/transcode_hw/transcode_sw and the tooltips consistently to Hungarian (Kozvetlen streameles / Atkodolas). - Layering (low): _PLAYBACK_CLASSES was module-private yet imported across into routes.plex.browse; renamed to public PLAYBACK_CLASSES (it is the shared source of truth for facet order + zero-fill). - a11y (low): the browse-card playback-class dot conveyed meaning only via a mouse-hover title; added role=img + aria-label so SR/touch users get the class. - tests: the NULL/unprobed transcode_hw -> SW fold (filter + facet), facet self-exclusion under an active playable filter, the migration codec-set == HW_DECODABLE_CODECS drift guard, the probe-batch bound + oldest-first order, the transient probe-failure retry path, and the same-codec re-encode re-probe. Backend gate green (ruff + full pytest); FE gate green (tsc/lint/knip/prettier/ vitest). NOTE: the facet-vs-runtime disagreement for 10-bit HEVC (C1) is the known merge-order constraint — this branch's runtime is codec-only; the facet becomes truthful once BUG1/tonemap's bit-depth-aware routing lands with it. Left as a sequencing constraint, not code-changed here.
This commit is contained in:
@@ -212,7 +212,8 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
|||||||
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
|
||||||
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
|
||||||
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
|
||||||
_PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill).
|
||||||
|
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||||||
|
|
||||||
|
|
||||||
def playback_class_expr():
|
def playback_class_expr():
|
||||||
@@ -230,7 +231,7 @@ def playback_class_expr():
|
|||||||
def playable_conds(sel: list[str]) -> list:
|
def playable_conds(sel: list[str]) -> list:
|
||||||
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||||||
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||||||
keep = [c for c in sel if c in _PLAYBACK_CLASSES]
|
keep = [c for c in sel if c in PLAYBACK_CLASSES]
|
||||||
return [playback_class_expr().in_(keep)] if keep else []
|
return [playback_class_expr().in_(keep)] if keep else []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -471,13 +471,20 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||||||
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
_apply_facet_fields(row, meta) # filterable/orderable extras (shared with shows)
|
||||||
mf = _media_facts(meta)
|
mf = _media_facts(meta)
|
||||||
new_vcodec = mf.get("codec_video")
|
new_vcodec = mf.get("codec_video")
|
||||||
# A changed video codec means the physical file was replaced — any probed bit-depth is stale, so
|
new_part_key = mf.get("part_key")
|
||||||
# reset the probe watermark and re-derive transcode_hw from codec alone until the backfill re-probes.
|
# The physical file was replaced when its Plex Part key changes (a re-encode / quality upgrade gets a
|
||||||
if (row.codec_video or "").lower() != (new_vcodec or "").lower():
|
# new part id) OR — a coarser signal — when the video codec changes. Either way any probed bit-depth is
|
||||||
|
# stale, so reset the probe watermark and re-derive transcode_hw from codec alone until the backfill
|
||||||
|
# re-probes. Codec alone is NOT enough: a same-codec bit-depth re-encode (8-bit→10-bit HEVC) keeps the
|
||||||
|
# codec `hevc` but flips the HW class, and only the part-key move catches it. A spurious reset (a rare
|
||||||
|
# part-id reassignment on an unchanged file) costs at most one extra bounded re-probe — safe direction.
|
||||||
|
if (row.codec_video or "").lower() != (
|
||||||
|
new_vcodec or ""
|
||||||
|
).lower() or row.part_key != new_part_key:
|
||||||
row.video_bit_depth = None
|
row.video_bit_depth = None
|
||||||
row.media_probed_at = None
|
row.media_probed_at = None
|
||||||
row.file_path = mf.get("file_path")
|
row.file_path = mf.get("file_path")
|
||||||
row.part_key = mf.get("part_key")
|
row.part_key = new_part_key
|
||||||
row.container = mf.get("container")
|
row.container = mf.get("container")
|
||||||
row.codec_video = new_vcodec
|
row.codec_video = new_vcodec
|
||||||
row.codec_audio = mf.get("codec_audio")
|
row.codec_audio = mf.get("codec_audio")
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ from app.models import (
|
|||||||
from app.plex import rollup
|
from app.plex import rollup
|
||||||
from app.plex.cards import episode_card
|
from app.plex.cards import episode_card
|
||||||
from app.plex.query import (
|
from app.plex.query import (
|
||||||
_PLAYBACK_CLASSES,
|
|
||||||
FIELD_COLUMNS,
|
FIELD_COLUMNS,
|
||||||
|
PLAYBACK_CLASSES,
|
||||||
TS_CONFIG,
|
TS_CONFIG,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
apply_meta_filters,
|
||||||
@@ -511,7 +511,7 @@ def facets(
|
|||||||
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
||||||
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
||||||
for c in split_csv(f.playable):
|
for c in split_csv(f.playable):
|
||||||
if c in _PLAYBACK_CLASSES:
|
if c in PLAYBACK_CLASSES:
|
||||||
playable_counts.setdefault(c, 0)
|
playable_counts.setdefault(c, 0)
|
||||||
return {
|
return {
|
||||||
"genres": [
|
"genres": [
|
||||||
@@ -527,7 +527,7 @@ def facets(
|
|||||||
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
||||||
"playable": [
|
"playable": [
|
||||||
{"value": c, "count": playable_counts[c]}
|
{"value": c, "count": playable_counts[c]}
|
||||||
for c in _PLAYBACK_CLASSES
|
for c in PLAYBACK_CLASSES
|
||||||
if c in playable_counts
|
if c in playable_counts
|
||||||
],
|
],
|
||||||
"year_min": min(years) if years else None,
|
"year_min": min(years) if years else None,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ backfill). These tests pin that a 10-bit/HDR HEVC lands in SW even though its co
|
|||||||
the exact case the runtime VAAPI path (plex.stream) also special-cases.
|
the exact case the runtime VAAPI path (plex.stream) also special-cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import app.state as state
|
import app.state as state
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
@@ -70,8 +73,11 @@ class TestSyncDerivation:
|
|||||||
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
|
"""`_apply_item` sets transcode_hw codec-only at sync (bit depth unknown from the listing), and a
|
||||||
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
|
changed video codec resets the probe watermark so a stale bit-depth can't linger."""
|
||||||
|
|
||||||
def _meta(self, vcodec):
|
def _meta(self, vcodec, *, key="/parts/1/file.mkv", file="/m.mkv"):
|
||||||
return {"title": "X", "Media": [{"videoCodec": vcodec, "Part": [{"file": "/m.mkv"}]}]}
|
return {
|
||||||
|
"title": "X",
|
||||||
|
"Media": [{"videoCodec": vcodec, "Part": [{"file": file, "key": key}]}],
|
||||||
|
}
|
||||||
|
|
||||||
def test_codec_only_derivation_at_sync(self):
|
def test_codec_only_derivation_at_sync(self):
|
||||||
row = PlexItem(rating_key="x", kind="movie")
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
@@ -102,6 +108,37 @@ class TestSyncDerivation:
|
|||||||
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("h264"))
|
||||||
assert row.video_bit_depth is None and row.media_probed_at is None
|
assert row.video_bit_depth is None and row.media_probed_at is None
|
||||||
|
|
||||||
|
def test_same_codec_reencode_with_a_new_part_resets_the_probe(self):
|
||||||
|
# A same-codec bit-depth re-encode (e.g. 8-bit→10-bit HEVC) keeps codec 'hevc' but replaces the
|
||||||
|
# physical file, so its Plex Part key changes. Codec-only reset would miss this and keep the stale
|
||||||
|
# 8-bit/HW verdict forever (the backfill only re-probes media_probed_at IS NULL rows) — the
|
||||||
|
# part-key move must reset the watermark so the new bit depth is re-probed.
|
||||||
|
row = PlexItem(rating_key="x", kind="movie")
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/1/f.mkv"))
|
||||||
|
row.transcode_hw = True
|
||||||
|
row.video_bit_depth = 8
|
||||||
|
row.media_probed_at = sync.datetime.now(sync.timezone.utc)
|
||||||
|
# Re-encoded in place: same codec 'hevc', but a new Part key (new file).
|
||||||
|
sync._apply_item(row, lib_id=1, kind="movie", meta=self._meta("hevc", key="/parts/2/f.mkv"))
|
||||||
|
assert row.video_bit_depth is None and row.media_probed_at is None # re-probe scheduled
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_codec_set_matches_hw_decodable_codecs():
|
||||||
|
# Migration 0077 hardcodes the HW-decodable codec set in raw SQL and its comment mandates it stay
|
||||||
|
# identical to plex.sync.HW_DECODABLE_CODECS. A comment isn't enforcement (house rule) — pin the
|
||||||
|
# equality so adding/removing a codec from the frozenset without editing the migration fails here
|
||||||
|
# (else already-migrated prod rows keep a wrong transcode_hw the runtime derivation wouldn't catch).
|
||||||
|
mig = (
|
||||||
|
Path(__file__).resolve().parent.parent
|
||||||
|
/ "alembic"
|
||||||
|
/ "versions"
|
||||||
|
/ "0077_plex_playback_class.py"
|
||||||
|
)
|
||||||
|
m = re.search(r"lower\(codec_video\)\s+IN\s+\(([^)]*)\)", mig.read_text())
|
||||||
|
assert m, "could not find the codec IN(...) list in migration 0077"
|
||||||
|
codecs = {c.strip().strip("'\"").lower() for c in m.group(1).split(",")}
|
||||||
|
assert codecs == {c.lower() for c in sync.HW_DECODABLE_CODECS}
|
||||||
|
|
||||||
|
|
||||||
class _FakePlex:
|
class _FakePlex:
|
||||||
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
|
"""Stands in for PlexClient.metadata — returns a canned full-metadata dict per rating_key."""
|
||||||
@@ -188,6 +225,93 @@ def test_probe_stamps_even_when_plex_has_no_stream(db):
|
|||||||
assert item.transcode_hw is True
|
assert item.transcode_hw is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_is_bounded_and_oldest_first(db):
|
||||||
|
# The per-run bound (limit) caps Plex load; oldest-id-first means successive runs cover the whole
|
||||||
|
# backlog without starving any item. Seed 3 eligible items, probe with limit=2.
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
items = [
|
||||||
|
PlexItem(
|
||||||
|
rating_key=f"h{i}",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title=f"m{i}",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
db.add_all(items) # inserted in order → ascending ids
|
||||||
|
db.commit()
|
||||||
|
plex = _FakePlex(
|
||||||
|
{
|
||||||
|
f"h{i}": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 8}]}]}]}
|
||||||
|
for i in range(3)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 2 # exactly the bound
|
||||||
|
for it in items:
|
||||||
|
db.refresh(it)
|
||||||
|
# The two LOWEST-id rows were probed; the third waits for the next run.
|
||||||
|
assert items[0].media_probed_at is not None and items[1].media_probed_at is not None
|
||||||
|
assert items[2].media_probed_at is None
|
||||||
|
# The next run continues with the remaining item.
|
||||||
|
assert sync._probe_media_batch(db, plex, limit=2) == 1
|
||||||
|
db.refresh(items[2])
|
||||||
|
assert items[2].media_probed_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
class _RaisingPlex:
|
||||||
|
"""metadata() raises for `bad_key`, returns a canned dict otherwise — to exercise the transient
|
||||||
|
fetch-failure branch (leave the watermark NULL so the item retries next run)."""
|
||||||
|
|
||||||
|
def __init__(self, by_key, bad_key):
|
||||||
|
self.by_key = by_key
|
||||||
|
self.bad_key = bad_key
|
||||||
|
|
||||||
|
def metadata(self, rating_key, markers=False):
|
||||||
|
if rating_key == self.bad_key:
|
||||||
|
raise RuntimeError("plex timeout")
|
||||||
|
return self.by_key.get(rating_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_media_batch_leaves_a_transient_failure_unstamped_to_retry(db):
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
good = PlexItem(
|
||||||
|
rating_key="g",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="good",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
bad = PlexItem(
|
||||||
|
rating_key="b",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="bad",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=True,
|
||||||
|
)
|
||||||
|
db.add_all([good, bad])
|
||||||
|
db.commit()
|
||||||
|
plex = _RaisingPlex(
|
||||||
|
{"g": {"Media": [{"Part": [{"Stream": [{"streamType": 1, "bitDepth": 10}]}]}]}}, bad_key="b"
|
||||||
|
)
|
||||||
|
# The raise on 'b' must not abort the batch: 'g' is still probed and counted; 'b' stays NULL.
|
||||||
|
assert sync._probe_media_batch(db, plex) == 1
|
||||||
|
db.refresh(good)
|
||||||
|
db.refresh(bad)
|
||||||
|
assert good.media_probed_at is not None and good.transcode_hw is False # 10-bit → SW
|
||||||
|
assert bad.media_probed_at is None # transient failure → retried next run, not stamped
|
||||||
|
|
||||||
|
|
||||||
def _seed_classes(db):
|
def _seed_classes(db):
|
||||||
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
db.add(lib)
|
db.add(lib)
|
||||||
@@ -274,3 +398,56 @@ def test_facets_count_playback_classes_in_severity_order(db, monkeypatch):
|
|||||||
"transcode_hw": 1,
|
"transcode_hw": 1,
|
||||||
"transcode_sw": 1,
|
"transcode_sw": 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unprobed_transcode_folds_into_sw(db, monkeypatch):
|
||||||
|
# transcode_hw NULL is the COMMON initial state (before the bit-depth backfill runs). The query's
|
||||||
|
# CASE has no explicit NULL arm — it relies on the `playable=='transcode'` else folding NULL in with
|
||||||
|
# software. Pin that fold for BOTH the filter and the facet, so a reorder can't silently drop the
|
||||||
|
# item from the feed (an honest-state violation).
|
||||||
|
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
|
||||||
|
db.add(lib)
|
||||||
|
db.flush()
|
||||||
|
db.add(
|
||||||
|
PlexItem(
|
||||||
|
rating_key="tnull",
|
||||||
|
library_id=lib.id,
|
||||||
|
kind="movie",
|
||||||
|
title="Unprobed",
|
||||||
|
playable="transcode",
|
||||||
|
codec_video="hevc",
|
||||||
|
transcode_hw=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user = User(email="pnull@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
sw = client.get("/api/plex/library?scope=movie&playable=transcode_sw")
|
||||||
|
assert {i["id"] for i in sw.json()["items"]} == {"tnull"} # NULL → SW bucket
|
||||||
|
hw = client.get("/api/plex/library?scope=movie&playable=transcode_hw")
|
||||||
|
assert hw.json()["items"] == [] # never HW
|
||||||
|
facets = client.get("/api/plex/facets?scope=movie")
|
||||||
|
counts = {p["value"]: p["count"] for p in facets.json()["playable"]}
|
||||||
|
assert counts.get("transcode_sw") == 1 and counts.get("transcode_hw", 0) == 0
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_facet_playable_self_excludes_the_active_selection(db, monkeypatch):
|
||||||
|
# A selected playable class must NOT narrow the playable facet's OWN counts (facet self-exclusion),
|
||||||
|
# otherwise picking one class would collapse the list and you couldn't OR-add a second. Selecting
|
||||||
|
# 'direct' must still return all four classes at their full, unnarrowed counts.
|
||||||
|
_seed_classes(db)
|
||||||
|
user = User(email="pself@example.com")
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
client = _client(db, user, monkeypatch)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/plex/facets?scope=movie&playable=direct")
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = {p["value"]: p["count"] for p in resp.json()["playable"]}
|
||||||
|
assert counts == {"direct": 1, "remux": 1, "transcode_hw": 1, "transcode_sw": 1}
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ export function PlexPosterCard({
|
|||||||
const cls = playableClass(card.playable, card.transcode_hw);
|
const cls = playableClass(card.playable, card.transcode_hw);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
role="img"
|
||||||
|
aria-label={t(`plex.playable.${cls}`)}
|
||||||
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
className={`absolute top-1.5 left-1.5 z-20 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-gray-400"}`}
|
||||||
title={t(`plex.playable.${cls}`)}
|
title={t(`plex.playable.${cls}`)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -107,9 +107,9 @@
|
|||||||
"playable": "Lejátszás",
|
"playable": "Lejátszás",
|
||||||
"playableOpt": {
|
"playableOpt": {
|
||||||
"direct": "Közvetlen lejátszás",
|
"direct": "Közvetlen lejátszás",
|
||||||
"remux": "Direct Stream",
|
"remux": "Közvetlen streamelés",
|
||||||
"transcode_hw": "Transcode · HW",
|
"transcode_hw": "Átkódolás · HW",
|
||||||
"transcode_sw": "Transcode · SW"
|
"transcode_sw": "Átkódolás · SW"
|
||||||
},
|
},
|
||||||
"moreRefine": "még {{count}} — finomíts kereséssel"
|
"moreRefine": "még {{count}} — finomíts kereséssel"
|
||||||
},
|
},
|
||||||
@@ -292,10 +292,10 @@
|
|||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
"remux": "Direct Stream — könnyű remux (nincs videó-újrakódolás)",
|
"remux": "Közvetlen streamelés — könnyű remux (nincs videó-újrakódolás)",
|
||||||
"transcode": "Transcode kell (nehezebb)",
|
"transcode": "Átkódolás szükséges (nehezebb)",
|
||||||
"transcode_hw": "Hardveres kódolóval transcode-olva",
|
"transcode_hw": "Hardveres kódolóval átkódolva",
|
||||||
"transcode_sw": "Szoftveresen transcode-olva (legnehezebb)"
|
"transcode_sw": "Szoftveresen átkódolva (legnehezebb)"
|
||||||
},
|
},
|
||||||
"playlistAdd": {
|
"playlistAdd": {
|
||||||
"manage": "Listához adás",
|
"manage": "Listához adás",
|
||||||
|
|||||||
Reference in New Issue
Block a user