feat(nfo): add minimal NFO fallback when TMDB fails
- Add create_minimal_nfo() method to NFOService for fallback when TMDB lookup fails - Update API endpoints (single and batch) to use minimal NFO fallback on TMDBAPIError - Document fallback behavior in NFO_GUIDE.md section 3.6 - Add unit tests for minimal NFO creation (11 tests passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -171,6 +171,35 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 3.6 Fallback Behavior When TMDB is Unavailable
|
||||||
|
|
||||||
|
When TMDB lookup fails (network issues, API errors, or no match found), the system creates a **minimal NFO** to ensure the series is still tracked. This behavior applies to:
|
||||||
|
|
||||||
|
- Manual NFO creation via API
|
||||||
|
- Batch NFO creation operations
|
||||||
|
- Automatic NFO creation during downloads
|
||||||
|
|
||||||
|
**What a minimal NFO contains:**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<tvshow>
|
||||||
|
<title>Series Name</title>
|
||||||
|
<year>2024</year>
|
||||||
|
<plot>No metadata available for Series Name. TMDB lookup failed.</plot>
|
||||||
|
</tvshow>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Limitations of minimal NFOs:**
|
||||||
|
- No poster, logo, or fanart images
|
||||||
|
- No rating, genre, or studio information
|
||||||
|
- No TMDB or other provider IDs
|
||||||
|
- May not display correctly in some media servers
|
||||||
|
|
||||||
|
**To upgrade a minimal NFO:**
|
||||||
|
1. Use the Update endpoint (`PUT /api/nfo/{serie_id}/update`) when TMDB is available
|
||||||
|
2. Or delete the NFO and recreate it with full metadata
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. File Structure
|
## 4. File Structure
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from src.core.services.tmdb_client import TMDBAPIError, TMDBClient
|
|||||||
from src.core.utils.image_downloader import ImageDownloader
|
from src.core.utils.image_downloader import ImageDownloader
|
||||||
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
from src.core.utils.nfo_generator import generate_tvshow_nfo
|
||||||
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
|
from src.core.utils.nfo_mapper import tmdb_to_nfo_model
|
||||||
|
from src.core.entities.nfo_models import TVShowNFO
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -424,6 +425,62 @@ class NFOService:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def parse_nfo_year(self, nfo_path: Path) -> Optional[int]:
|
||||||
|
"""Parse year from an existing NFO file.
|
||||||
|
|
||||||
|
Extracts year from <year> or <premiered> elements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nfo_path: Path to tvshow.nfo file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Year as integer if found, None otherwise.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> year = nfo_service.parse_nfo_year(Path("/anime/series/tvshow.nfo"))
|
||||||
|
>>> print(year)
|
||||||
|
2013
|
||||||
|
"""
|
||||||
|
if not nfo_path.exists():
|
||||||
|
logger.debug("NFO file not found: %s", nfo_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
tree = etree.parse(str(nfo_path))
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
# Try <year> element first
|
||||||
|
year_elem = root.find(".//year")
|
||||||
|
if year_elem is not None and year_elem.text:
|
||||||
|
try:
|
||||||
|
year = int(year_elem.text)
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
logger.debug("Found year in NFO: %d", year)
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: try <premiered> element (format: YYYY-MM-DD)
|
||||||
|
premiered_elem = root.find(".//premiered")
|
||||||
|
if premiered_elem is not None and premiered_elem.text:
|
||||||
|
if premiered_elem.text and len(premiered_elem.text) >= 4:
|
||||||
|
try:
|
||||||
|
year = int(premiered_elem.text[:4])
|
||||||
|
if 1900 <= year <= 2100:
|
||||||
|
logger.debug("Found year from premiered in NFO: %d", year)
|
||||||
|
return year
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("No year found in NFO: %s", nfo_path)
|
||||||
|
|
||||||
|
except etree.XMLSyntaxError as e:
|
||||||
|
logger.error("Invalid XML in NFO file %s: %s", nfo_path, e)
|
||||||
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
logger.error("Error parsing year from NFO file %s: %s", nfo_path, e)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def _enrich_details_with_fallback(
|
async def _enrich_details_with_fallback(
|
||||||
self,
|
self,
|
||||||
details: Dict[str, Any],
|
details: Dict[str, Any],
|
||||||
@@ -727,3 +784,52 @@ class NFOService:
|
|||||||
async def close(self):
|
async def close(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
await self.tmdb_client.close()
|
await self.tmdb_client.close()
|
||||||
|
|
||||||
|
async def create_minimal_nfo(
|
||||||
|
self,
|
||||||
|
serie_name: str,
|
||||||
|
serie_folder: str,
|
||||||
|
year: Optional[int] = None
|
||||||
|
) -> Path:
|
||||||
|
"""Create minimal tvshow.nfo when TMDB lookup fails.
|
||||||
|
|
||||||
|
Creates a basic NFO with just the title (and year if available)
|
||||||
|
so the series is tracked even without TMDB metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
serie_name: Name of the series (may include year in parentheses)
|
||||||
|
serie_folder: Series folder name
|
||||||
|
year: Optional release year
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created NFO file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If series folder doesn't exist
|
||||||
|
"""
|
||||||
|
# Extract year from name if not provided
|
||||||
|
clean_name, extracted_year = self._extract_year_from_name(serie_name)
|
||||||
|
if year is None and extracted_year is not None:
|
||||||
|
year = extracted_year
|
||||||
|
|
||||||
|
folder_path = self.anime_directory / serie_folder
|
||||||
|
if not folder_path.exists():
|
||||||
|
logger.info("Creating series folder: %s", folder_path)
|
||||||
|
folder_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create minimal NFO model with just title and year
|
||||||
|
nfo_model = TVShowNFO(
|
||||||
|
title=clean_name,
|
||||||
|
year=year,
|
||||||
|
plot=f"No metadata available for {clean_name}. TMDB lookup failed."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate XML
|
||||||
|
nfo_xml = generate_tvshow_nfo(nfo_model)
|
||||||
|
|
||||||
|
# Save NFO file
|
||||||
|
nfo_path = folder_path / "tvshow.nfo"
|
||||||
|
nfo_path.write_text(nfo_xml, encoding="utf-8")
|
||||||
|
logger.info("Created minimal NFO (no TMDB): %s", nfo_path)
|
||||||
|
|
||||||
|
return nfo_path
|
||||||
|
|||||||
@@ -144,6 +144,27 @@ async def batch_create_nfo(
|
|||||||
nfo_path=str(nfo_path)
|
nfo_path=str(nfo_path)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except TMDBAPIError as e:
|
||||||
|
logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e)
|
||||||
|
# TMDB failed, create minimal NFO
|
||||||
|
try:
|
||||||
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
|
except Exception:
|
||||||
|
serie_folder = serie_folder
|
||||||
|
|
||||||
|
serie_name = serie.name or serie_folder
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=serie_name,
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
return NFOBatchResult(
|
||||||
|
serie_id=serie_id,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
success=True,
|
||||||
|
message="Created minimal NFO (TMDB lookup failed)",
|
||||||
|
nfo_path=str(nfo_path)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error creating NFO for {serie_id}: {e}",
|
f"Error creating NFO for {serie_id}: {e}",
|
||||||
@@ -429,11 +450,42 @@ async def create_nfo(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except TMDBAPIError as e:
|
except TMDBAPIError as e:
|
||||||
logger.warning("TMDB API error creating NFO for %s: %s", serie_id, e)
|
logger.warning("TMDB API error for %s, creating minimal fallback: %s", serie_id, e)
|
||||||
raise HTTPException(
|
# TMDB failed, create minimal NFO with just folder name
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
try:
|
||||||
detail=f"TMDB API error: {str(e)}"
|
serie_folder = serie.ensure_folder_with_year()
|
||||||
) from e
|
except Exception:
|
||||||
|
serie_folder = serie_folder
|
||||||
|
|
||||||
|
folder_path = Path(settings.anime_directory) / serie_folder
|
||||||
|
serie_name_fallback = request.serie_name or serie.name or serie_folder
|
||||||
|
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name=serie_name_fallback,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=year
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check media files (will likely be empty)
|
||||||
|
media_status = check_media_files(folder_path)
|
||||||
|
file_paths = get_media_file_paths(folder_path)
|
||||||
|
|
||||||
|
media_files = MediaFilesStatus(
|
||||||
|
has_poster=media_status.get("poster", False),
|
||||||
|
has_logo=media_status.get("logo", False),
|
||||||
|
has_fanart=media_status.get("fanart", False),
|
||||||
|
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
|
||||||
|
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
|
||||||
|
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return NFOCreateResponse(
|
||||||
|
serie_id=serie_id,
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
nfo_path=str(nfo_path),
|
||||||
|
media_files=media_files,
|
||||||
|
message="Created minimal NFO (TMDB lookup failed)"
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error creating NFO for {serie_id}: {e}",
|
f"Error creating NFO for {serie_id}: {e}",
|
||||||
|
|||||||
240
tests/unit/test_nfo_minimal_fallback.py
Normal file
240
tests/unit/test_nfo_minimal_fallback.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""Unit tests for minimal NFO creation when TMDB fails.
|
||||||
|
|
||||||
|
Tests the fallback behavior when TMDB lookup fails and we need to create
|
||||||
|
a minimal NFO file just to track the series.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.services.nfo_service import NFOService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def nfo_service(tmp_path):
|
||||||
|
"""Create NFO service with test directory.
|
||||||
|
|
||||||
|
Note: anime_directory is set to tmp_path directly (not tmp_path / "anime")
|
||||||
|
because tmp_path already represents the test anime directory.
|
||||||
|
"""
|
||||||
|
service = NFOService(
|
||||||
|
tmdb_api_key="test_api_key",
|
||||||
|
anime_directory=str(tmp_path),
|
||||||
|
image_size="w500",
|
||||||
|
auto_create=True
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateMinimalNFO:
|
||||||
|
"""Test minimal NFO creation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_basic(self, nfo_service, tmp_path):
|
||||||
|
"""Test creating minimal NFO with just title."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series"
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
assert nfo_path.name == "tvshow.nfo"
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "No metadata available" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_with_year(self, nfo_service, tmp_path):
|
||||||
|
"""Test creating minimal NFO with year."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series (2024)"
|
||||||
|
|
||||||
|
# Create minimal NFO with explicit year
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series",
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=2024
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "<year>2024</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_extracts_year_from_name(self, nfo_service, tmp_path):
|
||||||
|
"""Test that year is extracted from series name format (YYYY)."""
|
||||||
|
# Setup - anime_directory is already tmp_path
|
||||||
|
serie_folder = "Test Series (2024)"
|
||||||
|
|
||||||
|
# Create with name that has year
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Series (2024)",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify year was extracted
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Test Series</title>" in content
|
||||||
|
assert "<year>2024</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_creates_folder_if_missing(self, nfo_service, tmp_path):
|
||||||
|
"""Test that folder is created if it doesn't exist."""
|
||||||
|
# Setup - anime_directory is tmp_path itself
|
||||||
|
serie_folder = "New Series"
|
||||||
|
|
||||||
|
# Folder should not exist yet (under anime_directory which is tmp_path)
|
||||||
|
folder_path = tmp_path / serie_folder
|
||||||
|
assert not folder_path.exists()
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="New Series",
|
||||||
|
serie_folder=serie_folder
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify folder and file were created
|
||||||
|
assert folder_path.exists()
|
||||||
|
assert nfo_path.exists()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_xml_is_valid(self, nfo_service, tmp_path):
|
||||||
|
"""Test that generated XML is valid."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Test Anime",
|
||||||
|
serie_folder="Test Anime",
|
||||||
|
year=2020
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify XML is valid
|
||||||
|
from lxml import etree
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Should parse without errors
|
||||||
|
tree = etree.fromstring(content.encode("utf-8"))
|
||||||
|
assert tree is not None
|
||||||
|
assert tree.tag == "tvshow"
|
||||||
|
|
||||||
|
# Check title element
|
||||||
|
title = tree.find("title")
|
||||||
|
assert title is not None
|
||||||
|
assert title.text == "Test Anime"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_no_tmdb_id(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO has no TMDB ID."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Unknown Series",
|
||||||
|
serie_folder="Unknown Series",
|
||||||
|
year=1999
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no TMDB ID
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<tmdbid>" not in content
|
||||||
|
assert "uniqueid" not in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_minimal_nfo_has_plot_explanation(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO contains explanation in plot."""
|
||||||
|
# Create minimal NFO (anime_directory is already tmp_path)
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Mysterious Anime",
|
||||||
|
serie_folder="Mysterious Anime"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify plot explains why metadata is missing
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "TMDB lookup failed" in content
|
||||||
|
assert "Mysterious Anime" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateMinimalNFOIntegration:
|
||||||
|
"""Integration tests for minimal NFO with TMDB failure scenarios."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_on_tmdb_search_failure(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO is created when TMDB search fails."""
|
||||||
|
# Mock TMDB client to raise error
|
||||||
|
nfo_service.tmdb_client.search_tv_show = AsyncMock(
|
||||||
|
side_effect=Exception("TMDB API Error")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to create full NFO (should fail and fallback to minimal)
|
||||||
|
# We test the fallback method directly
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Failed Series",
|
||||||
|
serie_folder="Failed Series",
|
||||||
|
year=2021
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert nfo_path.exists()
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Failed Series</title>" in content
|
||||||
|
assert "<year>2021</year>" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_allows_series_tracking(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO allows series to be tracked."""
|
||||||
|
# anime_directory is already tmp_path
|
||||||
|
serie_folder = "Untracked Series"
|
||||||
|
|
||||||
|
# Create minimal NFO
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Untracked Series",
|
||||||
|
serie_folder=serie_folder,
|
||||||
|
year=2018
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify NFO exists (series can be tracked)
|
||||||
|
assert nfo_service.has_nfo(serie_folder) is True
|
||||||
|
|
||||||
|
# Verify minimal content
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
assert "<title>Untracked Series</title>" in content
|
||||||
|
|
||||||
|
|
||||||
|
class TestMinimalNFOContent:
|
||||||
|
"""Test content of minimal NFO files."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_contains_required_elements(self, nfo_service, tmp_path):
|
||||||
|
"""Test that minimal NFO has title and plot."""
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="Minimal Test",
|
||||||
|
serie_folder="Minimal Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Must have title
|
||||||
|
assert "<title>Minimal Test</title>" in content
|
||||||
|
# Must have plot explaining situation
|
||||||
|
assert "plot" in content.lower()
|
||||||
|
assert "No metadata available" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_minimal_nfo_xml_declaration(self, nfo_service, tmp_path):
|
||||||
|
"""Test that NFO has proper XML declaration."""
|
||||||
|
nfo_path = await nfo_service.create_minimal_nfo(
|
||||||
|
serie_name="XML Test",
|
||||||
|
serie_folder="XML Test"
|
||||||
|
)
|
||||||
|
|
||||||
|
content = nfo_path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Should have XML declaration
|
||||||
|
assert content.startswith('<?xml version="1.0" encoding="UTF-8"')
|
||||||
Reference in New Issue
Block a user