feat(setup): track unresolved folders for manual key resolution

When SetupService cannot auto-resolve a provider key for an anime folder,
the folder is now tracked in the new 'unresolved_folders' table instead of
being silently skipped. Users can then resolve these via the new API:

- GET /api/setup/unresolved - list unresolved folders with search suggestions
- POST /api/setup/unresolved/{folder}/resolve - provide key to resolve folder

The SetupService.run() now:
- Tracks unresolved folders instead of skipping them
- Re-creates AnimeSeries for previously unresolved folders that are now resolved
- Includes unresolved count in logs

New files:
- src/server/api/setup_endpoints.py - API endpoints for unresolved management
- tests/unit/test_unresolved_folder_service.py - service and model tests

Modified:
- src/server/database/models.py - add UnresolvedFolder model
- src/server/database/service.py - add UnresolvedFolderService
- src/server/services/setup_service.py - track unresolved folders
- src/server/fastapi_app.py - include setup router
This commit is contained in:
2026-06-05 21:07:52 +02:00
parent d9738ffb78
commit ecef21eec4
7 changed files with 944 additions and 40 deletions

View File

@@ -626,6 +626,96 @@ class UserSession(Base, TimestampMixin):
self.is_active = False
class UnresolvedFolder(Base, TimestampMixin):
"""SQLAlchemy model for folders that couldn't be resolved during setup.
Tracks anime folders whose provider key couldn't be auto-resolved
during the initial setup scan. Users can provide the correct key
via the API to complete the series registration.
Attributes:
id: Primary key
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
provider_key: User-provided provider key to resolve this folder
search_attempts: Number of auto-search attempts made
last_search_result: Cached search results (JSON string) for UI suggestions
resolved_at: Timestamp when provider_key was provided
created_at: Creation timestamp (from TimestampMixin)
updated_at: Last update timestamp (from TimestampMixin)
"""
__tablename__ = "unresolved_folders"
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True
)
# Folder metadata
folder_name: Mapped[str] = mapped_column(
String(1000), unique=True, nullable=False, index=True,
doc="Original filesystem folder name"
)
title: Mapped[str] = mapped_column(
String(500), nullable=False,
doc="Extracted title from folder name"
)
year: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True,
doc="Extracted release year"
)
# Resolution data
provider_key: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True,
doc="User-provided provider key to resolve this folder"
)
search_attempts: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0",
doc="Number of auto-search attempts made"
)
last_search_result: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
doc="Cached search results (JSON) for UI display"
)
resolved_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when this folder was resolved"
)
@validates('folder_name')
def validate_folder_name(self, key: str, value: str) -> str:
"""Validate folder name is not empty."""
if not value or not value.strip():
raise ValueError("Folder name cannot be empty")
if len(value) > 1000:
raise ValueError("Folder name must be 1000 characters or less")
return value.strip()
@validates('title')
def validate_title(self, key: str, value: str) -> str:
"""Validate title is not empty."""
if not value or not value.strip():
raise ValueError("Title cannot be empty")
if len(value) > 500:
raise ValueError("Title must be 500 characters or less")
return value.strip()
@property
def is_resolved(self) -> bool:
"""Check if this folder has been resolved with a provider key."""
return self.provider_key is not None and self.resolved_at is not None
def __repr__(self) -> str:
return (
f"<UnresolvedFolder(id={self.id}, "
f"folder_name='{self.folder_name}', "
f"title='{self.title}', "
f"resolved={self.is_resolved})>"
)
class SystemSettings(Base, TimestampMixin):
"""SQLAlchemy model for system-wide settings and state.

View File

@@ -34,6 +34,7 @@ from src.server.database.models import (
AnimeSeries,
DownloadQueueItem,
Episode,
UnresolvedFolder,
UserSession,
)
@@ -1364,3 +1365,176 @@ class UserSessionService:
return new_session
# ============================================================================
# Unresolved Folder Service
# ============================================================================
class UnresolvedFolderService:
"""Service for tracking and resolving folders that couldn't be auto-resolved.
During initial setup, some folders may not resolve to a provider key
(no search match or multiple ambiguous matches). These are tracked as
UnresolvedFolder records and can later be resolved by the user providing
the correct provider key.
"""
@staticmethod
async def create(
db: AsyncSession,
folder_name: str,
title: str,
year: int | None = None,
search_attempts: int = 1,
last_search_result: str | None = None,
) -> UnresolvedFolder:
"""Create a new unresolved folder tracking record.
Args:
db: Database session
folder_name: Original filesystem folder name
title: Extracted title from folder name
year: Extracted release year (optional)
search_attempts: Number of search attempts made (default: 1)
last_search_result: JSON string of search results for UI (optional)
Returns:
Created UnresolvedFolder instance
"""
folder = UnresolvedFolder(
folder_name=folder_name,
title=title,
year=year,
search_attempts=search_attempts,
last_search_result=last_search_result,
)
db.add(folder)
await db.flush()
await db.refresh(folder)
logger.info(
"Created unresolved folder tracking: %s (title=%s, year=%s)",
folder_name, title, year
)
return folder
@staticmethod
async def get_by_folder_name(
db: AsyncSession,
folder_name: str,
) -> Optional[UnresolvedFolder]:
"""Get unresolved folder by folder name.
Args:
db: Database session
folder_name: Filesystem folder name to look up
Returns:
UnresolvedFolder instance or None if not found
"""
result = await db.execute(
select(UnresolvedFolder).where(
UnresolvedFolder.folder_name == folder_name
)
)
return result.scalar_one_or_none()
@staticmethod
async def get_all_unresolved(
db: AsyncSession,
) -> list[UnresolvedFolder]:
"""Get all unresolved folders that haven't been resolved yet.
Args:
db: Database session
Returns:
List of unresolved UnresolvedFolder instances
"""
result = await db.execute(
select(UnresolvedFolder)
.where(UnresolvedFolder.provider_key.is_(None))
.order_by(UnresolvedFolder.created_at)
)
return list(result.scalars().all())
@staticmethod
async def resolve(
db: AsyncSession,
folder_name: str,
provider_key: str,
) -> Optional[UnresolvedFolder]:
"""Mark an unresolved folder as resolved with the given provider key.
Args:
db: Database session
folder_name: Filesystem folder name to resolve
provider_key: Provider key to associate with this folder
Returns:
Updated UnresolvedFolder instance or None if not found
"""
from datetime import datetime, timezone
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.provider_key = provider_key
folder.resolved_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(folder)
logger.info(
"Resolved unresolved folder: %s -> key=%s",
folder_name, provider_key
)
return folder
@staticmethod
async def delete(
db: AsyncSession,
folder_name: str,
) -> bool:
"""Delete an unresolved folder record (e.g., after manual add).
Args:
db: Database session
folder_name: Filesystem folder name to delete
Returns:
True if deleted, False if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return False
await db.delete(folder)
await db.flush()
return True
@staticmethod
async def update_search_result(
db: AsyncSession,
folder_name: str,
search_result: str,
) -> Optional[UnresolvedFolder]:
"""Update the cached search result for an unresolved folder.
Args:
db: Database session
folder_name: Filesystem folder name to update
search_result: JSON string of search results
Returns:
Updated UnresolvedFolder instance or None if not found
"""
folder = await UnresolvedFolderService.get_by_folder_name(db, folder_name)
if not folder:
return None
folder.search_attempts += 1
folder.last_search_result = search_result
await db.flush()
await db.refresh(folder)
return folder