fix: migrations, auth, providers health check, E2E tests, remove neko-sama
- 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:
@@ -69,3 +69,4 @@ test-results/
|
|||||||
.opencode/
|
.opencode/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
playwright/.auth/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from .anime_sites import (
|
|||||||
BaseAnimeSite,
|
BaseAnimeSite,
|
||||||
get_anime_site,
|
get_anime_site,
|
||||||
AnimeSamaDownloader,
|
AnimeSamaDownloader,
|
||||||
NekoSamaDownloader,
|
|
||||||
AnimeUltimeDownloader,
|
AnimeUltimeDownloader,
|
||||||
VostfreeDownloader
|
VostfreeDownloader
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+16
-2173
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Smoke test to verify Vitest setup
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
|
|
||||||
describe('smoke', () => {
|
|
||||||
it('works', () => {
|
|
||||||
expect(true).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user