Compare commits

...

8 Commits

Author SHA1 Message Date
46e8b2c9eb chore: bump version 2026-07-31 10:41:06 +02:00
ec24325036 chore: bump version 2026-07-31 10:40:18 +02:00
4ec95d8ba9 chore: bump version 2026-07-31 09:53:41 +02:00
d52b9a55f4 fix: exclude downloaded episodes from episodeDict and GetMissingEpisode
Prevent fully-downloaded series from being queued by auto-download:

- SerieList.GetMissingEpisode(): filter by is_downloaded instead of
  checking if episodeDict is non-empty. episodeDict from the DB
  relationship includes all episodes (including downloaded), so a
  series with only downloaded episodes still had a truthy episodeDict.

- AnimeSeries.episodeDict property: skip episodes where is_downloaded=True
  when building the dict from the DB relationship. This makes the
  property consistent with the is_downloaded filtering already done
  manually in list_series_with_filters(), and ensures that calling
  code anywhere in the codebase gets the correct missing-episode view.

Fixes hana-kimi (and any other fully-downloaded series) incorrectly
appearing in the auto-download queue after a rescan.
2026-07-31 09:50:59 +02:00
12681720e9 fix: process image loading batch sequentially to avoid concurrent AsyncSession use
SQLAlchemy async sessions are not safe for concurrent operations. load_series_images_batch was using asyncio.gather to run multiple load_series_images calls concurrently, all sharing the same db session. This caused 'session is provisioning a new connection; concurrent operations are not permitted' errors for every series in the batch.

Fix by processing each batch sequentially instead of concurrently. BATCH_SIZE=10 still paces TMDB requests as intended.
2026-07-31 09:09:04 +02:00
084488a88c chore: bump version 2026-07-31 08:45:27 +02:00
270da18543 fix: emit download_progress events during direct stream downloads
When _try_direct_stream() succeeded, it streamed chunks directly via
requests.get() without firing any progress events. This caused the WebSocket
frontend to only see 'started' (0%) and 'completed' (100%) — no incremental
updates.

Add per-chunk progress events to _try_direct_stream that mirror yt-dlp's
hook format (downloaded_bytes, total_bytes, speed, eta, status), emitted at
~1% intervals. These flow through the existing download_progress handler chain
(SeriesApp → anime_service → progress_service → WebSocket → frontend).
2026-07-31 08:42:59 +02:00
163532b1ef fix: use data.key instead of data.data in series_updated handler
The websocket-client strips the outer {type, data, ...} wrapper before
emitting to handlers, so handlers receive the inner series data object
directly (e.g. {key, name, missing_episodes}) — not {type, data: {...}}.

The series_updated handler was checking data.data which always failed,
causing every update to fall back to a full loadSeries() call instead of
calling updateSingleSeries() directly. This prevented the missing
episodes count from updating in real-time after a download completed.

Fix: check data.key directly and pass data (not data.data) to
updateSingleSeries().
2026-07-31 08:37:44 +02:00
8 changed files with 56 additions and 14 deletions

View File

@@ -1 +1 @@
v1.5.2 v1.5.5

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.5.2", "version": "1.5.5",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -134,7 +134,7 @@ class SerieList:
"""Return all series that still contain missing episodes.""" """Return all series that still contain missing episodes."""
return [ return [
anime for anime in self.keyDict.values() anime for anime in self.keyDict.values()
if anime.episodeDict if getattr(anime, 'episodeDict', None)
] ]
def get_missing_episodes(self) -> List[AnimeSeries]: def get_missing_episodes(self) -> List[AnimeSeries]:

View File

@@ -205,6 +205,8 @@ class AnimeSeries(Base, TimestampMixin):
try: try:
if self.episodes: if self.episodes:
for ep in self.episodes: for ep in self.episodes:
if ep.is_downloaded:
continue
season = ep.season or 1 season = ep.season or 1
if season not in episode_dict: if season not in episode_dict:
episode_dict[season] = [] episode_dict[season] = []

View File

@@ -5,6 +5,7 @@ import logging
import os import os
import re import re
import shutil import shutil
import time
import threading import threading
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -383,6 +384,12 @@ class AniworldLoader(Loader):
"Direct stream download starting (type=%s)", "Direct stream download starting (type=%s)",
content_type content_type
) )
total_size = int(response.headers.get(
"Content-Length", 0
))
received = 0
last_emit = 0
start_time = time.monotonic()
with open(output_path, "wb") as fh: with open(output_path, "wb") as fh:
for chunk in response.iter_content(chunk_size=1024 * 1024): for chunk in response.iter_content(chunk_size=1024 * 1024):
if self._cancel_flag.is_set(): if self._cancel_flag.is_set():
@@ -391,7 +398,33 @@ class AniworldLoader(Loader):
) )
return False return False
if chunk: if chunk:
received += len(chunk)
fh.write(chunk) fh.write(chunk)
# Emit progress events at ~1% intervals
if total_size > 0:
pct = (received / total_size) * 100
if pct - last_emit >= 1.0 or received == total_size:
elapsed = time.monotonic() - start_time
speed_bps = (
received / elapsed
if elapsed > 0 else 0
)
eta = (
int((total_size - received) / speed_bps)
if speed_bps > 0 else None
)
self.events.download_progress({
"downloaded_bytes": received,
"total_bytes": total_size,
"speed": speed_bps,
"eta": eta,
"status": (
"finished"
if received >= total_size
else "downloading"
),
})
last_emit = pct
return True return True
except requests.RequestException as exc: except requests.RequestException as exc:
logger.warning("Direct stream download failed: %s", exc) logger.warning("Direct stream download failed: %s", exc)

View File

@@ -162,17 +162,18 @@ class ImageLoadingService:
for i in range(0, len(series_list), self.BATCH_SIZE): for i in range(0, len(series_list), self.BATCH_SIZE):
batch = series_list[i : i + self.BATCH_SIZE] batch = series_list[i : i + self.BATCH_SIZE]
tasks = [ # Process each series sequentially to avoid concurrent use of the
self.load_series_images( # same AsyncSession (SQLAlchemy async sessions are not thread-safe
# for concurrent operations). BATCH_SIZE still paces TMDB requests.
results: List[Dict[str, Any] | Exception] = []
for series in batch:
result = await self.load_series_images(
key=series["key"], key=series["key"],
folder=series["folder"], folder=series["folder"],
anime_directory=anime_directory, anime_directory=anime_directory,
db=db, db=db,
) )
for series in batch results.append(result)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
for series, result in zip(batch, results): for series, result in zip(batch, results):
if isinstance(result, Exception): if isinstance(result, Exception):

View File

@@ -136,13 +136,16 @@ AniWorld.IndexSocketHandler = (function() {
// Series events // Series events
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) { socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
console.log('Series updated:', data); console.log('Series updated:', data);
// Use the data directly to update the series instead of full refresh // NOTE: websocket-client.js strips the outer {type, data, ...} wrapper
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) { // before emitting, so `data` here is the inner series data object
AniWorld.SeriesManager.updateSingleSeries(data.data); // (e.g. {key, name, missing_episodes, ...}) — NOT {type, data, ...}.
// AniWorld.SeriesManager.updateSingleSeries() expects this flat object.
if (data && data.key && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
AniWorld.SeriesManager.updateSingleSeries(data);
} else { } else {
// Fallback to full reload if data is incomplete // Fallback to full reload if data is incomplete
console.warn('Incomplete series update data, falling back to full reload'); console.warn('Incomplete series update data, falling back to full reload', data);
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) { if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
AniWorld.SeriesManager.loadSeries(); AniWorld.SeriesManager.loadSeries();
} }

3
uv.lock generated Normal file
View File

@@ -0,0 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.12"