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:
29
docs/API.md
29
docs/API.md
@@ -813,6 +813,7 @@ 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.
|
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:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- TMDB API key must be configured in settings
|
- TMDB API key must be configured in settings
|
||||||
- NFO service returns 503 if API key not configured
|
- NFO service returns 503 if API key not configured
|
||||||
|
|
||||||
@@ -823,9 +824,11 @@ Check if NFO file and media files exist for a series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_id": "one-piece",
|
"serie_id": "one-piece",
|
||||||
@@ -844,6 +847,7 @@ Check if NFO file and media files exist for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series not found
|
- `404 Not Found` - Series not found
|
||||||
- `503 Service Unavailable` - TMDB API key not configured
|
- `503 Service Unavailable` - TMDB API key not configured
|
||||||
@@ -857,9 +861,11 @@ Create NFO file and download media for a series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_name": "One Piece",
|
"serie_name": "One Piece",
|
||||||
@@ -872,6 +878,7 @@ Create NFO file and download media for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Fields:**
|
**Fields:**
|
||||||
|
|
||||||
- `serie_name` (string, optional): Series name for TMDB search (defaults to folder name)
|
- `serie_name` (string, optional): Series name for TMDB search (defaults to folder name)
|
||||||
- `year` (integer, optional): Series year to help narrow TMDB search
|
- `year` (integer, optional): Series year to help narrow TMDB search
|
||||||
- `download_poster` (boolean, default: true): Download poster.jpg
|
- `download_poster` (boolean, default: true): Download poster.jpg
|
||||||
@@ -880,6 +887,7 @@ Create NFO file and download media for a series.
|
|||||||
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
|
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_id": "one-piece",
|
"serie_id": "one-piece",
|
||||||
@@ -898,6 +906,7 @@ Create NFO file and download media for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series not found
|
- `404 Not Found` - Series not found
|
||||||
- `409 Conflict` - NFO already exists (use `overwrite_existing: true`)
|
- `409 Conflict` - NFO already exists (use `overwrite_existing: true`)
|
||||||
@@ -912,12 +921,15 @@ Update existing NFO file with fresh TMDB data.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
|
|
||||||
- `download_media` (boolean, default: true): Re-download media files
|
- `download_media` (boolean, default: true): Re-download media files
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_id": "one-piece",
|
"serie_id": "one-piece",
|
||||||
@@ -936,6 +948,7 @@ Update existing NFO file with fresh TMDB data.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series or NFO not found (use create endpoint)
|
- `404 Not Found` - Series or NFO not found (use create endpoint)
|
||||||
- `503 Service Unavailable` - TMDB API error
|
- `503 Service Unavailable` - TMDB API error
|
||||||
@@ -949,9 +962,11 @@ Get NFO file XML content for a series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_id": "one-piece",
|
"serie_id": "one-piece",
|
||||||
@@ -963,6 +978,7 @@ Get NFO file XML content for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series or NFO not found
|
- `404 Not Found` - Series or NFO not found
|
||||||
|
|
||||||
@@ -975,9 +991,11 @@ Get media files status for a series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"has_poster": true,
|
"has_poster": true,
|
||||||
@@ -990,6 +1008,7 @@ Get media files status for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series not found
|
- `404 Not Found` - Series not found
|
||||||
|
|
||||||
@@ -1002,9 +1021,11 @@ Download missing media files for a series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"download_poster": true,
|
"download_poster": true,
|
||||||
@@ -1014,6 +1035,7 @@ Download missing media files for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"has_poster": true,
|
"has_poster": true,
|
||||||
@@ -1026,6 +1048,7 @@ Download missing media files for a series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `404 Not Found` - Series or NFO not found (NFO required for TMDB ID)
|
- `404 Not Found` - Series or NFO not found (NFO required for TMDB ID)
|
||||||
- `503 Service Unavailable` - TMDB API error
|
- `503 Service Unavailable` - TMDB API error
|
||||||
@@ -1039,6 +1062,7 @@ Batch create NFO files for multiple series.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"serie_ids": ["one-piece", "naruto", "bleach"],
|
"serie_ids": ["one-piece", "naruto", "bleach"],
|
||||||
@@ -1049,12 +1073,14 @@ Batch create NFO files for multiple series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Fields:**
|
**Fields:**
|
||||||
|
|
||||||
- `serie_ids` (array of strings): Series identifiers to process
|
- `serie_ids` (array of strings): Series identifiers to process
|
||||||
- `download_media` (boolean, default: true): Download media files
|
- `download_media` (boolean, default: true): Download media files
|
||||||
- `skip_existing` (boolean, default: true): Skip series with existing NFOs
|
- `skip_existing` (boolean, default: true): Skip series with existing NFOs
|
||||||
- `max_concurrent` (integer, 1-10, default: 3): Number of concurrent operations
|
- `max_concurrent` (integer, 1-10, default: 3): Number of concurrent operations
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total": 3,
|
"total": 3,
|
||||||
@@ -1088,6 +1114,7 @@ Batch create NFO files for multiple series.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `503 Service Unavailable` - TMDB API key not configured
|
- `503 Service Unavailable` - TMDB API key not configured
|
||||||
|
|
||||||
@@ -1100,6 +1127,7 @@ Get list of series without NFO files.
|
|||||||
**Authentication:** Required
|
**Authentication:** Required
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total_series": 150,
|
"total_series": 150,
|
||||||
@@ -1124,6 +1152,7 @@ Get list of series without NFO files.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Not authenticated
|
- `401 Unauthorized` - Not authenticated
|
||||||
- `503 Service Unavailable` - TMDB API key not configured
|
- `503 Service Unavailable` - TMDB API key not configured
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,7 @@ Integrate NFO checking into the download workflow - check for tvshow.nfo before
|
|||||||
- `tests/unit/test_series_app.py`
|
- `tests/unit/test_series_app.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Task 5: Add NFO Management API Endpoints ✅ **COMPLETE**
|
#### 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
|
**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.
|
Track NFO file status in the database.
|
||||||
|
|
||||||
@@ -672,11 +682,11 @@ Track NFO file status in the database.
|
|||||||
|
|
||||||
**Acceptance Criteria:**
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
- [ ] Database schema updated (SQLAlchemy will auto-create columns)
|
- [x] Database schema updated (SQLAlchemy will auto-create columns)
|
||||||
- [ ] NFO status tracked in DB
|
- [x] NFO status tracked in DB
|
||||||
- [ ] Queries for missing NFOs work
|
- [x] Queries for missing NFOs work
|
||||||
- [ ] Backward compatible with existing data
|
- [x] Backward compatible with existing data
|
||||||
- [ ] Database tests pass
|
- [x] Database tests pass
|
||||||
|
|
||||||
**Testing Requirements:**
|
**Testing Requirements:**
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ Task 5 is fully complete with all endpoints, models, tests, and documentation im
|
|||||||
6. ✅ FastAPI integration complete
|
6. ✅ FastAPI integration complete
|
||||||
|
|
||||||
**Time Investment:**
|
**Time Investment:**
|
||||||
|
|
||||||
- Estimated: 3-4 hours
|
- Estimated: 3-4 hours
|
||||||
- Actual: ~3 hours
|
- Actual: ~3 hours
|
||||||
|
|
||||||
|
|||||||
170
docs/task8_status.md
Normal file
170
docs/task8_status.md
Normal 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
|
||||||
@@ -78,6 +78,28 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
doc="Release year of the series"
|
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
|
# Relationships
|
||||||
episodes: Mapped[List["Episode"]] = relationship(
|
episodes: Mapped[List["Episode"]] = relationship(
|
||||||
"Episode",
|
"Episode",
|
||||||
@@ -127,7 +149,10 @@ class AnimeSeries(Base, TimestampMixin):
|
|||||||
return value.strip()
|
return value.strip()
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
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):
|
class Episode(Base, TimestampMixin):
|
||||||
|
|||||||
@@ -863,6 +863,215 @@ class AnimeService:
|
|||||||
logger.exception("download failed")
|
logger.exception("download failed")
|
||||||
raise AnimeServiceError("Download failed") from exc
|
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:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
|
|||||||
224
tests/integration/test_nfo_database.py
Normal file
224
tests/integration/test_nfo_database.py
Normal 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
|
||||||
@@ -341,6 +341,139 @@ class TestConcurrency:
|
|||||||
assert all(len(r) == 1 for r in results)
|
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:
|
class TestFactoryFunction:
|
||||||
"""Test factory function."""
|
"""Test factory function."""
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,169 @@ class TestAnimeSeries:
|
|||||||
)
|
)
|
||||||
assert result.scalar_one_or_none() is None
|
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:
|
class TestEpisode:
|
||||||
"""Test cases for Episode model."""
|
"""Test cases for Episode model."""
|
||||||
|
|||||||
Reference in New Issue
Block a user