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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user