Complete Task 8: Database Support for NFO Status

- Added 5 NFO tracking fields to AnimeSeries model
- Fields: has_nfo, nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id
- Added 3 service methods to AnimeService for NFO operations
- Methods: update_nfo_status, get_series_without_nfo, get_nfo_statistics
- SQLAlchemy auto-migration (no manual migration needed)
- Backward compatible with existing data
- 15 new tests added (19/19 passing)
- Tests: database models, service methods, integration queries
This commit is contained in:
2026-01-16 18:50:04 +01:00
parent 56b4975d10
commit d642234814
9 changed files with 1014 additions and 50 deletions

View File

@@ -813,8 +813,9 @@ Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L1-L684)
These endpoints manage tvshow.nfo metadata files and associated media (poster, logo, fanart) for anime series. NFO files use Kodi/XBMC format and are scraped from TMDB API.
**Prerequisites:**
- TMDB API key must be configured in settings
- NFO service returns 503 if API key not configured
- TMDB API key must be configured in settings
- NFO service returns 503 if API key not configured
### GET /api/nfo/{serie_id}/check
@@ -823,9 +824,11 @@ Check if NFO file and media files exist for a series.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Response (200 OK):**
```json
{
"serie_id": "one-piece",
@@ -844,9 +847,10 @@ Check if NFO file and media files exist for a series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
- `503 Service Unavailable` - TMDB API key not configured
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
- `503 Service Unavailable` - TMDB API key not configured
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L90-L147)
@@ -857,9 +861,11 @@ Create NFO file and download media for a series.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Request Body:**
```json
{
"serie_name": "One Piece",
@@ -872,14 +878,16 @@ Create NFO file and download media for a series.
```
**Fields:**
- `serie_name` (string, optional): Series name for TMDB search (defaults to folder name)
- `year` (integer, optional): Series year to help narrow TMDB search
- `download_poster` (boolean, default: true): Download poster.jpg
- `download_logo` (boolean, default: true): Download logo.png
- `download_fanart` (boolean, default: true): Download fanart.jpg
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
- `serie_name` (string, optional): Series name for TMDB search (defaults to folder name)
- `year` (integer, optional): Series year to help narrow TMDB search
- `download_poster` (boolean, default: true): Download poster.jpg
- `download_logo` (boolean, default: true): Download logo.png
- `download_fanart` (boolean, default: true): Download fanart.jpg
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
**Response (200 OK):**
```json
{
"serie_id": "one-piece",
@@ -898,10 +906,11 @@ Create NFO file and download media for a series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
- `409 Conflict` - NFO already exists (use `overwrite_existing: true`)
- `503 Service Unavailable` - TMDB API error or key not configured
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
- `409 Conflict` - NFO already exists (use `overwrite_existing: true`)
- `503 Service Unavailable` - TMDB API error or key not configured
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L150-L240)
@@ -912,12 +921,15 @@ Update existing NFO file with fresh TMDB data.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Query Parameters:**
- `download_media` (boolean, default: true): Re-download media files
- `download_media` (boolean, default: true): Re-download media files
**Response (200 OK):**
```json
{
"serie_id": "one-piece",
@@ -936,9 +948,10 @@ Update existing NFO file with fresh TMDB data.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found (use create endpoint)
- `503 Service Unavailable` - TMDB API error
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found (use create endpoint)
- `503 Service Unavailable` - TMDB API error
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
@@ -949,9 +962,11 @@ Get NFO file XML content for a series.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Response (200 OK):**
```json
{
"serie_id": "one-piece",
@@ -963,8 +978,9 @@ Get NFO file XML content for a series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397)
@@ -975,9 +991,11 @@ Get media files status for a series.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Response (200 OK):**
```json
{
"has_poster": true,
@@ -990,8 +1008,9 @@ Get media files status for a series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series not found
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L400-L447)
@@ -1002,9 +1021,11 @@ Download missing media files for a series.
**Authentication:** Required
**Path Parameters:**
- `serie_id` (string): Series identifier
- `serie_id` (string): Series identifier
**Request Body:**
```json
{
"download_poster": true,
@@ -1014,6 +1035,7 @@ Download missing media files for a series.
```
**Response (200 OK):**
```json
{
"has_poster": true,
@@ -1026,9 +1048,10 @@ Download missing media files for a series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found (NFO required for TMDB ID)
- `503 Service Unavailable` - TMDB API error
- `401 Unauthorized` - Not authenticated
- `404 Not Found` - Series or NFO not found (NFO required for TMDB ID)
- `503 Service Unavailable` - TMDB API error
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L450-L519)
@@ -1039,6 +1062,7 @@ Batch create NFO files for multiple series.
**Authentication:** Required
**Request Body:**
```json
{
"serie_ids": ["one-piece", "naruto", "bleach"],
@@ -1049,12 +1073,14 @@ Batch create NFO files for multiple series.
```
**Fields:**
- `serie_ids` (array of strings): Series identifiers to process
- `download_media` (boolean, default: true): Download media files
- `skip_existing` (boolean, default: true): Skip series with existing NFOs
- `max_concurrent` (integer, 1-10, default: 3): Number of concurrent operations
- `serie_ids` (array of strings): Series identifiers to process
- `download_media` (boolean, default: true): Download media files
- `skip_existing` (boolean, default: true): Skip series with existing NFOs
- `max_concurrent` (integer, 1-10, default: 3): Number of concurrent operations
**Response (200 OK):**
```json
{
"total": 3,
@@ -1088,8 +1114,9 @@ Batch create NFO files for multiple series.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `503 Service Unavailable` - TMDB API key not configured
- `401 Unauthorized` - Not authenticated
- `503 Service Unavailable` - TMDB API key not configured
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L522-L634)
@@ -1100,6 +1127,7 @@ Get list of series without NFO files.
**Authentication:** Required
**Response (200 OK):**
```json
{
"total_series": 150,
@@ -1124,8 +1152,9 @@ Get list of series without NFO files.
```
**Errors:**
- `401 Unauthorized` - Not authenticated
- `503 Service Unavailable` - TMDB API key not configured
- `401 Unauthorized` - Not authenticated
- `503 Service Unavailable` - TMDB API key not configured
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L637-L684)

View File

@@ -377,6 +377,7 @@ Integrate NFO checking into the download workflow - check for tvshow.nfo before
- `tests/unit/test_series_app.py`
---
---
#### Task 5: Add NFO Management API Endpoints ✅ **COMPLETE**
@@ -644,10 +645,19 @@ Add NFO configuration options to the settings UI.
---
#### Task 8: Add Database Support for NFO Status
#### Task 8: Add Database Support for NFO Status ✅ **COMPLETE**
**Priority:** Medium
**Estimated Time:** 2-3 hours
**Estimated Time:** 2-3 hours
**Status:** Complete. See [task8_status.md](task8_status.md) for details.
**What Was Completed:**
- ✅ 5 new database fields for NFO tracking (has_nfo, nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id)
- ✅ 3 new service methods in AnimeService
- ✅ 15 comprehensive tests (all passing)
- ✅ Backward compatibility maintained
- ✅ SQLAlchemy auto-migration support
Track NFO file status in the database.
@@ -672,11 +682,11 @@ Track NFO file status in the database.
**Acceptance Criteria:**
- [ ] Database schema updated (SQLAlchemy will auto-create columns)
- [ ] NFO status tracked in DB
- [ ] Queries for missing NFOs work
- [ ] Backward compatible with existing data
- [ ] Database tests pass
- [x] Database schema updated (SQLAlchemy will auto-create columns)
- [x] NFO status tracked in DB
- [x] Queries for missing NFOs work
- [x] Backward compatible with existing data
- [x] Database tests pass
**Testing Requirements:**

View File

@@ -72,8 +72,9 @@ Task 5 is fully complete with all endpoints, models, tests, and documentation im
6. ✅ FastAPI integration complete
**Time Investment:**
- Estimated: 3-4 hours
- Actual: ~3 hours
- Estimated: 3-4 hours
- Actual: ~3 hours
## 🎯 Acceptance Criteria Status

170
docs/task8_status.md Normal file
View File

@@ -0,0 +1,170 @@
# Task 8: Add Database Support for NFO Status - Status Report
## Summary
Task 8 adds database support to track NFO file status for anime series, including creation timestamps and external IDs (TMDB/TVDB).
## ✅ Completed (100%)
### 1. Database Model Updates (100%)
-**Updated `src/server/database/models.py`**
- Added `has_nfo` (Boolean) - Whether tvshow.nfo exists
- Added `nfo_created_at` (DateTime) - When NFO was first created
- Added `nfo_updated_at` (DateTime) - When NFO was last updated
- Added `tmdb_id` (Integer, indexed) - TMDB database ID
- Added `tvdb_id` (Integer, indexed) - TVDB database ID
- All fields nullable with proper defaults
- SQLAlchemy will auto-create new columns (no manual migration needed)
### 2. Service Layer Updates (100%)
-**Updated `src/server/services/anime_service.py`**
- Added `update_nfo_status()` method to update NFO tracking
- Added `get_series_without_nfo()` method to query series missing NFO
- Added `get_nfo_statistics()` method to get NFO statistics
- All methods support optional database session parameter
- Proper error handling and logging
- Comprehensive type hints
### 3. Database Model Tests (100%)
-**Updated `tests/unit/test_database_models.py`**
- Added 5 new tests for NFO fields in TestAnimeSeries class
- Test default values (has_nfo=False, nulls for timestamps)
- Test setting NFO field values
- Test updating NFO status after creation
- Test querying by has_nfo status
- Test querying by TMDB ID
- All 9 tests in TestAnimeSeries passing
### 4. Service Tests (100%)
-**Updated `tests/unit/test_anime_service.py`**
- Added TestNFOTracking class with 4 comprehensive tests
- Test `update_nfo_status()` success case
- Test `update_nfo_status()` when series not found
- Test `get_series_without_nfo()` query
- Test `get_nfo_statistics()` counts
- All tests use proper mocking
- All 4 tests passing
### 5. Integration Tests (100%)
-**Created `tests/integration/test_nfo_database.py`**
- 6 comprehensive integration tests
- Test creating series with NFO tracking
- Test querying series without NFO
- Test querying by TMDB ID
- Test updating NFO status
- Test backward compatibility with existing data
- Test statistics queries
- All 6 tests passing
## 📊 Test Statistics
- **Database Model Tests**: 9/9 passing (5 new NFO tests added)
- **Service Tests**: 4/4 passing (new TestNFOTracking class)
- **Integration Tests**: 6/6 passing (new test file created)
- **Total New Tests**: 15 tests added
- **Total Pass Rate**: 19/19 (100%)
## 🎯 Acceptance Criteria Status
All Task 8 acceptance criteria met:
- [x] Database schema updated (SQLAlchemy will auto-create columns)
- [x] NFO status tracked in DB
- [x] Queries for missing NFOs work
- [x] Backward compatible with existing data
- [x] Database tests pass
## 📝 Implementation Details
### Database Fields
All new fields added to `AnimeSeries` model:
```python
has_nfo: bool = False # Whether tvshow.nfo exists
nfo_created_at: Optional[datetime] = None # Creation timestamp
nfo_updated_at: Optional[datetime] = None # Last update timestamp
tmdb_id: Optional[int] = None # TMDB ID (indexed)
tvdb_id: Optional[int] = None # TVDB ID (indexed)
```
### Service Methods
Three new methods added to `AnimeService`:
1. **update_nfo_status(key, has_nfo, tmdb_id, tvdb_id, db)**
- Updates NFO status for a series
- Sets creation/update timestamps
- Stores external database IDs
2. **get_series_without_nfo(db)**
- Returns list of series without NFO files
- Includes key, name, folder, and IDs
- Useful for batch operations
3. **get_nfo_statistics(db)**
- Returns NFO statistics
- Counts: total, with_nfo, without_nfo, with_tmdb_id, with_tvdb_id
- Useful for dashboards and reporting
### Backward Compatibility
- All new fields are nullable with defaults
- Existing series will have `has_nfo=False` and null timestamps
- No manual database migration required
- SQLAlchemy auto-creates columns on first run
- Queries work with mixed old/new data
### Query Performance
- Indexed `tmdb_id` and `tvdb_id` for fast lookups
- Efficient boolean queries on `has_nfo`
- Statistics queries optimized with proper filters
## 📋 Files Created/Modified
### Modified Files
- [src/server/database/models.py](../src/server/database/models.py) - Added 5 NFO fields to AnimeSeries
- [src/server/services/anime_service.py](../src/server/services/anime_service.py) - Added 3 NFO tracking methods
- [tests/unit/test_database_models.py](../tests/unit/test_database_models.py) - Added 5 NFO field tests
- [tests/unit/test_anime_service.py](../tests/unit/test_anime_service.py) - Added TestNFOTracking class
### Created Files
- [tests/integration/test_nfo_database.py](../tests/integration/test_nfo_database.py) - 6 integration tests
## ✅ Task 8 Status: **100% COMPLETE**
Task 8 is fully complete with all database fields, service methods, and comprehensive tests implemented.
**What Was Delivered:**
1. ✅ 5 new database fields for NFO tracking
2. ✅ 3 new service methods for NFO operations
3. ✅ 15 comprehensive tests (all passing)
4. ✅ Backward compatibility maintained
5. ✅ SQLAlchemy auto-migration support
**Time Investment:**
- Estimated: 2-3 hours
- Actual: ~2 hours
## 🔄 No Remaining Work
All planned work for Task 8 is complete. Ready to proceed to Task 6: Add NFO UI Features.
## 🔗 Related Tasks
- **Task 3**: ✅ Complete - NFO service creates files
- **Task 4**: ✅ Complete - NFO integrated into download flow
- **Task 5**: ✅ Complete - NFO API endpoints
- **Task 8**: ✅ Complete - Database support (THIS TASK)
- **Task 6**: ⏭️ Next - UI features to display NFO status
- **Task 7**: Pending - Configuration settings
- **Task 9**: Pending - Documentation and testing

View File

@@ -78,6 +78,28 @@ class AnimeSeries(Base, TimestampMixin):
doc="Release year of the series"
)
# NFO metadata tracking
has_nfo: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="0",
doc="Whether tvshow.nfo file exists for this series"
)
nfo_created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when NFO was first created"
)
nfo_updated_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
doc="Timestamp when NFO was last updated"
)
tmdb_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, index=True,
doc="TMDB (The Movie Database) ID for series metadata"
)
tvdb_id: Mapped[Optional[int]] = mapped_column(
Integer, nullable=True, index=True,
doc="TVDB (TheTVDB) ID for series metadata"
)
# Relationships
episodes: Mapped[List["Episode"]] = relationship(
"Episode",
@@ -127,7 +149,10 @@ class AnimeSeries(Base, TimestampMixin):
return value.strip()
def __repr__(self) -> str:
return f"<AnimeSeries(id={self.id}, key='{self.key}', name='{self.name}')>"
return (
f"<AnimeSeries(id={self.id}, key='{self.key}', "
f"name='{self.name}')>"
)
class Episode(Base, TimestampMixin):

View File

@@ -863,6 +863,215 @@ class AnimeService:
logger.exception("download failed")
raise AnimeServiceError("Download failed") from exc
async def update_nfo_status(
self,
key: str,
has_nfo: bool,
tmdb_id: Optional[int] = None,
tvdb_id: Optional[int] = None,
db=None
) -> None:
"""Update NFO status for a series in the database.
Args:
key: Serie unique identifier
has_nfo: Whether tvshow.nfo exists
tmdb_id: Optional TMDB ID
tvdb_id: Optional TVDB ID
db: Optional database session (will create if not provided)
Raises:
AnimeServiceError: If update fails
"""
from datetime import datetime, timezone
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
try:
# Get or create database session
should_close = False
if db is None:
db = get_db_session()
should_close = True
try:
# Find series by key
series = db.query(AnimeSeries).filter(
AnimeSeries.key == key
).first()
if not series:
logger.warning(
"Series not found in database for NFO update",
key=key
)
return
# Update NFO fields
now = datetime.now(timezone.utc)
series.has_nfo = has_nfo
if has_nfo:
if series.nfo_created_at is None:
series.nfo_created_at = now
series.nfo_updated_at = now
if tmdb_id is not None:
series.tmdb_id = tmdb_id
if tvdb_id is not None:
series.tvdb_id = tvdb_id
db.commit()
logger.info(
"Updated NFO status in database",
key=key,
has_nfo=has_nfo,
tmdb_id=tmdb_id,
tvdb_id=tvdb_id
)
finally:
if should_close:
db.close()
except Exception as exc:
logger.exception(
"Failed to update NFO status",
key=key,
has_nfo=has_nfo
)
raise AnimeServiceError("NFO status update failed") from exc
async def get_series_without_nfo(self, db=None) -> list[dict]:
"""Get list of series that don't have NFO files.
Args:
db: Optional database session
Returns:
List of series dictionaries with keys:
- key: Series unique identifier
- name: Series name
- folder: Series folder name
- has_nfo: Always False
- tmdb_id: TMDB ID if available
- tvdb_id: TVDB ID if available
Raises:
AnimeServiceError: If query fails
"""
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
try:
# Get or create database session
should_close = False
if db is None:
db = get_db_session()
should_close = True
try:
# Query series without NFO
series_list = db.query(AnimeSeries).filter(
AnimeSeries.has_nfo == False # noqa: E712
).all()
result = []
for series in series_list:
result.append({
"key": series.key,
"name": series.name,
"folder": series.folder,
"has_nfo": False,
"tmdb_id": series.tmdb_id,
"tvdb_id": series.tvdb_id,
"nfo_created_at": None,
"nfo_updated_at": None
})
logger.info(
"Retrieved series without NFO",
count=len(result)
)
return result
finally:
if should_close:
db.close()
except Exception as exc:
logger.exception("Failed to query series without NFO")
raise AnimeServiceError(
"Query for series without NFO failed"
) from exc
async def get_nfo_statistics(self, db=None) -> dict:
"""Get NFO statistics for all series.
Args:
db: Optional database session
Returns:
Dictionary with statistics:
- total: Total series count
- with_nfo: Series with NFO files
- without_nfo: Series without NFO files
- with_tmdb_id: Series with TMDB ID
- with_tvdb_id: Series with TVDB ID
Raises:
AnimeServiceError: If query fails
"""
from src.server.database.connection import get_db_session
from src.server.database.models import AnimeSeries
try:
# Get or create database session
should_close = False
if db is None:
db = get_db_session()
should_close = True
try:
# Count total series
total = db.query(AnimeSeries).count()
# Count series with NFO
with_nfo = db.query(AnimeSeries).filter(
AnimeSeries.has_nfo == True # noqa: E712
).count()
# Count series with TMDB ID
with_tmdb = db.query(AnimeSeries).filter(
AnimeSeries.tmdb_id.isnot(None)
).count()
# Count series with TVDB ID
with_tvdb = db.query(AnimeSeries).filter(
AnimeSeries.tvdb_id.isnot(None)
).count()
stats = {
"total": total,
"with_nfo": with_nfo,
"without_nfo": total - with_nfo,
"with_tmdb_id": with_tmdb,
"with_tvdb_id": with_tvdb
}
logger.info("Retrieved NFO statistics", **stats)
return stats
finally:
if should_close:
db.close()
except Exception as exc:
logger.exception("Failed to get NFO statistics")
raise AnimeServiceError("NFO statistics query failed") from exc
def get_anime_service(series_app: SeriesApp) -> AnimeService:
"""Factory used for creating AnimeService with a SeriesApp instance."""

View File

@@ -0,0 +1,224 @@
"""Integration tests for NFO database operations.
Tests NFO status tracking across database models and services.
"""
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.server.database.base import Base
from src.server.database.models import AnimeSeries
@pytest.fixture
def db_engine():
"""Create in-memory SQLite database for testing."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
return engine
@pytest.fixture
def db_session(db_engine):
"""Create database session for testing."""
SessionLocal = sessionmaker(bind=db_engine)
session = SessionLocal()
yield session
session.close()
class TestNFODatabaseIntegration:
"""Integration tests for NFO database operations."""
def test_create_series_with_nfo_tracking(self, db_session):
"""Test creating series with NFO tracking fields."""
now = datetime.now(timezone.utc)
series = AnimeSeries(
key="test-series-nfo",
name="Test Series",
site="https://example.com",
folder="/anime/test",
has_nfo=True,
nfo_created_at=now,
tmdb_id=12345,
tvdb_id=67890
)
db_session.add(series)
db_session.commit()
# Retrieve and verify
retrieved = db_session.query(AnimeSeries).filter_by(
key="test-series-nfo"
).first()
assert retrieved is not None
assert retrieved.has_nfo is True
assert retrieved.tmdb_id == 12345
assert retrieved.tvdb_id == 67890
assert retrieved.nfo_created_at is not None
def test_query_series_without_nfo(self, db_session):
"""Test querying series without NFO files."""
# Create series with and without NFO
series_with = AnimeSeries(
key="with-nfo",
name="Series With NFO",
site="https://example.com",
folder="/anime/with",
has_nfo=True,
)
series_without_1 = AnimeSeries(
key="without-nfo-1",
name="Series Without NFO 1",
site="https://example.com",
folder="/anime/without1",
has_nfo=False,
)
series_without_2 = AnimeSeries(
key="without-nfo-2",
name="Series Without NFO 2",
site="https://example.com",
folder="/anime/without2",
has_nfo=False,
)
db_session.add_all([series_with, series_without_1, series_without_2])
db_session.commit()
# Query series without NFO
without_nfo = db_session.query(AnimeSeries).filter(
AnimeSeries.has_nfo == False # noqa: E712
).all()
assert len(without_nfo) == 2
assert all(s.has_nfo is False for s in without_nfo)
keys = [s.key for s in without_nfo]
assert "without-nfo-1" in keys
assert "without-nfo-2" in keys
def test_query_series_by_tmdb_id(self, db_session):
"""Test querying series by TMDB ID."""
series1 = AnimeSeries(
key="series-1",
name="Series 1",
site="https://example.com",
folder="/anime/1",
tmdb_id=111,
)
series2 = AnimeSeries(
key="series-2",
name="Series 2",
site="https://example.com",
folder="/anime/2",
tmdb_id=222,
)
series3 = AnimeSeries(
key="series-3",
name="Series 3",
site="https://example.com",
folder="/anime/3",
)
db_session.add_all([series1, series2, series3])
db_session.commit()
# Query specific TMDB ID
result = db_session.query(AnimeSeries).filter_by(
tmdb_id=111
).first()
assert result.key == "series-1"
# Query series with any TMDB ID
with_tmdb = db_session.query(AnimeSeries).filter(
AnimeSeries.tmdb_id.isnot(None)
).all()
assert len(with_tmdb) == 2
def test_update_nfo_status(self, db_session):
"""Test updating NFO status after creation."""
series = AnimeSeries(
key="update-test",
name="Update Test",
site="https://example.com",
folder="/anime/update",
has_nfo=False,
)
db_session.add(series)
db_session.commit()
# Update NFO status
now = datetime.now(timezone.utc)
series.has_nfo = True
series.nfo_created_at = now
series.nfo_updated_at = now
series.tmdb_id = 99999
db_session.commit()
# Refresh and verify
db_session.refresh(series)
assert series.has_nfo is True
assert series.nfo_created_at is not None
assert series.nfo_updated_at is not None
assert series.tmdb_id == 99999
def test_backward_compatibility(self, db_session):
"""Test backward compatibility with existing series."""
# Create series without NFO fields (like existing data)
series = AnimeSeries(
key="old-series",
name="Old Series",
site="https://example.com",
folder="/anime/old",
)
db_session.add(series)
db_session.commit()
# Verify default values
db_session.refresh(series)
assert series.has_nfo is False
assert series.nfo_created_at is None
assert series.nfo_updated_at is None
assert series.tmdb_id is None
assert series.tvdb_id is None
def test_nfo_statistics_queries(self, db_session):
"""Test queries for NFO statistics."""
# Create mixed data
for i in range(10):
has_nfo = i < 7 # 7 with NFO, 3 without
tmdb = i * 100 if i < 8 else None # 8 with TMDB
tvdb = i * 1000 if i < 5 else None # 5 with TVDB
series = AnimeSeries(
key=f"series-{i}",
name=f"Series {i}",
site="https://example.com",
folder=f"/anime/{i}",
has_nfo=has_nfo,
tmdb_id=tmdb,
tvdb_id=tvdb,
)
db_session.add(series)
db_session.commit()
# Count statistics
total = db_session.query(AnimeSeries).count()
with_nfo = db_session.query(AnimeSeries).filter(
AnimeSeries.has_nfo == True # noqa: E712
).count()
with_tmdb = db_session.query(AnimeSeries).filter(
AnimeSeries.tmdb_id.isnot(None)
).count()
with_tvdb = db_session.query(AnimeSeries).filter(
AnimeSeries.tvdb_id.isnot(None)
).count()
assert total == 10
assert with_nfo == 7
assert with_tmdb == 8
assert with_tvdb == 5

View File

@@ -341,6 +341,139 @@ class TestConcurrency:
assert all(len(r) == 1 for r in results)
class TestNFOTracking:
"""Test NFO status tracking methods."""
@pytest.mark.asyncio
async def test_update_nfo_status_success(self, anime_service):
"""Test successful NFO status update."""
mock_series = MagicMock()
mock_series.key = "test-series"
mock_series.has_nfo = False
mock_series.nfo_created_at = None
mock_series.nfo_updated_at = None
mock_series.tmdb_id = None
mock_query = MagicMock()
mock_query.filter.return_value.first.return_value = mock_series
mock_db = MagicMock()
mock_db.query.return_value = mock_query
# Update NFO status
await anime_service.update_nfo_status(
key="test-series",
has_nfo=True,
tmdb_id=12345,
db=mock_db
)
# Verify series was updated
assert mock_series.has_nfo is True
assert mock_series.tmdb_id == 12345
assert mock_series.nfo_created_at is not None
assert mock_series.nfo_updated_at is not None
mock_db.commit.assert_called_once()
@pytest.mark.asyncio
async def test_update_nfo_status_not_found(self, anime_service):
"""Test NFO status update when series not found."""
mock_query = MagicMock()
mock_query.filter.return_value.first.return_value = None
mock_db = MagicMock()
mock_db.query.return_value = mock_query
# Should not raise, just log warning
await anime_service.update_nfo_status(
key="nonexistent",
has_nfo=True,
db=mock_db
)
# Should not commit if series not found
mock_db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_get_series_without_nfo(self, anime_service):
"""Test getting series without NFO files."""
mock_series1 = MagicMock()
mock_series1.key = "series-1"
mock_series1.name = "Series 1"
mock_series1.folder = "Series 1 (2020)"
mock_series1.tmdb_id = 123
mock_series1.tvdb_id = None
mock_series2 = MagicMock()
mock_series2.key = "series-2"
mock_series2.name = "Series 2"
mock_series2.folder = "Series 2 (2021)"
mock_series2.tmdb_id = None
mock_series2.tvdb_id = 456
mock_query = MagicMock()
mock_query.filter.return_value.all.return_value = [
mock_series1,
mock_series2
]
mock_db = MagicMock()
mock_db.query.return_value = mock_query
result = await anime_service.get_series_without_nfo(db=mock_db)
assert len(result) == 2
assert result[0]["key"] == "series-1"
assert result[0]["has_nfo"] is False
assert result[0]["tmdb_id"] == 123
assert result[1]["key"] == "series-2"
assert result[1]["tvdb_id"] == 456
@pytest.mark.asyncio
async def test_get_nfo_statistics(self, anime_service):
"""Test getting NFO statistics."""
mock_db = MagicMock()
# Mock total count
mock_total_query = MagicMock()
mock_total_query.count.return_value = 100
# Mock with_nfo count
mock_with_nfo_query = MagicMock()
mock_with_nfo_filter = MagicMock()
mock_with_nfo_filter.count.return_value = 75
mock_with_nfo_query.filter.return_value = mock_with_nfo_filter
# Mock with_tmdb count
mock_with_tmdb_query = MagicMock()
mock_with_tmdb_filter = MagicMock()
mock_with_tmdb_filter.count.return_value = 80
mock_with_tmdb_query.filter.return_value = mock_with_tmdb_filter
# Mock with_tvdb count
mock_with_tvdb_query = MagicMock()
mock_with_tvdb_filter = MagicMock()
mock_with_tvdb_filter.count.return_value = 60
mock_with_tvdb_query.filter.return_value = mock_with_tvdb_filter
# Configure mock to return different queries for each call
query_returns = [
mock_total_query,
mock_with_nfo_query,
mock_with_tmdb_query,
mock_with_tvdb_query
]
mock_db.query.side_effect = query_returns
result = await anime_service.get_nfo_statistics(db=mock_db)
assert result["total"] == 100
assert result["with_nfo"] == 75
assert result["without_nfo"] == 25
assert result["with_tmdb_id"] == 80
assert result["with_tvdb_id"] == 60
class TestFactoryFunction:
"""Test factory function."""

View File

@@ -144,6 +144,169 @@ class TestAnimeSeries:
)
assert result.scalar_one_or_none() is None
def test_anime_series_nfo_fields_default_values(self, db_session: Session):
"""Test NFO fields have correct default values."""
series = AnimeSeries(
key="nfo-test",
name="NFO Test Series",
site="https://example.com",
folder="/anime/nfo-test",
)
db_session.add(series)
db_session.commit()
# Verify NFO fields default values
assert series.has_nfo is False
assert series.nfo_created_at is None
assert series.nfo_updated_at is None
assert series.tmdb_id is None
assert series.tvdb_id is None
def test_anime_series_nfo_fields_set_values(self, db_session: Session):
"""Test setting NFO field values."""
now = datetime.now(timezone.utc)
series = AnimeSeries(
key="nfo-values-test",
name="NFO Values Test",
site="https://example.com",
folder="/anime/nfo-values",
has_nfo=True,
nfo_created_at=now,
nfo_updated_at=now,
tmdb_id=12345,
tvdb_id=67890,
)
db_session.add(series)
db_session.commit()
# Verify NFO fields are saved
assert series.has_nfo is True
assert series.nfo_created_at is not None
assert series.nfo_updated_at is not None
# Check time is close (within 1 second)
# Timezone may be lost in SQLite
created_delta = abs(
(series.nfo_created_at.replace(tzinfo=timezone.utc) - now)
.total_seconds()
)
updated_delta = abs(
(series.nfo_updated_at.replace(tzinfo=timezone.utc) - now)
.total_seconds()
)
assert created_delta < 1
assert updated_delta < 1
assert series.tmdb_id == 12345
assert series.tvdb_id == 67890
def test_anime_series_update_nfo_status(self, db_session: Session):
"""Test updating NFO status fields."""
series = AnimeSeries(
key="nfo-update-test",
name="NFO Update Test",
site="https://example.com",
folder="/anime/nfo-update",
)
db_session.add(series)
db_session.commit()
# Initially no NFO
assert series.has_nfo is False
# Update NFO status
now = datetime.now(timezone.utc)
series.has_nfo = True
series.nfo_created_at = now
series.nfo_updated_at = now
series.tmdb_id = 99999
db_session.commit()
# Verify update
db_session.refresh(series)
assert series.has_nfo is True
assert series.nfo_created_at is not None
assert series.nfo_updated_at is not None
assert series.tmdb_id == 99999
def test_anime_series_query_by_nfo_status(self, db_session: Session):
"""Test querying series by NFO status."""
# Create series with and without NFO
series_with_nfo = AnimeSeries(
key="with-nfo",
name="Series With NFO",
site="https://example.com",
folder="/anime/with-nfo",
has_nfo=True,
tmdb_id=111,
)
series_without_nfo = AnimeSeries(
key="without-nfo",
name="Series Without NFO",
site="https://example.com",
folder="/anime/without-nfo",
has_nfo=False,
)
db_session.add_all([series_with_nfo, series_without_nfo])
db_session.commit()
# Query series with NFO
with_nfo = db_session.execute(
select(AnimeSeries).where(
AnimeSeries.has_nfo == True # noqa: E712
)
).scalars().all()
assert len(with_nfo) == 1
assert with_nfo[0].key == "with-nfo"
# Query series without NFO
without_nfo = db_session.execute(
select(AnimeSeries).where(
AnimeSeries.has_nfo == False # noqa: E712
)
).scalars().all()
assert len(without_nfo) == 1
assert without_nfo[0].key == "without-nfo"
def test_anime_series_query_by_tmdb_id(self, db_session: Session):
"""Test querying series by TMDB ID."""
series1 = AnimeSeries(
key="tmdb-test-1",
name="TMDB Test 1",
site="https://example.com",
folder="/anime/tmdb-1",
tmdb_id=12345,
)
series2 = AnimeSeries(
key="tmdb-test-2",
name="TMDB Test 2",
site="https://example.com",
folder="/anime/tmdb-2",
tmdb_id=67890,
)
series3 = AnimeSeries(
key="tmdb-test-3",
name="TMDB Test 3",
site="https://example.com",
folder="/anime/tmdb-3",
)
db_session.add_all([series1, series2, series3])
db_session.commit()
# Query by specific TMDB ID
result = db_session.execute(
select(AnimeSeries).where(AnimeSeries.tmdb_id == 12345)
).scalar_one_or_none()
assert result is not None
assert result.key == "tmdb-test-1"
# Query series with any TMDB ID
with_tmdb = db_session.execute(
select(AnimeSeries).where(AnimeSeries.tmdb_id.isnot(None))
).scalars().all()
assert len(with_tmdb) == 2
class TestEpisode:
"""Test cases for Episode model."""