fix: support missing/no-episodes library filters (API, UI, docs, tests)
This commit is contained in:
@@ -203,14 +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 missing episodes - episodes in DB that haven't been downloaded yet) |
|
| `filter` | string | null | Filter: `missing_episodes` (shows series with any missing episodes), `no_episodes` (shows series with zero downloaded episodes) |
|
||||||
|
|
||||||
**Filter Details:**
|
**Filter Details:**
|
||||||
|
|
||||||
- `no_episodes`: Returns series that have at least one episode in the database with `is_downloaded=False`
|
- `missing_episodes`: Returns series that have at least one missing episode recorded in the database (`is_downloaded=False`)
|
||||||
|
- `no_episodes`: Returns series that have missing episodes and no downloaded episodes (i.e., only missing episodes exist in the database)
|
||||||
- Episodes in the database represent MISSING episodes (from episodeDict during scanning)
|
- Episodes in the database represent MISSING episodes (from episodeDict during scanning)
|
||||||
- `is_downloaded=False` means the episode file was not found in the folder
|
- `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):**
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ The application now features a comprehensive configuration system that allows us
|
|||||||
## Anime Management
|
## Anime Management
|
||||||
|
|
||||||
- **Anime Library Page**: Display list of anime series with missing episodes
|
- **Anime Library Page**: Display list of anime series with missing episodes
|
||||||
|
- **Library Filters**:
|
||||||
|
- "Missing Episodes Only" (shows only series with missing episodes, including series that currently have no downloaded episodes)
|
||||||
|
- "No Episodes" (shows series that are present in the library but have zero downloaded episodes)
|
||||||
|
- "Show All Series" (overrides other filters to show every series)
|
||||||
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
|
- **Database-Backed Series Storage**: All series metadata and missing episodes stored in SQLite database
|
||||||
- **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem
|
- **Automatic Database Synchronization**: Series loaded from database on startup, stays in sync with filesystem
|
||||||
- **Series Selection**: Select individual anime series and add episodes to download queue
|
- **Series Selection**: Select individual anime series and add episodes to download queue
|
||||||
|
|||||||
@@ -101,20 +101,26 @@ conda run -n AniWorld python -m uvicorn src.server.fastapi_app:app --host 127.0.
|
|||||||
|
|
||||||
For each task completed:
|
For each task completed:
|
||||||
|
|
||||||
- [ ] Implementation follows coding standards
|
- [x] Implementation follows coding standards
|
||||||
- [ ] Unit tests written and passing
|
- [x] Unit tests written and passing
|
||||||
- [ ] Integration tests passing
|
- [x] Integration tests passing
|
||||||
- [ ] Documentation updated
|
- [x] Documentation updated
|
||||||
- [ ] Error handling implemented
|
- [x] Error handling implemented
|
||||||
- [ ] Logging added
|
- [x] Logging added
|
||||||
- [ ] Security considerations addressed
|
- [x] Security considerations addressed
|
||||||
- [ ] Performance validated
|
- [x] Performance validated
|
||||||
- [ ] Code reviewed
|
- [x] Code reviewed
|
||||||
- [ ] Task marked as complete in instructions.md
|
- [x] Task marked as complete in instructions.md
|
||||||
- [ ] Infrastructure.md updated and other docs
|
- [x] Infrastructure.md updated and other docs
|
||||||
- [ ] Changes committed to git; keep your messages in git short and clear
|
- [x] Changes committed to git; keep your messages in git short and clear
|
||||||
- [ ] Take the next task
|
- [x] Take the next task
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## TODO List:
|
## TODO List:
|
||||||
|
|
||||||
|
- [x] Add a UI option to show series that have **no episodes downloaded** (i.e., series present in the library but with zero episodes). This should be a new filter state that is distinct from the current "Missing Episodes Only" behavior.
|
||||||
|
- [x] Ensure the existing "Missing Episodes Only" filter correctly shows series that are missing episodes (including those with no episodes) and does not omit series that are in the library but filtered out by other filters.
|
||||||
|
- [x] Update the frontend series library filter UI (buttons and toggle text) to clearly indicate when the view is showing "Missing Episodes Only", "No Episodes", or "Show All Series".
|
||||||
|
- [x] Update `docs/features.md` to document the new filter behavior and any new UI options.
|
||||||
|
- [x] Add or update unit/integration tests to validate the new filter logic and API behavior.
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ async def list_anime(
|
|||||||
sort_by: Optional sorting parameter. Allowed: title, id, name,
|
sort_by: Optional sorting parameter. Allowed: title, id, name,
|
||||||
missing_episodes
|
missing_episodes
|
||||||
filter: Optional filter parameter. Allowed values:
|
filter: Optional filter parameter. Allowed values:
|
||||||
- "no_episodes": Show only series with no downloaded
|
- "missing_episodes": Show only series that have any missing episodes
|
||||||
episodes in folder
|
- "no_episodes": Show only series that have no downloaded episodes
|
||||||
_auth: Ensures the caller is authenticated (value unused)
|
_auth: Ensures the caller is authenticated (value unused)
|
||||||
anime_service: AnimeService instance provided via dependency
|
anime_service: AnimeService instance provided via dependency
|
||||||
|
|
||||||
@@ -298,7 +298,7 @@ async def list_anime(
|
|||||||
# Validate filter parameter
|
# Validate filter parameter
|
||||||
if filter:
|
if filter:
|
||||||
try:
|
try:
|
||||||
allowed_filters = ["no_episodes"]
|
allowed_filters = ["missing_episodes", "no_episodes"]
|
||||||
validate_filter_value(filter, allowed_filters)
|
validate_filter_value(filter, allowed_filters)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ValidationError(message=str(e))
|
raise ValidationError(message=str(e))
|
||||||
|
|||||||
@@ -253,48 +253,92 @@ class AnimeSeriesService:
|
|||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_series_with_no_episodes(
|
async def get_series_with_missing_episodes(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
limit: Optional[int] = None,
|
limit: Optional[int] = None,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> List[AnimeSeries]:
|
) -> List[AnimeSeries]:
|
||||||
"""Get anime series that have no episodes found in folder.
|
"""Get anime series that currently have missing episodes.
|
||||||
|
|
||||||
Since episodes in the database represent MISSING episodes
|
Episodes in the database represent missing episodes (from episodeDict).
|
||||||
(from episodeDict), this returns series that have episodes
|
This returns series that have at least one missing episode recorded in
|
||||||
in the DB with is_downloaded=False, meaning they have missing
|
the database (is_downloaded=False).
|
||||||
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
|
||||||
limit: Optional limit for results
|
limit: Optional limit for results
|
||||||
offset: Offset for pagination
|
offset: Offset for pagination
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of AnimeSeries with missing episodes (not in folder)
|
List of AnimeSeries that have missing episodes.
|
||||||
"""
|
"""
|
||||||
# Subquery to find series IDs with at least one undownloaded episode
|
# Subquery to find series IDs with at least one missing episode
|
||||||
undownloaded_series_ids = (
|
missing_series_ids = (
|
||||||
select(Episode.series_id)
|
select(Episode.series_id)
|
||||||
.where(Episode.is_downloaded == False)
|
.where(Episode.is_downloaded == False)
|
||||||
.distinct()
|
.distinct()
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Select series that have undownloaded episodes
|
|
||||||
query = (
|
query = (
|
||||||
select(AnimeSeries)
|
select(AnimeSeries)
|
||||||
.where(AnimeSeries.id.in_(select(undownloaded_series_ids.c.series_id)))
|
.where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
|
||||||
.order_by(AnimeSeries.name)
|
.order_by(AnimeSeries.name)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
|
||||||
if limit:
|
if limit:
|
||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_series_with_no_episodes(
|
||||||
|
db: AsyncSession,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> List[AnimeSeries]:
|
||||||
|
"""Get anime series that have no downloaded episodes.
|
||||||
|
|
||||||
|
A series has "no episodes" if it has at least one missing episode
|
||||||
|
(is_downloaded=False) and no downloaded episodes (is_downloaded=True).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
limit: Optional limit for results
|
||||||
|
offset: Offset for pagination
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AnimeSeries where no episodes are downloaded.
|
||||||
|
"""
|
||||||
|
# Series with missing episodes
|
||||||
|
missing_series_ids = (
|
||||||
|
select(Episode.series_id)
|
||||||
|
.where(Episode.is_downloaded == False)
|
||||||
|
.distinct()
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Series with any downloaded episodes
|
||||||
|
downloaded_series_ids = (
|
||||||
|
select(Episode.series_id)
|
||||||
|
.where(Episode.is_downloaded == True)
|
||||||
|
.distinct()
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AnimeSeries)
|
||||||
|
.where(AnimeSeries.id.in_(select(missing_series_ids.c.series_id)))
|
||||||
|
.where(~AnimeSeries.id.in_(select(downloaded_series_ids.c.series_id)))
|
||||||
|
.order_by(AnimeSeries.name)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|||||||
@@ -524,19 +524,20 @@ class AnimeService:
|
|||||||
"series_id": db_series.id,
|
"series_id": db_series.id,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If filter is "no_episodes", get series with no
|
# If filter is "missing_episodes", get series with any missing episodes
|
||||||
# downloaded episodes
|
if filter_type == "missing_episodes":
|
||||||
if filter_type == "no_episodes":
|
series_missing = (
|
||||||
# Use service method to get series with
|
await AnimeSeriesService.get_series_with_missing_episodes(db)
|
||||||
# undownloaded episodes
|
|
||||||
series_no_downloads = (
|
|
||||||
await AnimeSeriesService
|
|
||||||
.get_series_with_no_episodes(db)
|
|
||||||
)
|
)
|
||||||
series_with_no_episodes = {
|
series_with_missing_episodes = {s.folder for s in series_missing}
|
||||||
s.folder for s in series_no_downloads
|
|
||||||
}
|
# If filter is "no_episodes", get series with no downloaded episodes
|
||||||
|
if filter_type == "no_episodes":
|
||||||
|
series_no_downloads = (
|
||||||
|
await AnimeSeriesService.get_series_with_no_episodes(db)
|
||||||
|
)
|
||||||
|
series_with_no_episodes = {s.folder for s in series_no_downloads}
|
||||||
|
|
||||||
# Build result list with enriched metadata
|
# Build result list with enriched metadata
|
||||||
result_list = []
|
result_list = []
|
||||||
for serie in series:
|
for serie in series:
|
||||||
@@ -545,8 +546,11 @@ class AnimeService:
|
|||||||
site = getattr(serie, "site", "")
|
site = getattr(serie, "site", "")
|
||||||
folder = getattr(serie, "folder", "")
|
folder = getattr(serie, "folder", "")
|
||||||
episode_dict = getattr(serie, "episodeDict", {}) or {}
|
episode_dict = getattr(serie, "episodeDict", {}) or {}
|
||||||
|
|
||||||
# Apply filter if specified
|
# Apply filter if specified
|
||||||
|
if filter_type == "missing_episodes":
|
||||||
|
if folder not in series_with_missing_episodes:
|
||||||
|
continue
|
||||||
if filter_type == "no_episodes":
|
if filter_type == "no_episodes":
|
||||||
if folder not in series_with_no_episodes:
|
if folder not in series_with_no_episodes:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ AniWorld.SeriesManager = (function() {
|
|||||||
// State
|
// State
|
||||||
let seriesData = [];
|
let seriesData = [];
|
||||||
let filteredSeriesData = [];
|
let filteredSeriesData = [];
|
||||||
let showMissingOnly = false;
|
let filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes'
|
||||||
let sortAlphabetical = false;
|
let sortAlphabetical = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,15 +24,16 @@ AniWorld.SeriesManager = (function() {
|
|||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
|
updateFilterButtonUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bind UI events for filtering and sorting
|
* Bind UI events for filtering and sorting
|
||||||
*/
|
*/
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
const missingOnlyBtn = document.getElementById('show-missing-only');
|
const filterBtn = document.getElementById('show-missing-only');
|
||||||
if (missingOnlyBtn) {
|
if (filterBtn) {
|
||||||
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter);
|
filterBtn.addEventListener('click', toggleFilterMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortBtn = document.getElementById('sort-alphabetical');
|
const sortBtn = document.getElementById('sort-alphabetical');
|
||||||
@@ -49,7 +50,10 @@ AniWorld.SeriesManager = (function() {
|
|||||||
try {
|
try {
|
||||||
AniWorld.UI.showLoading();
|
AniWorld.UI.showLoading();
|
||||||
|
|
||||||
const response = await AniWorld.ApiClient.get(API.ANIME_LIST);
|
const url = filterMode && filterMode !== 'all'
|
||||||
|
? `${API.ANIME_LIST}?filter=${encodeURIComponent(filterMode)}`
|
||||||
|
: API.ANIME_LIST;
|
||||||
|
const response = await AniWorld.ApiClient.get(url);
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
return [];
|
return [];
|
||||||
@@ -111,28 +115,28 @@ AniWorld.SeriesManager = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle missing episodes only filter
|
* Cycle through filter modes:
|
||||||
|
* - all: Show all series
|
||||||
|
* - missing_episodes: Show only series with missing episodes
|
||||||
|
* - no_episodes: Show only series with zero downloaded episodes
|
||||||
*/
|
*/
|
||||||
function toggleMissingOnlyFilter() {
|
async function toggleFilterMode() {
|
||||||
showMissingOnly = !showMissingOnly;
|
|
||||||
const button = document.getElementById('show-missing-only');
|
const button = document.getElementById('show-missing-only');
|
||||||
|
|
||||||
button.setAttribute('data-active', showMissingOnly);
|
|
||||||
button.classList.toggle('active', showMissingOnly);
|
|
||||||
|
|
||||||
const icon = button.querySelector('i');
|
const icon = button.querySelector('i');
|
||||||
const text = button.querySelector('span');
|
const text = button.querySelector('span');
|
||||||
|
|
||||||
if (showMissingOnly) {
|
// Cycle through modes
|
||||||
icon.className = 'fas fa-filter-circle-xmark';
|
if (filterMode === 'all') {
|
||||||
text.textContent = 'Show All Series';
|
filterMode = 'missing_episodes';
|
||||||
|
} else if (filterMode === 'missing_episodes') {
|
||||||
|
filterMode = 'no_episodes';
|
||||||
} else {
|
} else {
|
||||||
icon.className = 'fas fa-filter';
|
filterMode = 'all';
|
||||||
text.textContent = 'Missing Episodes Only';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyFiltersAndSort();
|
// Update button UI and reload list based on new filter.
|
||||||
renderSeries();
|
updateFilterButtonUI();
|
||||||
|
await loadSeries();
|
||||||
|
|
||||||
// Clear selection when filter changes
|
// Clear selection when filter changes
|
||||||
if (AniWorld.SelectionManager) {
|
if (AniWorld.SelectionManager) {
|
||||||
@@ -140,6 +144,34 @@ AniWorld.SeriesManager = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the filter button UI to reflect current filter mode
|
||||||
|
*/
|
||||||
|
function updateFilterButtonUI() {
|
||||||
|
const button = document.getElementById('show-missing-only');
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const icon = button.querySelector('i');
|
||||||
|
const text = button.querySelector('span');
|
||||||
|
|
||||||
|
const isActive = filterMode !== 'all';
|
||||||
|
button.setAttribute('data-active', isActive);
|
||||||
|
button.classList.toggle('active', isActive);
|
||||||
|
|
||||||
|
if (filterMode === 'missing_episodes') {
|
||||||
|
icon.className = 'fas fa-filter';
|
||||||
|
text.textContent = 'Missing Episodes Only';
|
||||||
|
} else if (filterMode === 'no_episodes') {
|
||||||
|
icon.className = 'fas fa-ban';
|
||||||
|
text.textContent = 'No Episodes';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-filter-circle-xmark';
|
||||||
|
text.textContent = 'Show All Series';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle alphabetical sorting
|
* Toggle alphabetical sorting
|
||||||
*/
|
*/
|
||||||
@@ -193,13 +225,6 @@ AniWorld.SeriesManager = (function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apply missing episodes filter
|
|
||||||
if (showMissingOnly) {
|
|
||||||
filtered = filtered.filter(function(serie) {
|
|
||||||
return serie.missing_episodes > 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredSeriesData = filtered;
|
filteredSeriesData = filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,9 +237,14 @@ AniWorld.SeriesManager = (function() {
|
|||||||
(seriesData.length > 0 ? seriesData : []);
|
(seriesData.length > 0 ? seriesData : []);
|
||||||
|
|
||||||
if (dataToRender.length === 0) {
|
if (dataToRender.length === 0) {
|
||||||
const message = showMissingOnly ?
|
let message;
|
||||||
'No series with missing episodes found.' :
|
if (filterMode === 'missing_episodes') {
|
||||||
'No series found. Try searching for anime or rescanning your directory.';
|
message = 'No series with missing episodes found.';
|
||||||
|
} else if (filterMode === 'no_episodes') {
|
||||||
|
message = 'No series with zero downloaded episodes found.';
|
||||||
|
} else {
|
||||||
|
message = 'No series found. Try searching for anime or rescanning your directory.';
|
||||||
|
}
|
||||||
|
|
||||||
grid.innerHTML =
|
grid.innerHTML =
|
||||||
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +
|
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ 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.
|
Series should NOT appear when there is at least one downloaded episode.
|
||||||
"""
|
"""
|
||||||
# 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(
|
||||||
@@ -135,7 +135,7 @@ async def test_get_series_with_no_episodes_mixed_downloads(
|
|||||||
folder="Test Series Mixed (2024)",
|
folder="Test Series Mixed (2024)",
|
||||||
site="https://example.com/testmixed",
|
site="https://example.com/testmixed",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add downloaded episode
|
# Add downloaded episode
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -144,7 +144,7 @@ async def test_get_series_with_no_episodes_mixed_downloads(
|
|||||||
episode_number=1,
|
episode_number=1,
|
||||||
is_downloaded=True,
|
is_downloaded=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add undownloaded episode (MISSING)
|
# Add undownloaded episode (MISSING)
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -153,15 +153,56 @@ async def test_get_series_with_no_episodes_mixed_downloads(
|
|||||||
episode_number=2,
|
episode_number=2,
|
||||||
is_downloaded=False,
|
is_downloaded=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
# Query for series with no episodes in folder
|
# 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 the series because it has missing episodes
|
# Should not return the series because it has at least one downloaded episode
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_series_with_missing_episodes_mixed_downloads(
|
||||||
|
async_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""Test missing episodes filter includes series with mixed downloads."""
|
||||||
|
series = await AnimeSeriesService.create(
|
||||||
|
async_session,
|
||||||
|
key="test-series-mixed",
|
||||||
|
name="Test Series Mixed",
|
||||||
|
folder="Test Series Mixed (2024)",
|
||||||
|
site="https://example.com/testmixed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add downloaded episode
|
||||||
|
await EpisodeService.create(
|
||||||
|
async_session,
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=1,
|
||||||
|
is_downloaded=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add undownloaded episode (MISSING)
|
||||||
|
await EpisodeService.create(
|
||||||
|
async_session,
|
||||||
|
series_id=series.id,
|
||||||
|
season=1,
|
||||||
|
episode_number=2,
|
||||||
|
is_downloaded=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
await async_session.commit()
|
||||||
|
|
||||||
|
# Query for series with missing episodes
|
||||||
|
result = await AnimeSeriesService.get_series_with_missing_episodes(
|
||||||
|
async_session
|
||||||
|
)
|
||||||
|
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0].id == series.id
|
assert result[0].id == series.id
|
||||||
|
|
||||||
@@ -171,8 +212,8 @@ async def test_get_series_with_no_episodes_mixed_seasons(
|
|||||||
async_session: AsyncSession
|
async_session: AsyncSession
|
||||||
):
|
):
|
||||||
"""Test series with some seasons downloaded, some not.
|
"""Test series with some seasons downloaded, some not.
|
||||||
|
|
||||||
If ANY episode is still missing (is_downloaded=False), series should appear.
|
Series should not appear when any episode is downloaded.
|
||||||
"""
|
"""
|
||||||
series = await AnimeSeriesService.create(
|
series = await AnimeSeriesService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -181,7 +222,7 @@ async def test_get_series_with_no_episodes_mixed_seasons(
|
|||||||
folder="Test Series (2024)",
|
folder="Test Series (2024)",
|
||||||
site="https://example.com/test",
|
site="https://example.com/test",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Season 1: all episodes downloaded
|
# Season 1: all episodes downloaded
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -190,7 +231,7 @@ async def test_get_series_with_no_episodes_mixed_seasons(
|
|||||||
episode_number=1,
|
episode_number=1,
|
||||||
is_downloaded=True,
|
is_downloaded=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Season 2: has missing episode
|
# Season 2: has missing episode
|
||||||
await EpisodeService.create(
|
await EpisodeService.create(
|
||||||
async_session,
|
async_session,
|
||||||
@@ -199,16 +240,15 @@ async def test_get_series_with_no_episodes_mixed_seasons(
|
|||||||
episode_number=1,
|
episode_number=1,
|
||||||
is_downloaded=False,
|
is_downloaded=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
await async_session.commit()
|
await async_session.commit()
|
||||||
|
|
||||||
result = await AnimeSeriesService.get_series_with_no_episodes(
|
result = await AnimeSeriesService.get_series_with_no_episodes(
|
||||||
async_session
|
async_session
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should return the series because season 2 has missing episodes
|
# Should not return the series because it has downloaded episodes
|
||||||
assert len(result) == 1
|
assert len(result) == 0
|
||||||
assert result[0].id == series.id
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user