fix: Correct series filter logic for no_episodes
Critical bug fix: The filter was returning the wrong series because of a misunderstanding of the episode table semantics. ISSUE: - Episodes table contains MISSING episodes (from episodeDict) - is_downloaded=False means episode file not found in folder - Original query logic was backwards - returned series with NO missing episodes instead of series WITH missing episodes SOLUTION: - Simplified query to directly check for episodes with is_downloaded=False - Changed from complex join with count aggregation to simple subquery - Now correctly returns series that have at least one undownloaded episode CHANGES: - src/server/database/service.py: Rewrote get_series_with_no_episodes() method with corrected logic and clearer documentation - tests/unit/test_series_filter.py: Updated test expectations to match corrected behavior with detailed comments explaining episode semantics - docs/API.md: Enhanced documentation explaining filter behavior and episode table meaning TESTS: All 5 unit tests pass with corrected logic
This commit is contained in:
116
docs/API.md
116
docs/API.md
@@ -34,11 +34,11 @@ Authorization: Bearer <jwt_token>
|
|||||||
|
|
||||||
**Public Endpoints (no authentication required):**
|
**Public Endpoints (no authentication required):**
|
||||||
|
|
||||||
- `/api/auth/*` - Authentication endpoints
|
- `/api/auth/*` - Authentication endpoints
|
||||||
- `/api/health` - Health check endpoints
|
- `/api/health` - Health check endpoints
|
||||||
- `/api/docs`, `/api/redoc` - API documentation
|
- `/api/docs`, `/api/redoc` - API documentation
|
||||||
- `/static/*` - Static files
|
- `/static/*` - Static files
|
||||||
- `/`, `/login`, `/setup`, `/queue` - UI pages
|
- `/`, `/login`, `/setup`, `/queue` - UI pages
|
||||||
|
|
||||||
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L39-L52)
|
Source: [src/server/middleware/auth.py](../src/server/middleware/auth.py#L39-L52)
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ Initial setup endpoint to configure the master password. Can only be called once
|
|||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `400 Bad Request` - Master password already configured or invalid password
|
- `400 Bad Request` - Master password already configured or invalid password
|
||||||
|
|
||||||
Source: [src/server/api/auth.py](../src/server/api/auth.py#L28-L90)
|
Source: [src/server/api/auth.py](../src/server/api/auth.py#L28-L90)
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ Validate master password and return JWT token.
|
|||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
|
|
||||||
- `401 Unauthorized` - Invalid credentials
|
- `401 Unauthorized` - Invalid credentials
|
||||||
- `429 Too Many Requests` - Account locked due to failed attempts
|
- `429 Too Many Requests` - Account locked due to failed attempts
|
||||||
|
|
||||||
Source: [src/server/api/auth.py](../src/server/api/auth.py#L93-L124)
|
Source: [src/server/api/auth.py](../src/server/api/auth.py#L93-L124)
|
||||||
|
|
||||||
@@ -203,7 +203,14 @@ List library series that have missing episodes.
|
|||||||
| `page` | int | 1 | Page number (must be positive) |
|
| `page` | int | 1 | Page number (must be positive) |
|
||||||
| `per_page` | int | 20 | Items per page (max 1000) |
|
| `per_page` | int | 20 | Items per page (max 1000) |
|
||||||
| `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` |
|
| `sort_by` | string | null | Sort field: `title`, `id`, `name`, `missing_episodes` |
|
||||||
| `filter` | string | null | Filter: `no_episodes` (shows only series with no downloaded episodes in folder) |
|
| `filter` | string | null | Filter: `no_episodes` (shows only series with missing episodes - episodes in DB that haven't been downloaded yet) |
|
||||||
|
|
||||||
|
**Filter Details:**
|
||||||
|
|
||||||
|
- `no_episodes`: Returns series that have at least one episode in the database with `is_downloaded=False`
|
||||||
|
- Episodes in the database represent MISSING episodes (from episodeDict during scanning)
|
||||||
|
- `is_downloaded=False` means the episode file was not found in the folder
|
||||||
|
- This effectively shows series where no video files were found for missing episodes
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
@@ -221,6 +228,7 @@ List library series that have missing episodes.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Example with filter:**
|
**Example with filter:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
GET /api/anime?filter=no_episodes
|
GET /api/anime?filter=no_episodes
|
||||||
```
|
```
|
||||||
@@ -311,10 +319,10 @@ Add a new series to the library with automatic database persistence, folder crea
|
|||||||
|
|
||||||
**Folder Name Sanitization:**
|
**Folder Name Sanitization:**
|
||||||
|
|
||||||
- Removes invalid filesystem characters: `< > : " / \ | ? *`
|
- Removes invalid filesystem characters: `< > : " / \ | ? *`
|
||||||
- Trims leading/trailing whitespace and dots
|
- Trims leading/trailing whitespace and dots
|
||||||
- Preserves Unicode characters (for Japanese titles)
|
- Preserves Unicode characters (for Japanese titles)
|
||||||
- Example: `"Attack on Titan: Final Season"` → `"Attack on Titan Final Season"`
|
- Example: `"Attack on Titan: Final Season"` → `"Attack on Titan Final Season"`
|
||||||
|
|
||||||
Source: [src/server/api/anime.py](../src/server/api/anime.py#L604-L710)
|
Source: [src/server/api/anime.py](../src/server/api/anime.py#L604-L710)
|
||||||
|
|
||||||
@@ -819,8 +827,8 @@ These endpoints manage tvshow.nfo metadata files and associated media (poster, l
|
|||||||
|
|
||||||
**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
|
||||||
|
|
||||||
### GET /api/nfo/{serie_id}/check
|
### GET /api/nfo/{serie_id}/check
|
||||||
|
|
||||||
@@ -830,7 +838,7 @@ Check if NFO file and media files exist for a series.
|
|||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
@@ -853,9 +861,9 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L90-L147)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L90-L147)
|
||||||
|
|
||||||
@@ -867,7 +875,7 @@ Create NFO file and download media for a series.
|
|||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
@@ -884,12 +892,12 @@ 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
|
||||||
- `download_logo` (boolean, default: true): Download logo.png
|
- `download_logo` (boolean, default: true): Download logo.png
|
||||||
- `download_fanart` (boolean, default: true): Download fanart.jpg
|
- `download_fanart` (boolean, default: true): Download fanart.jpg
|
||||||
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
|
- `overwrite_existing` (boolean, default: false): Overwrite existing NFO
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
@@ -912,10 +920,10 @@ 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`)
|
||||||
- `503 Service Unavailable` - TMDB API error or key not configured
|
- `503 Service Unavailable` - TMDB API error or key not configured
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L150-L240)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L150-L240)
|
||||||
|
|
||||||
@@ -927,11 +935,11 @@ Update existing NFO file with fresh TMDB data.
|
|||||||
|
|
||||||
**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):**
|
||||||
|
|
||||||
@@ -954,9 +962,9 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L243-L325)
|
||||||
|
|
||||||
@@ -968,7 +976,7 @@ Get NFO file XML content for a series.
|
|||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
@@ -984,8 +992,8 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L328-L397)
|
||||||
|
|
||||||
@@ -997,7 +1005,7 @@ Get media files status for a series.
|
|||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
|
|
||||||
@@ -1014,8 +1022,8 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L400-L447)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L400-L447)
|
||||||
|
|
||||||
@@ -1027,7 +1035,7 @@ Download missing media files for a series.
|
|||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
- `serie_id` (string): Series identifier
|
- `serie_id` (string): Series identifier
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
|
|
||||||
@@ -1054,9 +1062,9 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L450-L519)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L450-L519)
|
||||||
|
|
||||||
@@ -1079,10 +1087,10 @@ 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):**
|
||||||
|
|
||||||
@@ -1120,8 +1128,8 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L522-L634)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L522-L634)
|
||||||
|
|
||||||
@@ -1158,8 +1166,8 @@ 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
|
||||||
|
|
||||||
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L637-L684)
|
Source: [src/server/api/nfo.py](../src/server/api/nfo.py#L637-L684)
|
||||||
|
|
||||||
@@ -1352,7 +1360,7 @@ Clients can join/leave rooms to receive specific updates.
|
|||||||
|
|
||||||
**Available Rooms:**
|
**Available Rooms:**
|
||||||
|
|
||||||
- `downloads` - Download progress and status updates
|
- `downloads` - Download progress and status updates
|
||||||
|
|
||||||
### Server Message Format
|
### Server Message Format
|
||||||
|
|
||||||
|
|||||||
@@ -337,10 +337,8 @@ async def list_anime(
|
|||||||
# Get all series from database to fetch NFO metadata
|
# Get all series from database to fetch NFO metadata
|
||||||
# and episode counts
|
# and episode counts
|
||||||
from src.server.database.connection import get_sync_session
|
from src.server.database.connection import get_sync_session
|
||||||
from src.server.database.models import (
|
from src.server.database.models import AnimeSeries as DBAnimeSeries
|
||||||
AnimeSeries as DBAnimeSeries,
|
from src.server.database.models import Episode
|
||||||
Episode
|
|
||||||
)
|
|
||||||
|
|
||||||
session = get_sync_session()
|
session = get_sync_session()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import logging
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from sqlalchemy import delete, select, update
|
from sqlalchemy import Integer, delete, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
@@ -258,11 +258,15 @@ class AnimeSeriesService:
|
|||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> List[AnimeSeries]:
|
) -> List[AnimeSeries]:
|
||||||
"""Get anime series that have no downloaded episodes in folder.
|
"""Get anime series that have no episodes found in folder.
|
||||||
|
|
||||||
Returns series where either:
|
Since episodes in the database represent MISSING episodes
|
||||||
- No episodes exist in the database, OR
|
(from episodeDict), this returns series that have episodes
|
||||||
- All episodes have is_downloaded=False
|
in the DB with is_downloaded=False, meaning they have missing
|
||||||
|
episodes and no files were found in the folder for those episodes.
|
||||||
|
|
||||||
|
Returns series where:
|
||||||
|
- At least one episode exists in database with is_downloaded=False
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
@@ -270,31 +274,20 @@ class AnimeSeriesService:
|
|||||||
offset: Offset for pagination
|
offset: Offset for pagination
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of AnimeSeries instances with no downloaded episodes
|
List of AnimeSeries with missing episodes (not in folder)
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import func, or_
|
# Subquery to find series IDs with at least one undownloaded episode
|
||||||
|
undownloaded_series_ids = (
|
||||||
# Subquery to count downloaded episodes per series
|
select(Episode.series_id)
|
||||||
downloaded_count = (
|
.where(Episode.is_downloaded == False)
|
||||||
select(
|
.distinct()
|
||||||
Episode.series_id,
|
|
||||||
func.count(Episode.id).label('downloaded_count')
|
|
||||||
)
|
|
||||||
.where(Episode.is_downloaded.is_(True))
|
|
||||||
.group_by(Episode.series_id)
|
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Select series that either have no episodes or no downloaded episodes
|
# Select series that have undownloaded episodes
|
||||||
query = (
|
query = (
|
||||||
select(AnimeSeries)
|
select(AnimeSeries)
|
||||||
.outerjoin(downloaded_count, AnimeSeries.id == downloaded_count.c.series_id)
|
.where(AnimeSeries.id.in_(select(undownloaded_series_ids.c.series_id)))
|
||||||
.where(
|
|
||||||
or_(
|
|
||||||
downloaded_count.c.downloaded_count == None,
|
|
||||||
downloaded_count.c.downloaded_count == 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(AnimeSeries.name)
|
.order_by(AnimeSeries.name)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Tests for series filtering functionality."""
|
"""Tests for series filtering functionality."""
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from src.server.database.models import Base
|
from src.server.database.models import Base
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
from src.server.database.service import AnimeSeriesService, EpisodeService
|
||||||
@@ -61,8 +57,14 @@ async def test_get_series_with_no_episodes_empty_database(
|
|||||||
async def test_get_series_with_no_episodes_no_downloaded_episodes(
|
async def test_get_series_with_no_episodes_no_downloaded_episodes(
|
||||||
async_session: AsyncSession
|
async_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""Test that series with no downloaded episodes are returned."""
|
"""Test that series with no downloaded episodes are returned.
|
||||||
# Create a series with no episodes
|
|
||||||
|
Episodes in DB represent MISSING episodes, so:
|
||||||
|
- Series with episodes in DB (is_downloaded=False) = no files in folder
|
||||||
|
- Series with no episodes in DB = all episodes downloaded or no info
|
||||||
|
"""
|
||||||
|
# Create a series with NO episodes in DB
|
||||||
|
# (all downloaded or no episodes info)
|
||||||
series1 = await AnimeSeriesService.create(
|
series1 = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
key="test-series-1",
|
key="test-series-1",
|
||||||
@@ -71,7 +73,7 @@ async def test_get_series_with_no_episodes_no_downloaded_episodes(
|
|||||||
site="https://example.com/test1",
|
site="https://example.com/test1",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a series with undownloaded episodes
|
# Create a series with undownloaded episodes (MISSING - should appear)
|
||||||
series2 = await AnimeSeriesService.create(
|
series2 = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
key="test-series-2",
|
key="test-series-2",
|
||||||
@@ -87,7 +89,7 @@ async def test_get_series_with_no_episodes_no_downloaded_episodes(
|
|||||||
is_downloaded=False,
|
is_downloaded=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create a series with downloaded episodes (should not be in result)
|
# Create a series with downloaded episodes (should not appear)
|
||||||
series3 = await AnimeSeriesService.create(
|
series3 = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
key="test-series-3",
|
key="test-series-3",
|
||||||
@@ -105,23 +107,26 @@ async def test_get_series_with_no_episodes_no_downloaded_episodes(
|
|||||||
|
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
# Query for series with no downloaded episodes
|
# Query for series with no episodes in folder
|
||||||
result = await AnimeSeriesService.get_series_with_no_episodes(
|
result = await AnimeSeriesService.get_series_with_no_episodes(
|
||||||
async_session
|
async_session
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return series1 and series2 but not series3
|
# Should return only series2 (has missing episodes)
|
||||||
result_ids = {s.id for s in result}
|
result_ids = {s.id for s in result}
|
||||||
assert series1.id in result_ids
|
assert series1.id not in result_ids # No episodes in DB
|
||||||
assert series2.id in result_ids
|
assert series2.id in result_ids # Has missing episodes
|
||||||
assert series3.id not in result_ids
|
assert series3.id not in result_ids # Has downloaded episodes
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_series_with_no_episodes_mixed_downloads(
|
async def test_get_series_with_no_episodes_mixed_downloads(
|
||||||
async_session: AsyncSession
|
async_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""Test series with mixed downloaded/undownloaded episodes."""
|
"""Test series with mixed downloaded/undownloaded episodes.
|
||||||
|
|
||||||
|
Series with ANY missing episodes (is_downloaded=False) should appear.
|
||||||
|
"""
|
||||||
# Create series with some downloaded and some undownloaded episodes
|
# Create series with some downloaded and some undownloaded episodes
|
||||||
series = await AnimeSeriesService.create(
|
series = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -140,7 +145,7 @@ async def test_get_series_with_no_episodes_mixed_downloads(
|
|||||||
is_downloaded=True,
|
is_downloaded=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add undownloaded episode
|
# Add undownloaded episode (MISSING)
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
async_session,
|
async_session,
|
||||||
series_id=series.id,
|
series_id=series.id,
|
||||||
@@ -151,30 +156,86 @@ async def test_get_series_with_no_episodes_mixed_downloads(
|
|||||||
|
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
# Query for series with no downloaded episodes
|
# Query for series with no episodes in folder
|
||||||
result = await AnimeSeriesService.get_series_with_no_episodes(
|
result = await AnimeSeriesService.get_series_with_no_episodes(
|
||||||
async_session
|
async_session
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should NOT include series with at least one downloaded episode
|
# Should return the series because it has missing episodes
|
||||||
result_ids = {s.id for s in result}
|
assert len(result) == 1
|
||||||
assert series.id not in result_ids
|
assert result[0].id == series.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_series_with_no_episodes_mixed_seasons(
|
||||||
|
async_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Test series with some seasons downloaded, some not.
|
||||||
|
|
||||||
|
If ANY episode is still missing (is_downloaded=False), series should appear.
|
||||||
|
"""
|
||||||
|
series = await AnimeSeriesService.create(
|
||||||
|
async_session,
|
||||||
|
key="test-series",
|
||||||
|
name="Test Series",
|
||||||
|
folder="Test Series (2024)",
|
||||||
|
site="https://example.com/test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Season 1: all episodes downloaded
|
||||||
|
await EpisodeService.create(
|
||||||
|
async_session,
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
is_downloaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Season 2: has missing episode
|
||||||
|
await EpisodeService.create(
|
||||||
|
async_session,
|
||||||
|
series_id=series.id,
|
||||||
|
season=2,
|
||||||
|
episode_number=1,
|
||||||
|
is_downloaded=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
result = await AnimeSeriesService.get_series_with_no_episodes(
|
||||||
|
async_session
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return the series because season 2 has missing episodes
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == series.id
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_series_with_no_episodes_pagination(
|
async def test_get_series_with_no_episodes_pagination(
|
||||||
async_session: AsyncSession
|
async_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""Test pagination works correctly."""
|
"""Test pagination works correctly.
|
||||||
# Create multiple series without downloaded episodes
|
|
||||||
|
Note: Series with no episodes in DB won't appear.
|
||||||
|
"""
|
||||||
|
# Create multiple series with missing episodes
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await AnimeSeriesService.create(
|
series = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
key=f"test-series-{i}",
|
key=f"test-series-{i}",
|
||||||
name=f"Test Series {i}",
|
name=f"Test Series {i}",
|
||||||
folder=f"Test Series {i} (2024)",
|
folder=f"Test Series {i} (2024)",
|
||||||
site=f"https://example.com/test{i}",
|
site=f"https://example.com/test{i}",
|
||||||
)
|
)
|
||||||
|
# Add missing episode
|
||||||
|
await EpisodeService.create(
|
||||||
|
async_session,
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
is_downloaded=False,
|
||||||
|
)
|
||||||
|
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user