fix: support missing/no-episodes library filters (API, UI, docs, tests)
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;">' +
|
||||
|
||||
Reference in New Issue
Block a user