Compare commits
11 Commits
c0f9c0c1c4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6521fe3416 | |||
| 520be53901 | |||
| 693615a7dc | |||
| 7529449f86 | |||
| 555816bf30 | |||
| 2da2a5bb27 | |||
| c921aafadd | |||
| e5b30741fe | |||
| 0af537e032 | |||
| 9f9df600c1 | |||
| 5d264d8f3b |
@@ -69,3 +69,4 @@ test-results/
|
||||
.opencode/
|
||||
.mypy_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
|
||||
- **JWT Tokens** - Stateless authentication with refresh token support
|
||||
- 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!)
|
||||
- Token verification and user extraction
|
||||
- **Password Security**
|
||||
@@ -348,7 +348,7 @@ The downloaders are organized into three categories with separate base classes:
|
||||
- **Configuration**
|
||||
- `JWT_SECRET_KEY` environment variable (MUST be changed from default)
|
||||
- Users stored in `config/users.json`
|
||||
- Refresh tokens stored in `config/refresh_tokens.json`
|
||||
- Refresh tokens stored in SQLite `refresh_tokens` table
|
||||
|
||||
**Authentication Endpoints:**
|
||||
- `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:**
|
||||
- `.env` - Environment configuration (create from .env.example)
|
||||
- `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_mappings.json` - Sonarr to anime provider mappings (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)
|
||||
- JWT secret key validation (minimum 32 characters, default rejected)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
import logging
|
||||
@@ -11,7 +9,7 @@ from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlmodel import Session, select
|
||||
from app.database import engine
|
||||
from app.models.auth import UserTable
|
||||
from app.models.auth import UserTable, RefreshTokenTable
|
||||
from app.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -189,33 +187,32 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
|
||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||
|
||||
|
||||
# Refresh tokens storage
|
||||
REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
||||
def _get_refresh_token(token_id: str) -> Optional[RefreshTokenTable]:
|
||||
"""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]:
|
||||
"""Load refresh tokens from file"""
|
||||
import json
|
||||
|
||||
try:
|
||||
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_token(token: RefreshTokenTable):
|
||||
"""Save or update a refresh token in the database"""
|
||||
with Session(engine) as session:
|
||||
session.add(token)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
||||
"""Save refresh tokens to file"""
|
||||
import json
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
||||
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving refresh tokens: {e}")
|
||||
def _revoke_refresh_token_db(token_id: str) -> bool:
|
||||
"""Revoke a refresh token in the database"""
|
||||
with Session(engine) as session:
|
||||
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
|
||||
db_token = session.exec(statement).first()
|
||||
if not db_token:
|
||||
return False
|
||||
db_token.revoked = True
|
||||
db_token.revoked_at = datetime.now()
|
||||
session.add(db_token)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
|
||||
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"]
|
||||
)
|
||||
|
||||
# Store refresh token mapping
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
refresh_tokens[token_id] = {
|
||||
"username": data["sub"],
|
||||
"token_id": token_id,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"expires_at": refresh_expire.isoformat(),
|
||||
}
|
||||
_save_refresh_tokens(refresh_tokens)
|
||||
# Store refresh token in database
|
||||
db_token = RefreshTokenTable(
|
||||
token_id=token_id,
|
||||
username=data["sub"],
|
||||
created_at=datetime.now(),
|
||||
expires_at=refresh_expire,
|
||||
revoked=False,
|
||||
)
|
||||
_save_refresh_token(db_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:
|
||||
return None
|
||||
|
||||
# Check if token exists in storage
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
stored_token = refresh_tokens.get(token_id)
|
||||
# Check if token exists in database
|
||||
stored_token = _get_refresh_token(token_id)
|
||||
|
||||
if not stored_token:
|
||||
return None
|
||||
|
||||
# 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 username
|
||||
@@ -341,14 +341,7 @@ def revoke_refresh_token(token: str) -> bool:
|
||||
if not token_id:
|
||||
return False
|
||||
|
||||
refresh_tokens = _load_refresh_tokens()
|
||||
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
|
||||
return _revoke_refresh_token_db(token_id)
|
||||
|
||||
except JWTError:
|
||||
return False
|
||||
|
||||
+37
-1
@@ -18,7 +18,7 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
def create_db_and_tables():
|
||||
"""Create the database and tables based on the models"""
|
||||
# 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.favorites import FavoriteTable
|
||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||
@@ -26,6 +26,42 @@ def create_db_and_tables():
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
# Add new columns to existing tables if they don't exist (SQLite workaround)
|
||||
_ensure_columns(engine)
|
||||
|
||||
|
||||
def _ensure_columns(engine):
|
||||
"""Add new columns to app_settings table if they don't exist"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
inspector = inspect(engine)
|
||||
if 'app_settings' not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
existing = {col['name'] for col in inspector.get_columns('app_settings')}
|
||||
|
||||
new_columns = {
|
||||
'recommendations_filter': 'TEXT DEFAULT "all"',
|
||||
'releases_filter': 'TEXT DEFAULT "all"',
|
||||
'anime_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'series_enabled': 'BOOLEAN DEFAULT 1',
|
||||
'download_dir': 'TEXT DEFAULT "downloads"',
|
||||
}
|
||||
|
||||
# Add is_admin to users table if missing
|
||||
if 'users' in inspector.get_table_names():
|
||||
user_cols = {col['name'] for col in inspector.get_columns('users')}
|
||||
if 'is_admin' not in user_cols:
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text('ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0'))
|
||||
conn.commit()
|
||||
|
||||
with engine.connect() as conn:
|
||||
for col_name, col_def in new_columns.items():
|
||||
if col_name not in existing:
|
||||
conn.execute(text(f'ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
"""Dependency for getting a database session"""
|
||||
|
||||
@@ -17,7 +17,6 @@ from .anime_sites import (
|
||||
BaseAnimeSite,
|
||||
get_anime_site,
|
||||
AnimeSamaDownloader,
|
||||
NekoSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
VostfreeDownloader
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from .base import BaseAnimeSite
|
||||
# Import all anime site downloaders
|
||||
from .animesama import AnimeSamaDownloader
|
||||
from .nekosama import NekoSamaDownloader
|
||||
from .animeultime import AnimeUltimeDownloader
|
||||
from .vostfree import VostfreeDownloader
|
||||
from .frenchmanga import FrenchMangaDownloader
|
||||
@@ -10,7 +9,6 @@ from .frenchmanga import FrenchMangaDownloader
|
||||
__all__ = [
|
||||
"BaseAnimeSite",
|
||||
"AnimeSamaDownloader",
|
||||
"NekoSamaDownloader",
|
||||
"AnimeUltimeDownloader",
|
||||
"VostfreeDownloader",
|
||||
"FrenchMangaDownloader",
|
||||
@@ -22,7 +20,6 @@ def get_anime_site(url: str) -> BaseAnimeSite:
|
||||
sites = [
|
||||
AnimeSamaDownloader(),
|
||||
AnimeUltimeDownloader(),
|
||||
NekoSamaDownloader(),
|
||||
VostfreeDownloader(),
|
||||
FrenchMangaDownloader(),
|
||||
]
|
||||
|
||||
@@ -490,15 +490,16 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
part.replace("saison", "").replace("Saison", "")
|
||||
)
|
||||
break
|
||||
except:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Could not parse season number from URL part")
|
||||
|
||||
episode = "01"
|
||||
if season_num:
|
||||
return f"{anime_name} - S{season_num} - Episode {episode}.mp4"
|
||||
else:
|
||||
return f"{anime_name} - Episode {episode}.mp4"
|
||||
except:
|
||||
except Exception:
|
||||
logger.debug("Could not generate filename, using default")
|
||||
return "Anime - Episode 01.Mp4"
|
||||
|
||||
def _generate_anime_name(self, anime_url: str) -> str:
|
||||
@@ -511,7 +512,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
return parts[i + 1].replace("-", " ").title()
|
||||
# Fallback
|
||||
return "Anime"
|
||||
except:
|
||||
except Exception:
|
||||
logger.debug("Could not extract anime name from URL")
|
||||
return "Anime"
|
||||
|
||||
def _extract_season_number(self, anime_url: str) -> int | None:
|
||||
@@ -522,7 +524,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
if "saison" in part.lower():
|
||||
return int(part.replace("saison", "").replace("Saison", ""))
|
||||
return None
|
||||
except:
|
||||
except Exception:
|
||||
logger.debug("Could not extract season number from URL")
|
||||
return None
|
||||
|
||||
async def _extract_from_lpayer(
|
||||
@@ -744,7 +747,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
except:
|
||||
except Exception:
|
||||
logger.debug("Could not extract video URL from scripts")
|
||||
pass
|
||||
|
||||
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)
|
||||
if fname:
|
||||
filename = fname
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return download_url, filename
|
||||
|
||||
@@ -102,7 +102,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
||||
try:
|
||||
await page.mouse.click(640, 360)
|
||||
await asyncio.sleep(3)
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try JavaScript extraction to find video URLs in DOM
|
||||
@@ -235,7 +235,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
||||
if browser:
|
||||
try:
|
||||
await browser.close()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
"""Extract video URL using Playwright to render JavaScript"""
|
||||
try:
|
||||
|
||||
@@ -124,7 +124,7 @@ class OneuploadDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(2)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[ONEUPLOAD] Play button interaction: {e}")
|
||||
|
||||
@@ -62,7 +62,7 @@ class RapidFileDownloader(BaseVideoPlayer):
|
||||
filename = fname
|
||||
else:
|
||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||
except:
|
||||
except Exception:
|
||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||
|
||||
return download_url, filename
|
||||
|
||||
@@ -118,7 +118,7 @@ class SmoothpreDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(2)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[SMOOTHPRE] Play button interaction: {e}")
|
||||
|
||||
@@ -42,7 +42,7 @@ class UnFichierDownloader(BaseVideoPlayer):
|
||||
if not filename:
|
||||
filename = href.split('/')[-1] or "downloaded_file"
|
||||
return href, filename
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise Exception("Could not find download link on page")
|
||||
|
||||
@@ -177,7 +177,7 @@ class VidMolyDownloader(BaseVideoPlayer):
|
||||
await element.click()
|
||||
await asyncio.sleep(3)
|
||||
break
|
||||
except:
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[VIDMOLY] Play button interaction: {e}")
|
||||
|
||||
+32
-16
@@ -27,11 +27,15 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Add an anime to favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
|
||||
if existing:
|
||||
@@ -53,17 +57,21 @@ class FavoritesManager:
|
||||
url=url,
|
||||
provider=provider,
|
||||
anime_metadata=metadata or {},
|
||||
poster_url=poster_url
|
||||
poster_url=poster_url,
|
||||
user_id=user_id
|
||||
)
|
||||
session.add(fav)
|
||||
session.commit()
|
||||
session.refresh(fav)
|
||||
return self._to_dict(fav)
|
||||
|
||||
async def remove_favorite(self, anime_id: str) -> bool:
|
||||
async def remove_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Remove an anime from favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
session.delete(existing)
|
||||
@@ -71,10 +79,13 @@ class FavoritesManager:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_favorite(self, anime_id: str) -> Optional[Dict]:
|
||||
async def get_favorite(self, anime_id: str, user_id: str = "default") -> Optional[Dict]:
|
||||
"""Get a specific favorite by ID"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
existing = session.exec(statement).first()
|
||||
if existing:
|
||||
return self._to_dict(existing)
|
||||
@@ -82,6 +93,7 @@ class FavoritesManager:
|
||||
|
||||
async def list_favorites(
|
||||
self,
|
||||
user_id: str = "default",
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: Optional[str] = None,
|
||||
@@ -89,7 +101,7 @@ class FavoritesManager:
|
||||
) -> List[Dict]:
|
||||
"""List all favorites with optional sorting and filtering"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable)
|
||||
statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
|
||||
|
||||
if filter_provider:
|
||||
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||
@@ -123,10 +135,13 @@ class FavoritesManager:
|
||||
|
||||
return favorites
|
||||
|
||||
async def is_favorite(self, anime_id: str) -> bool:
|
||||
async def is_favorite(self, anime_id: str, user_id: str = "default") -> bool:
|
||||
"""Check if an anime is in favorites"""
|
||||
with Session(engine) as session:
|
||||
statement = select(FavoriteTable).where(FavoriteTable.anime_id == anime_id)
|
||||
statement = select(FavoriteTable).where(
|
||||
FavoriteTable.anime_id == anime_id,
|
||||
FavoriteTable.user_id == user_id
|
||||
)
|
||||
return session.exec(statement).first() is not None
|
||||
|
||||
async def toggle_favorite(
|
||||
@@ -136,21 +151,22 @@ class FavoritesManager:
|
||||
url: str,
|
||||
provider: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
poster_url: Optional[str] = None
|
||||
poster_url: Optional[str] = None,
|
||||
user_id: str = "default"
|
||||
) -> Dict:
|
||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
||||
is_fav = await self.is_favorite(anime_id)
|
||||
is_fav = await self.is_favorite(anime_id, user_id=user_id)
|
||||
|
||||
if is_fav:
|
||||
await self.remove_favorite(anime_id)
|
||||
await self.remove_favorite(anime_id, user_id=user_id)
|
||||
return {"action": "removed", "anime_id": anime_id}
|
||||
else:
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url)
|
||||
fav = await self.add_favorite(anime_id, title, url, provider, metadata, poster_url, user_id=user_id)
|
||||
return {"action": "added", "anime_id": anime_id, "favorite": fav}
|
||||
|
||||
async def get_stats(self) -> Dict:
|
||||
async def get_stats(self, user_id: str = "default") -> Dict:
|
||||
"""Get statistics about favorites"""
|
||||
favorites = await self.list_favorites()
|
||||
favorites = await self.list_favorites(user_id=user_id)
|
||||
total = len(favorites)
|
||||
|
||||
# Count by provider
|
||||
|
||||
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
|
||||
email: Optional[str] = Field(default=None, index=True)
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool = Field(default=True)
|
||||
is_admin: bool = Field(default=False)
|
||||
|
||||
|
||||
class UserTable(UserBase, table=True):
|
||||
@@ -61,5 +62,24 @@ class UserInDB(User):
|
||||
"""Schema for user stored in database (with hashed password)"""
|
||||
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
|
||||
from .watchlist import WatchlistItemTable
|
||||
|
||||
+24
-1
@@ -15,11 +15,24 @@ class AppSettingsBase(SQLModel):
|
||||
# Store list of disabled providers as a JSON string
|
||||
disabled_providers_json: str = Field(default="[]", sa_column=Column(String))
|
||||
|
||||
# #9: Filter for recommendations section ("all", "anime", "series")
|
||||
recommendations_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #10: Filter for latest releases section ("all", "anime", "series")
|
||||
releases_filter: str = Field(default="all", sa_column=Column(String))
|
||||
|
||||
# #11: Enable/disable categories
|
||||
anime_enabled: bool = Field(default=True)
|
||||
series_enabled: bool = Field(default=True)
|
||||
|
||||
# #12: Custom download directory
|
||||
download_dir: str = Field(default="downloads")
|
||||
|
||||
@property
|
||||
def disabled_providers(self) -> List[str]:
|
||||
try:
|
||||
return json.loads(self.disabled_providers_json or "[]")
|
||||
except:
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
@disabled_providers.setter
|
||||
@@ -46,6 +59,11 @@ class AppSettings(BaseModel):
|
||||
default_lang: str = "vostfr"
|
||||
theme: str = "dark"
|
||||
disabled_providers: List[str] = []
|
||||
recommendations_filter: str = "all"
|
||||
releases_filter: str = "all"
|
||||
anime_enabled: bool = True
|
||||
series_enabled: bool = True
|
||||
download_dir: str = "downloads"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -56,3 +74,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
default_lang: Optional[str] = None
|
||||
theme: Optional[str] = None
|
||||
disabled_providers: Optional[List[str]] = None
|
||||
recommendations_filter: Optional[str] = None
|
||||
releases_filter: Optional[str] = None
|
||||
anime_enabled: Optional[bool] = None
|
||||
series_enabled: Optional[bool] = None
|
||||
download_dir: Optional[str] = None
|
||||
|
||||
@@ -160,7 +160,7 @@ class SonarrMapping(BaseModel):
|
||||
"""Mapping between Sonarr series and anime providers (API model)"""
|
||||
sonarr_series_id: int
|
||||
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_title: str
|
||||
lang: str = "vostfr"
|
||||
|
||||
@@ -25,13 +25,6 @@ ANIME_PROVIDERS = {
|
||||
"icon": "▶️",
|
||||
"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": {
|
||||
"name": "Vostfree",
|
||||
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
||||
|
||||
@@ -10,7 +10,6 @@ from datetime import datetime
|
||||
from app.downloaders.generic_scraper import GenericScraper
|
||||
from app.downloaders.anime_sites import (
|
||||
AnimeSamaDownloader,
|
||||
NekoSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
VostfreeDownloader,
|
||||
FrenchMangaDownloader,
|
||||
@@ -58,7 +57,6 @@ class ProvidersManager:
|
||||
"""Load hardcoded Python providers"""
|
||||
provider_classes = [
|
||||
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
|
||||
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
|
||||
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
||||
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
||||
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
||||
@@ -130,10 +128,23 @@ class ProvidersManager:
|
||||
return 200 <= response.status_code < 400
|
||||
elif hasattr(scraper, "search_anime"):
|
||||
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"):
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router
|
||||
from .router_static import router as static_router
|
||||
from .router_root import router as root_router
|
||||
from .router_settings import router as settings_router
|
||||
from .router_admin import router as admin_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
@@ -26,5 +27,6 @@ __all__ = [
|
||||
"static_router",
|
||||
"root_router",
|
||||
"settings_router",
|
||||
"admin_router",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Admin panel routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.database import get_session, engine
|
||||
from app.models.auth import User, UserTable
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user_from_token)) -> User:
|
||||
"""Dependency that requires the current user to be an admin."""
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""List all users (admin only)"""
|
||||
with Session(engine) as session:
|
||||
statement = select(UserTable)
|
||||
users = session.exec(statement).all()
|
||||
return {
|
||||
"users": [
|
||||
{
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"full_name": u.full_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
"last_login": u.last_login.isoformat() if u.last_login else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"total": len(users),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Get admin dashboard statistics"""
|
||||
from app.download_manager import DownloadManager
|
||||
from main import download_manager
|
||||
|
||||
with Session(engine) as session:
|
||||
total_users = len(session.exec(select(UserTable)).all())
|
||||
active_users = len(session.exec(select(UserTable).where(UserTable.is_active == True)).all())
|
||||
admin_users = len(session.exec(select(UserTable).where(UserTable.is_admin == True)).all())
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
total_downloads = len(tasks)
|
||||
completed_downloads = len([t for t in tasks if t.status == "completed"])
|
||||
active_downloads = len([t for t in tasks if t.status in ("downloading", "pending")])
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": total_users,
|
||||
"active": active_users,
|
||||
"admins": admin_users,
|
||||
},
|
||||
"downloads": {
|
||||
"total": total_downloads,
|
||||
"completed": completed_downloads,
|
||||
"active": active_downloads,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-active")
|
||||
async def toggle_user_active(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Activate or deactivate a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own account")
|
||||
user.is_active = not user.is_active
|
||||
session.add(user)
|
||||
session.commit()
|
||||
status = "active" if user.is_active else "inactive"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {user.username} is now {status}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_active": user.is_active}
|
||||
|
||||
|
||||
@router.put("/users/{user_id}/toggle-admin")
|
||||
async def toggle_user_admin(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Promote or demote a user to/from admin"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot modify your own admin status")
|
||||
user.is_admin = not user.is_admin
|
||||
session.add(user)
|
||||
session.commit()
|
||||
role = "admin" if user.is_admin else "user"
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{user.username} is now {role}", "type": "success"}}}}'
|
||||
return {"id": user_id, "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user"""
|
||||
with Session(engine) as session:
|
||||
user = session.exec(select(UserTable).where(UserTable.id == user_id)).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.id == current_user.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
username = user.username
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "User {username} deleted", "type": "info"}}}}'
|
||||
return {"deleted": user_id}
|
||||
|
||||
|
||||
@router.get("/ui")
|
||||
async def get_admin_ui(
|
||||
request: Request,
|
||||
current_user: Optional[User] = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get admin panel UI"""
|
||||
if current_user is None or not current_user.is_admin:
|
||||
from app.routers.router_auth import get_optional_user
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
users = session.exec(select(UserTable)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"components/admin_panel.html",
|
||||
{"request": request, "users": users, "current_user": current_user},
|
||||
)
|
||||
+30
-10
@@ -29,7 +29,6 @@ from app.download_manager import DownloadManager
|
||||
from app.downloaders import (
|
||||
AnimeSamaDownloader,
|
||||
AnimeUltimeDownloader,
|
||||
NekoSamaDownloader,
|
||||
VostfreeDownloader,
|
||||
ZoneTelechargementDownloader,
|
||||
get_downloader,
|
||||
@@ -59,12 +58,10 @@ async def get_providers_health():
|
||||
|
||||
|
||||
@router.post("/providers/health/check")
|
||||
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
||||
"""Trigger a manual health check of all providers in the background"""
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
|
||||
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
||||
return {"status": "Health check triggered in background"}
|
||||
async def trigger_providers_health_check():
|
||||
"""Trigger a manual health check of all providers"""
|
||||
await providers_manager.check_all_health()
|
||||
return {"status": "ok", "providers": providers_manager.get_all_status()}
|
||||
|
||||
|
||||
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_downloaders = {
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"neko-sama": NekoSamaDownloader(),
|
||||
"vostfree": VostfreeDownloader(),
|
||||
}
|
||||
for pid, dl in legacy_downloaders.items():
|
||||
@@ -174,10 +170,34 @@ async def search_anime_unified(
|
||||
|
||||
if url and url not in seen_urls:
|
||||
seen_urls.add(url)
|
||||
if q.lower() in (item_dict.get("title") or "").lower():
|
||||
# Fuzzy relevance scoring
|
||||
title = (item_dict.get("title") or "").lower()
|
||||
query_lower = q.lower()
|
||||
|
||||
# Exact match
|
||||
if query_lower == title:
|
||||
item_dict["_relevance_boost"] = 1.0
|
||||
else:
|
||||
# Title starts with query
|
||||
elif title.startswith(query_lower):
|
||||
item_dict["_relevance_boost"] = 0.95
|
||||
# Query is a substring of title
|
||||
elif query_lower in title:
|
||||
item_dict["_relevance_boost"] = 0.85
|
||||
# Words from query all appear in title
|
||||
elif all(word in title.split() for word in query_lower.split() if len(word) > 1):
|
||||
item_dict["_relevance_boost"] = 0.7
|
||||
# At least one word matches
|
||||
elif any(word in title.split() for word in query_lower.split() if len(word) > 2):
|
||||
item_dict["_relevance_boost"] = 0.5
|
||||
else:
|
||||
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)
|
||||
|
||||
# Prepare enrichment task for top 15 results per provider
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
Download management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
import json
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
|
||||
from app.download_manager import DownloadManager
|
||||
from app.models import DownloadRequest
|
||||
from app.routers.router_auth import get_current_user_from_token
|
||||
from app.models import DownloadRequest, DownloadStatus
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -24,13 +28,21 @@ async def get_downloads(
|
||||
request: Request,
|
||||
html: bool = Query(False),
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get list of all download tasks. Returns HTML for HTMX requests."""
|
||||
tasks = download_manager.get_all_tasks()
|
||||
|
||||
# Strictly check for HTMX or explicit HTML flag
|
||||
is_htmx = request.headers.get("HX-Request") == "true" or request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
tasks = download_manager.get_all_tasks()
|
||||
|
||||
if html or is_htmx:
|
||||
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
||||
return templates.TemplateResponse(
|
||||
@@ -56,8 +68,12 @@ async def create_download(
|
||||
async def get_download_status(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get status of a specific download task"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
@@ -106,6 +122,73 @@ async def cancel_download(
|
||||
raise HTTPException(status_code=400, detail="Failed to cancel download")
|
||||
|
||||
|
||||
@router.get("/video/{task_id}")
|
||||
async def stream_video(
|
||||
task_id: str,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Stream a completed download as video"""
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status != DownloadStatus.COMPLETED or not task.file_path:
|
||||
raise HTTPException(status_code=400, detail="Download not completed")
|
||||
file_path = Path(task.file_path)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
media_types = {
|
||||
".mp4": "video/mp4", ".mkv": "video/x-matroska", ".avi": "video/x-msvideo",
|
||||
".webm": "video/webm", ".flv": "video/x-flv",
|
||||
}
|
||||
media_type = media_types.get(file_path.suffix.lower(), "video/mp4")
|
||||
return FileResponse(str(file_path), media_type=media_type)
|
||||
|
||||
|
||||
@router.post("/{task_id}/retry")
|
||||
async def retry_download(
|
||||
task_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Retry a failed or cancelled download"""
|
||||
task = download_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
if task.status not in ("failed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Can only retry failed or cancelled downloads")
|
||||
task.status = DownloadStatus.PENDING
|
||||
task.progress = 0.0
|
||||
if hasattr(download_manager, "_process_download"):
|
||||
background_tasks.add_task(download_manager._process_download, task_id)
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": "Telechargement relance", "type": "success"}}
|
||||
)
|
||||
return {"status": "retrying"}
|
||||
|
||||
|
||||
@router.post("/cancel-all")
|
||||
async def cancel_all_downloads(
|
||||
response: Response,
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Cancel all active downloads"""
|
||||
count = 0
|
||||
for tid, task in list(download_manager.tasks.items()):
|
||||
if task.status in ("downloading", "pending"):
|
||||
download_manager.cancel_download(tid) if hasattr(download_manager, "cancel_download") else download_manager.tasks.pop(tid, None)
|
||||
count += 1
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"show-toast": {"message": f"{count} telechargement(s) annule(s)", "type": "info"}}
|
||||
)
|
||||
return {"status": "cancelled", "count": count}
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def cleanup_completed(
|
||||
download_manager: DownloadManager = Depends(get_download_manager),
|
||||
|
||||
@@ -2,24 +2,42 @@
|
||||
Favorites management routes for Ohm Stream Downloader API.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.favorites import get_favorites_manager
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_current_user_from_token, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_favorites(
|
||||
request: Request,
|
||||
sort_by: str = "created_at",
|
||||
order: str = "desc",
|
||||
filter_provider: str = None,
|
||||
filter_genre: str = None,
|
||||
filter_provider: Optional[str] = None,
|
||||
filter_genre: Optional[str] = None,
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""List all favorite anime with optional sorting and filtering"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorites = await fav_manager.list_favorites(
|
||||
user_id=current_user.id,
|
||||
sort_by=sort_by,
|
||||
order=order,
|
||||
filter_provider=filter_provider,
|
||||
@@ -38,7 +56,11 @@ async def list_favorites(
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def add_favorite(request: Request):
|
||||
async def add_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to favorites"""
|
||||
data = await request.json()
|
||||
|
||||
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.add_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "' + favorite['title'] + ' ajouté aux favoris", "type": "success"}}'
|
||||
return {"status": "added", "favorite": favorite}
|
||||
|
||||
|
||||
@router.delete("/{anime_id}")
|
||||
async def remove_favorite(anime_id: str):
|
||||
async def remove_favorite(
|
||||
anime_id: str,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove an anime from favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
removed = await fav_manager.remove_favorite(anime_id)
|
||||
removed = await fav_manager.remove_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
|
||||
response.headers["HX-Trigger"] = '{"show-toast": {"message": "Favori supprimé", "type": "info"}}'
|
||||
return {"status": "removed", "anime_id": anime_id}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_favorites_stats():
|
||||
async def get_favorites_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get statistics about favorites"""
|
||||
fav_manager = get_favorites_manager()
|
||||
stats = await fav_manager.get_stats()
|
||||
stats = await fav_manager.get_stats(user_id=current_user.id)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/{anime_id}")
|
||||
async def get_favorite(anime_id: str):
|
||||
async def get_favorite(
|
||||
anime_id: str,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get details of a specific favorite anime"""
|
||||
fav_manager = get_favorites_manager()
|
||||
favorite = await fav_manager.get_favorite(anime_id)
|
||||
favorite = await fav_manager.get_favorite(anime_id, user_id=current_user.id)
|
||||
|
||||
if not favorite:
|
||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
|
||||
|
||||
|
||||
@router.post("/toggle")
|
||||
async def toggle_favorite(request: Request):
|
||||
async def toggle_favorite(
|
||||
request: Request,
|
||||
response: Response,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Toggle an anime in favorites"""
|
||||
data = await request.json()
|
||||
|
||||
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
|
||||
|
||||
fav_manager = get_favorites_manager()
|
||||
result = await fav_manager.toggle_favorite(
|
||||
user_id=current_user.id,
|
||||
anime_id=data["anime_id"],
|
||||
title=data["title"],
|
||||
url=data["url"],
|
||||
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
|
||||
poster_url=data.get("poster_url"),
|
||||
)
|
||||
|
||||
action = result.get("action", "unknown")
|
||||
message = f"'{data['title']}' {'ajouté aux' if action == 'added' else 'retiré des'} favoris"
|
||||
toast_type = "success" if action == "added" else "info"
|
||||
|
||||
response.headers["HX-Trigger"] = f'{{"show-toast": {{"message": "{message}", "type": "{toast_type}"}}}}'
|
||||
return result
|
||||
|
||||
@@ -6,10 +6,12 @@ import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Query, HTTPException
|
||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.recommendation_engine import RecommendationEngine
|
||||
from app.models.auth import User
|
||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
@@ -26,14 +28,30 @@ async def get_recommendations(
|
||||
request: Request,
|
||||
limit: int = 15,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
"""Get personalized anime recommendations based on download history"""
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
if current_user is None and (html or is_htmx):
|
||||
return templates.TemplateResponse(
|
||||
"components/login_prompt.html", {"request": request}
|
||||
)
|
||||
|
||||
if current_user is None:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
try:
|
||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or is_htmx:
|
||||
return templates.TemplateResponse(
|
||||
"components/recommendations_list.html",
|
||||
{"request": request, "recommendations": recommendations}
|
||||
@@ -53,6 +71,7 @@ async def get_latest_releases(
|
||||
request: Request,
|
||||
limit: int = 20,
|
||||
html: bool = Query(False),
|
||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||
):
|
||||
"""Get latest anime releases"""
|
||||
from app.recommendations import get_latest_releases_with_info
|
||||
@@ -60,6 +79,10 @@ async def get_latest_releases(
|
||||
try:
|
||||
releases = await get_latest_releases_with_info(limit=limit)
|
||||
|
||||
# Filter by content_type if specified
|
||||
if content_type and content_type != "all":
|
||||
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
|
||||
|
||||
if html or request.headers.get("HX-Request"):
|
||||
return templates.TemplateResponse(
|
||||
"components/releases_list.html",
|
||||
@@ -140,7 +163,9 @@ async def get_top_anime(
|
||||
|
||||
|
||||
@router.get("/stats/downloads")
|
||||
async def get_download_statistics():
|
||||
async def get_download_statistics(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get download statistics and preferences"""
|
||||
engine = RecommendationEngine(download_dir="downloads")
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ async def get_settings(
|
||||
default_lang=settings_obj.default_lang,
|
||||
theme=settings_obj.theme,
|
||||
disabled_providers=settings_obj.disabled_providers,
|
||||
recommendations_filter=getattr(settings_obj, 'recommendations_filter', 'all'),
|
||||
releases_filter=getattr(settings_obj, 'releases_filter', 'all'),
|
||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||
)
|
||||
|
||||
|
||||
@@ -65,6 +70,22 @@ async def update_settings(
|
||||
settings_obj.theme = update_data.theme
|
||||
if update_data.disabled_providers is not None:
|
||||
settings_obj.disabled_providers = update_data.disabled_providers
|
||||
if update_data.recommendations_filter is not None:
|
||||
settings_obj.recommendations_filter = update_data.recommendations_filter
|
||||
if update_data.releases_filter is not None:
|
||||
settings_obj.releases_filter = update_data.releases_filter
|
||||
if update_data.anime_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.anime_enabled and not getattr(settings_obj, 'series_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.anime_enabled = update_data.anime_enabled
|
||||
if update_data.series_enabled is not None:
|
||||
# Prevent disabling both categories
|
||||
if not update_data.series_enabled and not getattr(settings_obj, 'anime_enabled', True):
|
||||
raise HTTPException(status_code=400, detail="Au moins une categorie doit rester active")
|
||||
settings_obj.series_enabled = update_data.series_enabled
|
||||
if update_data.download_dir is not None:
|
||||
settings_obj.download_dir = update_data.download_dir
|
||||
|
||||
session.add(settings_obj)
|
||||
session.commit()
|
||||
|
||||
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
|
||||
|
||||
|
||||
@router.get("/sonarr/config")
|
||||
async def get_sonarr_config():
|
||||
async def get_sonarr_config(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_config()
|
||||
|
||||
|
||||
@router.put("/sonarr/config")
|
||||
async def update_sonarr_config(config: SonarrConfig):
|
||||
async def update_sonarr_config(
|
||||
config: SonarrConfig,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update Sonarr webhook configuration"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings")
|
||||
async def get_sonarr_mappings():
|
||||
async def get_sonarr_mappings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get all Sonarr to anime mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
return sonarr_handler.get_mappings()
|
||||
|
||||
|
||||
@router.get("/sonarr/mappings/{series_id}")
|
||||
async def get_sonarr_mapping(series_id: int):
|
||||
async def get_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get specific mapping by Sonarr series ID"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
mapping = sonarr_handler.get_mapping(series_id)
|
||||
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
|
||||
|
||||
|
||||
@router.post("/sonarr/mappings")
|
||||
async def create_sonarr_mapping(mapping: SonarrMapping):
|
||||
async def create_sonarr_mapping(
|
||||
mapping: SonarrMapping,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Create or update a Sonarr to anime mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
try:
|
||||
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
|
||||
|
||||
|
||||
@router.delete("/sonarr/mappings/{series_id}")
|
||||
async def delete_sonarr_mapping(series_id: int):
|
||||
async def delete_sonarr_mapping(
|
||||
series_id: int,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Delete a Sonarr mapping"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
success = sonarr_handler.delete_mapping(series_id)
|
||||
@@ -130,6 +146,7 @@ async def search_anime_for_sonarr(
|
||||
q: str = Query(..., description="Series title to search"),
|
||||
provider: str = Query("anime-sama", description="Anime provider to search"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Search for anime on providers to create Sonarr mappings"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -152,6 +169,7 @@ async def get_anime_episodes(
|
||||
url: str = Query(..., description="Anime URL from provider"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get episode list for anime"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
|
||||
sonarr_title: str = Query(..., description="Sonarr series title"),
|
||||
provider: str = Query("anime-sama", description="Anime provider"),
|
||||
lang: str = Query("vostfr", description="Language"),
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Suggest possible anime mappings based on Sonarr series title"""
|
||||
sonarr_handler = get_sonarr_handler()
|
||||
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
|
||||
async def trigger_sonarr_download(
|
||||
request: SonarrDownloadRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Manually trigger a download based on Sonarr information"""
|
||||
from main import download_manager
|
||||
|
||||
@@ -47,7 +47,7 @@ async def add_to_watchlist(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Add an anime to the watchlist"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
try:
|
||||
existing = watchlist_manager.get_by_anime_url(
|
||||
@@ -81,7 +81,7 @@ async def get_watchlist(
|
||||
html: bool = Query(False),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
is_htmx = request.headers.get("HX-Request")
|
||||
|
||||
@@ -108,7 +108,7 @@ async def get_watchlist_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get global watchlist settings"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_settings()
|
||||
|
||||
@@ -120,7 +120,8 @@ async def update_watchlist_settings(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update global watchlist settings"""
|
||||
from main import auto_download_scheduler, watchlist_manager
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
try:
|
||||
updated_settings = watchlist_manager.update_settings(settings)
|
||||
@@ -148,7 +149,7 @@ async def get_watchlist_item(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get a specific watchlist item"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
@@ -164,7 +165,7 @@ async def update_watchlist_item(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Update a watchlist item"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
@@ -190,7 +191,7 @@ async def delete_from_watchlist(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Remove an anime from the watchlist"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
item = watchlist_manager.get_by_id(item_id)
|
||||
if not item or item.user_id != current_user.id:
|
||||
@@ -219,7 +220,7 @@ async def check_watchlist_now(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Trigger an immediate check for new episodes"""
|
||||
from main import auto_download_scheduler
|
||||
from app.auto_download_scheduler import auto_download_scheduler
|
||||
|
||||
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
@@ -239,6 +240,6 @@ async def get_watchlist_stats(
|
||||
current_user: User = Depends(get_current_user_from_token),
|
||||
):
|
||||
"""Get watchlist statistics for the user"""
|
||||
from main import watchlist_manager
|
||||
from app.watchlist import watchlist_manager
|
||||
|
||||
return watchlist_manager.get_stats(current_user.id)
|
||||
|
||||
@@ -17,7 +17,7 @@ from app.models.sonarr import (
|
||||
SonarrDownloadRequest
|
||||
)
|
||||
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
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -205,7 +205,6 @@ class SonarrHandler:
|
||||
"""Get downloader instance for provider"""
|
||||
providers = {
|
||||
"anime-sama": AnimeSamaDownloader(),
|
||||
"neko-sama": NekoSamaDownloader(),
|
||||
"anime-ultime": AnimeUltimeDownloader(),
|
||||
"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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -42,6 +43,8 @@ app.add_middleware(
|
||||
"http://192.168.1.204",
|
||||
"http://192.168.1.200:3000",
|
||||
"http://192.168.1.200",
|
||||
"http://192.168.5.127:3000",
|
||||
"http://192.168.5.127",
|
||||
],
|
||||
allow_credentials=True,
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -144,6 +152,7 @@ from app.routers import (
|
||||
static_router,
|
||||
root_router,
|
||||
settings_router,
|
||||
admin_router,
|
||||
)
|
||||
|
||||
|
||||
@@ -159,6 +168,7 @@ app.include_router(sonarr_router)
|
||||
app.include_router(player_router)
|
||||
app.include_router(static_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Generated
+16
-2173
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -8,8 +8,6 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"jsdom": "^29.0.0",
|
||||
"vitest": "^1.0.0"
|
||||
"@playwright/test": "^1.60.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
globalSetup: './tests/e2e/global-setup.ts',
|
||||
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
@@ -38,16 +38,21 @@ export default defineConfig({
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
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 */
|
||||
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',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
});
|
||||
|
||||
+685
-140
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,102 @@
|
||||
<div class="settings-container section-container">
|
||||
<div class="section-header">
|
||||
<h2>Administration</h2>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div id="admin-stats" class="admin-stats-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; margin-bottom: 30px;">
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
|
||||
</div>
|
||||
<div class="admin-stat-card" style="padding: 20px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); text-align: center;">
|
||||
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
|
||||
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;">
|
||||
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
|
||||
</div>
|
||||
|
||||
{% if users %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
|
||||
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Role</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
|
||||
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
|
||||
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
|
||||
<td style="padding: 12px 20px;">
|
||||
<div style="font-weight: 600;">{{ user.username }}</div>
|
||||
{% if user.full_name %}
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_active %}rgba(0,255,136,0.15); color: var(--accent){% else %}rgba(255,77,77,0.15); color: var(--danger){% endif %};">
|
||||
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 600; background: {% if user.is_admin %}rgba(240,165,0,0.15); color: #f0a500{% else %}rgba(255,255,255,0.05); color: var(--text-dim){% endif %};">
|
||||
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.85rem;">
|
||||
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
|
||||
</td>
|
||||
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
|
||||
{% if user.id != current_user.id %}
|
||||
<button class="btn btn-sm {% if user.is_active %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-active" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
|
||||
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm {% if user.is_admin %}btn-secondary{% else %}btn-accent{% endif %}"
|
||||
hx-put="/api/admin/users/{{ user.id }}/toggle-admin" hx-swap="none"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
|
||||
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
|
||||
hx-confirm="Supprimer {{ user.username }} ?"
|
||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
<button class="sr-btn sr-btn-follow"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||
<i class="fas fa-plus"></i> Suivre
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{% if tasks %}
|
||||
<div class="downloads-grid">
|
||||
{% for task in tasks %}
|
||||
<div class="download-item task-{{ task.status }}">
|
||||
<div class="download-item status-{{ task.status.value }}">
|
||||
<div class="download-info">
|
||||
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
||||
<span class="badge badge-{{ task.status }}">{{ task.status | upper }}</span>
|
||||
<span class="badge badge-{{ task.status.value }}">{{ task.status | upper }}</span>
|
||||
</div>
|
||||
|
||||
<div class="progress-container">
|
||||
@@ -19,28 +19,38 @@
|
||||
|
||||
<div class="download-actions">
|
||||
{% if task.status == 'downloading' or task.status == 'pending' %}
|
||||
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none">
|
||||
<button class="btn-icon" hx-post="/api/downloads/{{ task.id }}/pause" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Pause">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
{% elif task.status == 'paused' %}
|
||||
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none">
|
||||
<button class="btn-icon success" hx-post="/api/downloads/{{ task.id }}/resume" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Reprendre">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status == 'failed' or task.status == 'cancelled' %}
|
||||
<button class="btn-icon warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if task.status == 'completed' %}
|
||||
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
<a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</a>
|
||||
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Télécharger le fichier">
|
||||
<a href="/downloads/{{ task.filename }}" class="btn-icon" download title="Telecharger">
|
||||
<i class="fas fa-file-download"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<button class="btn-icon danger"
|
||||
hx-delete="/api/downloads/{{ task.id }}"
|
||||
hx-confirm="Supprimer ce téléchargement ?"
|
||||
hx-confirm="Supprimer ce telechargement ?"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
@@ -51,6 +61,6 @@
|
||||
{% else %}
|
||||
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
|
||||
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
|
||||
<p>Aucun téléchargement en cours</p>
|
||||
<p>Aucun telechargement en cours</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<div class="section-container">
|
||||
<div class="section-header">
|
||||
<h2>📥 Téléchargements</h2>
|
||||
<h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-sm btn-secondary"
|
||||
hx-post="/api/downloads/cleanup"
|
||||
hx-swap="none"
|
||||
hx-confirm="Nettoyer tous les telechargements termines ?"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
|
||||
Nettoyer terminés
|
||||
<i class="fas fa-broom"></i> Nettoyer termines
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-post="/api/downloads/cancel-all"
|
||||
hx-swap="none"
|
||||
hx-confirm="Annuler tous les telechargements actifs ?"
|
||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
|
||||
<i class="fas fa-stop-circle"></i> Tout annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,12 +25,20 @@
|
||||
hx-trigger="load, refresh, every 3s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement des téléchargements...
|
||||
<div class="spinner"></div> Chargement des telechargements...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-container { margin-bottom: 40px; }
|
||||
/* Styles already defined or moved to downloads_list.html */
|
||||
.active-downloads-counter {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<button class="sr-btn sr-btn-follow"
|
||||
hx-post="/api/watchlist"
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}"}'
|
||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||
<i class="fas fa-plus"></i> Suivre
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
<div class="settings-container section-container">
|
||||
<div class="section-header">
|
||||
<h2>⚙️ Paramètres</h2>
|
||||
<h2>Parametres</h2>
|
||||
</div>
|
||||
|
||||
<!-- General Preferences -->
|
||||
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin-bottom: 20px; color: var(--primary);">Général</h3>
|
||||
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
|
||||
|
||||
<form hx-patch="/api/settings" hx-swap="none" hx-ext="json-enc" class="settings-form">
|
||||
<form id="settings-form" class="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="default_lang">Langue par défaut</label>
|
||||
<label for="default_lang">Langue par defaut</label>
|
||||
<select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
|
||||
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR (Version Originale Sous-Titrée Français)</option>
|
||||
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF (Version Française)</option>
|
||||
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
|
||||
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="theme">Thème</label>
|
||||
<label for="theme">Theme</label>
|
||||
<select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
|
||||
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
|
||||
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
|
||||
@@ -25,18 +25,77 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;">
|
||||
<i class="fas fa-save"></i> Enregistrer les préférences
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<label for="download_dir">Repertoire de telechargement</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" name="download_dir" id="download_dir" value="{{ settings.download_dir }}"
|
||||
class="btn btn-secondary" style="flex: 1; text-align: left; padding: 12px 15px;">
|
||||
</div>
|
||||
<small style="color: var(--text-dim); font-size: 12px; margin-top: 5px; display: block;">
|
||||
Repertoire ou les fichiers seront telecharges (defaut: downloads/)
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;" onclick="event.preventDefault(); saveSettings();">
|
||||
<i class="fas fa-save"></i> Enregistrer les preferences
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Content Filters -->
|
||||
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="recommendations_filter">Recommande pour vous : afficher</label>
|
||||
<select name="recommendations_filter" id="recommendations_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('recommendations_filter', this.value)">
|
||||
<option value="all" {% if settings.recommendations_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
|
||||
<option value="anime" {% if settings.recommendations_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
||||
<option value="series" {% if settings.recommendations_filter == 'series' %}selected{% endif %}>Series uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 15px;">
|
||||
<label for="releases_filter">Dernieres sorties : afficher</label>
|
||||
<select name="releases_filter" id="releases_filter" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;" onchange="saveFilter('releases_filter', this.value)">
|
||||
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
|
||||
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
||||
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Series uniquement</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="settings-card card" style="margin-bottom: 30px; padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
|
||||
<h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3>
|
||||
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p>
|
||||
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||||
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">Animes</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div>
|
||||
</div>
|
||||
<input type="checkbox" id="anime_enabled" {% if settings.anime_enabled %}checked{% endif %} onchange="toggleCategory('anime_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
|
||||
</label>
|
||||
|
||||
<label class="toggle-card" style="flex: 1; min-width: 200px; padding: 15px; background: rgba(255,255,255,0.02); border-radius: 10px; border: 1px solid rgba(255,255,255,0.05); display: flex; align-items: center; justify-content: space-between; cursor: pointer;">
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 1.1rem;">Series TV</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div>
|
||||
</div>
|
||||
<input type="checkbox" id="series_enabled" {% if settings.series_enabled %}checked{% endif %} onchange="toggleCategory('series_enabled', this.checked)" style="width: 20px; height: 20px; cursor: pointer; accent-color: var(--primary);">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Providers Management -->
|
||||
<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;">
|
||||
<h3 style="margin: 0; color: var(--primary);">Disponibilité des Fournisseurs</h3>
|
||||
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none">
|
||||
<i class="fas fa-sync-alt"></i> Forcer vérification
|
||||
<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"
|
||||
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')">
|
||||
<i class="fas fa-sync-alt"></i> Forcer verification
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +120,7 @@
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
|
||||
style="min-width: 100px;">
|
||||
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
|
||||
{% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -69,6 +128,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getToken() {
|
||||
return localStorage.getItem('auth_token') || null;
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const data = {
|
||||
default_lang: document.getElementById('default_lang').value,
|
||||
theme: document.getElementById('theme').value,
|
||||
download_dir: document.getElementById('download_dir').value,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (r.ok) {
|
||||
showToast('Preferences enregistrees', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFilter(field, value) {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
if (r.ok) {
|
||||
showToast('Filtre mis a jour', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCategory(field, value) {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
// Prevent disabling both
|
||||
if (!value) {
|
||||
const otherField = field === 'anime_enabled' ? 'series_enabled' : 'anime_enabled';
|
||||
const otherCheckbox = document.getElementById(otherField);
|
||||
if (otherCheckbox && !otherCheckbox.checked) {
|
||||
showToast('Au moins une categorie doit rester active', 'error');
|
||||
document.getElementById(field).checked = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: value })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
showToast(err.detail || 'Erreur', 'error');
|
||||
document.getElementById(field).checked = !value;
|
||||
} else {
|
||||
showToast('Categorie ' + (value ? 'activee' : 'desactivee'), 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Erreur: ' + e.message, 'error');
|
||||
document.getElementById(field).checked = !value;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.settings-form label {
|
||||
display: block;
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.toast {
|
||||
min-width: 250px;
|
||||
|
||||
@@ -1,39 +1,491 @@
|
||||
{% if items %}
|
||||
<div class="watchlist-grid">
|
||||
{% for item in items %}
|
||||
<div class="watchlist-item card" id="watchlist-{{ item.id }}">
|
||||
<div class="item-poster">
|
||||
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}">
|
||||
</div>
|
||||
<div class="item-info">
|
||||
<h3>{{ item.anime_title }}</h3>
|
||||
<div class="item-meta">
|
||||
<span class="badge">{{ item.provider_id }}</span>
|
||||
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
|
||||
{% set status_filter = request.query_params.get('status', 'all') %}
|
||||
<div class="watchlist-container" x-data="{ currentFilter: '{{ status_filter }}' }">
|
||||
<!-- Filter Tabs -->
|
||||
<div class="filter-tabs">
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'all' or not status_filter else '' }}"
|
||||
hx-get="/api/watchlist?status=all"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-list"></i> Tous
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'active' else '' }}"
|
||||
hx-get="/api/watchlist?status=active"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-play"></i> Actifs
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'paused' else '' }}"
|
||||
hx-get="/api/watchlist?status=paused"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-pause"></i> En pause
|
||||
</button>
|
||||
<button class="filter-tab {{ 'active' if status_filter == 'completed' else '' }}"
|
||||
hx-get="/api/watchlist?status=completed"
|
||||
hx-target="#watchlist-items-container"
|
||||
hx-swap="outerHTML"
|
||||
@click="$el.closest('.watchlist-container').querySelector('.filter-tab').forEach(t => t.classList.remove('active')); $el.classList.add('active');">
|
||||
<i class="fas fa-check"></i> Terminés
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Watchlist Items Grid -->
|
||||
{% if items and items | length > 0 %}
|
||||
<div class="watchlist-grid">
|
||||
{% for item in items %}
|
||||
<div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
|
||||
<!-- Poster -->
|
||||
<div class="watchlist-poster">
|
||||
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
|
||||
alt="{{ item.anime_title }}"
|
||||
onerror="this.src='/static/img/no-poster.png'">
|
||||
<div class="poster-badge {{ item.status }}">
|
||||
{% if item.status == 'active' %}
|
||||
<i class="fas fa-play"></i> Actif
|
||||
{% elif item.status == 'paused' %}
|
||||
<i class="fas fa-pause"></i> En pause
|
||||
{% elif item.status == 'completed' %}
|
||||
<i class="fas fa-check"></i> Terminé
|
||||
{% else %}
|
||||
<i class="fas fa-archive"></i> Archivé
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.auto_download %}
|
||||
<div class="auto-download-badge">
|
||||
<i class="fas fa-magic"></i> Auto
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-stats">
|
||||
<span>Épisode: {{ item.last_episode_downloaded }}</span>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<button class="btn btn-sm btn-primary"
|
||||
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}"
|
||||
hx-target="#player-container">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger"
|
||||
hx-delete="/api/watchlist/{{ item.id }}"
|
||||
hx-target="#watchlist-{{ item.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Retirer de la watchlist ?">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="watchlist-content">
|
||||
<h3 class="watchlist-title">{{ item.anime_title }}</h3>
|
||||
|
||||
<div class="watchlist-meta">
|
||||
<span class="meta-provider">
|
||||
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
|
||||
</span>
|
||||
<span class="meta-lang">{{ item.lang | upper }}</span>
|
||||
{% if item.quality_preference and item.quality_preference != 'auto' %}
|
||||
<span class="meta-quality">{{ item.quality_preference }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.synopsis %}
|
||||
<p class="watchlist-synopsis">{{ item.synopsis | truncate(150) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="watchlist-stats">
|
||||
<span class="stat">
|
||||
<i class="fas fa-download"></i>
|
||||
Ép. {{ item.last_episode_downloaded }}
|
||||
{% if item.total_episodes %}
|
||||
/ {{ item.total_episodes }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if item.added_at %}
|
||||
<span class="stat" title="Ajouté le {{ item.added_at.strftime('%d/%m/%Y') }}">
|
||||
<i class="fas fa-calendar"></i>
|
||||
{{ item.added_at.strftime('%d/%m/%Y') }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="watchlist-actions">
|
||||
<!-- Pause/Resume Toggle -->
|
||||
{% if item.status == 'active' %}
|
||||
<button class="action-btn btn-pause"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "paused"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Mettre en pause">
|
||||
<i class="fas fa-pause"></i>
|
||||
</button>
|
||||
{% elif item.status == 'paused' %}
|
||||
<button class="action-btn btn-resume"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "active"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Reprendre">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mark as completed -->
|
||||
{% if item.status not in ['completed', 'archived'] %}
|
||||
<button class="action-btn btn-complete"
|
||||
hx-put="/api/watchlist/{{ item.id }}"
|
||||
hx-vals='{"status": "completed"}'
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||
title="Marquer comme terminé">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete -->
|
||||
<button class="action-btn btn-delete"
|
||||
hx-delete="/api/watchlist/{{ item.id }}"
|
||||
hx-target="#watchlist-{{ item.id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
|
||||
title="Supprimer">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Votre watchlist est vide.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="watchlist-empty">
|
||||
<i class="fas fa-inbox"></i>
|
||||
<h3>Votre watchlist est vide</h3>
|
||||
<p>Ajoutez des animes ou séries depuis l'onglet Recherche pour commencer à suivre leurs sorties.</p>
|
||||
<button class="btn btn-primary" @click="$dispatch('set-tab', {tab: 'anime'})">
|
||||
<i class="fas fa-search"></i> Rechercher des animes
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Container */
|
||||
.watchlist-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Filter Tabs */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border-radius: var(--input-radius);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.filter-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.filter-tab.active {
|
||||
background: var(--primary);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.watchlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.watchlist-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--card-radius);
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.watchlist-card:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Poster */
|
||||
.watchlist-poster {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 100px;
|
||||
aspect-ratio: 2/3;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
.watchlist-poster img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.poster-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.poster-badge.active {
|
||||
background: rgba(0, 255, 136, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.paused {
|
||||
background: rgba(255, 193, 7, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.completed {
|
||||
background: rgba(156, 39, 176, 0.9);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.poster-badge.archived {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.auto-download-badge {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 217, 255, 0.9);
|
||||
color: var(--bg-dark);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.watchlist-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watchlist-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.watchlist-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-provider,
|
||||
.meta-lang,
|
||||
.meta-quality {
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.meta-provider {
|
||||
background: rgba(0, 217, 255, 0.15);
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.meta-lang {
|
||||
background: rgba(255, 107, 107, 0.15);
|
||||
color: var(--secondary);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.meta-quality {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
color: var(--accent);
|
||||
border: 1px solid rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.watchlist-synopsis {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.watchlist-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.watchlist-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: auto;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.btn-pause {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.btn-pause:hover {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.btn-resume {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-resume:hover {
|
||||
background: rgba(0, 255, 136, 0.15);
|
||||
}
|
||||
|
||||
.btn-complete {
|
||||
color: #9c27b0;
|
||||
}
|
||||
|
||||
.btn-complete:hover {
|
||||
background: rgba(156, 39, 176, 0.15);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.watchlist-empty {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--card-radius);
|
||||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.watchlist-empty i {
|
||||
font-size: 4rem;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.3;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.watchlist-empty h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.watchlist-empty p {
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.watchlist-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.filter-tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.watchlist-card {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.watchlist-poster {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.watchlist-meta,
|
||||
.watchlist-stats {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+24
-22
@@ -12,7 +12,7 @@
|
||||
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
|
||||
<!-- Anime Search Section -->
|
||||
<div class="section-header">
|
||||
<h2>🎬 Rechercher un Anime</h2>
|
||||
<h2>Rechercher un Anime</h2>
|
||||
</div>
|
||||
<div class="url-form">
|
||||
<form hx-get="/api/anime/search"
|
||||
@@ -38,9 +38,6 @@
|
||||
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
|
||||
<div class="spinner"></div> Recherche en cours...
|
||||
</div>
|
||||
<div style="margin-top: 15px; padding: 12px; background: rgba(0, 217, 255, 0.05); border: 1px solid rgba(0, 217, 255, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||
💡 <strong>Astuce :</strong> La recherche unifiée explore plusieurs sources pour trouver vos animes préférés.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anime search results -->
|
||||
@@ -51,11 +48,11 @@
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<!-- Latest Releases Section - Anime only -->
|
||||
<div class="section-header">
|
||||
<h2>🔥 Dernières sorties Anime</h2>
|
||||
<h2>Dernieres sorties Anime</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-get="/api/releases/latest?content_type=anime&html=1"
|
||||
hx-target="#animeReleasesList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -63,13 +60,13 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:500ms"></div>
|
||||
<div id="animeReleasesList" class="recommendations-carousel" hx-get="/api/releases/latest?content_type=anime&html=1" hx-trigger="load delay:500ms"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
|
||||
<!-- Series Search Section -->
|
||||
<div class="section-header">
|
||||
<h2>📺 Rechercher une Série TV</h2>
|
||||
<h2>Rechercher une Serie TV</h2>
|
||||
</div>
|
||||
<div class="url-form">
|
||||
<form hx-get="/api/series/search"
|
||||
@@ -82,7 +79,7 @@
|
||||
type="text"
|
||||
name="q"
|
||||
id="seriesSearchInput"
|
||||
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
|
||||
placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="btn btn-primary btn-search">
|
||||
@@ -95,9 +92,6 @@
|
||||
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
|
||||
<div class="spinner"></div> Recherche en cours...
|
||||
</div>
|
||||
<div style="margin-top: 15px; padding: 12px; background: rgba(255, 107, 107, 0.05); border: 1px solid rgba(255, 107, 107, 0.1); border-radius: var(--input-radius); font-size: 13px; color: var(--text-dim);">
|
||||
💡 <strong>Info:</strong> La recherche utilise FS7 pour trouver des séries TV américaines et européennes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Series search results -->
|
||||
@@ -105,11 +99,11 @@
|
||||
|
||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||
|
||||
<!-- Recommendations Section -->
|
||||
<!-- Recommendations Section - Series only -->
|
||||
<div class="section-header">
|
||||
<h2>🎯 Recommandé pour vous</h2>
|
||||
<h2>Recommande pour vous</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/recommendations"
|
||||
hx-get="/api/recommendations?content_type=series&html=1"
|
||||
hx-target="#seriesRecommendationsList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -117,13 +111,13 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations" hx-trigger="load delay:600ms"></div>
|
||||
<div id="seriesRecommendationsList" class="recommendations-carousel" style="margin-bottom: 40px;" hx-get="/api/recommendations?content_type=series&html=1" hx-trigger="load delay:600ms"></div>
|
||||
|
||||
<!-- Latest Releases Section -->
|
||||
<!-- Latest Releases Section - Series only -->
|
||||
<div class="section-header" style="margin-top: 40px;">
|
||||
<h2>🔥 Dernières sorties Séries TV</h2>
|
||||
<h2>Dernieres sorties Series TV</h2>
|
||||
<button class="btn btn-secondary btn-small"
|
||||
hx-get="/api/releases/latest"
|
||||
hx-get="/api/releases/latest?content_type=series&html=1"
|
||||
hx-target="#seriesReleasesList">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
@@ -131,7 +125,7 @@
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest" hx-trigger="load delay:700ms"></div>
|
||||
<div id="seriesReleasesList" class="releases-carousel" hx-get="/api/releases/latest?content_type=series&html=1" hx-trigger="load delay:700ms"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
||||
@@ -145,7 +139,15 @@
|
||||
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
|
||||
<div hx-get="/api/settings/ui" hx-trigger="set-tab[detail.tab === 'settings'] from:window, refresh-settings" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement des paramètres...
|
||||
<div class="spinner"></div> Chargement des parametres...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
|
||||
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
|
||||
<div class="loading-placeholder">
|
||||
<div class="spinner"></div> Chargement du panel admin...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-9
@@ -41,7 +41,7 @@ async def test_watchlist_manager():
|
||||
)
|
||||
|
||||
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" Title: {item.anime_title}")
|
||||
print(f" Status: {item.status}")
|
||||
@@ -127,8 +127,8 @@ async def test_scheduler():
|
||||
|
||||
print("\n2. Testing scheduler status...")
|
||||
try:
|
||||
status = auto_download_scheduler.get_status()
|
||||
print(f" ✅ Scheduler status: running={status['running']}")
|
||||
running = auto_download_scheduler.is_running()
|
||||
print(f" ✅ Scheduler status: running={running}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Status failed: {e}")
|
||||
return False
|
||||
@@ -136,20 +136,18 @@ async def test_scheduler():
|
||||
print("\n3. Testing scheduler start/stop...")
|
||||
try:
|
||||
# Start scheduler
|
||||
await auto_download_scheduler.start()
|
||||
auto_download_scheduler.start()
|
||||
print(" ✅ Scheduler started")
|
||||
|
||||
status = auto_download_scheduler.get_status()
|
||||
if not status['running']:
|
||||
if not auto_download_scheduler.is_running():
|
||||
print(" ❌ Scheduler not running after start")
|
||||
return False
|
||||
|
||||
# Stop scheduler
|
||||
await auto_download_scheduler.stop()
|
||||
auto_download_scheduler.stop()
|
||||
print(" ✅ Scheduler stopped")
|
||||
|
||||
status = auto_download_scheduler.get_status()
|
||||
if status['running']:
|
||||
if auto_download_scheduler.is_running():
|
||||
print(" ❌ Scheduler still running after stop")
|
||||
return False
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ def test_watchlist_basics():
|
||||
)
|
||||
|
||||
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" Title: {item.anime_title}")
|
||||
print(f" Status: {item.status}")
|
||||
@@ -178,7 +178,7 @@ async def test_scheduler():
|
||||
print("🧪 TEST 3: Auto-Download Scheduler")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Testing scheduler start (async)...")
|
||||
print("\n1. Testing scheduler start...")
|
||||
try:
|
||||
auto_download_scheduler.start()
|
||||
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 });
|
||||
});
|
||||
+44
-70
@@ -1,119 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_USER, login } from './helpers';
|
||||
|
||||
test.describe('Auth Flow', () => {
|
||||
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
|
||||
await page.fill('#loginUsername', 'testuser');
|
||||
await page.fill('#loginPassword', 'password123');
|
||||
// Verify redirect to /web
|
||||
await expect(page).toHaveURL(/\/web/);
|
||||
|
||||
// Click login button
|
||||
await page.click('#loginSubmit');
|
||||
|
||||
// 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();
|
||||
// Verify token stored
|
||||
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
expect(token).toBeTruthy();
|
||||
});
|
||||
|
||||
test('login with wrong credentials shows error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Fill login form with wrong credentials
|
||||
await page.fill('#loginUsername', 'nonexistentuser');
|
||||
await page.fill('#loginUsername', 'nonexistentuser_xyz');
|
||||
await page.fill('#loginPassword', 'wrongpassword');
|
||||
|
||||
// Click login button
|
||||
await page.click('#loginSubmit');
|
||||
const [response] = await Promise.all([
|
||||
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);
|
||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
||||
|
||||
// Error should be shown (and NOT be "[object Object]")
|
||||
expect(errorVisible || errorText.length > 0).toBeTruthy();
|
||||
expect(errorText).not.toContain('[object Object]');
|
||||
// Error message should be visible
|
||||
const errorLocator = page.locator('#authError');
|
||||
await expect(errorLocator).toBeVisible();
|
||||
await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i);
|
||||
});
|
||||
|
||||
test('register new user shows success', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Switch to register tab
|
||||
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('#registerPassword', 'password123');
|
||||
await page.fill('#registerPasswordConfirm', 'password123');
|
||||
|
||||
// Click register button
|
||||
await page.click('#registerSubmit');
|
||||
const [response] = await Promise.all([
|
||||
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);
|
||||
const successText = await page.locator('#authSuccess').textContent().catch(() => '');
|
||||
|
||||
// Success should be shown
|
||||
expect(successVisible || successText.includes('réussie')).toBeTruthy();
|
||||
await expect(page.locator('#authSuccess')).toBeVisible();
|
||||
await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i);
|
||||
});
|
||||
|
||||
test('password mismatch shows validation error', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Switch to register tab
|
||||
await page.click('text=Inscription');
|
||||
|
||||
// Fill register form with mismatching passwords
|
||||
await page.fill('#registerUsername', 'testuser');
|
||||
await page.fill('#registerPassword', 'password123');
|
||||
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
||||
|
||||
// Click register button
|
||||
await page.click('#registerSubmit');
|
||||
|
||||
// Wait for error
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check error message
|
||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
||||
|
||||
// Should show password mismatch error
|
||||
expect(errorText).toContain('correspondent');
|
||||
await expect(page.locator('#authError')).toBeVisible();
|
||||
await expect(page.locator('#authError')).toContainText(/correspondent|match/i);
|
||||
});
|
||||
|
||||
test('login button shows loading state during request', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
// Get button and check initial state
|
||||
const button = page.locator('#loginSubmit');
|
||||
const initialText = await button.textContent();
|
||||
|
||||
// Fill form and click
|
||||
await page.fill('#loginUsername', 'testuser');
|
||||
await page.fill('#loginPassword', 'password123');
|
||||
await page.fill('#loginUsername', TEST_USER.username);
|
||||
await page.fill('#loginPassword', TEST_USER.password);
|
||||
|
||||
// Click and immediately check loading state
|
||||
await button.click();
|
||||
// Start the click but don't await it fully — we want to observe the loading state
|
||||
const clickPromise = button.click();
|
||||
|
||||
// Check loading state (should change text or be disabled)
|
||||
await page.waitForTimeout(100);
|
||||
const buttonText = await button.textContent();
|
||||
const isDisabled = await button.isDisabled().catch(() => false);
|
||||
// Poll briefly for loading state
|
||||
let sawLoading = false;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const text = await button.textContent();
|
||||
const disabled = await button.isDisabled();
|
||||
if (text !== initialText || disabled) {
|
||||
sawLoading = true;
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
// Button should either show loading text or be disabled
|
||||
expect(buttonText !== initialText || isDisabled).toBeTruthy();
|
||||
await clickPromise;
|
||||
expect(sawLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { switchTab, waitForHtmx, collectJsErrors } from './helpers';
|
||||
|
||||
/**
|
||||
* Download Flow E2E Tests
|
||||
*
|
||||
* These tests cover the complete user journey for discovering and downloading
|
||||
* anime/series content, including mocked provider flows and real file downloads.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getAuthToken(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem('auth_token'));
|
||||
}
|
||||
|
||||
async function createDownloadViaApi(page: Page, url: string): Promise<string> {
|
||||
const token = await getAuthToken(page);
|
||||
if (!token) throw new Error('No auth token found');
|
||||
|
||||
const response = await page.request.post(`/api/anime/download?url=${encodeURIComponent(url)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
const body = await response.json();
|
||||
return body.task_id as string;
|
||||
}
|
||||
|
||||
async function deleteDownloadViaApi(page: Page, taskId: string): Promise<void> {
|
||||
const token = await getAuthToken(page);
|
||||
if (!token) return;
|
||||
|
||||
await page.request.delete(`/api/downloads/${taskId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Episode picker + download toast (fully mocked)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Download Flow E2E', () => {
|
||||
test('should choose episodes from search result and trigger download toast', async ({ page }) => {
|
||||
const jsErrors = collectJsErrors(page);
|
||||
|
||||
// 1. Mock search results with a full card including dropdown
|
||||
await page.route('/api/anime/search?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||
<div class="sr-card" style="--sr-accent: #00d9ff;">
|
||||
<a class="sr-poster-link" href="https://example.com/anime/frieren" target="_blank" rel="noopener">
|
||||
<img class="sr-poster-img" src="https://placehold.co/240x360" alt="Frieren" loading="lazy">
|
||||
</a>
|
||||
<div class="sr-body">
|
||||
<div class="sr-top">
|
||||
<h3 class="sr-title">Frieren: Beyond Journey's End</h3>
|
||||
</div>
|
||||
<div class="sr-actions">
|
||||
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||
<button class="sr-btn sr-btn-dl" @click.stop="openDropdown = (openDropdown === 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren') ? null : 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren'">
|
||||
<i class="fas fa-download"></i> Telecharger
|
||||
</button>
|
||||
<div class="sr-dropdown-menu" x-show="openDropdown === 'https%3A%2F%2Fexample.com%2Fanime%2Ffrieren'" x-transition>
|
||||
<button class="sr-dropdown-item"
|
||||
hx-post="/api/anime/download-season?url=https%3A%2F%2Fexample.com%2Fanime%2Ffrieren&lang=vostfr"
|
||||
hx-swap="none">
|
||||
<i class="fas fa-layer-group"></i> Saison complete
|
||||
</button>
|
||||
<button class="sr-dropdown-item"
|
||||
hx-get="/api/anime/episodes?url=https%3A%2F%2Fexample.com%2Fanime%2Ffrieren&lang=vostfr&html=1"
|
||||
hx-target="#player-container"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Mock episode list pointing to local static file for real download
|
||||
const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4';
|
||||
await page.route('/api/anime/episodes?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div class="episode-list-container section-container" x-data="{ view: 'grid' }">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h2 style="border: none; padding: 0; margin-bottom: 5px;">Frieren</h2>
|
||||
<span class="badge">1 épisodes disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="video-player-display"></div>
|
||||
<div class="episodes-content view-grid" style="margin-top: 25px;">
|
||||
<div class="episode-item">
|
||||
<div class="ep-number">EP 1</div>
|
||||
<div class="ep-title" title="Le départ">Le départ</div>
|
||||
<div class="ep-actions">
|
||||
<button class="btn btn-primary btn-small"
|
||||
hx-get="/api/player/embed?url=${encodeURIComponent(testFileUrl)}"
|
||||
hx-target="#video-player-display"
|
||||
hx-swap="innerHTML">
|
||||
<i class="fas fa-play"></i> Regarder
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-icon btn-small"
|
||||
hx-post="/api/anime/download?url=${encodeURIComponent(testFileUrl)}"
|
||||
hx-swap="none"
|
||||
title="Télécharger cet épisode">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Anime');
|
||||
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Trigger search
|
||||
await page.fill('#animeSearchInput', 'Frieren');
|
||||
await page.click('#tab-anime button[type="submit"]');
|
||||
|
||||
// Wait for search results
|
||||
await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(page.locator('#animeSearchResults')).toContainText("Frieren: Beyond Journey's End");
|
||||
|
||||
// Open dropdown
|
||||
await page.locator('#animeSearchResults .sr-card').first().locator('.sr-btn-dl').click();
|
||||
await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Click "Choisir des épisodes"
|
||||
await page.locator('.sr-dropdown-item:has-text("Choisir des episodes")').click();
|
||||
|
||||
// Wait for episode list
|
||||
await page.locator('#player-container .episode-item').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(page.locator('#player-container')).toContainText('EP 1');
|
||||
|
||||
// Click download on first episode and wait for the real server response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => resp.url().includes('/api/anime/download') && resp.request().method() === 'POST'
|
||||
),
|
||||
page.locator('#player-container .episode-item').first()
|
||||
.locator('button[title="Télécharger cet épisode"]').click(),
|
||||
]);
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Wait for toast triggered by HX-Trigger header
|
||||
await page.locator('#toast-container .toast-success')
|
||||
.filter({ hasText: /Téléchargement lancé/i })
|
||||
.waitFor({ state: 'visible', timeout: 8000 });
|
||||
|
||||
// Cleanup the created download task via API
|
||||
const body = await response.json();
|
||||
if (body.task_id) {
|
||||
await deleteDownloadViaApi(page, body.task_id as string);
|
||||
}
|
||||
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Real file download via static fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('should download a real file and show it in downloads list', async ({ page }) => {
|
||||
test.setTimeout(60000);
|
||||
const jsErrors = collectJsErrors(page);
|
||||
|
||||
// Navigate first so localStorage is available on the correct origin
|
||||
await page.goto('/web');
|
||||
|
||||
// Use the static test file served by the app itself
|
||||
const testFileUrl = 'http://localhost:3000/static/test_download/test_episode_01.mp4';
|
||||
|
||||
// 1. Create download via API
|
||||
const taskId = await createDownloadViaApi(page, testFileUrl);
|
||||
|
||||
// 2. Navigate to downloads tab
|
||||
await switchTab(page, 'Téléchargements');
|
||||
await page.locator('#tab-downloads').waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// 3. Wait for the task to appear in the list
|
||||
await page.locator('#downloads-container-inner .download-item').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 4. Wait for completion (poll until status is completed)
|
||||
await expect(page.locator('#downloads-container-inner .download-item.status-completed')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// 5. Verify progress is 100%
|
||||
const progressText = await page.locator('#downloads-container-inner .download-item.status-completed .download-meta span').first().textContent();
|
||||
expect(progressText).toContain('100');
|
||||
|
||||
// 6. Verify filename is shown
|
||||
await expect(page.locator('#downloads-container-inner .download-item .download-name')).toContainText('test_episode_01.mp4');
|
||||
|
||||
// 7. Verify completed actions are present (stream + download links)
|
||||
await expect(page.locator('#downloads-container-inner .download-item.status-completed a[title="Streamer"]')).toBeVisible();
|
||||
await expect(page.locator('#downloads-container-inner .download-item.status-completed a[download]')).toBeVisible();
|
||||
|
||||
// Cleanup
|
||||
await deleteDownloadViaApi(page, taskId);
|
||||
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Click a new release on homepage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('should click a new release and switch to anime search', async ({ page }) => {
|
||||
const jsErrors = collectJsErrors(page);
|
||||
|
||||
// Mock releases with a single anime card
|
||||
await page.route('/api/releases/latest', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div class="hc" id="anime-abc123"
|
||||
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = 'Spy x Family'; htmx.trigger(input, 'keyup'); } });"
|
||||
style="cursor: pointer;">
|
||||
<div class="hc-poster">
|
||||
<img src="https://placehold.co/400x600" alt="Spy x Family" loading="lazy">
|
||||
</div>
|
||||
<div class="hc-info">
|
||||
<span class="hc-src">Anime-Sama</span>
|
||||
<span class="hc-title">Spy x Family</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
// Mock empty recommendations so they don't interfere
|
||||
await page.route('/api/recommendations', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'text/html', body: '<p></p>' });
|
||||
});
|
||||
|
||||
await page.goto('/web');
|
||||
|
||||
// Wait for releases to load
|
||||
await page.locator('#releasesList .hc').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(page.locator('#releasesList .hc-title')).toContainText('Spy x Family');
|
||||
|
||||
// Click the release card
|
||||
await page.locator('#releasesList .hc').first().click();
|
||||
|
||||
// Should switch to anime tab and populate search input
|
||||
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||
await expect(page.locator('#animeSearchInput')).toHaveValue('Spy x Family');
|
||||
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test: Series search flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('should search for series and display results', async ({ page }) => {
|
||||
const jsErrors = collectJsErrors(page);
|
||||
|
||||
await page.route('/api/series/search?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: `
|
||||
<div class="sr-list">
|
||||
<div class="sr-card">
|
||||
<div class="sr-body">
|
||||
<div class="sr-top">
|
||||
<h3 class="sr-title">Breaking Bad</h3>
|
||||
</div>
|
||||
<p class="sr-synopsis">A high school chemistry teacher turned methamphetamine producer.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sr-card">
|
||||
<div class="sr-body">
|
||||
<div class="sr-top">
|
||||
<h3 class="sr-title">Better Call Saul</h3>
|
||||
</div>
|
||||
<p class="sr-synopsis">The trials and tribulations of criminal lawyer Jimmy McGill.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/web');
|
||||
await switchTab(page, 'Série');
|
||||
await page.locator('#tab-series').waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
await page.fill('#seriesSearchInput', 'Breaking');
|
||||
await page.click('#tab-series button[type="submit"]');
|
||||
|
||||
await page.locator('#seriesSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||
await expect(page.locator('#seriesSearchResults')).toContainText('Breaking Bad');
|
||||
await expect(page.locator('#seriesSearchResults')).toContainText('Better Call Saul');
|
||||
|
||||
expect(jsErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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 { switchTab, waitForHtmx, collectJsErrors } from './helpers';
|
||||
|
||||
/**
|
||||
* User Journey E2E Tests
|
||||
*
|
||||
* Simulates a complete user flow: register → login → browse → search → settings → logout.
|
||||
* All tests are serial because they share browser state (auth token, navigation).
|
||||
*
|
||||
* FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector()
|
||||
* Tests authenticated user flows. Auth is handled by auth.setup.ts + storageState.
|
||||
*/
|
||||
|
||||
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 = {
|
||||
username: `e2e_user_${Date.now()}`,
|
||||
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
|
||||
// Main content should be visible
|
||||
await expect(page.locator('#main-content')).toBeVisible();
|
||||
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();
|
||||
|
||||
// Verify the user info panel (logged-in state indicator)
|
||||
// Authenticated user info should be visible
|
||||
await expect(page.locator('#userInfo')).toBeVisible();
|
||||
|
||||
// No JavaScript errors should have been thrown
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(jsErrors).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 }) => {
|
||||
// Navigate to the Anime tab
|
||||
await page.click('.tab:has-text("Anime")');
|
||||
// Mock the anime search API to return deterministic HTML
|
||||
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 });
|
||||
|
||||
// Fill the search input — HTMX debounce triggers the request automatically
|
||||
await page.fill('#animeSearchInput', 'Naruto');
|
||||
|
||||
// Wait for either results, an empty-state message, or the loading spinner to disappear
|
||||
await Promise.race([
|
||||
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 }),
|
||||
]);
|
||||
// Click search button to trigger submit
|
||||
await page.click('#tab-anime button[type="submit"]');
|
||||
|
||||
// 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')).toContainText('Naruto Shippuden');
|
||||
});
|
||||
|
||||
// Change a setting (language) and verify the PATCH response and toast notification
|
||||
test('should update settings', async ({ page }) => {
|
||||
// Open the settings tab
|
||||
await page.click('.tab:has-text("Paramètres")');
|
||||
await page.goto('/web');
|
||||
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 });
|
||||
|
||||
// Change the default language
|
||||
await page.selectOption('#default_lang', 'vf');
|
||||
|
||||
// Submit the settings form and capture the PATCH response
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(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);
|
||||
|
||||
// Verify a toast notification appears confirming the save
|
||||
await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 });
|
||||
// Verify the setting was updated in the UI
|
||||
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 }) => {
|
||||
// Click the logout button and wait for the API response
|
||||
await page.goto('/web');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
|
||||
page.locator('#userInfo button:has-text("Déconnexion")').click(),
|
||||
@@ -154,7 +92,7 @@ test.describe('User Journey E2E', () => {
|
||||
|
||||
expect(response.status()).toBeLessThan(400);
|
||||
|
||||
// Should be redirected back to the login page
|
||||
// Should redirect to login
|
||||
await page.waitForURL('**/login**', { timeout: 10000 });
|
||||
|
||||
// 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
|
||||
try:
|
||||
client.delete("/api/favorites/test-toggle-add")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = client.post(
|
||||
@@ -448,7 +448,7 @@ class TestAPIFavorites:
|
||||
# Make sure it doesn't exist first
|
||||
try:
|
||||
client.delete("/api/favorites/test-toggle-remove")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add first
|
||||
|
||||
@@ -354,7 +354,7 @@ class TestDownloadManagerErrorHandling:
|
||||
try:
|
||||
await manager.start_download(task.id)
|
||||
await asyncio.sleep(0.1) # Give it time to process
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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