fix: support missing/no-episodes library filters (API, UI, docs, tests)

This commit is contained in:
2026-03-16 21:01:59 +01:00
parent e44a8190d0
commit 151a08e033
8 changed files with 227 additions and 99 deletions

View File

@@ -236,8 +236,8 @@ async def list_anime(
sort_by: Optional sorting parameter. Allowed: title, id, name,
missing_episodes
filter: Optional filter parameter. Allowed values:
- "no_episodes": Show only series with no downloaded
episodes in folder
- "missing_episodes": Show only series that have any missing episodes
- "no_episodes": Show only series that have no downloaded episodes
_auth: Ensures the caller is authenticated (value unused)
anime_service: AnimeService instance provided via dependency
@@ -298,7 +298,7 @@ async def list_anime(
# Validate filter parameter
if filter:
try:
allowed_filters = ["no_episodes"]
allowed_filters = ["missing_episodes", "no_episodes"]
validate_filter_value(filter, allowed_filters)
except ValueError as e:
raise ValidationError(message=str(e))

View File

@@ -253,48 +253,92 @@ class AnimeSeriesService:
return list(result.scalars().all())
@staticmethod
async def get_series_with_no_episodes(
async def get_series_with_missing_episodes(
db: AsyncSession,
limit: Optional[int] = None,
offset: int = 0,
) -> List[AnimeSeries]:
"""Get anime series that have no episodes found in folder.
Since episodes in the database represent MISSING episodes
(from episodeDict), this returns series that have episodes
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
"""Get anime series that currently have missing episodes.
Episodes in the database represent missing episodes (from episodeDict).
This returns series that have at least one missing episode recorded in
the database (is_downloaded=False).
Args:
db: Database session
limit: Optional limit for results
offset: Offset for pagination
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
undownloaded_series_ids = (
# Subquery to find series IDs with at least one missing episode
missing_series_ids = (
select(Episode.series_id)
.where(Episode.is_downloaded == False)
.distinct()
.subquery()
)
# Select series that have undownloaded episodes
query = (
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)
.offset(offset)
)
if 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)
return list(result.scalars().all())

View File

@@ -524,19 +524,20 @@ class AnimeService:
"series_id": db_series.id,
}
# If filter is "no_episodes", get series with no
# downloaded episodes
if filter_type == "no_episodes":
# Use service method to get series with
# undownloaded episodes
series_no_downloads = (
await AnimeSeriesService
.get_series_with_no_episodes(db)
# If filter is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes":
series_missing = (
await AnimeSeriesService.get_series_with_missing_episodes(db)
)
series_with_no_episodes = {
s.folder for s in series_no_downloads
}
series_with_missing_episodes = {s.folder for s in series_missing}
# 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
result_list = []
for serie in series:
@@ -545,8 +546,11 @@ class AnimeService:
site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {}
# Apply filter if specified
if filter_type == "missing_episodes":
if folder not in series_with_missing_episodes:
continue
if filter_type == "no_episodes":
if folder not in series_with_no_episodes:
continue

View File

@@ -16,7 +16,7 @@ AniWorld.SeriesManager = (function() {
// State
let seriesData = [];
let filteredSeriesData = [];
let showMissingOnly = false;
let filterMode = 'all'; // 'all' | 'missing_episodes' | 'no_episodes'
let sortAlphabetical = false;
/**
@@ -24,15 +24,16 @@ AniWorld.SeriesManager = (function() {
*/
function init() {
bindEvents();
updateFilterButtonUI();
}
/**
* Bind UI events for filtering and sorting
*/
function bindEvents() {
const missingOnlyBtn = document.getElementById('show-missing-only');
if (missingOnlyBtn) {
missingOnlyBtn.addEventListener('click', toggleMissingOnlyFilter);
const filterBtn = document.getElementById('show-missing-only');
if (filterBtn) {
filterBtn.addEventListener('click', toggleFilterMode);
}
const sortBtn = document.getElementById('sort-alphabetical');
@@ -49,7 +50,10 @@ AniWorld.SeriesManager = (function() {
try {
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) {
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() {
showMissingOnly = !showMissingOnly;
async function toggleFilterMode() {
const button = document.getElementById('show-missing-only');
button.setAttribute('data-active', showMissingOnly);
button.classList.toggle('active', showMissingOnly);
const icon = button.querySelector('i');
const text = button.querySelector('span');
if (showMissingOnly) {
icon.className = 'fas fa-filter-circle-xmark';
text.textContent = 'Show All Series';
// Cycle through modes
if (filterMode === 'all') {
filterMode = 'missing_episodes';
} else if (filterMode === 'missing_episodes') {
filterMode = 'no_episodes';
} else {
icon.className = 'fas fa-filter';
text.textContent = 'Missing Episodes Only';
filterMode = 'all';
}
applyFiltersAndSort();
renderSeries();
// Update button UI and reload list based on new filter.
updateFilterButtonUI();
await loadSeries();
// Clear selection when filter changes
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
*/
@@ -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;
}
@@ -212,9 +237,14 @@ AniWorld.SeriesManager = (function() {
(seriesData.length > 0 ? seriesData : []);
if (dataToRender.length === 0) {
const message = showMissingOnly ?
'No series with missing episodes found.' :
'No series found. Try searching for anime or rescanning your directory.';
let message;
if (filterMode === 'missing_episodes') {
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 =
'<div class="text-center" style="grid-column: 1 / -1; padding: 2rem;">' +