fix: migrations, auth, providers health check, E2E tests, remove neko-sama
CI / Test (Python 3.11) (push) Has been cancelled
CI / Test (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Type Check (push) Has been cancelled
CI / Summary (push) Has been cancelled

- Add proper Alembic initial migration (0001_initial_schema.py)
- Migrate refresh tokens from JSON file to SQLite (RefreshTokenTable)
- Remove Neko-Sama provider entirely (redirects to Gupy, not a host)
- Fix provider health check always showing UNKNOWN
  - Run check_all_health() on startup
  - Fix POST /providers/health/check background task bug
  - Add HTMX refresh after manual health check trigger
- Fix anime search relevance scoring with MIN_RELEVANCE_THRESHOLD=0.5
- Replace bare 'except:' with 'except Exception:' across codebase
- Add Playwright E2E test suite (12 tests, auth setup, helpers)
- Fix toast container blocking clicks via pointer-events: none
- Remove obsolete Jest/Vite test files and config
- Clean up obsolete test_watchlist scripts
- Update sonarr model comment for active providers
This commit is contained in:
Kimi Agent
2026-05-12 11:45:56 +00:00
parent 693615a7dc
commit 520be53901
47 changed files with 654 additions and 3437 deletions
+1
View File
@@ -69,3 +69,4 @@ test-results/
.opencode/ .opencode/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
playwright/.auth/
+4 -4
View File
@@ -335,7 +335,7 @@ The downloaders are organized into three categories with separate base classes:
- User authentication and last login tracking - User authentication and last login tracking
- **JWT Tokens** - Stateless authentication with refresh token support - **JWT Tokens** - Stateless authentication with refresh token support
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`) - Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
- Refresh tokens: 30-day expiration (stored in `config/refresh_tokens.json`) - Refresh tokens: 30-day expiration (stored in SQLite `refresh_tokens` table)
- HS256 algorithm with JWT_SECRET_KEY (change in production!) - HS256 algorithm with JWT_SECRET_KEY (change in production!)
- Token verification and user extraction - Token verification and user extraction
- **Password Security** - **Password Security**
@@ -348,7 +348,7 @@ The downloaders are organized into three categories with separate base classes:
- **Configuration** - **Configuration**
- `JWT_SECRET_KEY` environment variable (MUST be changed from default) - `JWT_SECRET_KEY` environment variable (MUST be changed from default)
- Users stored in `config/users.json` - Users stored in `config/users.json`
- Refresh tokens stored in `config/refresh_tokens.json` - Refresh tokens stored in SQLite `refresh_tokens` table
**Authentication Endpoints:** **Authentication Endpoints:**
- `POST /api/auth/register` - User registration - `POST /api/auth/register` - User registration
@@ -709,7 +709,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min
**Configuration Files:** **Configuration Files:**
- `.env` - Environment configuration (create from .env.example) - `.env` - Environment configuration (create from .env.example)
- `config/users.json` - User authentication database (created automatically) - `config/users.json` - User authentication database (created automatically)
- `config/refresh_tokens.json` - Refresh token storage (created automatically) - `refresh_tokens` table - Refresh token storage (SQLite database)
- `config/sonarr.json` - Sonarr webhook configuration (created automatically) - `config/sonarr.json` - Sonarr webhook configuration (created automatically)
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically) - `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
- `config/watchlist.json` - User watchlist items (created automatically) - `config/watchlist.json` - User watchlist items (created automatically)
@@ -746,7 +746,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min
- Passwords truncated to 72 bytes (bcrypt limitation) - Passwords truncated to 72 bytes (bcrypt limitation)
- JWT secret key validation (minimum 32 characters, default rejected) - JWT secret key validation (minimum 32 characters, default rejected)
- Credentials stored in `config/users.json` - Credentials stored in `config/users.json`
- Refresh tokens stored in `config/refresh_tokens.json` - Refresh tokens stored in SQLite `refresh_tokens` table
## Key Implementation Details ## Key Implementation Details
+209
View File
@@ -0,0 +1,209 @@
"""Initial schema
Revision ID: 0001_initial_schema
Revises:
Create Date: 2026-05-12 08:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0001_initial_schema'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('username', sa.String(), nullable=False),
sa.Column('email', sa.String(), nullable=True),
sa.Column('full_name', sa.String(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_admin', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('hashed_password', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
op.create_table(
'app_settings',
sa.Column('default_lang', sa.String(), nullable=False),
sa.Column('theme', sa.String(), nullable=False),
sa.Column('disabled_providers_json', sa.String(), nullable=False),
sa.Column('recommendations_filter', sa.String(), nullable=False),
sa.Column('releases_filter', sa.String(), nullable=False),
sa.Column('anime_enabled', sa.Boolean(), nullable=False),
sa.Column('series_enabled', sa.Boolean(), nullable=False),
sa.Column('download_dir', sa.String(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id')
)
op.create_index(op.f('ix_app_settings_id'), 'app_settings', ['id'], unique=False)
op.create_index(op.f('ix_app_settings_user_id'), 'app_settings', ['user_id'], unique=True)
op.create_table(
'favorites',
sa.Column('anime_id', sa.String(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('poster_url', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.Column('metadata_json', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_favorites_anime_id'), 'favorites', ['anime_id'], unique=False)
op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False)
op.create_index(op.f('ix_favorites_title'), 'favorites', ['title'], unique=False)
op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'], unique=False)
op.create_table(
'refresh_tokens',
sa.Column('token_id', sa.String(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('revoked', sa.Boolean(), nullable=False),
sa.Column('revoked_at', sa.DateTime(), nullable=True),
sa.Column('id', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_id')
)
op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_token_id'), 'refresh_tokens', ['token_id'], unique=True)
op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False)
op.create_table(
'sonarr_config',
sa.Column('webhook_enabled', sa.Boolean(), nullable=False),
sa.Column('webhook_secret', sa.String(), nullable=True),
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
sa.Column('default_language', sa.String(), nullable=False),
sa.Column('default_quality', sa.String(), nullable=True),
sa.Column('default_provider', sa.String(), nullable=False),
sa.Column('verify_hmac', sa.Boolean(), nullable=False),
sa.Column('log_webhooks', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sonarr_config_id'), 'sonarr_config', ['id'], unique=False)
op.create_table(
'sonarr_mappings',
sa.Column('sonarr_series_id', sa.Integer(), nullable=False),
sa.Column('sonarr_title', sa.String(), nullable=False),
sa.Column('anime_provider', sa.String(), nullable=False),
sa.Column('anime_url', sa.String(), nullable=False),
sa.Column('anime_title', sa.String(), nullable=False),
sa.Column('lang', sa.String(), nullable=False),
sa.Column('quality_preference', sa.String(), nullable=True),
sa.Column('auto_download', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('sonarr_series_id')
)
op.create_index(op.f('ix_sonarr_mappings_id'), 'sonarr_mappings', ['id'], unique=False)
op.create_index(op.f('ix_sonarr_mappings_sonarr_series_id'), 'sonarr_mappings', ['sonarr_series_id'], unique=True)
op.create_index(op.f('ix_sonarr_mappings_user_id'), 'sonarr_mappings', ['user_id'], unique=False)
op.create_table(
'watchlist_items',
sa.Column('anime_title', sa.String(), nullable=False),
sa.Column('anime_url', sa.String(), nullable=False),
sa.Column('provider_id', sa.String(), nullable=False),
sa.Column('lang', sa.String(), nullable=False),
sa.Column('last_checked', sa.DateTime(), nullable=True),
sa.Column('last_episode_downloaded', sa.Integer(), nullable=False),
sa.Column('total_episodes', sa.Integer(), nullable=True),
sa.Column('auto_download', sa.Boolean(), nullable=False),
sa.Column('quality_preference', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('poster_image', sa.String(), nullable=True),
sa.Column('cover_image', sa.String(), nullable=True),
sa.Column('synopsis', sa.String(), nullable=True),
sa.Column('genres_json', sa.String(), nullable=True),
sa.Column('added_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_watchlist_items_anime_title'), 'watchlist_items', ['anime_title'], unique=False)
op.create_index(op.f('ix_watchlist_items_id'), 'watchlist_items', ['id'], unique=False)
op.create_index(op.f('ix_watchlist_items_user_id'), 'watchlist_items', ['user_id'], unique=False)
op.create_table(
'watchlist_settings',
sa.Column('check_interval_hours', sa.Integer(), nullable=False),
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
sa.Column('max_concurrent_auto_downloads', sa.Integer(), nullable=False),
sa.Column('notify_on_new_episodes', sa.Boolean(), nullable=False),
sa.Column('include_completed_anime', sa.Boolean(), nullable=False),
sa.Column('id', sa.String(), nullable=False),
sa.Column('user_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_watchlist_settings_id'), 'watchlist_settings', ['id'], unique=False)
op.create_index(op.f('ix_watchlist_settings_user_id'), 'watchlist_settings', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_watchlist_settings_user_id'), table_name='watchlist_settings')
op.drop_index(op.f('ix_watchlist_settings_id'), table_name='watchlist_settings')
op.drop_table('watchlist_settings')
op.drop_index(op.f('ix_watchlist_items_user_id'), table_name='watchlist_items')
op.drop_index(op.f('ix_watchlist_items_id'), table_name='watchlist_items')
op.drop_index(op.f('ix_watchlist_items_anime_title'), table_name='watchlist_items')
op.drop_table('watchlist_items')
op.drop_index(op.f('ix_sonarr_mappings_user_id'), table_name='sonarr_mappings')
op.drop_index(op.f('ix_sonarr_mappings_sonarr_series_id'), table_name='sonarr_mappings')
op.drop_index(op.f('ix_sonarr_mappings_id'), table_name='sonarr_mappings')
op.drop_table('sonarr_mappings')
op.drop_index(op.f('ix_sonarr_config_id'), table_name='sonarr_config')
op.drop_table('sonarr_config')
op.drop_index(op.f('ix_refresh_tokens_username'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_token_id'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites')
op.drop_index(op.f('ix_favorites_title'), table_name='favorites')
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
op.drop_index(op.f('ix_favorites_anime_id'), table_name='favorites')
op.drop_table('favorites')
op.drop_index(op.f('ix_app_settings_user_id'), table_name='app_settings')
op.drop_index(op.f('ix_app_settings_id'), table_name='app_settings')
op.drop_table('app_settings')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###
@@ -1,30 +0,0 @@
"""Initial migration
Revision ID: e0273f326a15
Revises:
Create Date: 2026-03-24 17:05:50.046027
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e0273f326a15'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
@@ -1,30 +0,0 @@
"""Add WatchlistSettingsTable
Revision ID: e88271d11851
Revises: e0273f326a15
Create Date: 2026-03-24 17:07:10.189457
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e88271d11851'
down_revision: Union[str, None] = 'e0273f326a15'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
+41 -48
View File
@@ -1,9 +1,7 @@
"""User authentication and management system with SQLModel support""" """User authentication and management system with SQLModel support"""
import os
import hashlib
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, List from typing import Optional
from jose import jwt from jose import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
import logging import logging
@@ -11,7 +9,7 @@ from fastapi import HTTPException
from fastapi.security import HTTPAuthorizationCredentials from fastapi.security import HTTPAuthorizationCredentials
from sqlmodel import Session, select from sqlmodel import Session, select
from app.database import engine from app.database import engine
from app.models.auth import UserTable from app.models.auth import UserTable, RefreshTokenTable
from app.config import get_settings from app.config import get_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -189,33 +187,32 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
raise HTTPException(status_code=401, detail="Invalid authentication credentials") raise HTTPException(status_code=401, detail="Invalid authentication credentials")
# Refresh tokens storage def _get_refresh_token(token_id: str) -> Optional[RefreshTokenTable]:
REFRESH_TOKENS_FILE = "config/refresh_tokens.json" """Get a refresh token from the database by token_id"""
with Session(engine) as session:
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
return session.exec(statement).first()
def _load_refresh_tokens() -> Dict[str, dict]: def _save_refresh_token(token: RefreshTokenTable):
"""Load refresh tokens from file""" """Save or update a refresh token in the database"""
import json with Session(engine) as session:
session.add(token)
try: session.commit()
if os.path.exists(REFRESH_TOKENS_FILE):
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"Error loading refresh tokens: {e}")
return {}
def _save_refresh_tokens(tokens: Dict[str, dict]): def _revoke_refresh_token_db(token_id: str) -> bool:
"""Save refresh tokens to file""" """Revoke a refresh token in the database"""
import json with Session(engine) as session:
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
try: db_token = session.exec(statement).first()
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True) if not db_token:
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f: return False
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str) db_token.revoked = True
except Exception as e: db_token.revoked_at = datetime.now()
logger.error(f"Error saving refresh tokens: {e}") session.add(db_token)
session.commit()
return True
def _get_jwt_config() -> dict: def _get_jwt_config() -> dict:
@@ -267,15 +264,15 @@ def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"] refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
) )
# Store refresh token mapping # Store refresh token in database
refresh_tokens = _load_refresh_tokens() db_token = RefreshTokenTable(
refresh_tokens[token_id] = { token_id=token_id,
"username": data["sub"], username=data["sub"],
"token_id": token_id, created_at=datetime.now(),
"created_at": datetime.now().isoformat(), expires_at=refresh_expire,
"expires_at": refresh_expire.isoformat(), revoked=False,
} )
_save_refresh_tokens(refresh_tokens) _save_refresh_token(db_token)
return access_token, refresh_token return access_token, refresh_token
@@ -305,15 +302,18 @@ def verify_refresh_token(token: str) -> Optional[str]:
if not username or not token_id: if not username or not token_id:
return None return None
# Check if token exists in storage # Check if token exists in database
refresh_tokens = _load_refresh_tokens() stored_token = _get_refresh_token(token_id)
stored_token = refresh_tokens.get(token_id)
if not stored_token: if not stored_token:
return None return None
# Verify token hasn't been revoked or expired # Verify token hasn't been revoked or expired
if stored_token.get("revoked"): if stored_token.revoked:
return None
# Also check expiration in database
if stored_token.expires_at and stored_token.expires_at < datetime.now():
return None return None
return username return username
@@ -341,14 +341,7 @@ def revoke_refresh_token(token: str) -> bool:
if not token_id: if not token_id:
return False return False
refresh_tokens = _load_refresh_tokens() return _revoke_refresh_token_db(token_id)
if token_id in refresh_tokens:
refresh_tokens[token_id]["revoked"] = True
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
_save_refresh_tokens(refresh_tokens)
return True
return False
except JWTError: except JWTError:
return False return False
+1 -1
View File
@@ -18,7 +18,7 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
def create_db_and_tables(): def create_db_and_tables():
"""Create the database and tables based on the models""" """Create the database and tables based on the models"""
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata # CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
from app.models.auth import UserTable from app.models.auth import UserTable, RefreshTokenTable
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
from app.models.favorites import FavoriteTable from app.models.favorites import FavoriteTable
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
-1
View File
@@ -17,7 +17,6 @@ from .anime_sites import (
BaseAnimeSite, BaseAnimeSite,
get_anime_site, get_anime_site,
AnimeSamaDownloader, AnimeSamaDownloader,
NekoSamaDownloader,
AnimeUltimeDownloader, AnimeUltimeDownloader,
VostfreeDownloader VostfreeDownloader
) )
-3
View File
@@ -2,7 +2,6 @@
from .base import BaseAnimeSite from .base import BaseAnimeSite
# Import all anime site downloaders # Import all anime site downloaders
from .animesama import AnimeSamaDownloader from .animesama import AnimeSamaDownloader
from .nekosama import NekoSamaDownloader
from .animeultime import AnimeUltimeDownloader from .animeultime import AnimeUltimeDownloader
from .vostfree import VostfreeDownloader from .vostfree import VostfreeDownloader
from .frenchmanga import FrenchMangaDownloader from .frenchmanga import FrenchMangaDownloader
@@ -10,7 +9,6 @@ from .frenchmanga import FrenchMangaDownloader
__all__ = [ __all__ = [
"BaseAnimeSite", "BaseAnimeSite",
"AnimeSamaDownloader", "AnimeSamaDownloader",
"NekoSamaDownloader",
"AnimeUltimeDownloader", "AnimeUltimeDownloader",
"VostfreeDownloader", "VostfreeDownloader",
"FrenchMangaDownloader", "FrenchMangaDownloader",
@@ -22,7 +20,6 @@ def get_anime_site(url: str) -> BaseAnimeSite:
sites = [ sites = [
AnimeSamaDownloader(), AnimeSamaDownloader(),
AnimeUltimeDownloader(), AnimeUltimeDownloader(),
NekoSamaDownloader(),
VostfreeDownloader(), VostfreeDownloader(),
FrenchMangaDownloader(), FrenchMangaDownloader(),
] ]
+10 -6
View File
@@ -490,15 +490,16 @@ class AnimeSamaDownloader(BaseAnimeSite):
part.replace("saison", "").replace("Saison", "") part.replace("saison", "").replace("Saison", "")
) )
break break
except: except Exception:
pass logger.debug("Could not parse season number from URL part")
episode = "01" episode = "01"
if season_num: if season_num:
return f"{anime_name} - S{season_num} - Episode {episode}.mp4" return f"{anime_name} - S{season_num} - Episode {episode}.mp4"
else: else:
return f"{anime_name} - Episode {episode}.mp4" return f"{anime_name} - Episode {episode}.mp4"
except: except Exception:
logger.debug("Could not generate filename, using default")
return "Anime - Episode 01.Mp4" return "Anime - Episode 01.Mp4"
def _generate_anime_name(self, anime_url: str) -> str: def _generate_anime_name(self, anime_url: str) -> str:
@@ -511,7 +512,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
return parts[i + 1].replace("-", " ").title() return parts[i + 1].replace("-", " ").title()
# Fallback # Fallback
return "Anime" return "Anime"
except: except Exception:
logger.debug("Could not extract anime name from URL")
return "Anime" return "Anime"
def _extract_season_number(self, anime_url: str) -> int | None: def _extract_season_number(self, anime_url: str) -> int | None:
@@ -522,7 +524,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
if "saison" in part.lower(): if "saison" in part.lower():
return int(part.replace("saison", "").replace("Saison", "")) return int(part.replace("saison", "").replace("Saison", ""))
return None return None
except: except Exception:
logger.debug("Could not extract season number from URL")
return None return None
async def _extract_from_lpayer( async def _extract_from_lpayer(
@@ -744,7 +747,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
if match: if match:
return match.group(1) return match.group(1)
except: except Exception:
logger.debug("Could not extract video URL from scripts")
pass pass
return None return None
-317
View File
@@ -1,317 +0,0 @@
from .base import BaseAnimeSite
from bs4 import BeautifulSoup
import re
from typing import Optional
from urllib.parse import urljoin
class NekoSamaDownloader(BaseAnimeSite):
"""Downloader for neko-sama.org (anime streaming via Gupy)
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
It does NOT host video content - it provides metadata about where to watch legally.
This provider can search and get metadata but cannot provide direct download links.
"""
BASE_DOMAINS = [
"neko-sama.org",
"www.neko-sama.org",
"neko-sama.fr",
"nekosama.fr",
"www.gupy.fr",
"gupy.fr",
]
def __init__(self):
super().__init__()
self.id = "neko-sama"
def can_handle(self, url: str) -> bool:
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
async def get_download_link(
self, url: str, target_filename: Optional[str] = None
) -> tuple[str, str]:
"""
Extract download link from neko-sama URL.
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
This returns streaming platform information instead of direct video links.
"""
try:
# Check if this is a Gupy URL
if "gupy.fr" in url or "neko-sama.org" in url:
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, "lxml")
# Look for streaming platform links
streaming_links = []
for link in soup.find_all("a", href=True):
href = link.get("href", "")
if "/out/" in href:
text = link.get_text(strip=True)
if text and "Regarder" in text:
streaming_links.append(f"{text}: {href}")
if streaming_links:
title_elem = soup.find("h1") or soup.find("title")
title = (
title_elem.get_text(strip=True).split("|")[0].strip()
if title_elem
else "Unknown"
)
info = "Available streaming platforms:\n" + "\n".join(
streaming_links[:5]
)
filename = target_filename or f"{title}_streaming_info.txt"
return info, filename
raise Exception(
"No streaming links found - Gupy is a legal streaming search, not a video host"
)
# Legacy: try original method for other URLs
response = await self.client.get(url, follow_redirects=True)
soup = BeautifulSoup(response.text, "lxml")
# Method 1: Look for iframes with video
iframes = soup.find_all("iframe")
for iframe in iframes:
src = iframe.get("src", "")
if src and any(p in src for p in ["video", "player", "stream"]):
if not src.startswith("http"):
src = urljoin(str(response.url), src)
filename = self._generate_filename(str(response.url))
return src, filename
# Method 2: Look for video tags
videos = soup.find_all("video")
for video in videos:
src = video.get("src") or video.get("data-src")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
sources = video.find_all("source")
for source in sources:
src = source.get("src", "")
if src:
filename = self._generate_filename(str(response.url))
return src, filename
# Method 3: Look in scripts
scripts = soup.find_all("script")
for script in scripts:
if script.string:
patterns = [
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
r'"url":"([^"]+)"',
r'"video":"([^"]+)"',
]
for pattern in patterns:
matches = re.findall(pattern, script.string)
for match in matches:
match = match.replace("\\/", "/")
if any(ext in match for ext in ["mp4", "m3u8"]):
filename = self._generate_filename(str(response.url))
return match, filename
raise Exception(
"Could not find video link - Neko-Sama/Gupy does not host video content"
)
except Exception as e:
raise Exception(f"Error extracting NekoSama link: {str(e)}")
def _generate_filename(self, url: str) -> str:
parts = url.split("/")
anime_name = "anime"
episode = "1"
for i, part in enumerate(parts):
if "episode" in part.lower():
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
if match:
episode = match.group(1)
filename = f"{anime_name} - Episode {episode}.mp4"
return filename.title()
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
"""Get list of episodes for an anime."""
try:
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
episodes = []
# Try to find episode links
episode_links = soup.find_all("a", href=re.compile(r"episode"))
for link in episode_links:
href = link.get("href", "")
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
if match:
episode_num = match.group(1)
if not href.startswith("http"):
href = urljoin(anime_url, href)
episodes.append({"episode": episode_num, "url": href})
# Deduplicate and sort
seen = set()
unique_episodes = []
for ep in episodes:
if ep["episode"] not in seen:
seen.add(ep["episode"])
unique_episodes.append(ep)
unique_episodes.sort(key=lambda x: int(x["episode"]))
return unique_episodes
except Exception as e:
return []
async def get_anime_metadata(self, anime_url: str) -> dict:
"""Extract rich metadata from anime page."""
try:
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
response = await self.client.get(anime_url)
soup = BeautifulSoup(response.text, "lxml")
metadata = {
"synopsis": None,
"genres": [],
"rating": None,
"release_year": None,
"studio": None,
"poster_image": None,
"banner_image": None,
"total_episodes": None,
"status": None,
"alternative_titles": [],
}
# Extract title and year from h1
title_elem = soup.find("h1")
if title_elem:
title_text = title_elem.get_text(strip=True)
# Extract year from title like "Naruto (2002)"
year_match = re.search(r"\((\d{4})\)", title_text)
if year_match:
metadata["release_year"] = int(year_match.group(1))
# Extract synopsis - Gupy shows it as paragraphs
synopsis_elem = soup.find("p")
if synopsis_elem:
text = synopsis_elem.get_text(strip=True)
if len(text) > 50:
metadata["synopsis"] = text
# Extract genres from meta tags or links
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
if genre_links:
genres = []
for link in genre_links[:5]:
text = link.get_text(strip=True)
if text and "/" not in text and len(text) < 30:
genres.append(text)
metadata["genres"] = genres
# Extract rating from percentage
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
if rating_elem:
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
if match:
rating = float(match.group(1)) / 10
metadata["rating"] = f"{rating:.1f}/10"
# Extract poster image
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
if poster_elem:
metadata["poster_image"] = poster_elem.get("src")
# Extract episode count from page text
page_text = soup.get_text()
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
if ep_match:
metadata["total_episodes"] = int(ep_match.group(1))
# Extract studio/director
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
if director_elem:
metadata["studio"] = director_elem.get_text(strip=True)
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
return metadata
except Exception as e:
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
return {}
async def search_anime(
self, query: str, lang: str = "vostfr", include_metadata: bool = False
) -> list[dict]:
"""Search for anime on neko-sama (uses Gupy backend)."""
try:
import time
from html import unescape
start = time.time()
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
# Neko-Sama now uses Gupy - try the direct URL pattern
search_slug = query.lower().replace(" ", "-")
search_urls = [
f"https://www.gupy.fr/series/{search_slug}/",
f"https://neko-sama.org/series/{search_slug}/",
]
results = []
for search_url in search_urls:
response = await self.client.get(search_url, follow_redirects=True)
print(f"[NEKO-SAMA] Tried {search_url} -> {response.status_code}")
if response.status_code == 200:
final_url = str(response.url)
print(f"[NEKO-SAMA] Found anime at {final_url}")
# Extract title from page
soup = BeautifulSoup(response.text, "lxml")
title_elem = soup.find("h1") or soup.find("title")
title = (
unescape(title_elem.get_text(strip=True))
if title_elem
else query
)
# Clean up title
title = title.split("|")[0].split("-")[0].strip()
result = {
"title": title,
"url": final_url,
"cover_image": None,
"type": "direct",
"metadata": None,
}
# Try to get poster
poster = soup.find("img", src=re.compile(r"poster"))
if poster:
result["cover_image"] = poster.get("src")
if include_metadata:
metadata = await self.get_anime_metadata(final_url)
result["metadata"] = metadata
results.append(result)
break
elapsed = time.time() - start
print(
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
)
return results
except Exception as e:
print(f"[NEKO-SAMA] Error: {str(e)}")
return []
+1 -1
View File
@@ -68,7 +68,7 @@ class DoodStreamDownloader(BaseVideoPlayer):
fname = self._extract_filename_from_headers(head_resp.headers) fname = self._extract_filename_from_headers(head_resp.headers)
if fname: if fname:
filename = fname filename = fname
except: except Exception:
pass pass
return download_url, filename return download_url, filename
+2 -2
View File
@@ -102,7 +102,7 @@ class LpayerDownloader(BaseVideoPlayer):
try: try:
await page.mouse.click(640, 360) await page.mouse.click(640, 360)
await asyncio.sleep(3) await asyncio.sleep(3)
except: except Exception:
pass pass
# Try JavaScript extraction to find video URLs in DOM # Try JavaScript extraction to find video URLs in DOM
@@ -235,7 +235,7 @@ class LpayerDownloader(BaseVideoPlayer):
if browser: if browser:
try: try:
await browser.close() await browser.close()
except: except Exception:
pass pass
"""Extract video URL using Playwright to render JavaScript""" """Extract video URL using Playwright to render JavaScript"""
try: try:
+1 -1
View File
@@ -124,7 +124,7 @@ class OneuploadDownloader(BaseVideoPlayer):
await element.click() await element.click()
await asyncio.sleep(2) await asyncio.sleep(2)
break break
except: except Exception:
continue continue
except Exception as e: except Exception as e:
print(f"[ONEUPLOAD] Play button interaction: {e}") print(f"[ONEUPLOAD] Play button interaction: {e}")
+1 -1
View File
@@ -62,7 +62,7 @@ class RapidFileDownloader(BaseVideoPlayer):
filename = fname filename = fname
else: else:
filename = download_url.split('/')[-1] or "rapidfile_download" filename = download_url.split('/')[-1] or "rapidfile_download"
except: except Exception:
filename = download_url.split('/')[-1] or "rapidfile_download" filename = download_url.split('/')[-1] or "rapidfile_download"
return download_url, filename return download_url, filename
+1 -1
View File
@@ -118,7 +118,7 @@ class SmoothpreDownloader(BaseVideoPlayer):
await element.click() await element.click()
await asyncio.sleep(2) await asyncio.sleep(2)
break break
except: except Exception:
continue continue
except Exception as e: except Exception as e:
print(f"[SMOOTHPRE] Play button interaction: {e}") print(f"[SMOOTHPRE] Play button interaction: {e}")
+1 -1
View File
@@ -42,7 +42,7 @@ class UnFichierDownloader(BaseVideoPlayer):
if not filename: if not filename:
filename = href.split('/')[-1] or "downloaded_file" filename = href.split('/')[-1] or "downloaded_file"
return href, filename return href, filename
except: except Exception:
continue continue
raise Exception("Could not find download link on page") raise Exception("Could not find download link on page")
+1 -1
View File
@@ -177,7 +177,7 @@ class VidMolyDownloader(BaseVideoPlayer):
await element.click() await element.click()
await asyncio.sleep(3) await asyncio.sleep(3)
break break
except: except Exception:
continue continue
except Exception as e: except Exception as e:
print(f"[VIDMOLY] Play button interaction: {e}") print(f"[VIDMOLY] Play button interaction: {e}")
+19
View File
@@ -62,5 +62,24 @@ class UserInDB(User):
"""Schema for user stored in database (with hashed password)""" """Schema for user stored in database (with hashed password)"""
hashed_password: str hashed_password: str
class RefreshTokenTable(SQLModel, table=True):
"""Database table for refresh tokens"""
__tablename__ = "refresh_tokens"
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
primary_key=True,
index=True,
nullable=False
)
token_id: str = Field(index=True, unique=True)
username: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.now)
expires_at: Optional[datetime] = None
revoked: bool = Field(default=False)
revoked_at: Optional[datetime] = None
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings # Import WatchlistItemTable here to resolve SQLModel Relationship mappings
from .watchlist import WatchlistItemTable from .watchlist import WatchlistItemTable
+1 -1
View File
@@ -32,7 +32,7 @@ class AppSettingsBase(SQLModel):
def disabled_providers(self) -> List[str]: def disabled_providers(self) -> List[str]:
try: try:
return json.loads(self.disabled_providers_json or "[]") return json.loads(self.disabled_providers_json or "[]")
except: except json.JSONDecodeError:
return [] return []
@disabled_providers.setter @disabled_providers.setter
+1 -1
View File
@@ -160,7 +160,7 @@ class SonarrMapping(BaseModel):
"""Mapping between Sonarr series and anime providers (API model)""" """Mapping between Sonarr series and anime providers (API model)"""
sonarr_series_id: int sonarr_series_id: int
sonarr_title: str sonarr_title: str
anime_provider: str # 'anime-sama', 'neko-sama', etc. anime_provider: str # 'anime-sama', 'anime-ultime', 'vostfree', 'french-manga', etc.
anime_url: str anime_url: str
anime_title: str anime_title: str
lang: str = "vostfr" lang: str = "vostfr"
-7
View File
@@ -25,13 +25,6 @@ ANIME_PROVIDERS = {
"icon": "▶️", "icon": "▶️",
"color": "#00ff88", "color": "#00ff88",
}, },
"neko-sama": {
"name": "Neko-Sama",
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
"url_pattern": "https://neko-sama.fr/anime/{slug}",
"icon": "🐱",
"color": "#ff6b6b",
},
"vostfree": { "vostfree": {
"name": "Vostfree", "name": "Vostfree",
"domains": ["vostfree.tv", "www.vostfree.tv"], "domains": ["vostfree.tv", "www.vostfree.tv"],
+15 -4
View File
@@ -10,7 +10,6 @@ from datetime import datetime
from app.downloaders.generic_scraper import GenericScraper from app.downloaders.generic_scraper import GenericScraper
from app.downloaders.anime_sites import ( from app.downloaders.anime_sites import (
AnimeSamaDownloader, AnimeSamaDownloader,
NekoSamaDownloader,
AnimeUltimeDownloader, AnimeUltimeDownloader,
VostfreeDownloader, VostfreeDownloader,
FrenchMangaDownloader, FrenchMangaDownloader,
@@ -58,7 +57,6 @@ class ProvidersManager:
"""Load hardcoded Python providers""" """Load hardcoded Python providers"""
provider_classes = [ provider_classes = [
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS), ("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS), ("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
("vostfree", VostfreeDownloader, ANIME_PROVIDERS), ("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS), ("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
@@ -130,10 +128,23 @@ class ProvidersManager:
return 200 <= response.status_code < 400 return 200 <= response.status_code < 400
elif hasattr(scraper, "search_anime"): elif hasattr(scraper, "search_anime"):
results = await scraper.search_anime("One Piece", lang="vostfr") results = await scraper.search_anime("One Piece", lang="vostfr")
return len(results) > 0 # Validate that results actually match the query
if not results:
return False
for r in results:
title = (r.get("title") or "").lower()
if "one" in title or "piece" in title:
return True
return False
elif hasattr(scraper, "search"): elif hasattr(scraper, "search"):
results = await scraper.search("One Piece") results = await scraper.search("One Piece")
return len(results) > 0 if not results:
return False
for r in results:
title = (r.get("title") or "").lower()
if "one" in title or "piece" in title:
return True
return False
return False return False
except Exception as e: except Exception as e:
logger.error( logger.error(
+10 -8
View File
@@ -29,7 +29,6 @@ from app.download_manager import DownloadManager
from app.downloaders import ( from app.downloaders import (
AnimeSamaDownloader, AnimeSamaDownloader,
AnimeUltimeDownloader, AnimeUltimeDownloader,
NekoSamaDownloader,
VostfreeDownloader, VostfreeDownloader,
ZoneTelechargementDownloader, ZoneTelechargementDownloader,
get_downloader, get_downloader,
@@ -59,12 +58,10 @@ async def get_providers_health():
@router.post("/providers/health/check") @router.post("/providers/health/check")
async def trigger_providers_health_check(background_tasks: BackgroundTasks): async def trigger_providers_health_check():
"""Trigger a manual health check of all providers in the background""" """Trigger a manual health check of all providers"""
from app.auto_download_scheduler import auto_download_scheduler await providers_manager.check_all_health()
return {"status": "ok", "providers": providers_manager.get_all_status()}
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
return {"status": "Health check triggered in background"}
def get_download_manager() -> DownloadManager: def get_download_manager() -> DownloadManager:
@@ -136,7 +133,6 @@ async def search_anime_unified(
# Legacy providers (already included in providers_manager, but keep for fallback) # Legacy providers (already included in providers_manager, but keep for fallback)
legacy_downloaders = { legacy_downloaders = {
"anime-ultime": AnimeUltimeDownloader(), "anime-ultime": AnimeUltimeDownloader(),
"neko-sama": NekoSamaDownloader(),
"vostfree": VostfreeDownloader(), "vostfree": VostfreeDownloader(),
} }
for pid, dl in legacy_downloaders.items(): for pid, dl in legacy_downloaders.items():
@@ -196,6 +192,12 @@ async def search_anime_unified(
else: else:
item_dict["_relevance_boost"] = 0.3 item_dict["_relevance_boost"] = 0.3
# Filter out results with very low relevance
MIN_RELEVANCE_THRESHOLD = 0.5
if item_dict["_relevance_boost"] < MIN_RELEVANCE_THRESHOLD:
logger.debug(f"Filtered low-relevance result '{title}' for query '{q}' (score: {item_dict['_relevance_boost']})")
continue
results[pid].append(item_dict) results[pid].append(item_dict)
# Prepare enrichment task for top 15 results per provider # Prepare enrichment task for top 15 results per provider
+1 -2
View File
@@ -17,7 +17,7 @@ from app.models.sonarr import (
SonarrDownloadRequest SonarrDownloadRequest
) )
from app.models import DownloadRequest from app.models import DownloadRequest
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader from app.downloaders import get_downloader, AnimeSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
# Configure logging # Configure logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -205,7 +205,6 @@ class SonarrHandler:
"""Get downloader instance for provider""" """Get downloader instance for provider"""
providers = { providers = {
"anime-sama": AnimeSamaDownloader(), "anime-sama": AnimeSamaDownloader(),
"neko-sama": NekoSamaDownloader(),
"anime-ultime": AnimeUltimeDownloader(), "anime-ultime": AnimeUltimeDownloader(),
"vostfree": VostfreeDownloader() "vostfree": VostfreeDownloader()
} }
-380
View File
@@ -1,380 +0,0 @@
{
"ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU": {
"username": "testuser",
"token_id": "ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU",
"created_at": "2026-03-06T22:01:01.865697",
"expires_at": "2026-04-05T22:01:01.865619"
},
"vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs": {
"username": "testuser",
"token_id": "vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs",
"created_at": "2026-03-06T22:03:55.154118",
"expires_at": "2026-04-05T22:03:55.154019"
},
"fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o": {
"username": "testuser",
"token_id": "fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o",
"created_at": "2026-03-06T22:06:48.751392",
"expires_at": "2026-04-05T22:06:48.751237"
},
"OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU": {
"username": "testuser",
"token_id": "OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU",
"created_at": "2026-03-06T22:06:48.753454",
"expires_at": "2026-04-05T22:06:48.753349"
},
"pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY": {
"username": "testuser",
"token_id": "pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY",
"created_at": "2026-03-06T22:06:48.756403",
"expires_at": "2026-04-05T22:06:48.756301"
},
"-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o": {
"username": "testuser",
"token_id": "-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o",
"created_at": "2026-03-06T22:06:48.757822",
"expires_at": "2026-04-05T22:06:48.757728"
},
"x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g": {
"username": "testuser",
"token_id": "x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g",
"created_at": "2026-03-06T22:06:48.759219",
"expires_at": "2026-04-05T22:06:48.759121"
},
"E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw": {
"username": "testuser",
"token_id": "E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw",
"created_at": "2026-03-06T22:07:03.414591",
"expires_at": "2026-04-05T22:07:03.414466"
},
"XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8": {
"username": "testuser",
"token_id": "XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8",
"created_at": "2026-03-06T22:07:27.981118",
"expires_at": "2026-04-05T22:07:27.980974"
},
"YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ": {
"username": "testuser",
"token_id": "YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ",
"created_at": "2026-03-06T22:07:27.982903",
"expires_at": "2026-04-05T22:07:27.982803"
},
"OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek": {
"username": "testuser",
"token_id": "OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek",
"created_at": "2026-03-06T22:07:27.985521",
"expires_at": "2026-04-05T22:07:27.985410"
},
"9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s": {
"username": "testuser",
"token_id": "9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s",
"created_at": "2026-03-06T22:07:27.986984",
"expires_at": "2026-04-05T22:07:27.986883"
},
"vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI": {
"username": "testuser",
"token_id": "vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI",
"created_at": "2026-03-06T22:07:27.988625",
"expires_at": "2026-04-05T22:07:27.988525"
},
"qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM": {
"username": "testuser",
"token_id": "qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM",
"created_at": "2026-03-06T22:07:33.163399",
"expires_at": "2026-04-05T22:07:33.163230"
},
"8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8": {
"username": "testuser",
"token_id": "8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8",
"created_at": "2026-03-06T22:07:33.165736",
"expires_at": "2026-04-05T22:07:33.165608"
},
"jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE": {
"username": "testuser",
"token_id": "jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE",
"created_at": "2026-03-06T22:07:33.168776",
"expires_at": "2026-04-05T22:07:33.168669"
},
"XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA": {
"username": "testuser",
"token_id": "XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA",
"created_at": "2026-03-06T22:07:33.170429",
"expires_at": "2026-04-05T22:07:33.170321"
},
"Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og": {
"username": "testuser",
"token_id": "Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og",
"created_at": "2026-03-06T22:07:33.172080",
"expires_at": "2026-04-05T22:07:33.171974"
},
"N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY": {
"username": "testuser",
"token_id": "N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY",
"created_at": "2026-03-06T22:08:54.290837",
"expires_at": "2026-04-05T22:08:54.290674"
},
"DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ": {
"username": "testuser",
"token_id": "DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ",
"created_at": "2026-03-06T22:08:54.292851",
"expires_at": "2026-04-05T22:08:54.292732"
},
"MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4": {
"username": "testuser",
"token_id": "MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4",
"created_at": "2026-03-06T22:08:54.295788",
"expires_at": "2026-04-05T22:08:54.295675"
},
"3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM": {
"username": "testuser",
"token_id": "3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM",
"created_at": "2026-03-06T22:08:54.297426",
"expires_at": "2026-04-05T22:08:54.297325"
},
"_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0": {
"username": "testuser",
"token_id": "_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0",
"created_at": "2026-03-06T22:08:54.299268",
"expires_at": "2026-04-05T22:08:54.299159"
},
"pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4": {
"username": "testuser",
"token_id": "pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4",
"created_at": "2026-03-06T22:09:24.318148",
"expires_at": "2026-04-05T22:09:24.317977"
},
"3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc": {
"username": "testuser",
"token_id": "3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc",
"created_at": "2026-03-06T22:09:24.320197",
"expires_at": "2026-04-05T22:09:24.320080"
},
"U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0": {
"username": "testuser",
"token_id": "U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0",
"created_at": "2026-03-06T22:09:24.323151",
"expires_at": "2026-04-05T22:09:24.323044"
},
"ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70": {
"username": "testuser",
"token_id": "ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70",
"created_at": "2026-03-06T22:09:24.324867",
"expires_at": "2026-04-05T22:09:24.324760"
},
"NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU": {
"username": "testuser",
"token_id": "NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU",
"created_at": "2026-03-06T22:09:24.326840",
"expires_at": "2026-04-05T22:09:24.326737"
},
"OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4": {
"username": "testuser",
"token_id": "OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4",
"created_at": "2026-03-06T22:10:26.790594",
"expires_at": "2026-04-05T22:10:26.790416"
},
"1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I": {
"username": "testuser",
"token_id": "1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I",
"created_at": "2026-03-06T22:10:26.792786",
"expires_at": "2026-04-05T22:10:26.792640"
},
"okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w": {
"username": "testuser",
"token_id": "okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w",
"created_at": "2026-03-06T22:10:26.795866",
"expires_at": "2026-04-05T22:10:26.795737"
},
"ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE": {
"username": "testuser",
"token_id": "ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE",
"created_at": "2026-03-06T22:10:26.797631",
"expires_at": "2026-04-05T22:10:26.797524"
},
"CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE": {
"username": "testuser",
"token_id": "CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE",
"created_at": "2026-03-06T22:10:26.799655",
"expires_at": "2026-04-05T22:10:26.799536"
},
"kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE": {
"username": "testuser",
"token_id": "kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE",
"created_at": "2026-03-06T22:27:21.684870",
"expires_at": "2026-04-05T22:27:21.684713"
},
"X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg": {
"username": "testuser",
"token_id": "X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg",
"created_at": "2026-03-06T22:27:21.686951",
"expires_at": "2026-04-05T22:27:21.686838"
},
"lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM": {
"username": "testuser",
"token_id": "lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM",
"created_at": "2026-03-06T22:27:21.689978",
"expires_at": "2026-04-05T22:27:21.689871"
},
"CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4": {
"username": "testuser",
"token_id": "CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4",
"created_at": "2026-03-06T22:27:21.694564",
"expires_at": "2026-04-05T22:27:21.694451"
},
"2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4": {
"username": "testuser",
"token_id": "2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4",
"created_at": "2026-03-06T22:27:21.696368",
"expires_at": "2026-04-05T22:27:21.696259"
},
"innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w": {
"username": "testuser",
"token_id": "innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w",
"created_at": "2026-03-06T22:28:22.440825",
"expires_at": "2026-04-05T22:28:22.440584"
},
"FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc": {
"username": "testuser",
"token_id": "FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc",
"created_at": "2026-03-06T22:28:22.443279",
"expires_at": "2026-04-05T22:28:22.443148"
},
"xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc": {
"username": "testuser",
"token_id": "xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc",
"created_at": "2026-03-06T22:28:22.446772",
"expires_at": "2026-04-05T22:28:22.446637"
},
"Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI": {
"username": "testuser",
"token_id": "Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI",
"created_at": "2026-03-06T22:28:22.448831",
"expires_at": "2026-04-05T22:28:22.448710"
},
"cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U": {
"username": "testuser",
"token_id": "cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U",
"created_at": "2026-03-06T22:28:22.450873",
"expires_at": "2026-04-05T22:28:22.450755"
},
"oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE": {
"username": "testuser",
"token_id": "oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE",
"created_at": "2026-03-06T22:43:41.536641",
"expires_at": "2026-04-05T22:43:41.536473"
},
"8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw": {
"username": "testuser",
"token_id": "8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw",
"created_at": "2026-03-06T22:43:41.538970",
"expires_at": "2026-04-05T22:43:41.538842"
},
"9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk": {
"username": "testuser",
"token_id": "9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk",
"created_at": "2026-03-06T22:43:41.542159",
"expires_at": "2026-04-05T22:43:41.542042"
},
"-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU": {
"username": "testuser",
"token_id": "-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU",
"created_at": "2026-03-06T22:43:41.544148",
"expires_at": "2026-04-05T22:43:41.544030"
},
"L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY": {
"username": "testuser",
"token_id": "L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY",
"created_at": "2026-03-06T22:43:41.546116",
"expires_at": "2026-04-05T22:43:41.545999"
},
"Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0": {
"username": "testuser",
"token_id": "Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0",
"created_at": "2026-03-23T15:14:58.571086",
"expires_at": "2026-04-22T15:14:58.570921"
},
"glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww": {
"username": "testuser",
"token_id": "glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww",
"created_at": "2026-03-23T15:14:58.573282",
"expires_at": "2026-04-22T15:14:58.573168"
},
"3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA": {
"username": "testuser",
"token_id": "3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA",
"created_at": "2026-03-23T15:14:58.576669",
"expires_at": "2026-04-22T15:14:58.576537"
},
"Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg": {
"username": "testuser",
"token_id": "Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg",
"created_at": "2026-03-23T15:14:58.578685",
"expires_at": "2026-04-22T15:14:58.578562"
},
"8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY": {
"username": "testuser",
"token_id": "8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY",
"created_at": "2026-03-23T15:14:58.580654",
"expires_at": "2026-04-22T15:14:58.580531"
},
"Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8": {
"username": "testuser",
"token_id": "Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8",
"created_at": "2026-03-23T15:34:35.684297",
"expires_at": "2026-04-22T15:34:35.684116"
},
"BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ": {
"username": "testuser",
"token_id": "BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ",
"created_at": "2026-03-23T15:34:35.686743",
"expires_at": "2026-04-22T15:34:35.686606"
},
"H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU": {
"username": "testuser",
"token_id": "H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU",
"created_at": "2026-03-23T15:34:35.690100",
"expires_at": "2026-04-22T15:34:35.689977"
},
"9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM": {
"username": "testuser",
"token_id": "9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM",
"created_at": "2026-03-23T15:34:35.692293",
"expires_at": "2026-04-22T15:34:35.692176"
},
"BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI": {
"username": "testuser",
"token_id": "BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI",
"created_at": "2026-03-23T15:34:35.694464",
"expires_at": "2026-04-22T15:34:35.694325"
},
"wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34": {
"username": "testuser",
"token_id": "wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34",
"created_at": "2026-03-23T16:15:23.555117",
"expires_at": "2026-04-22T16:15:23.554918"
},
"sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0": {
"username": "testuser",
"token_id": "sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0",
"created_at": "2026-03-23T16:15:23.557727",
"expires_at": "2026-04-22T16:15:23.557585"
},
"ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s": {
"username": "testuser",
"token_id": "ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s",
"created_at": "2026-03-23T16:15:23.561170",
"expires_at": "2026-04-22T16:15:23.561048"
},
"3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc": {
"username": "testuser",
"token_id": "3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc",
"created_at": "2026-03-23T16:15:23.563391",
"expires_at": "2026-04-22T16:15:23.563269"
},
"-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE": {
"username": "testuser",
"token_id": "-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE",
"created_at": "2026-03-23T16:15:23.565588",
"expires_at": "2026-04-22T16:15:23.565458"
}
}
+8
View File
@@ -5,6 +5,7 @@ Main application file with startup configuration and middleware.
All API routes have been migrated to app/routers/ for better maintainability. All API routes have been migrated to app/routers/ for better maintainability.
""" """
import asyncio
import logging import logging
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -42,6 +43,8 @@ app.add_middleware(
"http://192.168.1.204", "http://192.168.1.204",
"http://192.168.1.200:3000", "http://192.168.1.200:3000",
"http://192.168.1.200", "http://192.168.1.200",
"http://192.168.5.127:3000",
"http://192.168.5.127",
], ],
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
@@ -82,6 +85,11 @@ async def startup_event():
from app.auto_download_scheduler import auto_download_scheduler from app.auto_download_scheduler import auto_download_scheduler
auto_download_scheduler.start() auto_download_scheduler.start()
# Run initial provider health check in background
from app.providers_manager import providers_manager
asyncio.create_task(providers_manager.check_all_health())
logger.info("Application started: Sonarr handler and scheduler initialized") logger.info("Application started: Sonarr handler and scheduler initialized")
+16 -2173
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -8,8 +8,6 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2", "@playwright/test": "^1.60.0"
"jsdom": "^29.0.0",
"vitest": "^1.0.0"
} }
} }
+20 -15
View File
@@ -4,50 +4,55 @@ import { defineConfig, devices } from '@playwright/test';
* @see https://playwright.dev/docs/test-configuration * @see https://playwright.dev/docs/test-configuration
*/ */
export default defineConfig({ export default defineConfig({
testDir: './tests/e2e', globalSetup: './tests/e2e/global-setup.ts',
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: true, fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */ /* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:3000', baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
/* Capture screenshot on failure */ /* Capture screenshot on failure */
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
/* Video recording on failure */ /* Video recording on failure */
video: 'retain-on-failure', video: 'retain-on-failure',
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ {
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
}, },
], ],
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'uvicorn main:app --host 0.0.0.0 --port 3000', command: 'venv/bin/uvicorn main:app --host 0.0.0.0 --port 3000',
url: 'http://localhost:3000', url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: true,
}, },
}); });
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, expect, beforeAll } from 'vitest';
// Set up global window object for jsdom
global.window = global.window || {};
// Define skeleton functions for testing (same as in auth-api.js)
const API_BASE = '/api';
async function login(username, password) {
throw new Error('Not implemented yet');
}
async function register(username, password, email = null, full_name = null) {
throw new Error('Not implemented yet');
}
async function logout() {
throw new Error('Not implemented yet');
}
async function getMe(token) {
throw new Error('Not implemented yet');
}
// Set up window object
window.authApi = {
login,
register,
logout,
getMe,
};
describe('authApi', () => {
describe('login function', () => {
it('should be a function', () => {
expect(typeof window.authApi.login).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.login('test', 'test');
expect(result).toBeInstanceOf(Promise);
});
});
describe('register function', () => {
it('should be a function', () => {
expect(typeof window.authApi.register).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.register('testuser', 'password123', null, null);
expect(result).toBeInstanceOf(Promise);
});
it('should handle optional parameters', async () => {
try {
await window.authApi.register('test', 'password');
} catch (e) {
expect(e.message).toBe('Not implemented yet');
}
});
});
describe('logout function', () => {
it('should be a function', () => {
expect(typeof window.authApi.logout).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.logout();
expect(result).toBeInstanceOf(Promise);
});
});
describe('getMe function', () => {
it('should be a function', () => {
expect(typeof window.authApi.getMe).toBe('function');
});
it('should return a Promise', () => {
const result = window.authApi.getMe('fake-token');
expect(result).toBeInstanceOf(Promise);
});
});
});
-80
View File
@@ -1,80 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
// Mock DOM elements for displayError tests
const mockDocument = () => {
const elements = {};
global.document = {
getElementById: (id) => elements[id] || null,
};
beforeEach(() => {
elements.authError = {
textContent: '',
classList: {
add: () => {},
remove: () => {}
}
};
elements.authSuccess = {
textContent: '',
classList: {
add: () => {},
remove: () => {}
}
};
});
};
describe('safeJsonParse', () => {
// Import the function - we'll need to make it work with Vitest
// For now, we'll define it inline for testing
const safeJsonParse = (text, fallback = null) => {
try {
if (text === undefined || text === null || text === '') {
return fallback;
}
return JSON.parse(text);
} catch (error) {
return fallback;
}
};
it('should parse valid JSON string', () => {
const result = safeJsonParse('{"key":"value"}');
expect(result).toEqual({ key: 'value' });
});
it('should return fallback for invalid JSON', () => {
const result = safeJsonParse('invalid json');
expect(result).toBeNull();
});
it('should return custom fallback when provided', () => {
const result = safeJsonParse('invalid', 'custom fallback');
expect(result).toBe('custom fallback');
});
it('should return fallback for undefined input', () => {
const result = safeJsonParse(undefined);
expect(result).toBeNull();
});
it('should return fallback for null input', () => {
const result = safeJsonParse(null);
expect(result).toBeNull();
});
it('should return fallback for empty string', () => {
const result = safeJsonParse('');
expect(result).toBeNull();
});
it('should parse valid JSON array', () => {
const result = safeJsonParse('[1, 2, 3]');
expect(result).toEqual([1, 2, 3]);
});
it('should parse nested JSON', () => {
const result = safeJsonParse('{"user":{"name":"John","age":30}}');
expect(result).toEqual({ user: { name: 'John', age: 30 } });
});
});
-8
View File
@@ -1,8 +0,0 @@
// Smoke test to verify Vitest setup
import { describe, it, expect } from 'vitest';
describe('smoke', () => {
it('works', () => {
expect(true).toBe(true);
});
});
+2 -1
View File
@@ -93,7 +93,8 @@
<div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);"> <div class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3> <h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3>
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none"> <button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none"
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')">
<i class="fas fa-sync-alt"></i> Forcer verification <i class="fas fa-sync-alt"></i> Forcer verification
</button> </button>
</div> </div>
@@ -33,6 +33,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
pointer-events: none;
}
.toast {
pointer-events: auto;
} }
.toast { .toast {
min-width: 250px; min-width: 250px;
+7 -9
View File
@@ -41,7 +41,7 @@ async def test_watchlist_manager():
) )
try: try:
item = watchlist_manager.create(test_user, item_data) item = watchlist_manager.add(test_user, item_data)
print(f" ✅ Item created: {item.id}") print(f" ✅ Item created: {item.id}")
print(f" Title: {item.anime_title}") print(f" Title: {item.anime_title}")
print(f" Status: {item.status}") print(f" Status: {item.status}")
@@ -127,8 +127,8 @@ async def test_scheduler():
print("\n2. Testing scheduler status...") print("\n2. Testing scheduler status...")
try: try:
status = auto_download_scheduler.get_status() running = auto_download_scheduler.is_running()
print(f" ✅ Scheduler status: running={status['running']}") print(f" ✅ Scheduler status: running={running}")
except Exception as e: except Exception as e:
print(f" ❌ Status failed: {e}") print(f" ❌ Status failed: {e}")
return False return False
@@ -136,20 +136,18 @@ async def test_scheduler():
print("\n3. Testing scheduler start/stop...") print("\n3. Testing scheduler start/stop...")
try: try:
# Start scheduler # Start scheduler
await auto_download_scheduler.start() auto_download_scheduler.start()
print(" ✅ Scheduler started") print(" ✅ Scheduler started")
status = auto_download_scheduler.get_status() if not auto_download_scheduler.is_running():
if not status['running']:
print(" ❌ Scheduler not running after start") print(" ❌ Scheduler not running after start")
return False return False
# Stop scheduler # Stop scheduler
await auto_download_scheduler.stop() auto_download_scheduler.stop()
print(" ✅ Scheduler stopped") print(" ✅ Scheduler stopped")
status = auto_download_scheduler.get_status() if auto_download_scheduler.is_running():
if status['running']:
print(" ❌ Scheduler still running after stop") print(" ❌ Scheduler still running after stop")
return False return False
+2 -2
View File
@@ -50,7 +50,7 @@ def test_watchlist_basics():
) )
try: try:
item = watchlist_manager.create(test_user, item_data) item = watchlist_manager.add(test_user, item_data)
print(f" ✅ Item created: {item.id}") print(f" ✅ Item created: {item.id}")
print(f" Title: {item.anime_title}") print(f" Title: {item.anime_title}")
print(f" Status: {item.status}") print(f" Status: {item.status}")
@@ -178,7 +178,7 @@ async def test_scheduler():
print("🧪 TEST 3: Auto-Download Scheduler") print("🧪 TEST 3: Auto-Download Scheduler")
print("="*60) print("="*60)
print("\n1. Testing scheduler start (async)...") print("\n1. Testing scheduler start...")
try: try:
auto_download_scheduler.start() auto_download_scheduler.start()
print(f" ✅ Scheduler started") print(f" ✅ Scheduler started")
+33
View File
@@ -0,0 +1,33 @@
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Create user if not exists (global setup should have done it, but be safe)
const resp = await page.request.post('/api/auth/register', {
data: {
username: 'e2e_testuser',
password: 'TestPassword123!',
email: 'e2e@example.com',
full_name: 'E2E Test User',
},
});
if (!resp.ok() && resp.status() !== 400) {
console.warn('Register failed:', await resp.text());
}
// Login via UI
await page.goto('/login');
await page.fill('#loginUsername', 'e2e_testuser');
await page.fill('#loginPassword', 'TestPassword123!');
await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
page.click('#loginSubmit'),
]);
await page.waitForURL('**/web**', { timeout: 10000 });
// Save storage state (localStorage + cookies)
await page.context().storageState({ path: authFile });
});
+59 -85
View File
@@ -1,119 +1,93 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { TEST_USER, login } from './helpers';
test.describe('Auth Flow', () => { test.describe('Auth Flow', () => {
test('login success - redirects to home and stores token', async ({ page }) => { test('login success - redirects to home and stores token', async ({ page }) => {
await page.goto('/login'); await login(page, TEST_USER.username, TEST_USER.password);
// Fill login form // Verify redirect to /web
await page.fill('#loginUsername', 'testuser'); await expect(page).toHaveURL(/\/web/);
await page.fill('#loginPassword', 'password123');
// Verify token stored
// Click login button const token = await page.evaluate(() => localStorage.getItem('auth_token'));
await page.click('#loginSubmit'); expect(token).toBeTruthy();
// Wait for redirect or success message
await page.waitForTimeout(2000);
// Check if redirected or success message shown
const currentUrl = page.url();
const successMessage = await page.locator('#authSuccess').textContent().catch(() => '');
// Either redirect happened or success message shown
expect(currentUrl.includes('/web') || successMessage.includes('réussie')).toBeTruthy();
}); });
test('login with wrong credentials shows error', async ({ page }) => { test('login with wrong credentials shows error', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
await page.fill('#loginUsername', 'nonexistentuser_xyz');
// Fill login form with wrong credentials
await page.fill('#loginUsername', 'nonexistentuser');
await page.fill('#loginPassword', 'wrongpassword'); await page.fill('#loginPassword', 'wrongpassword');
// Click login button const [response] = await Promise.all([
await page.click('#loginSubmit'); page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
page.click('#loginSubmit'),
// Wait for error ]);
await page.waitForTimeout(2000);
expect(response.status()).toBe(401);
// Check error message is displayed
const errorVisible = await page.locator('#authError').isVisible().catch(() => false); // Error message should be visible
const errorText = await page.locator('#authError').textContent().catch(() => ''); const errorLocator = page.locator('#authError');
await expect(errorLocator).toBeVisible();
// Error should be shown (and NOT be "[object Object]") await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i);
expect(errorVisible || errorText.length > 0).toBeTruthy();
expect(errorText).not.toContain('[object Object]');
}); });
test('register new user shows success', async ({ page }) => { test('register new user shows success', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
// Switch to register tab
await page.click('text=Inscription'); await page.click('text=Inscription');
// Fill register form with unique username const uniqueUsername = `testuser_${Date.now()}`;
const uniqueUsername = 'testuser_' + Date.now();
await page.fill('#registerUsername', uniqueUsername); await page.fill('#registerUsername', uniqueUsername);
await page.fill('#registerPassword', 'password123'); await page.fill('#registerPassword', 'password123');
await page.fill('#registerPasswordConfirm', 'password123'); await page.fill('#registerPasswordConfirm', 'password123');
// Click register button const [response] = await Promise.all([
await page.click('#registerSubmit'); page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
page.click('#registerSubmit'),
// Wait for success ]);
await page.waitForTimeout(2000);
expect(response.status()).toBeLessThan(400);
// Check success message
const successVisible = await page.locator('#authSuccess').isVisible().catch(() => false); await expect(page.locator('#authSuccess')).toBeVisible();
const successText = await page.locator('#authSuccess').textContent().catch(() => ''); await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i);
// Success should be shown
expect(successVisible || successText.includes('réussie')).toBeTruthy();
}); });
test('password mismatch shows validation error', async ({ page }) => { test('password mismatch shows validation error', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
// Switch to register tab
await page.click('text=Inscription'); await page.click('text=Inscription');
// Fill register form with mismatching passwords
await page.fill('#registerUsername', 'testuser'); await page.fill('#registerUsername', 'testuser');
await page.fill('#registerPassword', 'password123'); await page.fill('#registerPassword', 'password123');
await page.fill('#registerPasswordConfirm', 'differentpassword'); await page.fill('#registerPasswordConfirm', 'differentpassword');
// Click register button
await page.click('#registerSubmit'); await page.click('#registerSubmit');
// Wait for error await expect(page.locator('#authError')).toBeVisible();
await page.waitForTimeout(1000); await expect(page.locator('#authError')).toContainText(/correspondent|match/i);
// Check error message
const errorText = await page.locator('#authError').textContent().catch(() => '');
// Should show password mismatch error
expect(errorText).toContain('correspondent');
}); });
test('login button shows loading state during request', async ({ page }) => { test('login button shows loading state during request', async ({ page }) => {
await page.goto('/login'); await page.goto('/login');
// Get button and check initial state
const button = page.locator('#loginSubmit'); const button = page.locator('#loginSubmit');
const initialText = await button.textContent(); const initialText = await button.textContent();
// Fill form and click await page.fill('#loginUsername', TEST_USER.username);
await page.fill('#loginUsername', 'testuser'); await page.fill('#loginPassword', TEST_USER.password);
await page.fill('#loginPassword', 'password123');
// Start the click but don't await it fully — we want to observe the loading state
// Click and immediately check loading state const clickPromise = button.click();
await button.click();
// Poll briefly for loading state
// Check loading state (should change text or be disabled) let sawLoading = false;
await page.waitForTimeout(100); for (let i = 0; i < 10; i++) {
const buttonText = await button.textContent(); const text = await button.textContent();
const isDisabled = await button.isDisabled().catch(() => false); const disabled = await button.isDisabled();
if (text !== initialText || disabled) {
// Button should either show loading text or be disabled sawLoading = true;
expect(buttonText !== initialText || isDisabled).toBeTruthy(); break;
}
await page.waitForTimeout(50);
}
await clickPromise;
expect(sawLoading).toBe(true);
}); });
}); });
+11
View File
@@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test';
import { switchTab, waitForHtmx } from './helpers';
test.describe('Downloads', () => {
test('should display downloads tab', async ({ page }) => {
await page.goto('/web');
await switchTab(page, 'Téléchargements');
await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 });
await expect(page.locator('#tab-downloads')).toBeVisible();
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Global setup for E2E tests.
* Creates a predictable test user so auth tests don't fail on missing accounts.
* Uses native fetch to avoid conflicts with vitest.
*/
export default async function globalSetup() {
const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000';
const testUser = {
username: 'e2e_testuser',
password: 'TestPassword123!',
email: 'e2e@example.com',
full_name: 'E2E Test User',
};
// Try to register the test user (ignore 400 if already exists)
const resp = await fetch(`${baseURL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(testUser),
});
if (resp.ok || resp.status === 400) {
console.log(`[global-setup] Test user "${testUser.username}" ready`);
} else {
const body = await resp.text().catch(() => '');
console.warn(`[global-setup] Register returned ${resp.status}: ${body}`);
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Page, expect } from '@playwright/test';
export const TEST_USER = {
username: 'e2e_testuser',
password: 'TestPassword123!',
};
/**
* Log in via the UI login form.
*/
export async function login(page: Page, username: string, password: string) {
await page.goto('/login');
await page.fill('#loginUsername', username);
await page.fill('#loginPassword', password);
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
page.click('#loginSubmit'),
]);
expect(response.status()).toBeLessThan(400);
// Wait for success message or redirect
await Promise.race([
page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 }),
page.waitForURL('**/web**', { timeout: 5000 }),
]);
}
/**
* Register a new unique user via the UI form.
*/
export async function register(page: Page, username: string, password: string) {
await page.goto('/login');
await page.click('text=Inscription');
await page.fill('#registerUsername', username);
await page.fill('#registerPassword', password);
await page.fill('#registerPasswordConfirm', password);
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
page.click('#registerSubmit'),
]);
expect(response.status()).toBeLessThan(400);
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 5000 });
}
/**
* Switch to a tab by name (Accueil, Anime, Série, Watchlist, etc.)
*/
export async function switchTab(page: Page, tabName: string) {
// Wait for tabs to be rendered
await page.locator('nav#mainTabs .tab').first().waitFor({ state: 'visible', timeout: 5000 });
const tab = page.locator('nav#mainTabs .tab', { hasText: new RegExp(tabName, 'i') });
await tab.waitFor({ state: 'visible', timeout: 5000 });
await tab.click();
await expect(tab).toHaveClass(/active/);
}
/**
* Wait for HTMX content to settle (no more hx-request in flight).
*/
export async function waitForHtmx(page: Page, timeout = 10000) {
await page.waitForFunction(
() => document.querySelectorAll('.htmx-request').length === 0,
{ timeout }
);
}
/**
* Check that no unhandled JS errors occurred on the page.
*/
export function collectJsErrors(page: Page): string[] {
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
+46 -108
View File
@@ -1,152 +1,90 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { switchTab, waitForHtmx, collectJsErrors } from './helpers';
/** /**
* User Journey E2E Tests * User Journey E2E Tests
* *
* Simulates a complete user flow: register → login → browse → search → settings → logout. * Tests authenticated user flows. Auth is handled by auth.setup.ts + storageState.
* All tests are serial because they share browser state (auth token, navigation).
*
* FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector()
*/ */
test.describe('User Journey E2E', () => { test.describe('User Journey E2E', () => {
test.describe.configure({ mode: 'serial' }); test('should browse homepage without JS errors', async ({ page }) => {
const jsErrors = collectJsErrors(page);
await page.goto('/web');
const testData = { // Main content should be visible
username: `e2e_user_${Date.now()}`, await expect(page.locator('#main-content')).toBeVisible();
password: 'TestPass123!',
};
// Register a new user account via the UI form
test('should register a new user', async ({ page }) => {
await page.goto('/login');
// Switch to the register tab
await page.click('text=Inscription');
// Fill out the registration form
await page.fill('#registerUsername', testData.username);
await page.fill('#registerPassword', testData.password);
await page.fill('#registerPasswordConfirm', testData.password);
// Submit and wait for the API response
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
page.click('#registerSubmit'),
]);
// Registration should succeed (201 or 200)
expect(response.status()).toBeLessThan(400);
// Verify the success message appears
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
const successText = await page.locator('#authSuccess').textContent();
expect(successText).toMatch(/réussie|inscription/i);
});
// Login with the credentials registered in the previous test
test('should login with registered credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('#loginUsername', testData.username);
await page.fill('#loginPassword', testData.password);
// Submit and wait for the login API response
const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
page.click('#loginSubmit'),
]);
expect(response.status()).toBeLessThan(400);
// Verify success message
await page.locator('#authSuccess').waitFor({ state: 'visible', timeout: 10000 });
const successText = await page.locator('#authSuccess').textContent();
expect(successText).toMatch(/réussie/i);
// Wait for redirect to /web
await page.waitForURL('**/web**', { timeout: 10000 });
// Verify the auth token is stored in localStorage
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
expect(token).toBeTruthy();
});
// Browse the homepage — verify layout loads without JS errors
test('should browse homepage without errors', async ({ page }) => {
// Collect JS page errors
const errors: string[] = [];
page.on('pageerror', (err) => errors.push(err.message));
// Ensure we are on /web (carried over from login)
if (!page.url().includes('/web')) {
await page.goto('/web');
}
// Wait for main content area to be visible
await page.locator('#main-content').waitFor({ state: 'visible', timeout: 10000 });
// Verify the header heading
await expect(page.locator('header h1')).toContainText('Ohm Stream'); await expect(page.locator('header h1')).toContainText('Ohm Stream');
// Verify at least one navigation tab is visible // At least one tab visible
await expect(page.locator('.tab').first()).toBeVisible(); await expect(page.locator('.tab').first()).toBeVisible();
// Verify the user info panel (logged-in state indicator) // Authenticated user info should be visible
await expect(page.locator('#userInfo')).toBeVisible(); await expect(page.locator('#userInfo')).toBeVisible();
// No JavaScript errors should have been thrown expect(jsErrors).toHaveLength(0);
expect(errors).toHaveLength(0);
}); });
// Search for an anime via the Anime tab — results may be empty but the UI must respond
test('should search for anime', async ({ page }) => { test('should search for anime', async ({ page }) => {
// Navigate to the Anime tab // Mock the anime search API to return deterministic HTML
await page.click('.tab:has-text("Anime")'); await page.route('/api/anime/search?**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/html',
body: `
<div class="sr-card">
<h3>Naruto Shippuden</h3>
<p>Anime-Sama</p>
</div>
<div class="sr-card">
<h3>Boruto: Naruto Next Generations</h3>
<p>Neko-Sama</p>
</div>
`,
});
});
await page.goto('/web');
await switchTab(page, 'Anime');
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 }); await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
// Fill the search input — HTMX debounce triggers the request automatically
await page.fill('#animeSearchInput', 'Naruto'); await page.fill('#animeSearchInput', 'Naruto');
// Wait for either results, an empty-state message, or the loading spinner to disappear // Click search button to trigger submit
await Promise.race([ await page.click('#tab-anime button[type="submit"]');
page.locator('#animeSearchResults .sr-card').first().waitFor({ timeout: 15000 }),
page.locator('#animeSearchResults .sr-empty').first().waitFor({ timeout: 15000 }),
page.locator('#search-loading').waitFor({ state: 'detached', timeout: 15000 }),
]);
// The search results container must be present regardless of result count // Wait for results to appear
await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
// Results container should be visible and contain mocked data
await expect(page.locator('#animeSearchResults')).toBeVisible(); await expect(page.locator('#animeSearchResults')).toBeVisible();
await expect(page.locator('#animeSearchResults')).toContainText('Naruto Shippuden');
}); });
// Change a setting (language) and verify the PATCH response and toast notification
test('should update settings', async ({ page }) => { test('should update settings', async ({ page }) => {
// Open the settings tab await page.goto('/web');
await page.click('.tab:has-text("Paramètres")'); await switchTab(page, 'Paramètres');
// Settings panel is loaded dynamically via HTMX — wait for the form // Wait for settings form loaded via HTMX
await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 }); await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 });
// Change the default language
await page.selectOption('#default_lang', 'vf'); await page.selectOption('#default_lang', 'vf');
// Submit the settings form and capture the PATCH response
const [response] = await Promise.all([ const [response] = await Promise.all([
page.waitForResponse( page.waitForResponse(
(resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH' (resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH'
), ),
page.locator('form[hx-patch="/api/settings"] button[type="submit"]').click(), page.locator('button:has-text("Enregistrer les preferences")').click(),
]); ]);
expect(response.status()).toBe(200); expect(response.status()).toBe(200);
// Verify a toast notification appears confirming the save // Verify the setting was updated in the UI
await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 }); await expect(page.locator('#default_lang')).toHaveValue('vf');
}); });
// Logout — verify the API call succeeds, redirect happens, and token is cleared
test('should logout successfully', async ({ page }) => { test('should logout successfully', async ({ page }) => {
// Click the logout button and wait for the API response await page.goto('/web');
const [response] = await Promise.all([ const [response] = await Promise.all([
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')), page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
page.locator('#userInfo button:has-text("Déconnexion")').click(), page.locator('#userInfo button:has-text("Déconnexion")').click(),
@@ -154,7 +92,7 @@ test.describe('User Journey E2E', () => {
expect(response.status()).toBeLessThan(400); expect(response.status()).toBeLessThan(400);
// Should be redirected back to the login page // Should redirect to login
await page.waitForURL('**/login**', { timeout: 10000 }); await page.waitForURL('**/login**', { timeout: 10000 });
// The auth token must be cleared from localStorage // The auth token must be cleared from localStorage
+11
View File
@@ -0,0 +1,11 @@
import { test, expect } from '@playwright/test';
import { switchTab, waitForHtmx } from './helpers';
test.describe('Watchlist', () => {
test('should display watchlist tab', async ({ page }) => {
await page.goto('/web');
await switchTab(page, 'Watchlist');
await page.locator('#tab-watchlist').waitFor({ state: 'visible', timeout: 5000 });
await expect(page.locator('#tab-watchlist')).toBeVisible();
});
});
+2 -2
View File
@@ -426,7 +426,7 @@ class TestAPIFavorites:
# Make sure it doesn't exist first # Make sure it doesn't exist first
try: try:
client.delete("/api/favorites/test-toggle-add") client.delete("/api/favorites/test-toggle-add")
except: except Exception:
pass pass
response = client.post( response = client.post(
@@ -448,7 +448,7 @@ class TestAPIFavorites:
# Make sure it doesn't exist first # Make sure it doesn't exist first
try: try:
client.delete("/api/favorites/test-toggle-remove") client.delete("/api/favorites/test-toggle-remove")
except: except Exception:
pass pass
# Add first # Add first
+1 -1
View File
@@ -354,7 +354,7 @@ class TestDownloadManagerErrorHandling:
try: try:
await manager.start_download(task.id) await manager.start_download(task.id)
await asyncio.sleep(0.1) # Give it time to process await asyncio.sleep(0.1) # Give it time to process
except: except Exception:
pass pass
# The task should be in tasks dict # The task should be in tasks dict
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
include: ['static/js/__tests__/**/*.test.js'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reportsDirectory: 'htmlcov',
},
},
});