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/
|
.opencode/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
playwright/.auth/
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ The downloaders are organized into three categories with separate base classes:
|
|||||||
- User authentication and last login tracking
|
- User authentication and last login tracking
|
||||||
- **JWT Tokens** - Stateless authentication with refresh token support
|
- **JWT Tokens** - Stateless authentication with refresh token support
|
||||||
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
- Access tokens: 24-hour expiration (configurable via `ACCESS_TOKEN_EXPIRE_MINUTES`)
|
||||||
- Refresh tokens: 30-day expiration (stored in `config/refresh_tokens.json`)
|
- Refresh tokens: 30-day expiration (stored in SQLite `refresh_tokens` table)
|
||||||
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
|
- HS256 algorithm with JWT_SECRET_KEY (change in production!)
|
||||||
- Token verification and user extraction
|
- Token verification and user extraction
|
||||||
- **Password Security**
|
- **Password Security**
|
||||||
@@ -348,7 +348,7 @@ The downloaders are organized into three categories with separate base classes:
|
|||||||
- **Configuration**
|
- **Configuration**
|
||||||
- `JWT_SECRET_KEY` environment variable (MUST be changed from default)
|
- `JWT_SECRET_KEY` environment variable (MUST be changed from default)
|
||||||
- Users stored in `config/users.json`
|
- Users stored in `config/users.json`
|
||||||
- Refresh tokens stored in `config/refresh_tokens.json`
|
- Refresh tokens stored in SQLite `refresh_tokens` table
|
||||||
|
|
||||||
**Authentication Endpoints:**
|
**Authentication Endpoints:**
|
||||||
- `POST /api/auth/register` - User registration
|
- `POST /api/auth/register` - User registration
|
||||||
@@ -709,7 +709,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min
|
|||||||
**Configuration Files:**
|
**Configuration Files:**
|
||||||
- `.env` - Environment configuration (create from .env.example)
|
- `.env` - Environment configuration (create from .env.example)
|
||||||
- `config/users.json` - User authentication database (created automatically)
|
- `config/users.json` - User authentication database (created automatically)
|
||||||
- `config/refresh_tokens.json` - Refresh token storage (created automatically)
|
- `refresh_tokens` table - Refresh token storage (SQLite database)
|
||||||
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
- `config/sonarr.json` - Sonarr webhook configuration (created automatically)
|
||||||
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
|
- `config/sonarr_mappings.json` - Sonarr to anime provider mappings (created automatically)
|
||||||
- `config/watchlist.json` - User watchlist items (created automatically)
|
- `config/watchlist.json` - User watchlist items (created automatically)
|
||||||
@@ -746,7 +746,7 @@ JWT_SECRET_KEY=change-me-in-production # JWT signing key (MUST be changed, min
|
|||||||
- Passwords truncated to 72 bytes (bcrypt limitation)
|
- Passwords truncated to 72 bytes (bcrypt limitation)
|
||||||
- JWT secret key validation (minimum 32 characters, default rejected)
|
- JWT secret key validation (minimum 32 characters, default rejected)
|
||||||
- Credentials stored in `config/users.json`
|
- Credentials stored in `config/users.json`
|
||||||
- Refresh tokens stored in `config/refresh_tokens.json`
|
- Refresh tokens stored in SQLite `refresh_tokens` table
|
||||||
|
|
||||||
## Key Implementation Details
|
## Key Implementation Details
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Initial schema
|
||||||
|
|
||||||
|
Revision ID: 0001_initial_schema
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-05-12 08:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0001_initial_schema'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
'users',
|
||||||
|
sa.Column('username', sa.String(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(), nullable=True),
|
||||||
|
sa.Column('full_name', sa.String(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('is_admin', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('username')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'app_settings',
|
||||||
|
sa.Column('default_lang', sa.String(), nullable=False),
|
||||||
|
sa.Column('theme', sa.String(), nullable=False),
|
||||||
|
sa.Column('disabled_providers_json', sa.String(), nullable=False),
|
||||||
|
sa.Column('recommendations_filter', sa.String(), nullable=False),
|
||||||
|
sa.Column('releases_filter', sa.String(), nullable=False),
|
||||||
|
sa.Column('anime_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('series_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('download_dir', sa.String(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_app_settings_id'), 'app_settings', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_app_settings_user_id'), 'app_settings', ['user_id'], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'favorites',
|
||||||
|
sa.Column('anime_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(), nullable=False),
|
||||||
|
sa.Column('url', sa.String(), nullable=False),
|
||||||
|
sa.Column('provider', sa.String(), nullable=False),
|
||||||
|
sa.Column('poster_url', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('metadata_json', sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_favorites_anime_id'), 'favorites', ['anime_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_favorites_title'), 'favorites', ['title'], unique=False)
|
||||||
|
op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'refresh_tokens',
|
||||||
|
sa.Column('token_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('username', sa.String(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('revoked', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('token_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_refresh_tokens_token_id'), 'refresh_tokens', ['token_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_refresh_tokens_username'), 'refresh_tokens', ['username'], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'sonarr_config',
|
||||||
|
sa.Column('webhook_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('webhook_secret', sa.String(), nullable=True),
|
||||||
|
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('default_language', sa.String(), nullable=False),
|
||||||
|
sa.Column('default_quality', sa.String(), nullable=True),
|
||||||
|
sa.Column('default_provider', sa.String(), nullable=False),
|
||||||
|
sa.Column('verify_hmac', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('log_webhooks', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sonarr_config_id'), 'sonarr_config', ['id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'sonarr_mappings',
|
||||||
|
sa.Column('sonarr_series_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('sonarr_title', sa.String(), nullable=False),
|
||||||
|
sa.Column('anime_provider', sa.String(), nullable=False),
|
||||||
|
sa.Column('anime_url', sa.String(), nullable=False),
|
||||||
|
sa.Column('anime_title', sa.String(), nullable=False),
|
||||||
|
sa.Column('lang', sa.String(), nullable=False),
|
||||||
|
sa.Column('quality_preference', sa.String(), nullable=True),
|
||||||
|
sa.Column('auto_download', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('sonarr_series_id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sonarr_mappings_id'), 'sonarr_mappings', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_sonarr_mappings_sonarr_series_id'), 'sonarr_mappings', ['sonarr_series_id'], unique=True)
|
||||||
|
op.create_index(op.f('ix_sonarr_mappings_user_id'), 'sonarr_mappings', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'watchlist_items',
|
||||||
|
sa.Column('anime_title', sa.String(), nullable=False),
|
||||||
|
sa.Column('anime_url', sa.String(), nullable=False),
|
||||||
|
sa.Column('provider_id', sa.String(), nullable=False),
|
||||||
|
sa.Column('lang', sa.String(), nullable=False),
|
||||||
|
sa.Column('last_checked', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('last_episode_downloaded', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('total_episodes', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('auto_download', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('quality_preference', sa.String(), nullable=False),
|
||||||
|
sa.Column('status', sa.String(), nullable=False),
|
||||||
|
sa.Column('poster_image', sa.String(), nullable=True),
|
||||||
|
sa.Column('cover_image', sa.String(), nullable=True),
|
||||||
|
sa.Column('synopsis', sa.String(), nullable=True),
|
||||||
|
sa.Column('genres_json', sa.String(), nullable=True),
|
||||||
|
sa.Column('added_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_watchlist_items_anime_title'), 'watchlist_items', ['anime_title'], unique=False)
|
||||||
|
op.create_index(op.f('ix_watchlist_items_id'), 'watchlist_items', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_watchlist_items_user_id'), 'watchlist_items', ['user_id'], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'watchlist_settings',
|
||||||
|
sa.Column('check_interval_hours', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('auto_download_enabled', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('max_concurrent_auto_downloads', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('notify_on_new_episodes', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('include_completed_anime', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_watchlist_settings_id'), 'watchlist_settings', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_watchlist_settings_user_id'), 'watchlist_settings', ['user_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_watchlist_settings_user_id'), table_name='watchlist_settings')
|
||||||
|
op.drop_index(op.f('ix_watchlist_settings_id'), table_name='watchlist_settings')
|
||||||
|
op.drop_table('watchlist_settings')
|
||||||
|
op.drop_index(op.f('ix_watchlist_items_user_id'), table_name='watchlist_items')
|
||||||
|
op.drop_index(op.f('ix_watchlist_items_id'), table_name='watchlist_items')
|
||||||
|
op.drop_index(op.f('ix_watchlist_items_anime_title'), table_name='watchlist_items')
|
||||||
|
op.drop_table('watchlist_items')
|
||||||
|
op.drop_index(op.f('ix_sonarr_mappings_user_id'), table_name='sonarr_mappings')
|
||||||
|
op.drop_index(op.f('ix_sonarr_mappings_sonarr_series_id'), table_name='sonarr_mappings')
|
||||||
|
op.drop_index(op.f('ix_sonarr_mappings_id'), table_name='sonarr_mappings')
|
||||||
|
op.drop_table('sonarr_mappings')
|
||||||
|
op.drop_index(op.f('ix_sonarr_config_id'), table_name='sonarr_config')
|
||||||
|
op.drop_table('sonarr_config')
|
||||||
|
op.drop_index(op.f('ix_refresh_tokens_username'), table_name='refresh_tokens')
|
||||||
|
op.drop_index(op.f('ix_refresh_tokens_token_id'), table_name='refresh_tokens')
|
||||||
|
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
|
||||||
|
op.drop_table('refresh_tokens')
|
||||||
|
op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites')
|
||||||
|
op.drop_index(op.f('ix_favorites_title'), table_name='favorites')
|
||||||
|
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
|
||||||
|
op.drop_index(op.f('ix_favorites_anime_id'), table_name='favorites')
|
||||||
|
op.drop_table('favorites')
|
||||||
|
op.drop_index(op.f('ix_app_settings_user_id'), table_name='app_settings')
|
||||||
|
op.drop_index(op.f('ix_app_settings_id'), table_name='app_settings')
|
||||||
|
op.drop_table('app_settings')
|
||||||
|
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||||
|
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||||
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""Initial migration
|
|
||||||
|
|
||||||
Revision ID: e0273f326a15
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-03-24 17:05:50.046027
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'e0273f326a15'
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
"""Add WatchlistSettingsTable
|
|
||||||
|
|
||||||
Revision ID: e88271d11851
|
|
||||||
Revises: e0273f326a15
|
|
||||||
Create Date: 2026-03-24 17:07:10.189457
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'e88271d11851'
|
|
||||||
down_revision: Union[str, None] = 'e0273f326a15'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
pass
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
+41
-48
@@ -1,9 +1,7 @@
|
|||||||
"""User authentication and management system with SQLModel support"""
|
"""User authentication and management system with SQLModel support"""
|
||||||
|
|
||||||
import os
|
|
||||||
import hashlib
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Dict, List
|
from typing import Optional
|
||||||
from jose import jwt
|
from jose import jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import logging
|
import logging
|
||||||
@@ -11,7 +9,7 @@ from fastapi import HTTPException
|
|||||||
from fastapi.security import HTTPAuthorizationCredentials
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from app.database import engine
|
from app.database import engine
|
||||||
from app.models.auth import UserTable
|
from app.models.auth import UserTable, RefreshTokenTable
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -189,33 +187,32 @@ def get_current_user(credentials: HTTPAuthorizationCredentials) -> UserTable:
|
|||||||
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
|
||||||
|
|
||||||
|
|
||||||
# Refresh tokens storage
|
def _get_refresh_token(token_id: str) -> Optional[RefreshTokenTable]:
|
||||||
REFRESH_TOKENS_FILE = "config/refresh_tokens.json"
|
"""Get a refresh token from the database by token_id"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
|
||||||
|
return session.exec(statement).first()
|
||||||
|
|
||||||
|
|
||||||
def _load_refresh_tokens() -> Dict[str, dict]:
|
def _save_refresh_token(token: RefreshTokenTable):
|
||||||
"""Load refresh tokens from file"""
|
"""Save or update a refresh token in the database"""
|
||||||
import json
|
with Session(engine) as session:
|
||||||
|
session.add(token)
|
||||||
try:
|
session.commit()
|
||||||
if os.path.exists(REFRESH_TOKENS_FILE):
|
|
||||||
with open(REFRESH_TOKENS_FILE, "r", encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading refresh tokens: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _save_refresh_tokens(tokens: Dict[str, dict]):
|
def _revoke_refresh_token_db(token_id: str) -> bool:
|
||||||
"""Save refresh tokens to file"""
|
"""Revoke a refresh token in the database"""
|
||||||
import json
|
with Session(engine) as session:
|
||||||
|
statement = select(RefreshTokenTable).where(RefreshTokenTable.token_id == token_id)
|
||||||
try:
|
db_token = session.exec(statement).first()
|
||||||
os.makedirs(os.path.dirname(REFRESH_TOKENS_FILE), exist_ok=True)
|
if not db_token:
|
||||||
with open(REFRESH_TOKENS_FILE, "w", encoding="utf-8") as f:
|
return False
|
||||||
json.dump(tokens, f, indent=2, ensure_ascii=False, default=str)
|
db_token.revoked = True
|
||||||
except Exception as e:
|
db_token.revoked_at = datetime.now()
|
||||||
logger.error(f"Error saving refresh tokens: {e}")
|
session.add(db_token)
|
||||||
|
session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt_config() -> dict:
|
def _get_jwt_config() -> dict:
|
||||||
@@ -267,15 +264,15 @@ def create_access_refresh_tokens(data: dict) -> tuple[str, str]:
|
|||||||
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
refresh_data, jwt_config["SECRET_KEY"], algorithm=jwt_config["ALGORITHM"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store refresh token mapping
|
# Store refresh token in database
|
||||||
refresh_tokens = _load_refresh_tokens()
|
db_token = RefreshTokenTable(
|
||||||
refresh_tokens[token_id] = {
|
token_id=token_id,
|
||||||
"username": data["sub"],
|
username=data["sub"],
|
||||||
"token_id": token_id,
|
created_at=datetime.now(),
|
||||||
"created_at": datetime.now().isoformat(),
|
expires_at=refresh_expire,
|
||||||
"expires_at": refresh_expire.isoformat(),
|
revoked=False,
|
||||||
}
|
)
|
||||||
_save_refresh_tokens(refresh_tokens)
|
_save_refresh_token(db_token)
|
||||||
|
|
||||||
return access_token, refresh_token
|
return access_token, refresh_token
|
||||||
|
|
||||||
@@ -305,15 +302,18 @@ def verify_refresh_token(token: str) -> Optional[str]:
|
|||||||
if not username or not token_id:
|
if not username or not token_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if token exists in storage
|
# Check if token exists in database
|
||||||
refresh_tokens = _load_refresh_tokens()
|
stored_token = _get_refresh_token(token_id)
|
||||||
stored_token = refresh_tokens.get(token_id)
|
|
||||||
|
|
||||||
if not stored_token:
|
if not stored_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Verify token hasn't been revoked or expired
|
# Verify token hasn't been revoked or expired
|
||||||
if stored_token.get("revoked"):
|
if stored_token.revoked:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Also check expiration in database
|
||||||
|
if stored_token.expires_at and stored_token.expires_at < datetime.now():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return username
|
return username
|
||||||
@@ -341,14 +341,7 @@ def revoke_refresh_token(token: str) -> bool:
|
|||||||
if not token_id:
|
if not token_id:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
refresh_tokens = _load_refresh_tokens()
|
return _revoke_refresh_token_db(token_id)
|
||||||
if token_id in refresh_tokens:
|
|
||||||
refresh_tokens[token_id]["revoked"] = True
|
|
||||||
refresh_tokens[token_id]["revoked_at"] = datetime.now().isoformat()
|
|
||||||
_save_refresh_tokens(refresh_tokens)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return False
|
return False
|
||||||
|
|||||||
+37
-1
@@ -18,7 +18,7 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
|||||||
def create_db_and_tables():
|
def create_db_and_tables():
|
||||||
"""Create the database and tables based on the models"""
|
"""Create the database and tables based on the models"""
|
||||||
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
|
# CRITICAL: Import ALL models here to ensure they are registered with SQLModel.metadata
|
||||||
from app.models.auth import UserTable
|
from app.models.auth import UserTable, RefreshTokenTable
|
||||||
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
||||||
from app.models.favorites import FavoriteTable
|
from app.models.favorites import FavoriteTable
|
||||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
@@ -26,6 +26,42 @@ def create_db_and_tables():
|
|||||||
|
|
||||||
SQLModel.metadata.create_all(engine)
|
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]:
|
def get_session() -> Generator[Session, None, None]:
|
||||||
"""Dependency for getting a database session"""
|
"""Dependency for getting a database session"""
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from .anime_sites import (
|
|||||||
BaseAnimeSite,
|
BaseAnimeSite,
|
||||||
get_anime_site,
|
get_anime_site,
|
||||||
AnimeSamaDownloader,
|
AnimeSamaDownloader,
|
||||||
NekoSamaDownloader,
|
|
||||||
AnimeUltimeDownloader,
|
AnimeUltimeDownloader,
|
||||||
VostfreeDownloader
|
VostfreeDownloader
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
from .base import BaseAnimeSite
|
from .base import BaseAnimeSite
|
||||||
# Import all anime site downloaders
|
# Import all anime site downloaders
|
||||||
from .animesama import AnimeSamaDownloader
|
from .animesama import AnimeSamaDownloader
|
||||||
from .nekosama import NekoSamaDownloader
|
|
||||||
from .animeultime import AnimeUltimeDownloader
|
from .animeultime import AnimeUltimeDownloader
|
||||||
from .vostfree import VostfreeDownloader
|
from .vostfree import VostfreeDownloader
|
||||||
from .frenchmanga import FrenchMangaDownloader
|
from .frenchmanga import FrenchMangaDownloader
|
||||||
@@ -10,7 +9,6 @@ from .frenchmanga import FrenchMangaDownloader
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseAnimeSite",
|
"BaseAnimeSite",
|
||||||
"AnimeSamaDownloader",
|
"AnimeSamaDownloader",
|
||||||
"NekoSamaDownloader",
|
|
||||||
"AnimeUltimeDownloader",
|
"AnimeUltimeDownloader",
|
||||||
"VostfreeDownloader",
|
"VostfreeDownloader",
|
||||||
"FrenchMangaDownloader",
|
"FrenchMangaDownloader",
|
||||||
@@ -22,7 +20,6 @@ def get_anime_site(url: str) -> BaseAnimeSite:
|
|||||||
sites = [
|
sites = [
|
||||||
AnimeSamaDownloader(),
|
AnimeSamaDownloader(),
|
||||||
AnimeUltimeDownloader(),
|
AnimeUltimeDownloader(),
|
||||||
NekoSamaDownloader(),
|
|
||||||
VostfreeDownloader(),
|
VostfreeDownloader(),
|
||||||
FrenchMangaDownloader(),
|
FrenchMangaDownloader(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -490,15 +490,16 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
part.replace("saison", "").replace("Saison", "")
|
part.replace("saison", "").replace("Saison", "")
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
except:
|
except Exception:
|
||||||
pass
|
logger.debug("Could not parse season number from URL part")
|
||||||
|
|
||||||
episode = "01"
|
episode = "01"
|
||||||
if season_num:
|
if season_num:
|
||||||
return f"{anime_name} - S{season_num} - Episode {episode}.mp4"
|
return f"{anime_name} - S{season_num} - Episode {episode}.mp4"
|
||||||
else:
|
else:
|
||||||
return f"{anime_name} - Episode {episode}.mp4"
|
return f"{anime_name} - Episode {episode}.mp4"
|
||||||
except:
|
except Exception:
|
||||||
|
logger.debug("Could not generate filename, using default")
|
||||||
return "Anime - Episode 01.Mp4"
|
return "Anime - Episode 01.Mp4"
|
||||||
|
|
||||||
def _generate_anime_name(self, anime_url: str) -> str:
|
def _generate_anime_name(self, anime_url: str) -> str:
|
||||||
@@ -511,7 +512,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
return parts[i + 1].replace("-", " ").title()
|
return parts[i + 1].replace("-", " ").title()
|
||||||
# Fallback
|
# Fallback
|
||||||
return "Anime"
|
return "Anime"
|
||||||
except:
|
except Exception:
|
||||||
|
logger.debug("Could not extract anime name from URL")
|
||||||
return "Anime"
|
return "Anime"
|
||||||
|
|
||||||
def _extract_season_number(self, anime_url: str) -> int | None:
|
def _extract_season_number(self, anime_url: str) -> int | None:
|
||||||
@@ -522,7 +524,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
if "saison" in part.lower():
|
if "saison" in part.lower():
|
||||||
return int(part.replace("saison", "").replace("Saison", ""))
|
return int(part.replace("saison", "").replace("Saison", ""))
|
||||||
return None
|
return None
|
||||||
except:
|
except Exception:
|
||||||
|
logger.debug("Could not extract season number from URL")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _extract_from_lpayer(
|
async def _extract_from_lpayer(
|
||||||
@@ -744,7 +747,8 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
|
logger.debug("Could not extract video URL from scripts")
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
from .base import BaseAnimeSite
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import re
|
|
||||||
from typing import Optional
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
|
|
||||||
class NekoSamaDownloader(BaseAnimeSite):
|
|
||||||
"""Downloader for neko-sama.org (anime streaming via Gupy)
|
|
||||||
|
|
||||||
NOTE: neko-sama.org now redirects to Gupy, which is a legal streaming search engine.
|
|
||||||
It does NOT host video content - it provides metadata about where to watch legally.
|
|
||||||
This provider can search and get metadata but cannot provide direct download links.
|
|
||||||
"""
|
|
||||||
|
|
||||||
BASE_DOMAINS = [
|
|
||||||
"neko-sama.org",
|
|
||||||
"www.neko-sama.org",
|
|
||||||
"neko-sama.fr",
|
|
||||||
"nekosama.fr",
|
|
||||||
"www.gupy.fr",
|
|
||||||
"gupy.fr",
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.id = "neko-sama"
|
|
||||||
|
|
||||||
def can_handle(self, url: str) -> bool:
|
|
||||||
return any(domain in url.lower() for domain in self.BASE_DOMAINS)
|
|
||||||
|
|
||||||
async def get_download_link(
|
|
||||||
self, url: str, target_filename: Optional[str] = None
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Extract download link from neko-sama URL.
|
|
||||||
|
|
||||||
NOTE: neko-sama.org/Gupy is a legal streaming search engine, NOT a video host.
|
|
||||||
This returns streaming platform information instead of direct video links.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Check if this is a Gupy URL
|
|
||||||
if "gupy.fr" in url or "neko-sama.org" in url:
|
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
|
|
||||||
# Look for streaming platform links
|
|
||||||
streaming_links = []
|
|
||||||
for link in soup.find_all("a", href=True):
|
|
||||||
href = link.get("href", "")
|
|
||||||
if "/out/" in href:
|
|
||||||
text = link.get_text(strip=True)
|
|
||||||
if text and "Regarder" in text:
|
|
||||||
streaming_links.append(f"{text}: {href}")
|
|
||||||
|
|
||||||
if streaming_links:
|
|
||||||
title_elem = soup.find("h1") or soup.find("title")
|
|
||||||
title = (
|
|
||||||
title_elem.get_text(strip=True).split("|")[0].strip()
|
|
||||||
if title_elem
|
|
||||||
else "Unknown"
|
|
||||||
)
|
|
||||||
info = "Available streaming platforms:\n" + "\n".join(
|
|
||||||
streaming_links[:5]
|
|
||||||
)
|
|
||||||
filename = target_filename or f"{title}_streaming_info.txt"
|
|
||||||
return info, filename
|
|
||||||
|
|
||||||
raise Exception(
|
|
||||||
"No streaming links found - Gupy is a legal streaming search, not a video host"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Legacy: try original method for other URLs
|
|
||||||
response = await self.client.get(url, follow_redirects=True)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
|
|
||||||
# Method 1: Look for iframes with video
|
|
||||||
iframes = soup.find_all("iframe")
|
|
||||||
for iframe in iframes:
|
|
||||||
src = iframe.get("src", "")
|
|
||||||
if src and any(p in src for p in ["video", "player", "stream"]):
|
|
||||||
if not src.startswith("http"):
|
|
||||||
src = urljoin(str(response.url), src)
|
|
||||||
filename = self._generate_filename(str(response.url))
|
|
||||||
return src, filename
|
|
||||||
|
|
||||||
# Method 2: Look for video tags
|
|
||||||
videos = soup.find_all("video")
|
|
||||||
for video in videos:
|
|
||||||
src = video.get("src") or video.get("data-src")
|
|
||||||
if src:
|
|
||||||
filename = self._generate_filename(str(response.url))
|
|
||||||
return src, filename
|
|
||||||
|
|
||||||
sources = video.find_all("source")
|
|
||||||
for source in sources:
|
|
||||||
src = source.get("src", "")
|
|
||||||
if src:
|
|
||||||
filename = self._generate_filename(str(response.url))
|
|
||||||
return src, filename
|
|
||||||
|
|
||||||
# Method 3: Look in scripts
|
|
||||||
scripts = soup.find_all("script")
|
|
||||||
for script in scripts:
|
|
||||||
if script.string:
|
|
||||||
patterns = [
|
|
||||||
r'(https?://[^"\'>\s]+\.(?:mp4|m3u8)(?:\?[^"\'>\s]*)?)',
|
|
||||||
r'"url":"([^"]+)"',
|
|
||||||
r'"video":"([^"]+)"',
|
|
||||||
]
|
|
||||||
for pattern in patterns:
|
|
||||||
matches = re.findall(pattern, script.string)
|
|
||||||
for match in matches:
|
|
||||||
match = match.replace("\\/", "/")
|
|
||||||
if any(ext in match for ext in ["mp4", "m3u8"]):
|
|
||||||
filename = self._generate_filename(str(response.url))
|
|
||||||
return match, filename
|
|
||||||
|
|
||||||
raise Exception(
|
|
||||||
"Could not find video link - Neko-Sama/Gupy does not host video content"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception(f"Error extracting NekoSama link: {str(e)}")
|
|
||||||
|
|
||||||
def _generate_filename(self, url: str) -> str:
|
|
||||||
parts = url.split("/")
|
|
||||||
anime_name = "anime"
|
|
||||||
episode = "1"
|
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if "episode" in part.lower():
|
|
||||||
match = re.search(r"episode[-\s]*(\d+)", part, re.I)
|
|
||||||
if match:
|
|
||||||
episode = match.group(1)
|
|
||||||
|
|
||||||
filename = f"{anime_name} - Episode {episode}.mp4"
|
|
||||||
return filename.title()
|
|
||||||
|
|
||||||
async def get_episodes(self, anime_url: str, lang: str = "vostfr") -> list[dict]:
|
|
||||||
"""Get list of episodes for an anime."""
|
|
||||||
try:
|
|
||||||
response = await self.client.get(anime_url)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
|
|
||||||
episodes = []
|
|
||||||
# Try to find episode links
|
|
||||||
episode_links = soup.find_all("a", href=re.compile(r"episode"))
|
|
||||||
|
|
||||||
for link in episode_links:
|
|
||||||
href = link.get("href", "")
|
|
||||||
match = re.search(r"episode[-\s]*(\d+)", href, re.I)
|
|
||||||
if match:
|
|
||||||
episode_num = match.group(1)
|
|
||||||
if not href.startswith("http"):
|
|
||||||
href = urljoin(anime_url, href)
|
|
||||||
|
|
||||||
episodes.append({"episode": episode_num, "url": href})
|
|
||||||
|
|
||||||
# Deduplicate and sort
|
|
||||||
seen = set()
|
|
||||||
unique_episodes = []
|
|
||||||
for ep in episodes:
|
|
||||||
if ep["episode"] not in seen:
|
|
||||||
seen.add(ep["episode"])
|
|
||||||
unique_episodes.append(ep)
|
|
||||||
|
|
||||||
unique_episodes.sort(key=lambda x: int(x["episode"]))
|
|
||||||
return unique_episodes
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def get_anime_metadata(self, anime_url: str) -> dict:
|
|
||||||
"""Extract rich metadata from anime page."""
|
|
||||||
try:
|
|
||||||
print(f"[NEKO-SAMA] Extracting metadata from: {anime_url}")
|
|
||||||
response = await self.client.get(anime_url)
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
"synopsis": None,
|
|
||||||
"genres": [],
|
|
||||||
"rating": None,
|
|
||||||
"release_year": None,
|
|
||||||
"studio": None,
|
|
||||||
"poster_image": None,
|
|
||||||
"banner_image": None,
|
|
||||||
"total_episodes": None,
|
|
||||||
"status": None,
|
|
||||||
"alternative_titles": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract title and year from h1
|
|
||||||
title_elem = soup.find("h1")
|
|
||||||
if title_elem:
|
|
||||||
title_text = title_elem.get_text(strip=True)
|
|
||||||
# Extract year from title like "Naruto (2002)"
|
|
||||||
year_match = re.search(r"\((\d{4})\)", title_text)
|
|
||||||
if year_match:
|
|
||||||
metadata["release_year"] = int(year_match.group(1))
|
|
||||||
|
|
||||||
# Extract synopsis - Gupy shows it as paragraphs
|
|
||||||
synopsis_elem = soup.find("p")
|
|
||||||
if synopsis_elem:
|
|
||||||
text = synopsis_elem.get_text(strip=True)
|
|
||||||
if len(text) > 50:
|
|
||||||
metadata["synopsis"] = text
|
|
||||||
|
|
||||||
# Extract genres from meta tags or links
|
|
||||||
genre_links = soup.find_all("a", href=re.compile(r"serie-|genre|tag"))
|
|
||||||
if genre_links:
|
|
||||||
genres = []
|
|
||||||
for link in genre_links[:5]:
|
|
||||||
text = link.get_text(strip=True)
|
|
||||||
if text and "/" not in text and len(text) < 30:
|
|
||||||
genres.append(text)
|
|
||||||
metadata["genres"] = genres
|
|
||||||
|
|
||||||
# Extract rating from percentage
|
|
||||||
rating_elem = soup.find(string=re.compile(r"\d+(\.\d+)?%"))
|
|
||||||
if rating_elem:
|
|
||||||
match = re.search(r"(\d+(\.\d+)?)%", rating_elem)
|
|
||||||
if match:
|
|
||||||
rating = float(match.group(1)) / 10
|
|
||||||
metadata["rating"] = f"{rating:.1f}/10"
|
|
||||||
|
|
||||||
# Extract poster image
|
|
||||||
poster_elem = soup.find("img", src=re.compile(r"poster|poster"))
|
|
||||||
if poster_elem:
|
|
||||||
metadata["poster_image"] = poster_elem.get("src")
|
|
||||||
|
|
||||||
# Extract episode count from page text
|
|
||||||
page_text = soup.get_text()
|
|
||||||
ep_match = re.search(r"(\d+)\s*episodes?", page_text, re.I)
|
|
||||||
if ep_match:
|
|
||||||
metadata["total_episodes"] = int(ep_match.group(1))
|
|
||||||
|
|
||||||
# Extract studio/director
|
|
||||||
director_elem = soup.find("a", href=re.compile(r"person|réalisé"))
|
|
||||||
if director_elem:
|
|
||||||
metadata["studio"] = director_elem.get_text(strip=True)
|
|
||||||
|
|
||||||
print(f"[NEKO-SAMA] Extracted metadata: {metadata}")
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[NEKO-SAMA] Error extracting metadata: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def search_anime(
|
|
||||||
self, query: str, lang: str = "vostfr", include_metadata: bool = False
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Search for anime on neko-sama (uses Gupy backend)."""
|
|
||||||
try:
|
|
||||||
import time
|
|
||||||
from html import unescape
|
|
||||||
|
|
||||||
start = time.time()
|
|
||||||
print(f"[NEKO-SAMA] Searching for '{query}' ({lang})...")
|
|
||||||
|
|
||||||
# Neko-Sama now uses Gupy - try the direct URL pattern
|
|
||||||
search_slug = query.lower().replace(" ", "-")
|
|
||||||
search_urls = [
|
|
||||||
f"https://www.gupy.fr/series/{search_slug}/",
|
|
||||||
f"https://neko-sama.org/series/{search_slug}/",
|
|
||||||
]
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for search_url in search_urls:
|
|
||||||
response = await self.client.get(search_url, follow_redirects=True)
|
|
||||||
print(f"[NEKO-SAMA] Tried {search_url} -> {response.status_code}")
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
final_url = str(response.url)
|
|
||||||
print(f"[NEKO-SAMA] Found anime at {final_url}")
|
|
||||||
|
|
||||||
# Extract title from page
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
|
||||||
title_elem = soup.find("h1") or soup.find("title")
|
|
||||||
title = (
|
|
||||||
unescape(title_elem.get_text(strip=True))
|
|
||||||
if title_elem
|
|
||||||
else query
|
|
||||||
)
|
|
||||||
# Clean up title
|
|
||||||
title = title.split("|")[0].split("-")[0].strip()
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"title": title,
|
|
||||||
"url": final_url,
|
|
||||||
"cover_image": None,
|
|
||||||
"type": "direct",
|
|
||||||
"metadata": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try to get poster
|
|
||||||
poster = soup.find("img", src=re.compile(r"poster"))
|
|
||||||
if poster:
|
|
||||||
result["cover_image"] = poster.get("src")
|
|
||||||
|
|
||||||
if include_metadata:
|
|
||||||
metadata = await self.get_anime_metadata(final_url)
|
|
||||||
result["metadata"] = metadata
|
|
||||||
|
|
||||||
results.append(result)
|
|
||||||
break
|
|
||||||
|
|
||||||
elapsed = time.time() - start
|
|
||||||
print(
|
|
||||||
f"[NEKO-SAMA] Search completed in {elapsed:.2f}s, found {len(results)} results"
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[NEKO-SAMA] Error: {str(e)}")
|
|
||||||
return []
|
|
||||||
@@ -68,7 +68,7 @@ class DoodStreamDownloader(BaseVideoPlayer):
|
|||||||
fname = self._extract_filename_from_headers(head_resp.headers)
|
fname = self._extract_filename_from_headers(head_resp.headers)
|
||||||
if fname:
|
if fname:
|
||||||
filename = fname
|
filename = fname
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return download_url, filename
|
return download_url, filename
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
|||||||
try:
|
try:
|
||||||
await page.mouse.click(640, 360)
|
await page.mouse.click(640, 360)
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try JavaScript extraction to find video URLs in DOM
|
# Try JavaScript extraction to find video URLs in DOM
|
||||||
@@ -235,7 +235,7 @@ class LpayerDownloader(BaseVideoPlayer):
|
|||||||
if browser:
|
if browser:
|
||||||
try:
|
try:
|
||||||
await browser.close()
|
await browser.close()
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
"""Extract video URL using Playwright to render JavaScript"""
|
"""Extract video URL using Playwright to render JavaScript"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class OneuploadDownloader(BaseVideoPlayer):
|
|||||||
await element.click()
|
await element.click()
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
break
|
break
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ONEUPLOAD] Play button interaction: {e}")
|
print(f"[ONEUPLOAD] Play button interaction: {e}")
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class RapidFileDownloader(BaseVideoPlayer):
|
|||||||
filename = fname
|
filename = fname
|
||||||
else:
|
else:
|
||||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||||
except:
|
except Exception:
|
||||||
filename = download_url.split('/')[-1] or "rapidfile_download"
|
filename = download_url.split('/')[-1] or "rapidfile_download"
|
||||||
|
|
||||||
return download_url, filename
|
return download_url, filename
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class SmoothpreDownloader(BaseVideoPlayer):
|
|||||||
await element.click()
|
await element.click()
|
||||||
await asyncio.sleep(2)
|
await asyncio.sleep(2)
|
||||||
break
|
break
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[SMOOTHPRE] Play button interaction: {e}")
|
print(f"[SMOOTHPRE] Play button interaction: {e}")
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class UnFichierDownloader(BaseVideoPlayer):
|
|||||||
if not filename:
|
if not filename:
|
||||||
filename = href.split('/')[-1] or "downloaded_file"
|
filename = href.split('/')[-1] or "downloaded_file"
|
||||||
return href, filename
|
return href, filename
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raise Exception("Could not find download link on page")
|
raise Exception("Could not find download link on page")
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class VidMolyDownloader(BaseVideoPlayer):
|
|||||||
await element.click()
|
await element.click()
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
break
|
break
|
||||||
except:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[VIDMOLY] Play button interaction: {e}")
|
print(f"[VIDMOLY] Play button interaction: {e}")
|
||||||
|
|||||||
+32
-16
@@ -27,11 +27,15 @@ class FavoritesManager:
|
|||||||
url: str,
|
url: str,
|
||||||
provider: str,
|
provider: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None,
|
||||||
|
user_id: str = "default"
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Add an anime to favorites"""
|
"""Add an anime to favorites"""
|
||||||
with Session(engine) as session:
|
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()
|
existing = session.exec(statement).first()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -53,17 +57,21 @@ class FavoritesManager:
|
|||||||
url=url,
|
url=url,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
anime_metadata=metadata or {},
|
anime_metadata=metadata or {},
|
||||||
poster_url=poster_url
|
poster_url=poster_url,
|
||||||
|
user_id=user_id
|
||||||
)
|
)
|
||||||
session.add(fav)
|
session.add(fav)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(fav)
|
session.refresh(fav)
|
||||||
return self._to_dict(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"""
|
"""Remove an anime from favorites"""
|
||||||
with Session(engine) as session:
|
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()
|
existing = session.exec(statement).first()
|
||||||
if existing:
|
if existing:
|
||||||
session.delete(existing)
|
session.delete(existing)
|
||||||
@@ -71,10 +79,13 @@ class FavoritesManager:
|
|||||||
return True
|
return True
|
||||||
return False
|
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"""
|
"""Get a specific favorite by ID"""
|
||||||
with Session(engine) as session:
|
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()
|
existing = session.exec(statement).first()
|
||||||
if existing:
|
if existing:
|
||||||
return self._to_dict(existing)
|
return self._to_dict(existing)
|
||||||
@@ -82,6 +93,7 @@ class FavoritesManager:
|
|||||||
|
|
||||||
async def list_favorites(
|
async def list_favorites(
|
||||||
self,
|
self,
|
||||||
|
user_id: str = "default",
|
||||||
sort_by: str = "created_at",
|
sort_by: str = "created_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
filter_provider: Optional[str] = None,
|
filter_provider: Optional[str] = None,
|
||||||
@@ -89,7 +101,7 @@ class FavoritesManager:
|
|||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""List all favorites with optional sorting and filtering"""
|
"""List all favorites with optional sorting and filtering"""
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
statement = select(FavoriteTable)
|
statement = select(FavoriteTable).where(FavoriteTable.user_id == user_id)
|
||||||
|
|
||||||
if filter_provider:
|
if filter_provider:
|
||||||
statement = statement.where(FavoriteTable.provider == filter_provider)
|
statement = statement.where(FavoriteTable.provider == filter_provider)
|
||||||
@@ -123,10 +135,13 @@ class FavoritesManager:
|
|||||||
|
|
||||||
return favorites
|
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"""
|
"""Check if an anime is in favorites"""
|
||||||
with Session(engine) as session:
|
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
|
return session.exec(statement).first() is not None
|
||||||
|
|
||||||
async def toggle_favorite(
|
async def toggle_favorite(
|
||||||
@@ -136,21 +151,22 @@ class FavoritesManager:
|
|||||||
url: str,
|
url: str,
|
||||||
provider: str,
|
provider: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
poster_url: Optional[str] = None
|
poster_url: Optional[str] = None,
|
||||||
|
user_id: str = "default"
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Toggle an anime in favorites (add if not exists, remove if exists)"""
|
"""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:
|
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}
|
return {"action": "removed", "anime_id": anime_id}
|
||||||
else:
|
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}
|
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"""
|
"""Get statistics about favorites"""
|
||||||
favorites = await self.list_favorites()
|
favorites = await self.list_favorites(user_id=user_id)
|
||||||
total = len(favorites)
|
total = len(favorites)
|
||||||
|
|
||||||
# Count by provider
|
# Count by provider
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class UserBase(SQLModel):
|
|||||||
email: Optional[str] = Field(default=None, index=True)
|
email: Optional[str] = Field(default=None, index=True)
|
||||||
full_name: Optional[str] = None
|
full_name: Optional[str] = None
|
||||||
is_active: bool = Field(default=True)
|
is_active: bool = Field(default=True)
|
||||||
|
is_admin: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
class UserTable(UserBase, table=True):
|
class UserTable(UserBase, table=True):
|
||||||
@@ -61,5 +62,24 @@ class UserInDB(User):
|
|||||||
"""Schema for user stored in database (with hashed password)"""
|
"""Schema for user stored in database (with hashed password)"""
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenTable(SQLModel, table=True):
|
||||||
|
"""Database table for refresh tokens"""
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
token_id: str = Field(index=True, unique=True)
|
||||||
|
username: str = Field(index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
revoked: bool = Field(default=False)
|
||||||
|
revoked_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
||||||
from .watchlist import WatchlistItemTable
|
from .watchlist import WatchlistItemTable
|
||||||
|
|||||||
+24
-1
@@ -15,11 +15,24 @@ class AppSettingsBase(SQLModel):
|
|||||||
# Store list of disabled providers as a JSON string
|
# Store list of disabled providers as a JSON string
|
||||||
disabled_providers_json: str = Field(default="[]", sa_column=Column(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
|
@property
|
||||||
def disabled_providers(self) -> List[str]:
|
def disabled_providers(self) -> List[str]:
|
||||||
try:
|
try:
|
||||||
return json.loads(self.disabled_providers_json or "[]")
|
return json.loads(self.disabled_providers_json or "[]")
|
||||||
except:
|
except json.JSONDecodeError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@disabled_providers.setter
|
@disabled_providers.setter
|
||||||
@@ -46,6 +59,11 @@ class AppSettings(BaseModel):
|
|||||||
default_lang: str = "vostfr"
|
default_lang: str = "vostfr"
|
||||||
theme: str = "dark"
|
theme: str = "dark"
|
||||||
disabled_providers: List[str] = []
|
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:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -56,3 +74,8 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
default_lang: Optional[str] = None
|
default_lang: Optional[str] = None
|
||||||
theme: Optional[str] = None
|
theme: Optional[str] = None
|
||||||
disabled_providers: Optional[List[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)"""
|
"""Mapping between Sonarr series and anime providers (API model)"""
|
||||||
sonarr_series_id: int
|
sonarr_series_id: int
|
||||||
sonarr_title: str
|
sonarr_title: str
|
||||||
anime_provider: str # 'anime-sama', 'neko-sama', etc.
|
anime_provider: str # 'anime-sama', 'anime-ultime', 'vostfree', 'french-manga', etc.
|
||||||
anime_url: str
|
anime_url: str
|
||||||
anime_title: str
|
anime_title: str
|
||||||
lang: str = "vostfr"
|
lang: str = "vostfr"
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ ANIME_PROVIDERS = {
|
|||||||
"icon": "▶️",
|
"icon": "▶️",
|
||||||
"color": "#00ff88",
|
"color": "#00ff88",
|
||||||
},
|
},
|
||||||
"neko-sama": {
|
|
||||||
"name": "Neko-Sama",
|
|
||||||
"domains": ["neko-sama.fr", "nekosama.fr", "www.neko-sama.fr"],
|
|
||||||
"url_pattern": "https://neko-sama.fr/anime/{slug}",
|
|
||||||
"icon": "🐱",
|
|
||||||
"color": "#ff6b6b",
|
|
||||||
},
|
|
||||||
"vostfree": {
|
"vostfree": {
|
||||||
"name": "Vostfree",
|
"name": "Vostfree",
|
||||||
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
"domains": ["vostfree.tv", "www.vostfree.tv"],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from datetime import datetime
|
|||||||
from app.downloaders.generic_scraper import GenericScraper
|
from app.downloaders.generic_scraper import GenericScraper
|
||||||
from app.downloaders.anime_sites import (
|
from app.downloaders.anime_sites import (
|
||||||
AnimeSamaDownloader,
|
AnimeSamaDownloader,
|
||||||
NekoSamaDownloader,
|
|
||||||
AnimeUltimeDownloader,
|
AnimeUltimeDownloader,
|
||||||
VostfreeDownloader,
|
VostfreeDownloader,
|
||||||
FrenchMangaDownloader,
|
FrenchMangaDownloader,
|
||||||
@@ -58,7 +57,6 @@ class ProvidersManager:
|
|||||||
"""Load hardcoded Python providers"""
|
"""Load hardcoded Python providers"""
|
||||||
provider_classes = [
|
provider_classes = [
|
||||||
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
|
("anime-sama", AnimeSamaDownloader, ANIME_PROVIDERS),
|
||||||
("neko-sama", NekoSamaDownloader, ANIME_PROVIDERS),
|
|
||||||
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
("anime-ultime", AnimeUltimeDownloader, ANIME_PROVIDERS),
|
||||||
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
("vostfree", VostfreeDownloader, ANIME_PROVIDERS),
|
||||||
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
("french-manga", FrenchMangaDownloader, ANIME_PROVIDERS),
|
||||||
@@ -130,10 +128,23 @@ class ProvidersManager:
|
|||||||
return 200 <= response.status_code < 400
|
return 200 <= response.status_code < 400
|
||||||
elif hasattr(scraper, "search_anime"):
|
elif hasattr(scraper, "search_anime"):
|
||||||
results = await scraper.search_anime("One Piece", lang="vostfr")
|
results = await scraper.search_anime("One Piece", lang="vostfr")
|
||||||
return len(results) > 0
|
# Validate that results actually match the query
|
||||||
|
if not results:
|
||||||
|
return False
|
||||||
|
for r in results:
|
||||||
|
title = (r.get("title") or "").lower()
|
||||||
|
if "one" in title or "piece" in title:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
elif hasattr(scraper, "search"):
|
elif hasattr(scraper, "search"):
|
||||||
results = await scraper.search("One Piece")
|
results = await scraper.search("One Piece")
|
||||||
return len(results) > 0
|
if not results:
|
||||||
|
return False
|
||||||
|
for r in results:
|
||||||
|
title = (r.get("title") or "").lower()
|
||||||
|
if "one" in title or "piece" in title:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from app.routers.router_player import router as player_router
|
|||||||
from .router_static import router as static_router
|
from .router_static import router as static_router
|
||||||
from .router_root import router as root_router
|
from .router_root import router as root_router
|
||||||
from .router_settings import router as settings_router
|
from .router_settings import router as settings_router
|
||||||
|
from .router_admin import router as admin_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"auth_router",
|
"auth_router",
|
||||||
@@ -26,5 +27,6 @@ __all__ = [
|
|||||||
"static_router",
|
"static_router",
|
||||||
"root_router",
|
"root_router",
|
||||||
"settings_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 (
|
from app.downloaders import (
|
||||||
AnimeSamaDownloader,
|
AnimeSamaDownloader,
|
||||||
AnimeUltimeDownloader,
|
AnimeUltimeDownloader,
|
||||||
NekoSamaDownloader,
|
|
||||||
VostfreeDownloader,
|
VostfreeDownloader,
|
||||||
ZoneTelechargementDownloader,
|
ZoneTelechargementDownloader,
|
||||||
get_downloader,
|
get_downloader,
|
||||||
@@ -59,12 +58,10 @@ async def get_providers_health():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/providers/health/check")
|
@router.post("/providers/health/check")
|
||||||
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
async def trigger_providers_health_check():
|
||||||
"""Trigger a manual health check of all providers in the background"""
|
"""Trigger a manual health check of all providers"""
|
||||||
from app.auto_download_scheduler import auto_download_scheduler
|
await providers_manager.check_all_health()
|
||||||
|
return {"status": "ok", "providers": providers_manager.get_all_status()}
|
||||||
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
|
||||||
return {"status": "Health check triggered in background"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_manager() -> DownloadManager:
|
def get_download_manager() -> DownloadManager:
|
||||||
@@ -136,7 +133,6 @@ async def search_anime_unified(
|
|||||||
# Legacy providers (already included in providers_manager, but keep for fallback)
|
# Legacy providers (already included in providers_manager, but keep for fallback)
|
||||||
legacy_downloaders = {
|
legacy_downloaders = {
|
||||||
"anime-ultime": AnimeUltimeDownloader(),
|
"anime-ultime": AnimeUltimeDownloader(),
|
||||||
"neko-sama": NekoSamaDownloader(),
|
|
||||||
"vostfree": VostfreeDownloader(),
|
"vostfree": VostfreeDownloader(),
|
||||||
}
|
}
|
||||||
for pid, dl in legacy_downloaders.items():
|
for pid, dl in legacy_downloaders.items():
|
||||||
@@ -174,10 +170,34 @@ async def search_anime_unified(
|
|||||||
|
|
||||||
if url and url not in seen_urls:
|
if url and url not in seen_urls:
|
||||||
seen_urls.add(url)
|
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
|
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
|
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)
|
results[pid].append(item_dict)
|
||||||
|
|
||||||
# Prepare enrichment task for top 15 results per provider
|
# Prepare enrichment task for top 15 results per provider
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
Download management routes for Ohm Stream Downloader API.
|
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.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
|
||||||
from app.download_manager import DownloadManager
|
from app.download_manager import DownloadManager
|
||||||
from app.models import DownloadRequest
|
from app.models import DownloadRequest, DownloadStatus
|
||||||
from app.routers.router_auth import get_current_user_from_token
|
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"])
|
router = APIRouter(prefix="/api/downloads", tags=["downloads"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -24,13 +28,21 @@ async def get_downloads(
|
|||||||
request: Request,
|
request: Request,
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
download_manager: DownloadManager = Depends(get_download_manager),
|
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."""
|
"""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")
|
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:
|
if html or is_htmx:
|
||||||
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
print(f"[DOWNLOADS] HTML Request. Found {len(tasks)} tasks.")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -56,8 +68,12 @@ async def create_download(
|
|||||||
async def get_download_status(
|
async def get_download_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
download_manager: DownloadManager = Depends(get_download_manager),
|
download_manager: DownloadManager = Depends(get_download_manager),
|
||||||
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
):
|
):
|
||||||
"""Get status of a specific download task"""
|
"""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)
|
task = download_manager.get_task(task_id)
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
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")
|
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")
|
@router.post("/cleanup")
|
||||||
async def cleanup_completed(
|
async def cleanup_completed(
|
||||||
download_manager: DownloadManager = Depends(get_download_manager),
|
download_manager: DownloadManager = Depends(get_download_manager),
|
||||||
|
|||||||
@@ -2,24 +2,42 @@
|
|||||||
Favorites management routes for Ohm Stream Downloader API.
|
Favorites management routes for Ohm Stream Downloader API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from typing import Optional
|
||||||
from fastapi.requests import Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.favorites import get_favorites_manager
|
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"])
|
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_favorites(
|
async def list_favorites(
|
||||||
|
request: Request,
|
||||||
sort_by: str = "created_at",
|
sort_by: str = "created_at",
|
||||||
order: str = "desc",
|
order: str = "desc",
|
||||||
filter_provider: str = None,
|
filter_provider: Optional[str] = None,
|
||||||
filter_genre: 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"""
|
"""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()
|
fav_manager = get_favorites_manager()
|
||||||
favorites = await fav_manager.list_favorites(
|
favorites = await fav_manager.list_favorites(
|
||||||
|
user_id=current_user.id,
|
||||||
sort_by=sort_by,
|
sort_by=sort_by,
|
||||||
order=order,
|
order=order,
|
||||||
filter_provider=filter_provider,
|
filter_provider=filter_provider,
|
||||||
@@ -38,7 +56,11 @@ async def list_favorites(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@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"""
|
"""Add an anime to favorites"""
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
|
|
||||||
@@ -51,6 +73,7 @@ async def add_favorite(request: Request):
|
|||||||
|
|
||||||
fav_manager = get_favorites_manager()
|
fav_manager = get_favorites_manager()
|
||||||
favorite = await fav_manager.add_favorite(
|
favorite = await fav_manager.add_favorite(
|
||||||
|
user_id=current_user.id,
|
||||||
anime_id=data["anime_id"],
|
anime_id=data["anime_id"],
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
url=data["url"],
|
url=data["url"],
|
||||||
@@ -59,34 +82,45 @@ async def add_favorite(request: Request):
|
|||||||
poster_url=data.get("poster_url"),
|
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}
|
return {"status": "added", "favorite": favorite}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{anime_id}")
|
@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"""
|
"""Remove an anime from favorites"""
|
||||||
fav_manager = get_favorites_manager()
|
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:
|
if not removed:
|
||||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
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}
|
return {"status": "removed", "anime_id": anime_id}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@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"""
|
"""Get statistics about favorites"""
|
||||||
fav_manager = get_favorites_manager()
|
fav_manager = get_favorites_manager()
|
||||||
stats = await fav_manager.get_stats()
|
stats = await fav_manager.get_stats(user_id=current_user.id)
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{anime_id}")
|
@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"""
|
"""Get details of a specific favorite anime"""
|
||||||
fav_manager = get_favorites_manager()
|
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:
|
if not favorite:
|
||||||
raise HTTPException(status_code=404, detail="Favorite not found")
|
raise HTTPException(status_code=404, detail="Favorite not found")
|
||||||
@@ -95,7 +129,11 @@ async def get_favorite(anime_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/toggle")
|
@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"""
|
"""Toggle an anime in favorites"""
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
|
|
||||||
@@ -108,6 +146,7 @@ async def toggle_favorite(request: Request):
|
|||||||
|
|
||||||
fav_manager = get_favorites_manager()
|
fav_manager = get_favorites_manager()
|
||||||
result = await fav_manager.toggle_favorite(
|
result = await fav_manager.toggle_favorite(
|
||||||
|
user_id=current_user.id,
|
||||||
anime_id=data["anime_id"],
|
anime_id=data["anime_id"],
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
url=data["url"],
|
url=data["url"],
|
||||||
@@ -116,4 +155,9 @@ async def toggle_favorite(request: Request):
|
|||||||
poster_url=data.get("poster_url"),
|
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
|
return result
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import hashlib
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
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 fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from app.recommendation_engine import RecommendationEngine
|
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"])
|
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -26,14 +28,30 @@ async def get_recommendations(
|
|||||||
request: Request,
|
request: Request,
|
||||||
limit: int = 15,
|
limit: int = 15,
|
||||||
html: bool = Query(False),
|
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"""
|
"""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")
|
engine = RecommendationEngine(download_dir="downloads")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
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(
|
return templates.TemplateResponse(
|
||||||
"components/recommendations_list.html",
|
"components/recommendations_list.html",
|
||||||
{"request": request, "recommendations": recommendations}
|
{"request": request, "recommendations": recommendations}
|
||||||
@@ -53,6 +71,7 @@ async def get_latest_releases(
|
|||||||
request: Request,
|
request: Request,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
|
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||||
):
|
):
|
||||||
"""Get latest anime releases"""
|
"""Get latest anime releases"""
|
||||||
from app.recommendations import get_latest_releases_with_info
|
from app.recommendations import get_latest_releases_with_info
|
||||||
@@ -60,6 +79,10 @@ async def get_latest_releases(
|
|||||||
try:
|
try:
|
||||||
releases = await get_latest_releases_with_info(limit=limit)
|
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"):
|
if html or request.headers.get("HX-Request"):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/releases_list.html",
|
"components/releases_list.html",
|
||||||
@@ -140,7 +163,9 @@ async def get_top_anime(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats/downloads")
|
@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"""
|
"""Get download statistics and preferences"""
|
||||||
engine = RecommendationEngine(download_dir="downloads")
|
engine = RecommendationEngine(download_dir="downloads")
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ async def get_settings(
|
|||||||
default_lang=settings_obj.default_lang,
|
default_lang=settings_obj.default_lang,
|
||||||
theme=settings_obj.theme,
|
theme=settings_obj.theme,
|
||||||
disabled_providers=settings_obj.disabled_providers,
|
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
|
settings_obj.theme = update_data.theme
|
||||||
if update_data.disabled_providers is not None:
|
if update_data.disabled_providers is not None:
|
||||||
settings_obj.disabled_providers = update_data.disabled_providers
|
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.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -68,14 +68,19 @@ async def test_sonarr_webhook(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sonarr/config")
|
@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"""
|
"""Get Sonarr webhook configuration"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
return sonarr_handler.get_config()
|
return sonarr_handler.get_config()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/sonarr/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"""
|
"""Update Sonarr webhook configuration"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
try:
|
try:
|
||||||
@@ -87,14 +92,19 @@ async def update_sonarr_config(config: SonarrConfig):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/sonarr/mappings")
|
@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"""
|
"""Get all Sonarr to anime mappings"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
return sonarr_handler.get_mappings()
|
return sonarr_handler.get_mappings()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sonarr/mappings/{series_id}")
|
@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"""
|
"""Get specific mapping by Sonarr series ID"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
mapping = sonarr_handler.get_mapping(series_id)
|
mapping = sonarr_handler.get_mapping(series_id)
|
||||||
@@ -104,7 +114,10 @@ async def get_sonarr_mapping(series_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/sonarr/mappings")
|
@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"""
|
"""Create or update a Sonarr to anime mapping"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
try:
|
try:
|
||||||
@@ -116,7 +129,10 @@ async def create_sonarr_mapping(mapping: SonarrMapping):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/sonarr/mappings/{series_id}")
|
@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"""
|
"""Delete a Sonarr mapping"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
success = sonarr_handler.delete_mapping(series_id)
|
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"),
|
q: str = Query(..., description="Series title to search"),
|
||||||
provider: str = Query("anime-sama", description="Anime provider to search"),
|
provider: str = Query("anime-sama", description="Anime provider to search"),
|
||||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
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"""
|
"""Search for anime on providers to create Sonarr mappings"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
@@ -152,6 +169,7 @@ async def get_anime_episodes(
|
|||||||
url: str = Query(..., description="Anime URL from provider"),
|
url: str = Query(..., description="Anime URL from provider"),
|
||||||
provider: str = Query("anime-sama", description="Anime provider"),
|
provider: str = Query("anime-sama", description="Anime provider"),
|
||||||
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
lang: str = Query("vostfr", description="Language (vostfr, vf)"),
|
||||||
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Get episode list for anime"""
|
"""Get episode list for anime"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
@@ -174,6 +192,7 @@ async def suggest_anime_mapping(
|
|||||||
sonarr_title: str = Query(..., description="Sonarr series title"),
|
sonarr_title: str = Query(..., description="Sonarr series title"),
|
||||||
provider: str = Query("anime-sama", description="Anime provider"),
|
provider: str = Query("anime-sama", description="Anime provider"),
|
||||||
lang: str = Query("vostfr", description="Language"),
|
lang: str = Query("vostfr", description="Language"),
|
||||||
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Suggest possible anime mappings based on Sonarr series title"""
|
"""Suggest possible anime mappings based on Sonarr series title"""
|
||||||
sonarr_handler = get_sonarr_handler()
|
sonarr_handler = get_sonarr_handler()
|
||||||
@@ -195,6 +214,7 @@ async def suggest_anime_mapping(
|
|||||||
async def trigger_sonarr_download(
|
async def trigger_sonarr_download(
|
||||||
request: SonarrDownloadRequest,
|
request: SonarrDownloadRequest,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Manually trigger a download based on Sonarr information"""
|
"""Manually trigger a download based on Sonarr information"""
|
||||||
from main import download_manager
|
from main import download_manager
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ async def add_to_watchlist(
|
|||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Add an anime to the watchlist"""
|
"""Add an anime to the watchlist"""
|
||||||
from main import watchlist_manager
|
from app.watchlist import watchlist_manager
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = watchlist_manager.get_by_anime_url(
|
existing = watchlist_manager.get_by_anime_url(
|
||||||
@@ -81,7 +81,7 @@ async def get_watchlist(
|
|||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
current_user: Optional[User] = Depends(get_optional_user),
|
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")
|
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),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Get global watchlist settings"""
|
"""Get global watchlist settings"""
|
||||||
from main import watchlist_manager
|
from app.watchlist import watchlist_manager
|
||||||
|
|
||||||
return watchlist_manager.get_settings()
|
return watchlist_manager.get_settings()
|
||||||
|
|
||||||
@@ -120,7 +120,8 @@ async def update_watchlist_settings(
|
|||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Update global watchlist settings"""
|
"""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:
|
try:
|
||||||
updated_settings = watchlist_manager.update_settings(settings)
|
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),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Get a specific watchlist item"""
|
"""Get a specific watchlist item"""
|
||||||
from main import watchlist_manager
|
from app.watchlist import watchlist_manager
|
||||||
|
|
||||||
item = watchlist_manager.get_by_id(item_id)
|
item = watchlist_manager.get_by_id(item_id)
|
||||||
if not item or item.user_id != current_user.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),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Update a watchlist item"""
|
"""Update a watchlist item"""
|
||||||
from main import watchlist_manager
|
from app.watchlist import watchlist_manager
|
||||||
|
|
||||||
item = watchlist_manager.get_by_id(item_id)
|
item = watchlist_manager.get_by_id(item_id)
|
||||||
if not item or item.user_id != current_user.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),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Remove an anime from the watchlist"""
|
"""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)
|
item = watchlist_manager.get_by_id(item_id)
|
||||||
if not item or item.user_id != current_user.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),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Trigger an immediate check for new episodes"""
|
"""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)
|
background_tasks.add_task(auto_download_scheduler.trigger_check_now)
|
||||||
response.headers["HX-Trigger"] = json.dumps(
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
@@ -239,6 +240,6 @@ async def get_watchlist_stats(
|
|||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
):
|
):
|
||||||
"""Get watchlist statistics for the user"""
|
"""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)
|
return watchlist_manager.get_stats(current_user.id)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.models.sonarr import (
|
|||||||
SonarrDownloadRequest
|
SonarrDownloadRequest
|
||||||
)
|
)
|
||||||
from app.models import DownloadRequest
|
from app.models import DownloadRequest
|
||||||
from app.downloaders import get_downloader, AnimeSamaDownloader, NekoSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
from app.downloaders import get_downloader, AnimeSamaDownloader, AnimeUltimeDownloader, VostfreeDownloader
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -205,7 +205,6 @@ class SonarrHandler:
|
|||||||
"""Get downloader instance for provider"""
|
"""Get downloader instance for provider"""
|
||||||
providers = {
|
providers = {
|
||||||
"anime-sama": AnimeSamaDownloader(),
|
"anime-sama": AnimeSamaDownloader(),
|
||||||
"neko-sama": NekoSamaDownloader(),
|
|
||||||
"anime-ultime": AnimeUltimeDownloader(),
|
"anime-ultime": AnimeUltimeDownloader(),
|
||||||
"vostfree": VostfreeDownloader()
|
"vostfree": VostfreeDownloader()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,380 +0,0 @@
|
|||||||
{
|
|
||||||
"ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "ENN3p91PrQd0kyV3C_n4l6sGthyZgmDM77ua-VibJKU",
|
|
||||||
"created_at": "2026-03-06T22:01:01.865697",
|
|
||||||
"expires_at": "2026-04-05T22:01:01.865619"
|
|
||||||
},
|
|
||||||
"vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "vJGtD3bvFMXp6WRH7sBdQF7BlV9ioy2Ah8OcjiBtQTs",
|
|
||||||
"created_at": "2026-03-06T22:03:55.154118",
|
|
||||||
"expires_at": "2026-04-05T22:03:55.154019"
|
|
||||||
},
|
|
||||||
"fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "fOLNOTkf4nhC25NgLBNKutmaLqlZkg9wuXaP320mE-o",
|
|
||||||
"created_at": "2026-03-06T22:06:48.751392",
|
|
||||||
"expires_at": "2026-04-05T22:06:48.751237"
|
|
||||||
},
|
|
||||||
"OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "OYOLFjWqvNMFKAOIfuoEjSrLIKQ8MuuZckGs3Zib0WU",
|
|
||||||
"created_at": "2026-03-06T22:06:48.753454",
|
|
||||||
"expires_at": "2026-04-05T22:06:48.753349"
|
|
||||||
},
|
|
||||||
"pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "pxx-nUMwbKF3fIYtdRKLd4kemUkfOfDyh5IByuOY4iY",
|
|
||||||
"created_at": "2026-03-06T22:06:48.756403",
|
|
||||||
"expires_at": "2026-04-05T22:06:48.756301"
|
|
||||||
},
|
|
||||||
"-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "-lGSkRZ8uCFRTqvL9GRP7Fn1BaG7Wju3zXFKB3nu75o",
|
|
||||||
"created_at": "2026-03-06T22:06:48.757822",
|
|
||||||
"expires_at": "2026-04-05T22:06:48.757728"
|
|
||||||
},
|
|
||||||
"x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "x5is2S9FWTftUjmbwfuQpp3dL9Svxb7I9n0hmf1Gp0g",
|
|
||||||
"created_at": "2026-03-06T22:06:48.759219",
|
|
||||||
"expires_at": "2026-04-05T22:06:48.759121"
|
|
||||||
},
|
|
||||||
"E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "E5l5iq2i-PY5eBsh4KUMbvZnqTThpnzZacp0jJPSkDw",
|
|
||||||
"created_at": "2026-03-06T22:07:03.414591",
|
|
||||||
"expires_at": "2026-04-05T22:07:03.414466"
|
|
||||||
},
|
|
||||||
"XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "XfiKphKpqwI-nIaEY_kIA66esSkGHWRCxH-RQPr3jG8",
|
|
||||||
"created_at": "2026-03-06T22:07:27.981118",
|
|
||||||
"expires_at": "2026-04-05T22:07:27.980974"
|
|
||||||
},
|
|
||||||
"YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "YO1zHyOWn2xSzyT0QqiQRuXmlKz8QNIaxncndrYtPWQ",
|
|
||||||
"created_at": "2026-03-06T22:07:27.982903",
|
|
||||||
"expires_at": "2026-04-05T22:07:27.982803"
|
|
||||||
},
|
|
||||||
"OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "OBNRf9sSVBfJh2317mfHwsVeBwoIONWUafcFL6w-5Ek",
|
|
||||||
"created_at": "2026-03-06T22:07:27.985521",
|
|
||||||
"expires_at": "2026-04-05T22:07:27.985410"
|
|
||||||
},
|
|
||||||
"9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "9Tt2x8y4Gu2GekLuSs8Q_oWsYr1XvmD5zZIA9O1aE3s",
|
|
||||||
"created_at": "2026-03-06T22:07:27.986984",
|
|
||||||
"expires_at": "2026-04-05T22:07:27.986883"
|
|
||||||
},
|
|
||||||
"vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "vQGdyZKZTmGU0CsxE60KjlErK8WXpoD33rGqupeQ8MI",
|
|
||||||
"created_at": "2026-03-06T22:07:27.988625",
|
|
||||||
"expires_at": "2026-04-05T22:07:27.988525"
|
|
||||||
},
|
|
||||||
"qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "qSfC9YNqMDfd9-EyYsaFuwzOjRLiqUy_7Lvk9_KuIqM",
|
|
||||||
"created_at": "2026-03-06T22:07:33.163399",
|
|
||||||
"expires_at": "2026-04-05T22:07:33.163230"
|
|
||||||
},
|
|
||||||
"8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "8wLZuq1D4_XtCfUk_zET95vT-wMyf6sG4fuMv8Mfke8",
|
|
||||||
"created_at": "2026-03-06T22:07:33.165736",
|
|
||||||
"expires_at": "2026-04-05T22:07:33.165608"
|
|
||||||
},
|
|
||||||
"jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "jEQy3kLp6POI_R-CucHx2Vtb02LofZRHqqdaK779iGE",
|
|
||||||
"created_at": "2026-03-06T22:07:33.168776",
|
|
||||||
"expires_at": "2026-04-05T22:07:33.168669"
|
|
||||||
},
|
|
||||||
"XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "XZxP53qg7UXYpBv7jilxrrjMzCsKeutcz26y5JdgyZA",
|
|
||||||
"created_at": "2026-03-06T22:07:33.170429",
|
|
||||||
"expires_at": "2026-04-05T22:07:33.170321"
|
|
||||||
},
|
|
||||||
"Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "Bw-7nTNKf08sDNk2kvyNVUihAXPRK2Nx9dEpI-Ih1Og",
|
|
||||||
"created_at": "2026-03-06T22:07:33.172080",
|
|
||||||
"expires_at": "2026-04-05T22:07:33.171974"
|
|
||||||
},
|
|
||||||
"N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "N_zKMbptJrms8_X14cmqkqf-zfZZnh2x7geNgqxvWMY",
|
|
||||||
"created_at": "2026-03-06T22:08:54.290837",
|
|
||||||
"expires_at": "2026-04-05T22:08:54.290674"
|
|
||||||
},
|
|
||||||
"DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "DgR8zvaP24tLExKaTs2-yAvgc7UKX-eebF5f4Vd2tpQ",
|
|
||||||
"created_at": "2026-03-06T22:08:54.292851",
|
|
||||||
"expires_at": "2026-04-05T22:08:54.292732"
|
|
||||||
},
|
|
||||||
"MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "MbJtb1GcO1BDU10f9L0uhdJtoqJ-TuqVFoGvLAp8Sy4",
|
|
||||||
"created_at": "2026-03-06T22:08:54.295788",
|
|
||||||
"expires_at": "2026-04-05T22:08:54.295675"
|
|
||||||
},
|
|
||||||
"3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "3jYUIjL4CawzMUeoGMX4psEOBptQqGFFm7DNIQb_oWM",
|
|
||||||
"created_at": "2026-03-06T22:08:54.297426",
|
|
||||||
"expires_at": "2026-04-05T22:08:54.297325"
|
|
||||||
},
|
|
||||||
"_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "_hjtqlEx-bIXW7fl9mkRtQYEbDzIImZL31IhtQUPit0",
|
|
||||||
"created_at": "2026-03-06T22:08:54.299268",
|
|
||||||
"expires_at": "2026-04-05T22:08:54.299159"
|
|
||||||
},
|
|
||||||
"pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "pYGDEjV-snw-Ua9r1Kzu3Eq7t-Kr2VVWjGhBIrJFmj4",
|
|
||||||
"created_at": "2026-03-06T22:09:24.318148",
|
|
||||||
"expires_at": "2026-04-05T22:09:24.317977"
|
|
||||||
},
|
|
||||||
"3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "3nLTLes3RLl4wuB_EvKHVy37Prusdp334Cev-xroWCc",
|
|
||||||
"created_at": "2026-03-06T22:09:24.320197",
|
|
||||||
"expires_at": "2026-04-05T22:09:24.320080"
|
|
||||||
},
|
|
||||||
"U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "U-5pZ1bs0mTPQO5EetauFC1Tt9nvksMdy6o7RTAo9v0",
|
|
||||||
"created_at": "2026-03-06T22:09:24.323151",
|
|
||||||
"expires_at": "2026-04-05T22:09:24.323044"
|
|
||||||
},
|
|
||||||
"ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "ZTrkUSbJH8Glt7IIQUddUtAc2Aj4Y2R2h8FIAPEYH70",
|
|
||||||
"created_at": "2026-03-06T22:09:24.324867",
|
|
||||||
"expires_at": "2026-04-05T22:09:24.324760"
|
|
||||||
},
|
|
||||||
"NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "NXDOlsQHhIvU2iKAr5Lvd2TFWPtrDwCZRv_qhbkalXU",
|
|
||||||
"created_at": "2026-03-06T22:09:24.326840",
|
|
||||||
"expires_at": "2026-04-05T22:09:24.326737"
|
|
||||||
},
|
|
||||||
"OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "OtWEFFVAaWeEjhD4oABMjgCEMpgXqVoYQA1FurO0vv4",
|
|
||||||
"created_at": "2026-03-06T22:10:26.790594",
|
|
||||||
"expires_at": "2026-04-05T22:10:26.790416"
|
|
||||||
},
|
|
||||||
"1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "1LXmBhsC7ii_9mRA_UoHzXu-tEB-grWJOqZattviJ3I",
|
|
||||||
"created_at": "2026-03-06T22:10:26.792786",
|
|
||||||
"expires_at": "2026-04-05T22:10:26.792640"
|
|
||||||
},
|
|
||||||
"okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "okiqq-6OzY9oWg1W9-SZgzLatTCpae9MCCp6KZlV10w",
|
|
||||||
"created_at": "2026-03-06T22:10:26.795866",
|
|
||||||
"expires_at": "2026-04-05T22:10:26.795737"
|
|
||||||
},
|
|
||||||
"ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "ugGlPQF6pHzNwWeJtj6T291hTzohtNm4RmgVk8ZVDDE",
|
|
||||||
"created_at": "2026-03-06T22:10:26.797631",
|
|
||||||
"expires_at": "2026-04-05T22:10:26.797524"
|
|
||||||
},
|
|
||||||
"CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "CjKhxE2yHPbxqVl8xlHvqOY1JEaMWAMEvuwHZNVlLhE",
|
|
||||||
"created_at": "2026-03-06T22:10:26.799655",
|
|
||||||
"expires_at": "2026-04-05T22:10:26.799536"
|
|
||||||
},
|
|
||||||
"kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "kUZqeke-HAsST14Wc55f4H7cvgXk6mqZMt8QCImg3OE",
|
|
||||||
"created_at": "2026-03-06T22:27:21.684870",
|
|
||||||
"expires_at": "2026-04-05T22:27:21.684713"
|
|
||||||
},
|
|
||||||
"X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "X4oxSgjaDzKWhSTRlzNJIWwYK02uhTxt7flgk3iviZg",
|
|
||||||
"created_at": "2026-03-06T22:27:21.686951",
|
|
||||||
"expires_at": "2026-04-05T22:27:21.686838"
|
|
||||||
},
|
|
||||||
"lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "lK62sk0eZGKnlXPPtgl1_3RP2uKiIAUoBhp9qrGsmdM",
|
|
||||||
"created_at": "2026-03-06T22:27:21.689978",
|
|
||||||
"expires_at": "2026-04-05T22:27:21.689871"
|
|
||||||
},
|
|
||||||
"CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "CCQ6UCKyt6yeCBX1YK-e9oQ6TYBlFW_4NE2AlNoDaz4",
|
|
||||||
"created_at": "2026-03-06T22:27:21.694564",
|
|
||||||
"expires_at": "2026-04-05T22:27:21.694451"
|
|
||||||
},
|
|
||||||
"2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "2Rmed4CEavVu78CcBfrtkPP-CIfDrw7FJPWjuoWEMX4",
|
|
||||||
"created_at": "2026-03-06T22:27:21.696368",
|
|
||||||
"expires_at": "2026-04-05T22:27:21.696259"
|
|
||||||
},
|
|
||||||
"innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "innQUKWqDDOx87cBrpe_UyttX93T90HPo2AZP3Zcl0w",
|
|
||||||
"created_at": "2026-03-06T22:28:22.440825",
|
|
||||||
"expires_at": "2026-04-05T22:28:22.440584"
|
|
||||||
},
|
|
||||||
"FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "FHnmFUIbDHCy-WX1bynRQTaXSQ_T2A8TowTUrBxAvWc",
|
|
||||||
"created_at": "2026-03-06T22:28:22.443279",
|
|
||||||
"expires_at": "2026-04-05T22:28:22.443148"
|
|
||||||
},
|
|
||||||
"xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "xSe9XubjuLbcyJfdIXIFZpUO67c33DSMd452YXqlfLc",
|
|
||||||
"created_at": "2026-03-06T22:28:22.446772",
|
|
||||||
"expires_at": "2026-04-05T22:28:22.446637"
|
|
||||||
},
|
|
||||||
"Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "Ne1YW4IV1JXRde4OCuadCQORF40TSBR4cHWIWfrTXCI",
|
|
||||||
"created_at": "2026-03-06T22:28:22.448831",
|
|
||||||
"expires_at": "2026-04-05T22:28:22.448710"
|
|
||||||
},
|
|
||||||
"cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "cidw7ZAy2g1NmA6_1ynKKcwNH4nwMaH4MQr83JBfc4U",
|
|
||||||
"created_at": "2026-03-06T22:28:22.450873",
|
|
||||||
"expires_at": "2026-04-05T22:28:22.450755"
|
|
||||||
},
|
|
||||||
"oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "oyYCn0jtWSdBk7LAVms-SruvHH7Hn1UYV80OGdvLKlE",
|
|
||||||
"created_at": "2026-03-06T22:43:41.536641",
|
|
||||||
"expires_at": "2026-04-05T22:43:41.536473"
|
|
||||||
},
|
|
||||||
"8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "8IeInsR7vpXuRXsttGMErW8UN3mAJPSQqzgSxwtTUPw",
|
|
||||||
"created_at": "2026-03-06T22:43:41.538970",
|
|
||||||
"expires_at": "2026-04-05T22:43:41.538842"
|
|
||||||
},
|
|
||||||
"9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "9C6TOeSQrdXcjNgmyqqLs1Mwqv5kTnEHDWnwYzMZYOk",
|
|
||||||
"created_at": "2026-03-06T22:43:41.542159",
|
|
||||||
"expires_at": "2026-04-05T22:43:41.542042"
|
|
||||||
},
|
|
||||||
"-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "-DLO4uKd0tLii_n7Q1xcwjJlx95eH4Msv09-N-PBcqU",
|
|
||||||
"created_at": "2026-03-06T22:43:41.544148",
|
|
||||||
"expires_at": "2026-04-05T22:43:41.544030"
|
|
||||||
},
|
|
||||||
"L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "L4vF52USYYFI530NPFLjRKS6p3t8PQDuuQSMCUZKQpY",
|
|
||||||
"created_at": "2026-03-06T22:43:41.546116",
|
|
||||||
"expires_at": "2026-04-05T22:43:41.545999"
|
|
||||||
},
|
|
||||||
"Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "Ht2tY4F0bAEmqfx3zyhyvl0iE_AR3RSgaFU9t4nWju0",
|
|
||||||
"created_at": "2026-03-23T15:14:58.571086",
|
|
||||||
"expires_at": "2026-04-22T15:14:58.570921"
|
|
||||||
},
|
|
||||||
"glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "glwLjrtkAQpeayq4I3BXPhelUhHDx9q1XsfEdIRp3ww",
|
|
||||||
"created_at": "2026-03-23T15:14:58.573282",
|
|
||||||
"expires_at": "2026-04-22T15:14:58.573168"
|
|
||||||
},
|
|
||||||
"3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "3Zm0f39QvivZ-jjik2c6K66ohbFAnNkt0bV_H_2-mTA",
|
|
||||||
"created_at": "2026-03-23T15:14:58.576669",
|
|
||||||
"expires_at": "2026-04-22T15:14:58.576537"
|
|
||||||
},
|
|
||||||
"Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "Ek6emHS0OZLGzbl_BiTAvXPZzVimluCRI1NtENHNkOg",
|
|
||||||
"created_at": "2026-03-23T15:14:58.578685",
|
|
||||||
"expires_at": "2026-04-22T15:14:58.578562"
|
|
||||||
},
|
|
||||||
"8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "8we5TODV6hCiVnVb-8YPDjN5gzqBnrH9SNWRS6fe9tY",
|
|
||||||
"created_at": "2026-03-23T15:14:58.580654",
|
|
||||||
"expires_at": "2026-04-22T15:14:58.580531"
|
|
||||||
},
|
|
||||||
"Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "Fj8iMb8t120mE9Ja7vmq2wUPxzJcFuml-Z7iCLhSFW8",
|
|
||||||
"created_at": "2026-03-23T15:34:35.684297",
|
|
||||||
"expires_at": "2026-04-22T15:34:35.684116"
|
|
||||||
},
|
|
||||||
"BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "BoqiM9cAlxbSasUb95Mbd-nyysLOz0bfW3Wb1nzetkQ",
|
|
||||||
"created_at": "2026-03-23T15:34:35.686743",
|
|
||||||
"expires_at": "2026-04-22T15:34:35.686606"
|
|
||||||
},
|
|
||||||
"H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "H1z5Kul8c1jNt--CVizet8E_0IvSWb71p2re9wqwFdU",
|
|
||||||
"created_at": "2026-03-23T15:34:35.690100",
|
|
||||||
"expires_at": "2026-04-22T15:34:35.689977"
|
|
||||||
},
|
|
||||||
"9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "9RjhuJYCWA1hNMljUJTv394N6PQa1MQNH9zKlRHdOYM",
|
|
||||||
"created_at": "2026-03-23T15:34:35.692293",
|
|
||||||
"expires_at": "2026-04-22T15:34:35.692176"
|
|
||||||
},
|
|
||||||
"BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "BoqqWFQyUyghoo0D5c0TGFePWIWPW-Lc-iZ78c8K0TI",
|
|
||||||
"created_at": "2026-03-23T15:34:35.694464",
|
|
||||||
"expires_at": "2026-04-22T15:34:35.694325"
|
|
||||||
},
|
|
||||||
"wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "wbvoPHiM4uu_Zk8nmuCFKwB_aMbuQYGHpXKl_NqQ-34",
|
|
||||||
"created_at": "2026-03-23T16:15:23.555117",
|
|
||||||
"expires_at": "2026-04-22T16:15:23.554918"
|
|
||||||
},
|
|
||||||
"sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "sIjxlWmWy2g0ub9Q7lfV7jD891sLgfK6nl4lVo4IMf0",
|
|
||||||
"created_at": "2026-03-23T16:15:23.557727",
|
|
||||||
"expires_at": "2026-04-22T16:15:23.557585"
|
|
||||||
},
|
|
||||||
"ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "ywtFuhe0qTnVLbHEFJmHVKD7VX2_Xx9ylhBbaYy7w0s",
|
|
||||||
"created_at": "2026-03-23T16:15:23.561170",
|
|
||||||
"expires_at": "2026-04-22T16:15:23.561048"
|
|
||||||
},
|
|
||||||
"3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "3r07INGsvk6x1qP1HcUjEeo0Kw2j22mr53VK75mgZJc",
|
|
||||||
"created_at": "2026-03-23T16:15:23.563391",
|
|
||||||
"expires_at": "2026-04-22T16:15:23.563269"
|
|
||||||
},
|
|
||||||
"-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE": {
|
|
||||||
"username": "testuser",
|
|
||||||
"token_id": "-uq98ObS1V5yISkuFKGCkt93yc2_4PbfFsbcZlJ_TcE",
|
|
||||||
"created_at": "2026-03-23T16:15:23.565588",
|
|
||||||
"expires_at": "2026-04-22T16:15:23.565458"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,7 @@ Main application file with startup configuration and middleware.
|
|||||||
All API routes have been migrated to app/routers/ for better maintainability.
|
All API routes have been migrated to app/routers/ for better maintainability.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -42,6 +43,8 @@ app.add_middleware(
|
|||||||
"http://192.168.1.204",
|
"http://192.168.1.204",
|
||||||
"http://192.168.1.200:3000",
|
"http://192.168.1.200:3000",
|
||||||
"http://192.168.1.200",
|
"http://192.168.1.200",
|
||||||
|
"http://192.168.5.127:3000",
|
||||||
|
"http://192.168.5.127",
|
||||||
],
|
],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||||
@@ -82,6 +85,11 @@ async def startup_event():
|
|||||||
from app.auto_download_scheduler import auto_download_scheduler
|
from app.auto_download_scheduler import auto_download_scheduler
|
||||||
|
|
||||||
auto_download_scheduler.start()
|
auto_download_scheduler.start()
|
||||||
|
|
||||||
|
# Run initial provider health check in background
|
||||||
|
from app.providers_manager import providers_manager
|
||||||
|
|
||||||
|
asyncio.create_task(providers_manager.check_all_health())
|
||||||
logger.info("Application started: Sonarr handler and scheduler initialized")
|
logger.info("Application started: Sonarr handler and scheduler initialized")
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +152,7 @@ from app.routers import (
|
|||||||
static_router,
|
static_router,
|
||||||
root_router,
|
root_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
|
admin_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +168,7 @@ app.include_router(sonarr_router)
|
|||||||
app.include_router(player_router)
|
app.include_router(player_router)
|
||||||
app.include_router(static_router)
|
app.include_router(static_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.60.0"
|
||||||
"jsdom": "^29.0.0",
|
|
||||||
"vitest": "^1.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* @see https://playwright.dev/docs/test-configuration
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@@ -38,16 +38,21 @@ export default defineConfig({
|
|||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'uvicorn main:app --host 0.0.0.0 --port 3000',
|
command: 'venv/bin/uvicorn main:app --host 0.0.0.0 --port 3000',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+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>
|
</div>
|
||||||
<button class="sr-btn sr-btn-follow"
|
<button class="sr-btn sr-btn-follow"
|
||||||
hx-post="/api/watchlist"
|
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-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')}">
|
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
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="downloads-grid">
|
<div class="downloads-grid">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="download-item task-{{ task.status }}">
|
<div class="download-item status-{{ task.status.value }}">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
<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>
|
||||||
|
|
||||||
<div class="progress-container">
|
<div class="progress-container">
|
||||||
@@ -19,28 +19,38 @@
|
|||||||
|
|
||||||
<div class="download-actions">
|
<div class="download-actions">
|
||||||
{% if task.status == 'downloading' or task.status == 'pending' %}
|
{% 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>
|
<i class="fas fa-pause"></i>
|
||||||
</button>
|
</button>
|
||||||
{% elif task.status == 'paused' %}
|
{% 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>
|
<i class="fas fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if task.status == 'completed' %}
|
||||||
<a href="/video/{{ task.id }}" class="btn-icon success" title="Voir la vidéo">
|
<a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-play-circle"></i>
|
||||||
</a>
|
</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>
|
<i class="fas fa-file-download"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<button class="btn-icon danger"
|
<button class="btn-icon danger"
|
||||||
hx-delete="/api/downloads/{{ task.id }}"
|
hx-delete="/api/downloads/{{ task.id }}"
|
||||||
hx-confirm="Supprimer ce téléchargement ?"
|
hx-confirm="Supprimer ce telechargement ?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')"
|
||||||
title="Supprimer">
|
title="Supprimer">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -51,6 +61,6 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
|
<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>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<div class="section-container">
|
<div class="section-container">
|
||||||
<div class="section-header">
|
<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">
|
<div class="header-actions">
|
||||||
<button class="btn btn-sm btn-secondary"
|
<button class="btn btn-sm btn-secondary"
|
||||||
hx-post="/api/downloads/cleanup"
|
hx-post="/api/downloads/cleanup"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
|
hx-confirm="Nettoyer tous les telechargements termines ?"
|
||||||
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')">
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,12 +25,20 @@
|
|||||||
hx-trigger="load, refresh, every 3s"
|
hx-trigger="load, refresh, every 3s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="loading-placeholder">
|
<div class="loading-placeholder">
|
||||||
<div class="spinner"></div> Chargement des téléchargements...
|
<div class="spinner"></div> Chargement des telechargements...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.section-container { margin-bottom: 40px; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="sr-btn sr-btn-follow"
|
<button class="sr-btn sr-btn-follow"
|
||||||
hx-post="/api/watchlist"
|
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-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')}">
|
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
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<div class="settings-container section-container">
|
<div class="settings-container section-container">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>⚙️ Paramètres</h2>
|
<h2>Parametres</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Preferences -->
|
<!-- 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);">
|
<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">
|
<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;">
|
<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="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
|
||||||
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF (Version Française)</option>
|
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-top: 20px;">
|
<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;">
|
<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="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
|
||||||
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
|
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
|
||||||
@@ -25,18 +25,77 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;">
|
<div class="form-group" style="margin-top: 20px;">
|
||||||
<i class="fas fa-save"></i> Enregistrer les préférences
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 -->
|
<!-- 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 class="settings-card card" style="padding: 25px; background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05);">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
<h3 style="margin: 0; color: var(--primary);">Disponibilité des Fournisseurs</h3>
|
<h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3>
|
||||||
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none">
|
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none"
|
||||||
<i class="fas fa-sync-alt"></i> Forcer vérification
|
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')">
|
||||||
|
<i class="fas fa-sync-alt"></i> Forcer verification
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +120,7 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
|
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')"
|
||||||
style="min-width: 100px;">
|
style="min-width: 100px;">
|
||||||
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
|
{% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -69,6 +128,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
.settings-form label {
|
.settings-form label {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.toast {
|
.toast {
|
||||||
min-width: 250px;
|
min-width: 250px;
|
||||||
|
|||||||
@@ -1,30 +1,141 @@
|
|||||||
{% if items %}
|
{% 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">
|
<div class="watchlist-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="watchlist-item card" id="watchlist-{{ item.id }}">
|
<div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
|
||||||
<div class="item-poster">
|
<!-- Poster -->
|
||||||
<img src="{{ item.poster_image or '/static/img/no-poster.png' }}" alt="{{ item.anime_title }}">
|
<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>
|
</div>
|
||||||
<div class="item-info">
|
{% if item.auto_download %}
|
||||||
<h3>{{ item.anime_title }}</h3>
|
<div class="auto-download-badge">
|
||||||
<div class="item-meta">
|
<i class="fas fa-magic"></i> Auto
|
||||||
<span class="badge">{{ item.provider_id }}</span>
|
|
||||||
<span class="badge badge-{{ item.status }}">{{ item.status }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="item-stats">
|
{% endif %}
|
||||||
<span>Épisode: {{ item.last_episode_downloaded }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
|
||||||
<button class="btn btn-sm btn-primary"
|
<!-- Content -->
|
||||||
hx-get="/api/anime/episodes?url={{ item.anime_url | urlencode }}"
|
<div class="watchlist-content">
|
||||||
hx-target="#player-container">
|
<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>
|
<i class="fas fa-play"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-danger"
|
{% 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-delete="/api/watchlist/{{ item.id }}"
|
||||||
hx-target="#watchlist-{{ item.id }}"
|
hx-target="#watchlist-{{ item.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
hx-confirm="Retirer de la watchlist ?">
|
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
|
||||||
|
title="Supprimer">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,8 +143,349 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="watchlist-empty">
|
||||||
<p>Votre watchlist est vide.</p>
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% 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'">
|
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
|
||||||
<!-- Anime Search Section -->
|
<!-- Anime Search Section -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>🎬 Rechercher un Anime</h2>
|
<h2>Rechercher un Anime</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="url-form">
|
<div class="url-form">
|
||||||
<form hx-get="/api/anime/search"
|
<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 id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
|
||||||
<div class="spinner"></div> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Anime search results -->
|
<!-- Anime search results -->
|
||||||
@@ -51,11 +48,11 @@
|
|||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
<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">
|
<div class="section-header">
|
||||||
<h2>🔥 Dernières sorties Anime</h2>
|
<h2>Dernieres sorties Anime</h2>
|
||||||
<button class="btn btn-secondary btn-small"
|
<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">
|
hx-target="#animeReleasesList">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<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>
|
<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
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
|
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
|
||||||
<!-- Series Search Section -->
|
<!-- Series Search Section -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>📺 Rechercher une Série TV</h2>
|
<h2>Rechercher une Serie TV</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="url-form">
|
<div class="url-form">
|
||||||
<form hx-get="/api/series/search"
|
<form hx-get="/api/series/search"
|
||||||
@@ -82,7 +79,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
name="q"
|
name="q"
|
||||||
id="seriesSearchInput"
|
id="seriesSearchInput"
|
||||||
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
|
placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn btn-primary btn-search">
|
<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 id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
|
||||||
<div class="spinner"></div> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Series search results -->
|
<!-- Series search results -->
|
||||||
@@ -105,11 +99,11 @@
|
|||||||
|
|
||||||
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
<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">
|
<div class="section-header">
|
||||||
<h2>🎯 Recommandé pour vous</h2>
|
<h2>Recommande pour vous</h2>
|
||||||
<button class="btn btn-secondary btn-small"
|
<button class="btn btn-secondary btn-small"
|
||||||
hx-get="/api/recommendations"
|
hx-get="/api/recommendations?content_type=series&html=1"
|
||||||
hx-target="#seriesRecommendationsList">
|
hx-target="#seriesRecommendationsList">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<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>
|
<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
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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;">
|
<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"
|
<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">
|
hx-target="#seriesReleasesList">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
<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>
|
<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
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
<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 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 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="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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+7
-9
@@ -41,7 +41,7 @@ async def test_watchlist_manager():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item = watchlist_manager.create(test_user, item_data)
|
item = watchlist_manager.add(test_user, item_data)
|
||||||
print(f" ✅ Item created: {item.id}")
|
print(f" ✅ Item created: {item.id}")
|
||||||
print(f" Title: {item.anime_title}")
|
print(f" Title: {item.anime_title}")
|
||||||
print(f" Status: {item.status}")
|
print(f" Status: {item.status}")
|
||||||
@@ -127,8 +127,8 @@ async def test_scheduler():
|
|||||||
|
|
||||||
print("\n2. Testing scheduler status...")
|
print("\n2. Testing scheduler status...")
|
||||||
try:
|
try:
|
||||||
status = auto_download_scheduler.get_status()
|
running = auto_download_scheduler.is_running()
|
||||||
print(f" ✅ Scheduler status: running={status['running']}")
|
print(f" ✅ Scheduler status: running={running}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Status failed: {e}")
|
print(f" ❌ Status failed: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -136,20 +136,18 @@ async def test_scheduler():
|
|||||||
print("\n3. Testing scheduler start/stop...")
|
print("\n3. Testing scheduler start/stop...")
|
||||||
try:
|
try:
|
||||||
# Start scheduler
|
# Start scheduler
|
||||||
await auto_download_scheduler.start()
|
auto_download_scheduler.start()
|
||||||
print(" ✅ Scheduler started")
|
print(" ✅ Scheduler started")
|
||||||
|
|
||||||
status = auto_download_scheduler.get_status()
|
if not auto_download_scheduler.is_running():
|
||||||
if not status['running']:
|
|
||||||
print(" ❌ Scheduler not running after start")
|
print(" ❌ Scheduler not running after start")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Stop scheduler
|
# Stop scheduler
|
||||||
await auto_download_scheduler.stop()
|
auto_download_scheduler.stop()
|
||||||
print(" ✅ Scheduler stopped")
|
print(" ✅ Scheduler stopped")
|
||||||
|
|
||||||
status = auto_download_scheduler.get_status()
|
if auto_download_scheduler.is_running():
|
||||||
if status['running']:
|
|
||||||
print(" ❌ Scheduler still running after stop")
|
print(" ❌ Scheduler still running after stop")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ def test_watchlist_basics():
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
item = watchlist_manager.create(test_user, item_data)
|
item = watchlist_manager.add(test_user, item_data)
|
||||||
print(f" ✅ Item created: {item.id}")
|
print(f" ✅ Item created: {item.id}")
|
||||||
print(f" Title: {item.anime_title}")
|
print(f" Title: {item.anime_title}")
|
||||||
print(f" Status: {item.status}")
|
print(f" Status: {item.status}")
|
||||||
@@ -178,7 +178,7 @@ async def test_scheduler():
|
|||||||
print("🧪 TEST 3: Auto-Download Scheduler")
|
print("🧪 TEST 3: Auto-Download Scheduler")
|
||||||
print("="*60)
|
print("="*60)
|
||||||
|
|
||||||
print("\n1. Testing scheduler start (async)...")
|
print("\n1. Testing scheduler start...")
|
||||||
try:
|
try:
|
||||||
auto_download_scheduler.start()
|
auto_download_scheduler.start()
|
||||||
print(f" ✅ Scheduler started")
|
print(f" ✅ Scheduler started")
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test as setup, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const authFile = 'playwright/.auth/user.json';
|
||||||
|
|
||||||
|
setup('authenticate', async ({ page }) => {
|
||||||
|
// Create user if not exists (global setup should have done it, but be safe)
|
||||||
|
const resp = await page.request.post('/api/auth/register', {
|
||||||
|
data: {
|
||||||
|
username: 'e2e_testuser',
|
||||||
|
password: 'TestPassword123!',
|
||||||
|
email: 'e2e@example.com',
|
||||||
|
full_name: 'E2E Test User',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!resp.ok() && resp.status() !== 400) {
|
||||||
|
console.warn('Register failed:', await resp.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login via UI
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.fill('#loginUsername', 'e2e_testuser');
|
||||||
|
await page.fill('#loginPassword', 'TestPassword123!');
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||||
|
page.click('#loginSubmit'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await page.waitForURL('**/web**', { timeout: 10000 });
|
||||||
|
|
||||||
|
// Save storage state (localStorage + cookies)
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
});
|
||||||
+44
-70
@@ -1,119 +1,93 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TEST_USER, login } from './helpers';
|
||||||
|
|
||||||
test.describe('Auth Flow', () => {
|
test.describe('Auth Flow', () => {
|
||||||
test('login success - redirects to home and stores token', async ({ page }) => {
|
test('login success - redirects to home and stores token', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await login(page, TEST_USER.username, TEST_USER.password);
|
||||||
|
|
||||||
// Fill login form
|
// Verify redirect to /web
|
||||||
await page.fill('#loginUsername', 'testuser');
|
await expect(page).toHaveURL(/\/web/);
|
||||||
await page.fill('#loginPassword', 'password123');
|
|
||||||
|
|
||||||
// Click login button
|
// Verify token stored
|
||||||
await page.click('#loginSubmit');
|
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||||
|
expect(token).toBeTruthy();
|
||||||
// Wait for redirect or success message
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if redirected or success message shown
|
|
||||||
const currentUrl = page.url();
|
|
||||||
const successMessage = await page.locator('#authSuccess').textContent().catch(() => '');
|
|
||||||
|
|
||||||
// Either redirect happened or success message shown
|
|
||||||
expect(currentUrl.includes('/web') || successMessage.includes('réussie')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login with wrong credentials shows error', async ({ page }) => {
|
test('login with wrong credentials shows error', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('#loginUsername', 'nonexistentuser_xyz');
|
||||||
// Fill login form with wrong credentials
|
|
||||||
await page.fill('#loginUsername', 'nonexistentuser');
|
|
||||||
await page.fill('#loginPassword', 'wrongpassword');
|
await page.fill('#loginPassword', 'wrongpassword');
|
||||||
|
|
||||||
// Click login button
|
const [response] = await Promise.all([
|
||||||
await page.click('#loginSubmit');
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||||
|
page.click('#loginSubmit'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Wait for error
|
expect(response.status()).toBe(401);
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check error message is displayed
|
// Error message should be visible
|
||||||
const errorVisible = await page.locator('#authError').isVisible().catch(() => false);
|
const errorLocator = page.locator('#authError');
|
||||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
await expect(errorLocator).toBeVisible();
|
||||||
|
await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i);
|
||||||
// Error should be shown (and NOT be "[object Object]")
|
|
||||||
expect(errorVisible || errorText.length > 0).toBeTruthy();
|
|
||||||
expect(errorText).not.toContain('[object Object]');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('register new user shows success', async ({ page }) => {
|
test('register new user shows success', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Switch to register tab
|
|
||||||
await page.click('text=Inscription');
|
await page.click('text=Inscription');
|
||||||
|
|
||||||
// Fill register form with unique username
|
const uniqueUsername = `testuser_${Date.now()}`;
|
||||||
const uniqueUsername = 'testuser_' + Date.now();
|
|
||||||
await page.fill('#registerUsername', uniqueUsername);
|
await page.fill('#registerUsername', uniqueUsername);
|
||||||
await page.fill('#registerPassword', 'password123');
|
await page.fill('#registerPassword', 'password123');
|
||||||
await page.fill('#registerPasswordConfirm', 'password123');
|
await page.fill('#registerPasswordConfirm', 'password123');
|
||||||
|
|
||||||
// Click register button
|
const [response] = await Promise.all([
|
||||||
await page.click('#registerSubmit');
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
|
||||||
|
page.click('#registerSubmit'),
|
||||||
|
]);
|
||||||
|
|
||||||
// Wait for success
|
expect(response.status()).toBeLessThan(400);
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check success message
|
await expect(page.locator('#authSuccess')).toBeVisible();
|
||||||
const successVisible = await page.locator('#authSuccess').isVisible().catch(() => false);
|
await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i);
|
||||||
const successText = await page.locator('#authSuccess').textContent().catch(() => '');
|
|
||||||
|
|
||||||
// Success should be shown
|
|
||||||
expect(successVisible || successText.includes('réussie')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password mismatch shows validation error', async ({ page }) => {
|
test('password mismatch shows validation error', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Switch to register tab
|
|
||||||
await page.click('text=Inscription');
|
await page.click('text=Inscription');
|
||||||
|
|
||||||
// Fill register form with mismatching passwords
|
|
||||||
await page.fill('#registerUsername', 'testuser');
|
await page.fill('#registerUsername', 'testuser');
|
||||||
await page.fill('#registerPassword', 'password123');
|
await page.fill('#registerPassword', 'password123');
|
||||||
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
||||||
|
|
||||||
// Click register button
|
|
||||||
await page.click('#registerSubmit');
|
await page.click('#registerSubmit');
|
||||||
|
|
||||||
// Wait for error
|
await expect(page.locator('#authError')).toBeVisible();
|
||||||
await page.waitForTimeout(1000);
|
await expect(page.locator('#authError')).toContainText(/correspondent|match/i);
|
||||||
|
|
||||||
// Check error message
|
|
||||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
|
||||||
|
|
||||||
// Should show password mismatch error
|
|
||||||
expect(errorText).toContain('correspondent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login button shows loading state during request', async ({ page }) => {
|
test('login button shows loading state during request', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Get button and check initial state
|
|
||||||
const button = page.locator('#loginSubmit');
|
const button = page.locator('#loginSubmit');
|
||||||
const initialText = await button.textContent();
|
const initialText = await button.textContent();
|
||||||
|
|
||||||
// Fill form and click
|
await page.fill('#loginUsername', TEST_USER.username);
|
||||||
await page.fill('#loginUsername', 'testuser');
|
await page.fill('#loginPassword', TEST_USER.password);
|
||||||
await page.fill('#loginPassword', 'password123');
|
|
||||||
|
|
||||||
// Click and immediately check loading state
|
// Start the click but don't await it fully — we want to observe the loading state
|
||||||
await button.click();
|
const clickPromise = button.click();
|
||||||
|
|
||||||
// Check loading state (should change text or be disabled)
|
// Poll briefly for loading state
|
||||||
await page.waitForTimeout(100);
|
let sawLoading = false;
|
||||||
const buttonText = await button.textContent();
|
for (let i = 0; i < 10; i++) {
|
||||||
const isDisabled = await button.isDisabled().catch(() => false);
|
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
|
await clickPromise;
|
||||||
expect(buttonText !== initialText || isDisabled).toBeTruthy();
|
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;
|
||||||
|
}
|
||||||
+45
-107
@@ -1,152 +1,90 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { switchTab, waitForHtmx, collectJsErrors } from './helpers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User Journey E2E Tests
|
* User Journey E2E Tests
|
||||||
*
|
*
|
||||||
* Simulates a complete user flow: register → login → browse → search → settings → logout.
|
* Tests authenticated user flows. Auth is handled by auth.setup.ts + storageState.
|
||||||
* All tests are serial because they share browser state (auth token, navigation).
|
|
||||||
*
|
|
||||||
* FORBIDDEN: Do NOT use page.waitForTimeout() — use waitForResponse() or waitForSelector()
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test.describe('User Journey E2E', () => {
|
test.describe('User Journey E2E', () => {
|
||||||
test.describe.configure({ mode: 'serial' });
|
test('should browse homepage without JS errors', async ({ page }) => {
|
||||||
|
const jsErrors = collectJsErrors(page);
|
||||||
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');
|
await page.goto('/web');
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for main content area to be visible
|
// Main content should be visible
|
||||||
await page.locator('#main-content').waitFor({ state: 'visible', timeout: 10000 });
|
await expect(page.locator('#main-content')).toBeVisible();
|
||||||
|
|
||||||
// Verify the header heading
|
|
||||||
await expect(page.locator('header h1')).toContainText('Ohm Stream');
|
await expect(page.locator('header h1')).toContainText('Ohm Stream');
|
||||||
|
|
||||||
// Verify at least one navigation tab is visible
|
// At least one tab visible
|
||||||
await expect(page.locator('.tab').first()).toBeVisible();
|
await expect(page.locator('.tab').first()).toBeVisible();
|
||||||
|
|
||||||
// Verify the user info panel (logged-in state indicator)
|
// Authenticated user info should be visible
|
||||||
await expect(page.locator('#userInfo')).toBeVisible();
|
await expect(page.locator('#userInfo')).toBeVisible();
|
||||||
|
|
||||||
// No JavaScript errors should have been thrown
|
expect(jsErrors).toHaveLength(0);
|
||||||
expect(errors).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search for an anime via the Anime tab — results may be empty but the UI must respond
|
|
||||||
test('should search for anime', async ({ page }) => {
|
test('should search for anime', async ({ page }) => {
|
||||||
// Navigate to the Anime tab
|
// Mock the anime search API to return deterministic HTML
|
||||||
await page.click('.tab:has-text("Anime")');
|
await page.route('/api/anime/search?**', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'text/html',
|
||||||
|
body: `
|
||||||
|
<div class="sr-card">
|
||||||
|
<h3>Naruto Shippuden</h3>
|
||||||
|
<p>Anime-Sama</p>
|
||||||
|
</div>
|
||||||
|
<div class="sr-card">
|
||||||
|
<h3>Boruto: Naruto Next Generations</h3>
|
||||||
|
<p>Neko-Sama</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/web');
|
||||||
|
await switchTab(page, 'Anime');
|
||||||
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
await page.locator('#tab-anime').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
|
||||||
// Fill the search input — HTMX debounce triggers the request automatically
|
|
||||||
await page.fill('#animeSearchInput', 'Naruto');
|
await page.fill('#animeSearchInput', 'Naruto');
|
||||||
|
|
||||||
// Wait for either results, an empty-state message, or the loading spinner to disappear
|
// Click search button to trigger submit
|
||||||
await Promise.race([
|
await page.click('#tab-anime button[type="submit"]');
|
||||||
page.locator('#animeSearchResults .sr-card').first().waitFor({ timeout: 15000 }),
|
|
||||||
page.locator('#animeSearchResults .sr-empty').first().waitFor({ timeout: 15000 }),
|
|
||||||
page.locator('#search-loading').waitFor({ state: 'detached', timeout: 15000 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The search results container must be present regardless of result count
|
// Wait for results to appear
|
||||||
|
await page.locator('#animeSearchResults .sr-card').first().waitFor({ state: 'visible', timeout: 10000 });
|
||||||
|
|
||||||
|
// Results container should be visible and contain mocked data
|
||||||
await expect(page.locator('#animeSearchResults')).toBeVisible();
|
await expect(page.locator('#animeSearchResults')).toBeVisible();
|
||||||
|
await expect(page.locator('#animeSearchResults')).toContainText('Naruto Shippuden');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Change a setting (language) and verify the PATCH response and toast notification
|
|
||||||
test('should update settings', async ({ page }) => {
|
test('should update settings', async ({ page }) => {
|
||||||
// Open the settings tab
|
await page.goto('/web');
|
||||||
await page.click('.tab:has-text("Paramètres")');
|
await switchTab(page, 'Paramètres');
|
||||||
|
|
||||||
// Settings panel is loaded dynamically via HTMX — wait for the form
|
// Wait for settings form loaded via HTMX
|
||||||
await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 });
|
await page.locator('#default_lang').waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
|
||||||
// Change the default language
|
|
||||||
await page.selectOption('#default_lang', 'vf');
|
await page.selectOption('#default_lang', 'vf');
|
||||||
|
|
||||||
// Submit the settings form and capture the PATCH response
|
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
page.waitForResponse(
|
page.waitForResponse(
|
||||||
(resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH'
|
(resp) => resp.url().includes('/api/settings') && resp.request().method() === 'PATCH'
|
||||||
),
|
),
|
||||||
page.locator('form[hx-patch="/api/settings"] button[type="submit"]').click(),
|
page.locator('button:has-text("Enregistrer les preferences")').click(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(response.status()).toBe(200);
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
// Verify a toast notification appears confirming the save
|
// Verify the setting was updated in the UI
|
||||||
await page.locator('.toast').first().waitFor({ state: 'visible', timeout: 5000 });
|
await expect(page.locator('#default_lang')).toHaveValue('vf');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logout — verify the API call succeeds, redirect happens, and token is cleared
|
|
||||||
test('should logout successfully', async ({ page }) => {
|
test('should logout successfully', async ({ page }) => {
|
||||||
// Click the logout button and wait for the API response
|
await page.goto('/web');
|
||||||
|
|
||||||
const [response] = await Promise.all([
|
const [response] = await Promise.all([
|
||||||
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/logout')),
|
||||||
page.locator('#userInfo button:has-text("Déconnexion")').click(),
|
page.locator('#userInfo button:has-text("Déconnexion")').click(),
|
||||||
@@ -154,7 +92,7 @@ test.describe('User Journey E2E', () => {
|
|||||||
|
|
||||||
expect(response.status()).toBeLessThan(400);
|
expect(response.status()).toBeLessThan(400);
|
||||||
|
|
||||||
// Should be redirected back to the login page
|
// Should redirect to login
|
||||||
await page.waitForURL('**/login**', { timeout: 10000 });
|
await page.waitForURL('**/login**', { timeout: 10000 });
|
||||||
|
|
||||||
// The auth token must be cleared from localStorage
|
// The auth token must be cleared from localStorage
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { switchTab, waitForHtmx } from './helpers';
|
||||||
|
|
||||||
|
test.describe('Watchlist', () => {
|
||||||
|
test('should display watchlist tab', async ({ page }) => {
|
||||||
|
await page.goto('/web');
|
||||||
|
await switchTab(page, 'Watchlist');
|
||||||
|
await page.locator('#tab-watchlist').waitFor({ state: 'visible', timeout: 5000 });
|
||||||
|
await expect(page.locator('#tab-watchlist')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -426,7 +426,7 @@ class TestAPIFavorites:
|
|||||||
# Make sure it doesn't exist first
|
# Make sure it doesn't exist first
|
||||||
try:
|
try:
|
||||||
client.delete("/api/favorites/test-toggle-add")
|
client.delete("/api/favorites/test-toggle-add")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -448,7 +448,7 @@ class TestAPIFavorites:
|
|||||||
# Make sure it doesn't exist first
|
# Make sure it doesn't exist first
|
||||||
try:
|
try:
|
||||||
client.delete("/api/favorites/test-toggle-remove")
|
client.delete("/api/favorites/test-toggle-remove")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Add first
|
# Add first
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class TestDownloadManagerErrorHandling:
|
|||||||
try:
|
try:
|
||||||
await manager.start_download(task.id)
|
await manager.start_download(task.id)
|
||||||
await asyncio.sleep(0.1) # Give it time to process
|
await asyncio.sleep(0.1) # Give it time to process
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# The task should be in tasks dict
|
# The task should be in tasks dict
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
globals: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
include: ['static/js/__tests__/**/*.test.js'],
|
|
||||||
coverage: {
|
|
||||||
provider: 'v8',
|
|
||||||
reporter: ['text', 'json', 'html'],
|
|
||||||
reportsDirectory: 'htmlcov',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user