Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6521fe3416 | |||
| 520be53901 |
@@ -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 ###
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -18,12 +18,11 @@ 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
|
||||||
from app.models.settings import AppSettingsTable
|
from app.models.settings import AppSettingsTable
|
||||||
from app.models.download import DownloadTaskTable
|
|
||||||
|
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
import httpx
|
import httpx
|
||||||
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
from app.models import DownloadTask, DownloadStatus, DownloadRequest
|
||||||
from app.models.download import DownloadTaskTable
|
|
||||||
from app.database import engine
|
|
||||||
from sqlmodel import Session, select
|
|
||||||
from app.downloaders import get_downloader
|
from app.downloaders import get_downloader
|
||||||
from app.utils import sanitize_filename
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,92 +24,6 @@ class DownloadManager:
|
|||||||
self.active_downloads: Dict[str, asyncio.Task] = {}
|
self.active_downloads: Dict[str, asyncio.Task] = {}
|
||||||
self._semaphore = asyncio.Semaphore(max_parallel)
|
self._semaphore = asyncio.Semaphore(max_parallel)
|
||||||
|
|
||||||
# ==================== DB Persistence ====================
|
|
||||||
|
|
||||||
def _save_task_to_db(self, task: DownloadTask) -> None:
|
|
||||||
"""Persist a download task to the database (upsert)."""
|
|
||||||
try:
|
|
||||||
with Session(engine) as session:
|
|
||||||
existing = session.get(DownloadTaskTable, task.id)
|
|
||||||
if existing:
|
|
||||||
existing.url = task.url
|
|
||||||
existing.filename = task.filename
|
|
||||||
existing.host = task.host.value if hasattr(task.host, 'value') else str(task.host)
|
|
||||||
existing.status = task.status.value if hasattr(task.status, 'value') else str(task.status)
|
|
||||||
existing.progress = task.progress
|
|
||||||
existing.downloaded_bytes = task.downloaded_bytes
|
|
||||||
existing.total_bytes = task.total_bytes
|
|
||||||
existing.speed = task.speed
|
|
||||||
existing.error = task.error
|
|
||||||
existing.started_at = task.started_at
|
|
||||||
existing.completed_at = task.completed_at
|
|
||||||
existing.file_path = task.file_path
|
|
||||||
session.add(existing)
|
|
||||||
session.commit()
|
|
||||||
else:
|
|
||||||
db_task = DownloadTaskTable(
|
|
||||||
id=task.id,
|
|
||||||
url=task.url,
|
|
||||||
filename=task.filename,
|
|
||||||
host=task.host.value if hasattr(task.host, 'value') else str(task.host),
|
|
||||||
status=task.status.value if hasattr(task.status, 'value') else str(task.status),
|
|
||||||
progress=task.progress,
|
|
||||||
downloaded_bytes=task.downloaded_bytes,
|
|
||||||
total_bytes=task.total_bytes,
|
|
||||||
speed=task.speed,
|
|
||||||
error=task.error,
|
|
||||||
created_at=task.created_at,
|
|
||||||
started_at=task.started_at,
|
|
||||||
completed_at=task.completed_at,
|
|
||||||
file_path=task.file_path,
|
|
||||||
)
|
|
||||||
session.add(db_task)
|
|
||||||
session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to save task {task.id} to DB: {e}", exc_info=True)
|
|
||||||
|
|
||||||
def _delete_task_from_db(self, task_id: str) -> None:
|
|
||||||
"""Remove a download task from the database."""
|
|
||||||
try:
|
|
||||||
with Session(engine) as session:
|
|
||||||
db_task = session.get(DownloadTaskTable, task_id)
|
|
||||||
if db_task:
|
|
||||||
session.delete(db_task)
|
|
||||||
session.commit()
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete task {task_id} from DB: {e}", exc_info=True)
|
|
||||||
|
|
||||||
def _load_tasks_from_db(self) -> None:
|
|
||||||
"""Load persisted download tasks from the database into memory."""
|
|
||||||
try:
|
|
||||||
with Session(engine) as session:
|
|
||||||
statement = select(DownloadTaskTable)
|
|
||||||
db_tasks = session.exec(statement).all()
|
|
||||||
for db_task in db_tasks:
|
|
||||||
if db_task.id not in self.tasks:
|
|
||||||
task = DownloadTask(
|
|
||||||
id=db_task.id,
|
|
||||||
url=db_task.url,
|
|
||||||
filename=db_task.filename,
|
|
||||||
host="other",
|
|
||||||
status=DownloadStatus(db_task.status),
|
|
||||||
progress=db_task.progress,
|
|
||||||
downloaded_bytes=db_task.downloaded_bytes,
|
|
||||||
total_bytes=db_task.total_bytes,
|
|
||||||
speed=db_task.speed,
|
|
||||||
error=db_task.error,
|
|
||||||
created_at=db_task.created_at,
|
|
||||||
started_at=db_task.started_at,
|
|
||||||
completed_at=db_task.completed_at,
|
|
||||||
file_path=db_task.file_path,
|
|
||||||
)
|
|
||||||
self.tasks[task.id] = task
|
|
||||||
logger.info(f"Loaded {len(db_tasks)} download tasks from database")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to load tasks from DB: {e}", exc_info=True)
|
|
||||||
|
|
||||||
# ==================== Task Management ====================
|
|
||||||
|
|
||||||
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
def get_task(self, task_id: str) -> Optional[DownloadTask]:
|
||||||
return self.tasks.get(task_id)
|
return self.tasks.get(task_id)
|
||||||
|
|
||||||
@@ -149,8 +60,6 @@ class DownloadManager:
|
|||||||
created_at=datetime.now()
|
created_at=datetime.now()
|
||||||
)
|
)
|
||||||
self.tasks[task_id] = task
|
self.tasks[task_id] = task
|
||||||
# Persist to database
|
|
||||||
self._save_task_to_db(task)
|
|
||||||
return task
|
return task
|
||||||
|
|
||||||
async def start_download(self, task_id: str):
|
async def start_download(self, task_id: str):
|
||||||
@@ -173,7 +82,6 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task and task.status == DownloadStatus.DOWNLOADING:
|
if task and task.status == DownloadStatus.DOWNLOADING:
|
||||||
task.status = DownloadStatus.PAUSED
|
task.status = DownloadStatus.PAUSED
|
||||||
self._save_task_to_db(task)
|
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -182,7 +90,6 @@ class DownloadManager:
|
|||||||
task = self.tasks.get(task_id)
|
task = self.tasks.get(task_id)
|
||||||
if task:
|
if task:
|
||||||
task.status = DownloadStatus.CANCELLED
|
task.status = DownloadStatus.CANCELLED
|
||||||
self._save_task_to_db(task)
|
|
||||||
if task_id in self.active_downloads:
|
if task_id in self.active_downloads:
|
||||||
self.active_downloads[task_id].cancel()
|
self.active_downloads[task_id].cancel()
|
||||||
del self.active_downloads[task_id]
|
del self.active_downloads[task_id]
|
||||||
@@ -205,16 +112,14 @@ class DownloadManager:
|
|||||||
if task.file_path and os.path.exists(task.file_path):
|
if task.file_path and os.path.exists(task.file_path):
|
||||||
os.remove(task.file_path)
|
os.remove(task.file_path)
|
||||||
|
|
||||||
# Remove from tasks dict and database
|
# Remove from tasks dict
|
||||||
del self.tasks[task_id]
|
del self.tasks[task_id]
|
||||||
self._delete_task_from_db(task_id)
|
|
||||||
|
|
||||||
async def _download(self, task: DownloadTask):
|
async def _download(self, task: DownloadTask):
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
try:
|
try:
|
||||||
task.status = DownloadStatus.DOWNLOADING
|
task.status = DownloadStatus.DOWNLOADING
|
||||||
task.started_at = datetime.now()
|
task.started_at = datetime.now()
|
||||||
self._save_task_to_db(task)
|
|
||||||
|
|
||||||
# Get downloader and extract link
|
# Get downloader and extract link
|
||||||
downloader = get_downloader(task.url)
|
downloader = get_downloader(task.url)
|
||||||
@@ -245,9 +150,6 @@ class DownloadManager:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"Task filename kept as: {task.filename}")
|
logger.debug(f"Task filename kept as: {task.filename}")
|
||||||
|
|
||||||
# Sanitize filename to prevent path traversal and invalid characters
|
|
||||||
task.filename = sanitize_filename(task.filename)
|
|
||||||
|
|
||||||
task.file_path = str(self.download_dir / task.filename)
|
task.file_path = str(self.download_dir / task.filename)
|
||||||
|
|
||||||
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
# Check if URL is HLS/m3u8 - use ffmpeg to download
|
||||||
@@ -255,7 +157,6 @@ class DownloadManager:
|
|||||||
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
logger.info(f"Detected HLS stream, using ffmpeg to download: {task.filename}")
|
||||||
success = await self._download_hls(download_url, task)
|
success = await self._download_hls(download_url, task)
|
||||||
if success:
|
if success:
|
||||||
self._save_task_to_db(task)
|
|
||||||
return
|
return
|
||||||
# If ffmpeg fails, fall through to regular download attempt
|
# If ffmpeg fails, fall through to regular download attempt
|
||||||
logger.warning("ffmpeg download failed, trying regular download")
|
logger.warning("ffmpeg download failed, trying regular download")
|
||||||
@@ -266,12 +167,8 @@ class DownloadManager:
|
|||||||
# Move file to expected location if different
|
# Move file to expected location if different
|
||||||
import shutil
|
import shutil
|
||||||
if download_url != task.file_path:
|
if download_url != task.file_path:
|
||||||
try:
|
shutil.move(download_url, task.file_path)
|
||||||
shutil.move(download_url, task.file_path)
|
logger.debug(f"Moved file to: {task.file_path}")
|
||||||
logger.debug(f"Moved file to: {task.file_path}")
|
|
||||||
except shutil.Error:
|
|
||||||
# Same file, no move needed
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Mark as complete
|
# Mark as complete
|
||||||
file_size = os.path.getsize(task.file_path)
|
file_size = os.path.getsize(task.file_path)
|
||||||
@@ -281,7 +178,6 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
self._save_task_to_db(task)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
# Check if file already exists and is complete (for VidMoly which downloads directly)
|
||||||
@@ -294,7 +190,6 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
self._save_task_to_db(task)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for partial download (resume)
|
# Check for partial download (resume)
|
||||||
@@ -346,7 +241,6 @@ class DownloadManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
task.status = DownloadStatus.FAILED
|
task.status = DownloadStatus.FAILED
|
||||||
task.error = str(e)
|
task.error = str(e)
|
||||||
self._save_task_to_db(task)
|
|
||||||
finally:
|
finally:
|
||||||
if task.id in self.active_downloads:
|
if task.id in self.active_downloads:
|
||||||
del self.active_downloads[task.id]
|
del self.active_downloads[task.id]
|
||||||
@@ -375,11 +269,9 @@ class DownloadManager:
|
|||||||
|
|
||||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||||
if task.status == DownloadStatus.CANCELLED:
|
if task.status == DownloadStatus.CANCELLED:
|
||||||
self._save_task_to_db(task)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if task.status == DownloadStatus.PAUSED:
|
if task.status == DownloadStatus.PAUSED:
|
||||||
self._save_task_to_db(task)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
@@ -403,9 +295,6 @@ class DownloadManager:
|
|||||||
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
final_size = os.path.getsize(task.file_path) if os.path.exists(task.file_path) else 0
|
||||||
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
logger.info(f" ✅ Completed: {task.filename} ({final_size / (1024*1024):.2f} MB)")
|
||||||
|
|
||||||
# Persist to database
|
|
||||||
self._save_task_to_db(task)
|
|
||||||
|
|
||||||
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
async def _download_hls(self, m3u8_url: str, task: DownloadTask) -> bool:
|
||||||
"""Download HLS/m3u8 stream using ffmpeg"""
|
"""Download HLS/m3u8 stream using ffmpeg"""
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -497,7 +386,6 @@ class DownloadManager:
|
|||||||
task.downloaded_bytes = file_size
|
task.downloaded_bytes = file_size
|
||||||
task.total_bytes = file_size
|
task.total_bytes = file_size
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
self._save_task_to_db(task)
|
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
logger.error(f"HLS download failed: file not created")
|
logger.error(f"HLS download failed: file not created")
|
||||||
|
|||||||
@@ -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(),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -144,34 +144,12 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
logger.info(f"Direct video URL detected: {url[:60]}... -> {filename}")
|
||||||
return url, filename
|
return url, filename
|
||||||
|
|
||||||
# Check if URL contains the anime page context (format: video_url|...|anime_page_url|episode_title)
|
# Check if URL contains the anime page context (format: video_url|anime_page_url|episode_title?)
|
||||||
# The LAST two parts are always anime_page_url and episode_title.
|
|
||||||
# Everything before them is video URLs (multiple sources for fallback).
|
|
||||||
if "|" in url:
|
if "|" in url:
|
||||||
parts = url.split("|")
|
parts = url.split("|")
|
||||||
# Correctly identify anime_page_url (2nd to last) and episode_title (last)
|
video_url = parts[0]
|
||||||
if len(parts) >= 3:
|
anime_page_url = parts[1] if len(parts) > 1 else None
|
||||||
# Multiple video URLs + anime_page_url + episode_title
|
episode_title = parts[2] if len(parts) > 2 else None
|
||||||
potential_anime_url = parts[-2].strip()
|
|
||||||
potential_title = parts[-1].strip()
|
|
||||||
# Validate: anime_page_url should look like a URL
|
|
||||||
# episode_title should NOT look like a URL
|
|
||||||
if potential_title and not potential_title.startswith("http"):
|
|
||||||
anime_page_url = potential_anime_url if potential_anime_url.startswith("http") else None
|
|
||||||
episode_title = potential_title
|
|
||||||
elif len(parts) >= 5 and parts[-2].startswith("http"):
|
|
||||||
# Last part is also a URL (no episode title) - 2nd to last is anime page URL
|
|
||||||
anime_page_url = potential_anime_url
|
|
||||||
episode_title = None
|
|
||||||
else:
|
|
||||||
anime_page_url = None
|
|
||||||
episode_title = None
|
|
||||||
# Pass the full URL to fallback (it parses correctly)
|
|
||||||
video_url = url
|
|
||||||
else:
|
|
||||||
video_url = parts[0]
|
|
||||||
anime_page_url = parts[1] if len(parts) > 1 else None
|
|
||||||
episode_title = None
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
f"Split URL - video: {video_url[:60]}..., anime: {anime_page_url}, episode: {episode_title}"
|
||||||
@@ -182,7 +160,6 @@ class AnimeSamaDownloader(BaseAnimeSite):
|
|||||||
video_url,
|
video_url,
|
||||||
anime_page_url=anime_page_url,
|
anime_page_url=anime_page_url,
|
||||||
episode_title=episode_title,
|
episode_title=episode_title,
|
||||||
target_filename=target_filename,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if this is a third-party host URL
|
# Check if this is a third-party host URL
|
||||||
@@ -513,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:
|
||||||
@@ -534,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:
|
||||||
@@ -545,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(
|
||||||
@@ -767,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 []
|
|
||||||
@@ -23,7 +23,7 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
self.id = "fs7"
|
self.id = "fs7"
|
||||||
self.provider_id = "fs7"
|
self.provider_id = "fs7"
|
||||||
self.default_domain = "fs7.lol"
|
self.default_domain = "fs7.lol"
|
||||||
self.test_tlds = ["lol", "one", "site", "vip", "fun", "stream", "com", "net", "org", "tv", "ws", "cc", "co"]
|
self.test_tlds = ["lol", "com", "net", "org", "tv", "ws", "cc", "co"]
|
||||||
self.base_url = f"https://{self.default_domain}"
|
self.base_url = f"https://{self.default_domain}"
|
||||||
self._domain_checked = False
|
self._domain_checked = False
|
||||||
self.client.headers.update(
|
self.client.headers.update(
|
||||||
@@ -234,93 +234,35 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
# Clean up title: remove "affiche" suffix
|
# Clean up title: remove "affiche" suffix
|
||||||
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
title = re.sub(r"\s+affiche$", "", title, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
# --- Synopsis: div.fdesc > p ---
|
# Extract description/synopsis
|
||||||
description = ""
|
description_elem = soup.find("div", class_="full-text")
|
||||||
fdesc = soup.find("div", class_="fdesc")
|
description = (
|
||||||
if fdesc:
|
description_elem.get_text(strip=True) if description_elem else ""
|
||||||
p = fdesc.find("p")
|
)
|
||||||
if p:
|
|
||||||
description = p.get_text(strip=True)
|
|
||||||
else:
|
|
||||||
description = fdesc.get_text(strip=True)
|
|
||||||
|
|
||||||
# --- Poster: div.fleft > img ---
|
# Extract cover image
|
||||||
poster_image = ""
|
img = soup.find("img", class_="poster")
|
||||||
fleft = soup.find("div", class_="fleft")
|
poster_image = img.get("src", "") if img else ""
|
||||||
if fleft:
|
|
||||||
img = fleft.find("img")
|
|
||||||
if img:
|
|
||||||
poster_image = (
|
|
||||||
img.get("data-src")
|
|
||||||
or img.get("data-original")
|
|
||||||
or img.get("src")
|
|
||||||
or ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fallback: img.poster, then og:image
|
# Try to get poster from meta tag if not found
|
||||||
if not poster_image:
|
|
||||||
img = soup.find("img", class_="poster")
|
|
||||||
poster_image = img.get("src", "") if img else ""
|
|
||||||
if not poster_image:
|
if not poster_image:
|
||||||
meta_img = soup.find("meta", property="og:image")
|
meta_img = soup.find("meta", property="og:image")
|
||||||
poster_image = meta_img.get("content", "") if meta_img else ""
|
poster_image = meta_img.get("content", "") if meta_img else ""
|
||||||
|
|
||||||
# --- Year: span.release ---
|
# Extract year
|
||||||
release_year = None
|
year_match = re.search(r"\b(19|20)\d{2}\b", description)
|
||||||
release_span = soup.find("span", class_="release")
|
release_year = int(year_match.group()) if year_match else None
|
||||||
if release_span:
|
|
||||||
year_match = re.search(r"\b(19|20)\d{2}\b", release_span.get_text())
|
|
||||||
if year_match:
|
|
||||||
release_year = int(year_match.group())
|
|
||||||
|
|
||||||
# --- Genres: span.genres ---
|
|
||||||
genres = []
|
|
||||||
genres_span = soup.find("span", class_="genres")
|
|
||||||
if genres_span:
|
|
||||||
genres = [
|
|
||||||
g.strip()
|
|
||||||
for g in genres_span.get_text().split(",")
|
|
||||||
if g.strip()
|
|
||||||
]
|
|
||||||
|
|
||||||
# --- Runtime: span.runtime ---
|
|
||||||
runtime = None
|
|
||||||
runtime_span = soup.find("span", class_="runtime")
|
|
||||||
if runtime_span:
|
|
||||||
runtime = runtime_span.get_text(strip=True)
|
|
||||||
|
|
||||||
# --- Casting info from second div.flist ---
|
|
||||||
original_title = ""
|
|
||||||
director = ""
|
|
||||||
cast = []
|
|
||||||
flists = soup.find_all("div", class_="flist")
|
|
||||||
for fl in flists:
|
|
||||||
text = fl.get_text(strip=True)
|
|
||||||
if "Titre Original" in text:
|
|
||||||
m = re.search(r"Titre Original\s*:\s*(.+?)(?:Réalisateur|$)", text)
|
|
||||||
if m:
|
|
||||||
original_title = m.group(1).strip()
|
|
||||||
m2 = re.search(r"Réalisateur\s*:\s*(.+?)(?:Avec\s*:|$)", text)
|
|
||||||
if m2:
|
|
||||||
director = m2.group(1).strip()
|
|
||||||
m3 = re.search(r"Avec\s*:\s*(.+?)(?:plus|$)", text)
|
|
||||||
if m3:
|
|
||||||
cast = [c.strip() for c in m3.group(1).split(",") if c.strip()]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"title": title,
|
"title": title,
|
||||||
"synopsis": description,
|
"synopsis": description,
|
||||||
"poster_image": poster_image,
|
"poster_image": poster_image,
|
||||||
"release_year": release_year,
|
"release_year": release_year,
|
||||||
"genres": genres,
|
"genres": [],
|
||||||
"rating": None,
|
"rating": None,
|
||||||
"studio": None,
|
"studio": None,
|
||||||
"total_episodes": None,
|
"total_episodes": None,
|
||||||
"status": None,
|
"status": None,
|
||||||
"original_title": original_title,
|
|
||||||
"director": director,
|
|
||||||
"cast": cast,
|
|
||||||
"runtime": runtime,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -359,80 +301,3 @@ class FS7Downloader(BaseSeriesSite):
|
|||||||
return await player.get_download_link(url, target_filename)
|
return await player.get_download_link(url, target_filename)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"No video player found for URL: {url}")
|
raise ValueError(f"No video player found for URL: {url}")
|
||||||
|
|
||||||
async def get_latest_series(self, limit: int = 20) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Scrape the 'Nouveautés Séries' section from FS7 homepage.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of dicts with title, url, cover_image, synopsis, lang, provider_id.
|
|
||||||
"""
|
|
||||||
await self._ensure_base_url()
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = await self.client.get(self.base_url + "/", timeout=15)
|
|
||||||
soup = BeautifulSoup(resp.text, "html.parser")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch FS7 homepage: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Find the 'Nouveautés Séries' section
|
|
||||||
for section in soup.find_all("div", class_="pages"):
|
|
||||||
title_el = section.find("div", class_="sect-t")
|
|
||||||
if not title_el:
|
|
||||||
continue
|
|
||||||
title = title_el.get_text(strip=True)
|
|
||||||
if "Nouveautés" not in title or "Séries" not in title:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for item in section.find_all("div", class_="short"):
|
|
||||||
# Get the poster link (contains real URL)
|
|
||||||
poster_a = item.find("a", class_="short-poster", href=True)
|
|
||||||
if not poster_a:
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = poster_a["href"]
|
|
||||||
if url.startswith("/"):
|
|
||||||
url = self.base_url + url
|
|
||||||
|
|
||||||
# Title from alt attribute
|
|
||||||
title_attr = poster_a.get("alt", "").strip()
|
|
||||||
if not title_attr:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Poster image
|
|
||||||
img = poster_a.find("img")
|
|
||||||
cover_image = img.get("src", "") if img else ""
|
|
||||||
|
|
||||||
# Synopsis from hidden span
|
|
||||||
desc_span = item.find("span", id=re.compile(r"^desc-\d+"))
|
|
||||||
synopsis = desc_span.get_text(strip=True) if desc_span else ""
|
|
||||||
|
|
||||||
# Language (VF/VOSTFR)
|
|
||||||
lang = "vf"
|
|
||||||
version_span = item.find("span", class_="film-version")
|
|
||||||
if version_span:
|
|
||||||
version_text = version_span.get_text(strip=True).upper()
|
|
||||||
if "VOSTFR" in version_text:
|
|
||||||
lang = "vostfr"
|
|
||||||
elif "VF" in version_text:
|
|
||||||
lang = "vf"
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"title": title_attr,
|
|
||||||
"url": url,
|
|
||||||
"cover_image": cover_image,
|
|
||||||
"synopsis": synopsis,
|
|
||||||
"lang": lang,
|
|
||||||
"provider_id": self.provider_id,
|
|
||||||
"content_type": "series",
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(results) >= limit:
|
|
||||||
break
|
|
||||||
break # Only process the first matching section
|
|
||||||
|
|
||||||
logger.info(f"FS7 latest series: found {len(results)} items")
|
|
||||||
return results
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -70,4 +70,3 @@ from .watchlist import WatchlistItemTable, WatchlistSettingsTable
|
|||||||
from .favorites import FavoriteTable
|
from .favorites import FavoriteTable
|
||||||
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
from .sonarr import SonarrMappingTable, SonarrConfigTable
|
||||||
from .settings import AppSettingsTable
|
from .settings import AppSettingsTable
|
||||||
from .download import DownloadTaskTable
|
|
||||||
|
|||||||
@@ -62,5 +62,24 @@ class UserInDB(User):
|
|||||||
"""Schema for user stored in database (with hashed password)"""
|
"""Schema for user stored in database (with hashed password)"""
|
||||||
hashed_password: str
|
hashed_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshTokenTable(SQLModel, table=True):
|
||||||
|
"""Database table for refresh tokens"""
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
primary_key=True,
|
||||||
|
index=True,
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
token_id: str = Field(index=True, unique=True)
|
||||||
|
username: str = Field(index=True)
|
||||||
|
created_at: datetime = Field(default_factory=datetime.now)
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
revoked: bool = Field(default=False)
|
||||||
|
revoked_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
# Import WatchlistItemTable here to resolve SQLModel Relationship mappings
|
||||||
from .watchlist import WatchlistItemTable
|
from .watchlist import WatchlistItemTable
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Models for download task persistence with SQLModel support"""
|
|
||||||
import uuid
|
|
||||||
from typing import Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from sqlmodel import SQLModel, Field, Column, String
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadStatus(str, Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
DOWNLOADING = "downloading"
|
|
||||||
PAUSED = "paused"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadTaskTable(SQLModel, table=True):
|
|
||||||
"""Database table for persisting download tasks across server restarts."""
|
|
||||||
__tablename__ = "download_tasks"
|
|
||||||
|
|
||||||
id: str = Field(
|
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
|
||||||
primary_key=True,
|
|
||||||
index=True,
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
url: str = Field(default="", sa_column=Column(String))
|
|
||||||
filename: str = Field(sa_column=Column(String))
|
|
||||||
host: str = Field(default="other", sa_column=Column(String))
|
|
||||||
status: str = Field(default="pending", sa_column=Column(String))
|
|
||||||
progress: float = Field(default=0.0)
|
|
||||||
downloaded_bytes: int = Field(default=0)
|
|
||||||
total_bytes: Optional[int] = Field(default=None)
|
|
||||||
speed: float = Field(default=0.0)
|
|
||||||
error: Optional[str] = Field(default=None, sa_column=Column(String))
|
|
||||||
created_at: datetime = Field(default_factory=datetime.now)
|
|
||||||
started_at: Optional[datetime] = Field(default=None)
|
|
||||||
completed_at: Optional[datetime] = Field(default=None)
|
|
||||||
file_path: Optional[str] = Field(default=None, sa_column=Column(String))
|
|
||||||
@@ -27,19 +27,12 @@ class AppSettingsBase(SQLModel):
|
|||||||
|
|
||||||
# #12: Custom download directory
|
# #12: Custom download directory
|
||||||
download_dir: str = Field(default="downloads")
|
download_dir: str = Field(default="downloads")
|
||||||
|
|
||||||
# #13: Content weight mode ("auto" = based on download habits, "manual" = user-defined)
|
|
||||||
content_weight_mode: str = Field(default="auto", sa_column=Column(String))
|
|
||||||
|
|
||||||
# #14: Manual content weights (used when content_weight_mode = "manual")
|
|
||||||
content_weight_anime: int = Field(default=2)
|
|
||||||
content_weight_series: int = Field(default=1)
|
|
||||||
|
|
||||||
@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
|
||||||
@@ -71,9 +64,6 @@ class AppSettings(BaseModel):
|
|||||||
anime_enabled: bool = True
|
anime_enabled: bool = True
|
||||||
series_enabled: bool = True
|
series_enabled: bool = True
|
||||||
download_dir: str = "downloads"
|
download_dir: str = "downloads"
|
||||||
content_weight_mode: str = "auto"
|
|
||||||
content_weight_anime: int = 2
|
|
||||||
content_weight_series: int = 1
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -89,6 +79,3 @@ class AppSettingsUpdate(BaseModel):
|
|||||||
anime_enabled: Optional[bool] = None
|
anime_enabled: Optional[bool] = None
|
||||||
series_enabled: Optional[bool] = None
|
series_enabled: Optional[bool] = None
|
||||||
download_dir: Optional[str] = None
|
download_dir: Optional[str] = None
|
||||||
content_weight_mode: Optional[str] = None
|
|
||||||
content_weight_anime: Optional[int] = None
|
|
||||||
content_weight_series: Optional[int] = 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(
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from app.download_manager import DownloadManager
|
|||||||
from app.downloaders import (
|
from app.downloaders import (
|
||||||
AnimeSamaDownloader,
|
AnimeSamaDownloader,
|
||||||
AnimeUltimeDownloader,
|
AnimeUltimeDownloader,
|
||||||
NekoSamaDownloader,
|
|
||||||
VostfreeDownloader,
|
VostfreeDownloader,
|
||||||
ZoneTelechargementDownloader,
|
ZoneTelechargementDownloader,
|
||||||
get_downloader,
|
get_downloader,
|
||||||
@@ -59,12 +58,10 @@ async def get_providers_health():
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/providers/health/check")
|
@router.post("/providers/health/check")
|
||||||
async def trigger_providers_health_check(background_tasks: BackgroundTasks):
|
async def trigger_providers_health_check():
|
||||||
"""Trigger a manual health check of all providers in the background"""
|
"""Trigger a manual health check of all providers"""
|
||||||
from app.auto_download_scheduler import auto_download_scheduler
|
await providers_manager.check_all_health()
|
||||||
|
return {"status": "ok", "providers": providers_manager.get_all_status()}
|
||||||
background_tasks.add_task(auto_download_scheduler.trigger_health_check_now)
|
|
||||||
return {"status": "Health check triggered in background"}
|
|
||||||
|
|
||||||
|
|
||||||
def get_download_manager() -> DownloadManager:
|
def get_download_manager() -> DownloadManager:
|
||||||
@@ -136,7 +133,6 @@ async def search_anime_unified(
|
|||||||
# Legacy providers (already included in providers_manager, but keep for fallback)
|
# Legacy providers (already included in providers_manager, but keep for fallback)
|
||||||
legacy_downloaders = {
|
legacy_downloaders = {
|
||||||
"anime-ultime": AnimeUltimeDownloader(),
|
"anime-ultime": AnimeUltimeDownloader(),
|
||||||
"neko-sama": NekoSamaDownloader(),
|
|
||||||
"vostfree": VostfreeDownloader(),
|
"vostfree": VostfreeDownloader(),
|
||||||
}
|
}
|
||||||
for pid, dl in legacy_downloaders.items():
|
for pid, dl in legacy_downloaders.items():
|
||||||
@@ -196,6 +192,12 @@ async def search_anime_unified(
|
|||||||
else:
|
else:
|
||||||
item_dict["_relevance_boost"] = 0.3
|
item_dict["_relevance_boost"] = 0.3
|
||||||
|
|
||||||
|
# Filter out results with very low relevance
|
||||||
|
MIN_RELEVANCE_THRESHOLD = 0.5
|
||||||
|
if item_dict["_relevance_boost"] < MIN_RELEVANCE_THRESHOLD:
|
||||||
|
logger.debug(f"Filtered low-relevance result '{title}' for query '{q}' (score: {item_dict['_relevance_boost']})")
|
||||||
|
continue
|
||||||
|
|
||||||
results[pid].append(item_dict)
|
results[pid].append(item_dict)
|
||||||
|
|
||||||
# Prepare enrichment task for top 15 results per provider
|
# Prepare enrichment task for top 15 results per provider
|
||||||
@@ -296,7 +298,8 @@ async def search_series_unified(
|
|||||||
|
|
||||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||||
|
|
||||||
# Enrich results with metadata from scrapers (not Kitsu — Kitsu is anime-only)
|
# Enrich results with metadata (synopsis, rating, genres)
|
||||||
|
enricher = await get_metadata_enricher()
|
||||||
enrichment_tasks = []
|
enrichment_tasks = []
|
||||||
enrichment_mapping = []
|
enrichment_mapping = []
|
||||||
|
|
||||||
@@ -307,15 +310,17 @@ async def search_series_unified(
|
|||||||
elif result:
|
elif result:
|
||||||
results[provider_id] = result
|
results[provider_id] = result
|
||||||
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
print(f"[SERIES SEARCH] {provider_id}: Found {len(result)} results")
|
||||||
# Enrich top 10 results with metadata from the scraper itself
|
# Prepare enrichment for top 15 results
|
||||||
downloader = series_downloaders.get(provider_id)
|
for idx, item in enumerate(result[:15]):
|
||||||
if downloader and hasattr(downloader, "get_anime_metadata"):
|
if isinstance(item, dict):
|
||||||
for idx, item in enumerate(result[:10]):
|
enrichment_tasks.append(
|
||||||
if isinstance(item, dict) and item.get("url"):
|
enricher.enrich_metadata(
|
||||||
enrichment_tasks.append(
|
item.get("metadata") or {},
|
||||||
downloader.get_anime_metadata(item["url"])
|
item.get("title") or "",
|
||||||
|
item.get("url") or "",
|
||||||
)
|
)
|
||||||
enrichment_mapping.append((provider_id, idx))
|
)
|
||||||
|
enrichment_mapping.append((provider_id, idx))
|
||||||
else:
|
else:
|
||||||
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
print(f"[SERIES SEARCH] {provider_id}: No results returned")
|
||||||
|
|
||||||
@@ -331,7 +336,9 @@ async def search_series_unified(
|
|||||||
and provider_id in results
|
and provider_id in results
|
||||||
and pos < len(results[provider_id])
|
and pos < len(results[provider_id])
|
||||||
):
|
):
|
||||||
results[provider_id][pos]["metadata"] = meta
|
results[provider_id][pos]["metadata"] = (
|
||||||
|
meta.model_dump() if hasattr(meta, "model_dump") else meta
|
||||||
|
)
|
||||||
|
|
||||||
# Truncate synopses at sentence boundaries
|
# Truncate synopses at sentence boundaries
|
||||||
for pid in results:
|
for pid in results:
|
||||||
@@ -534,7 +541,5 @@ async def translate_text(request: Request):
|
|||||||
translated = "".join([item[0] for item in data[0] if item[0]])
|
translated = "".join([item[0] for item in data[0] if item[0]])
|
||||||
return {"translatedText": translated, "status": "success"}
|
return {"translatedText": translated, "status": "success"}
|
||||||
raise HTTPException(status_code=500, detail="Translation failed")
|
raise HTTPException(status_code=500, detail="Translation failed")
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Translation error: {str(e)}")
|
||||||
|
|||||||
@@ -3,22 +3,15 @@ Recommendations and releases routes for Ohm Stream Downloader API.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
from fastapi import APIRouter, Request, Query, HTTPException, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlmodel import Session, select
|
|
||||||
|
|
||||||
from app.recommendation_engine import RecommendationEngine
|
from app.recommendation_engine import RecommendationEngine
|
||||||
from app.models.auth import User
|
from app.models.auth import User
|
||||||
from app.models.settings import AppSettingsTable
|
|
||||||
from app.database import get_session
|
|
||||||
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
from app.routers.router_auth import get_optional_user, get_current_user_from_token
|
||||||
from app.routers.router_settings import _compute_auto_weights
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["recommendations"])
|
router = APIRouter(prefix="/api", tags=["recommendations"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -30,79 +23,6 @@ def hash_filter(s):
|
|||||||
templates.env.filters["hash"] = hash_filter
|
templates.env.filters["hash"] = hash_filter
|
||||||
|
|
||||||
|
|
||||||
def _get_effective_weights(session: Session, user_id: str) -> tuple:
|
|
||||||
"""Return (anime_enabled, series_enabled, anime_weight, series_weight)."""
|
|
||||||
settings = session.exec(
|
|
||||||
select(AppSettingsTable).where(AppSettingsTable.user_id == user_id)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if settings is None:
|
|
||||||
return True, True, 1, 1
|
|
||||||
|
|
||||||
anime_enabled = getattr(settings, 'anime_enabled', True)
|
|
||||||
series_enabled = getattr(settings, 'series_enabled', True)
|
|
||||||
mode = getattr(settings, 'content_weight_mode', 'auto')
|
|
||||||
download_dir = getattr(settings, 'download_dir', 'downloads')
|
|
||||||
|
|
||||||
if mode == "auto":
|
|
||||||
weights = _compute_auto_weights(download_dir)
|
|
||||||
return anime_enabled, series_enabled, weights["anime_weight"], weights["series_weight"]
|
|
||||||
else:
|
|
||||||
aw = getattr(settings, 'content_weight_anime', 2)
|
|
||||||
sw = getattr(settings, 'content_weight_series', 1)
|
|
||||||
return anime_enabled, series_enabled, int(aw), int(sw)
|
|
||||||
|
|
||||||
|
|
||||||
def _weighted_mix(items_a: List[Dict], items_b: List[Dict], limit: int,
|
|
||||||
weight_a: int = 2, weight_b: int = 1) -> List[Dict]:
|
|
||||||
"""Mix two lists using weights. Distributes items proportionally and interleaves.
|
|
||||||
|
|
||||||
If weight_a=2, weight_b=1 and limit=15:
|
|
||||||
- slots_a ≈ 10, slots_b ≈ 5
|
|
||||||
- B items are spaced evenly across the list
|
|
||||||
If one list is shorter, the other fills remaining slots.
|
|
||||||
"""
|
|
||||||
total_weight = weight_a + weight_b
|
|
||||||
if total_weight == 0:
|
|
||||||
return (items_a + items_b)[:limit]
|
|
||||||
|
|
||||||
slots_a = round(limit * weight_a / total_weight)
|
|
||||||
slots_b = limit - slots_a
|
|
||||||
|
|
||||||
pick_a = min(slots_a, len(items_a))
|
|
||||||
pick_b = min(slots_b, len(items_b))
|
|
||||||
|
|
||||||
# Redistribute unfilled slots
|
|
||||||
if pick_a < slots_a:
|
|
||||||
pick_b = min(pick_b + (slots_a - pick_a), len(items_b))
|
|
||||||
elif pick_b < slots_b:
|
|
||||||
pick_a = min(pick_a + (slots_b - pick_b), len(items_a))
|
|
||||||
|
|
||||||
a = items_a[:pick_a]
|
|
||||||
b = items_b[:pick_b]
|
|
||||||
|
|
||||||
total = pick_a + pick_b
|
|
||||||
if total == 0:
|
|
||||||
return []
|
|
||||||
if pick_b == 0:
|
|
||||||
return a[:limit]
|
|
||||||
if pick_a == 0:
|
|
||||||
return b[:limit]
|
|
||||||
|
|
||||||
# Place B items at evenly spaced positions, fill gaps with A
|
|
||||||
result = [None] * total
|
|
||||||
for i, item in enumerate(b):
|
|
||||||
pos = round(i * (total - 1) / max(pick_b - 1, 1))
|
|
||||||
result[pos] = item
|
|
||||||
a_idx = 0
|
|
||||||
for i in range(total):
|
|
||||||
if result[i] is None:
|
|
||||||
result[i] = a[a_idx]
|
|
||||||
a_idx += 1
|
|
||||||
|
|
||||||
return result[:limit]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recommendations")
|
@router.get("/recommendations")
|
||||||
async def get_recommendations(
|
async def get_recommendations(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -110,9 +30,8 @@ async def get_recommendations(
|
|||||||
html: bool = Query(False),
|
html: bool = Query(False),
|
||||||
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||||
current_user: Optional[User] = Depends(get_optional_user),
|
current_user: Optional[User] = Depends(get_optional_user),
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
):
|
||||||
"""Get personalized recommendations based on user settings (anime + series)"""
|
"""Get personalized anime recommendations based on download history"""
|
||||||
is_htmx = request.headers.get("HX-Request")
|
is_htmx = request.headers.get("HX-Request")
|
||||||
|
|
||||||
if current_user is None and (html or is_htmx):
|
if current_user is None and (html or is_htmx):
|
||||||
@@ -123,38 +42,14 @@ async def get_recommendations(
|
|||||||
if current_user is None:
|
if current_user is None:
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
|
||||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
engine = RecommendationEngine(download_dir="downloads")
|
||||||
recommendations = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if anime_enabled:
|
recommendations = await engine.get_personalized_recommendations(limit=limit)
|
||||||
engine = RecommendationEngine(download_dir="downloads")
|
|
||||||
try:
|
# Filter by content_type if specified
|
||||||
anime_recs = await engine.get_personalized_recommendations(limit=limit)
|
|
||||||
for r in anime_recs:
|
|
||||||
r['content_type'] = 'anime'
|
|
||||||
recommendations.extend(anime_recs)
|
|
||||||
finally:
|
|
||||||
await engine.close()
|
|
||||||
|
|
||||||
if series_enabled:
|
|
||||||
try:
|
|
||||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
|
||||||
downloader = FS7Downloader()
|
|
||||||
series_recs = await downloader.get_latest_series(limit=limit)
|
|
||||||
for r in series_recs:
|
|
||||||
r['content_type'] = 'series'
|
|
||||||
recommendations.extend(series_recs)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Series recommendations fetch failed: {e}")
|
|
||||||
|
|
||||||
if content_type and content_type != "all":
|
if content_type and content_type != "all":
|
||||||
recommendations = [r for r in recommendations if r.get("content_type") == content_type]
|
recommendations = [r for r in recommendations if r.get("content_type", r.get("type", "")) == content_type]
|
||||||
else:
|
|
||||||
anime_items = [r for r in recommendations if r.get("content_type") == "anime"]
|
|
||||||
series_items = [r for r in recommendations if r.get("content_type") == "series"]
|
|
||||||
recommendations = _weighted_mix(anime_items, series_items, limit,
|
|
||||||
weight_a=anime_weight, weight_b=series_weight)
|
|
||||||
|
|
||||||
if html or is_htmx:
|
if html or is_htmx:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -164,8 +59,11 @@ async def get_recommendations(
|
|||||||
|
|
||||||
return {"recommendations": recommendations, "count": len(recommendations)}
|
return {"recommendations": recommendations, "count": len(recommendations)}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Recommendations error: {e}", exc_info=True)
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"Recommendations error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
finally:
|
||||||
|
await engine.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/releases/latest")
|
@router.get("/releases/latest")
|
||||||
@@ -174,52 +72,18 @@ async def get_latest_releases(
|
|||||||
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"),
|
content_type: Optional[str] = Query(None, description="Filter: 'anime', 'series', or None for all"),
|
||||||
current_user: Optional[User] = Depends(get_optional_user),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
):
|
||||||
"""Get latest releases based on user settings (anime + series)"""
|
"""Get latest anime releases"""
|
||||||
from app.recommendations import get_latest_releases_with_info
|
from app.recommendations import get_latest_releases_with_info
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
anime_enabled, series_enabled, anime_weight, series_weight = _get_effective_weights(session, current_user.id)
|
|
||||||
releases = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if anime_enabled:
|
releases = await get_latest_releases_with_info(limit=limit)
|
||||||
anime_releases = await get_latest_releases_with_info(limit=limit)
|
|
||||||
for r in anime_releases:
|
# Filter by content_type if specified
|
||||||
r['content_type'] = 'anime'
|
|
||||||
releases.extend(anime_releases)
|
|
||||||
|
|
||||||
if series_enabled:
|
|
||||||
try:
|
|
||||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
|
||||||
downloader = FS7Downloader()
|
|
||||||
series_releases = await downloader.get_latest_series(limit=limit)
|
|
||||||
for r in series_releases:
|
|
||||||
r['content_type'] = 'series'
|
|
||||||
releases.extend(series_releases)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Series releases fetch failed: {e}")
|
|
||||||
|
|
||||||
if content_type and content_type != "all":
|
if content_type and content_type != "all":
|
||||||
releases = [r for r in releases if r.get("content_type") == content_type]
|
releases = [r for r in releases if r.get("content_type", r.get("type", "")) == content_type]
|
||||||
else:
|
|
||||||
anime_items = [r for r in releases if r.get("content_type") == "anime"]
|
|
||||||
series_items = [r for r in releases if r.get("content_type") == "series"]
|
|
||||||
releases = _weighted_mix(anime_items, series_items, limit,
|
|
||||||
weight_a=anime_weight, weight_b=series_weight)
|
|
||||||
|
|
||||||
if html or is_htmx:
|
if html or request.headers.get("HX-Request"):
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"components/releases_list.html",
|
"components/releases_list.html",
|
||||||
{"request": request, "releases": releases}
|
{"request": request, "releases": releases}
|
||||||
@@ -231,7 +95,8 @@ async def get_latest_releases(
|
|||||||
"updated": datetime.now().isoformat(),
|
"updated": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Latest releases error: {e}", exc_info=True)
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"Latest releases error: {e}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@@ -312,41 +177,3 @@ async def get_download_statistics(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
finally:
|
finally:
|
||||||
await engine.close()
|
await engine.close()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/series/latest")
|
|
||||||
async def get_latest_series(
|
|
||||||
request: Request,
|
|
||||||
limit: int = 20,
|
|
||||||
html: bool = Query(False),
|
|
||||||
current_user: Optional[User] = Depends(get_optional_user),
|
|
||||||
):
|
|
||||||
"""Get latest TV series releases from FS7 homepage"""
|
|
||||||
if current_user is None and (html or request.headers.get("HX-Request")):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"components/login_prompt.html", {"request": request}
|
|
||||||
)
|
|
||||||
|
|
||||||
if current_user is None:
|
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from app.downloaders.series_sites.fs7 import FS7Downloader
|
|
||||||
|
|
||||||
downloader = FS7Downloader()
|
|
||||||
series = await downloader.get_latest_series(limit=limit)
|
|
||||||
|
|
||||||
if html or request.headers.get("HX-Request"):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"components/series_releases_list.html",
|
|
||||||
{"request": request, "releases": series}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"releases": series,
|
|
||||||
"count": len(series),
|
|
||||||
"updated": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Latest series error: {e}", exc_info=True)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"""Application settings routes for Ohm Stream Downloader API"""
|
"""Application settings routes for Ohm Stream Downloader API"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -15,74 +13,10 @@ from app.routers.router_auth import get_current_user_from_token, get_optional_us
|
|||||||
from app.providers import get_anime_providers, get_series_providers
|
from app.providers import get_anime_providers, get_series_providers
|
||||||
from app.providers_manager import providers_manager
|
from app.providers_manager import providers_manager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
def _compute_auto_weights(download_dir: str) -> Dict[str, Any]:
|
|
||||||
"""Analyze downloaded files to compute anime vs series ratio.
|
|
||||||
|
|
||||||
Uses filename conventions:
|
|
||||||
- Series: contains "Saison" or "Season" keywords
|
|
||||||
- Anime: everything else in the downloads folder
|
|
||||||
Returns dict with counts and computed weights.
|
|
||||||
"""
|
|
||||||
base = Path(download_dir)
|
|
||||||
if not base.exists():
|
|
||||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
|
||||||
|
|
||||||
anime_count = 0
|
|
||||||
series_count = 0
|
|
||||||
|
|
||||||
for f in base.rglob("*"):
|
|
||||||
if not f.is_file():
|
|
||||||
continue
|
|
||||||
if f.suffix.lower() not in (".mp4", ".mkv", ".avi", ".wmv", ".flv", ".mov"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
name = f.stem.lower()
|
|
||||||
# Heuristic: series TV files often have "Saison" or "Season" + number
|
|
||||||
# Anime files rarely use this format (they use "Episode" or "S01E01")
|
|
||||||
import re
|
|
||||||
if re.search(r'(?:saison|season)\s*\d+', name):
|
|
||||||
series_count += 1
|
|
||||||
else:
|
|
||||||
anime_count += 1
|
|
||||||
|
|
||||||
total = anime_count + series_count
|
|
||||||
if total == 0:
|
|
||||||
return {"anime_count": 0, "series_count": 0, "anime_weight": 1, "series_weight": 1, "total": 0}
|
|
||||||
|
|
||||||
# Compute weights: proportional to download count, minimum 1
|
|
||||||
if anime_count == 0:
|
|
||||||
aw, sw = 0, 1
|
|
||||||
elif series_count == 0:
|
|
||||||
aw, sw = 1, 0
|
|
||||||
else:
|
|
||||||
# Keep weights small (max 5) for reasonable interleaving
|
|
||||||
ratio = anime_count / series_count
|
|
||||||
if ratio >= 4:
|
|
||||||
aw, sw = 4, 1
|
|
||||||
elif ratio >= 2:
|
|
||||||
aw, sw = 2, 1
|
|
||||||
elif ratio >= 1:
|
|
||||||
aw, sw = 1, 1
|
|
||||||
elif ratio >= 0.5:
|
|
||||||
aw, sw = 1, 2
|
|
||||||
else:
|
|
||||||
aw, sw = 1, 4
|
|
||||||
|
|
||||||
return {
|
|
||||||
"anime_count": anime_count,
|
|
||||||
"series_count": series_count,
|
|
||||||
"anime_weight": aw,
|
|
||||||
"series_weight": sw,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(
|
async def get_settings(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
@@ -110,9 +44,6 @@ async def get_settings(
|
|||||||
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
anime_enabled=getattr(settings_obj, 'anime_enabled', True),
|
||||||
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
series_enabled=getattr(settings_obj, 'series_enabled', True),
|
||||||
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
download_dir=getattr(settings_obj, 'download_dir', 'downloads'),
|
||||||
content_weight_mode=getattr(settings_obj, 'content_weight_mode', 'auto'),
|
|
||||||
content_weight_anime=getattr(settings_obj, 'content_weight_anime', 2),
|
|
||||||
content_weight_series=getattr(settings_obj, 'content_weight_series', 1),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -155,12 +86,6 @@ async def update_settings(
|
|||||||
settings_obj.series_enabled = update_data.series_enabled
|
settings_obj.series_enabled = update_data.series_enabled
|
||||||
if update_data.download_dir is not None:
|
if update_data.download_dir is not None:
|
||||||
settings_obj.download_dir = update_data.download_dir
|
settings_obj.download_dir = update_data.download_dir
|
||||||
if update_data.content_weight_mode is not None:
|
|
||||||
settings_obj.content_weight_mode = update_data.content_weight_mode
|
|
||||||
if update_data.content_weight_anime is not None:
|
|
||||||
settings_obj.content_weight_anime = update_data.content_weight_anime
|
|
||||||
if update_data.content_weight_series is not None:
|
|
||||||
settings_obj.content_weight_series = update_data.content_weight_series
|
|
||||||
|
|
||||||
session.add(settings_obj)
|
session.add(settings_obj)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -173,34 +98,6 @@ async def update_settings(
|
|||||||
return settings_obj
|
return settings_obj
|
||||||
|
|
||||||
|
|
||||||
@router.get("/content-weight")
|
|
||||||
async def get_content_weight(
|
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
):
|
|
||||||
"""Get current effective content weights (auto-computed or manual)"""
|
|
||||||
statement = select(AppSettingsTable).where(
|
|
||||||
AppSettingsTable.user_id == current_user.id
|
|
||||||
)
|
|
||||||
settings_obj = session.exec(statement).first()
|
|
||||||
download_dir = getattr(settings_obj, 'download_dir', 'downloads') if settings_obj else 'downloads'
|
|
||||||
mode = getattr(settings_obj, 'content_weight_mode', 'auto') if settings_obj else 'auto'
|
|
||||||
|
|
||||||
if mode == "auto":
|
|
||||||
weights = _compute_auto_weights(download_dir)
|
|
||||||
weights["mode"] = "auto"
|
|
||||||
return weights
|
|
||||||
else:
|
|
||||||
return {
|
|
||||||
"mode": "manual",
|
|
||||||
"anime_weight": getattr(settings_obj, 'content_weight_anime', 2),
|
|
||||||
"series_weight": getattr(settings_obj, 'content_weight_series', 1),
|
|
||||||
"anime_count": None,
|
|
||||||
"series_count": None,
|
|
||||||
"total": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/providers/availability")
|
@router.get("/providers/availability")
|
||||||
async def get_providers_availability(
|
async def get_providers_availability(
|
||||||
current_user: User = Depends(get_current_user_from_token),
|
current_user: User = Depends(get_current_user_from_token),
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ async def delete_from_watchlist(
|
|||||||
raise HTTPException(status_code=500, detail="Failed to delete item")
|
raise HTTPException(status_code=500, detail="Failed to delete item")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/check")
|
@router.post("/check", response_model=List)
|
||||||
async def check_watchlist_now(
|
async def check_watchlist_now(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,18 +95,13 @@ class DomainManager:
|
|||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
# Verify it's actually the right site, not a parking/placeholder page
|
logger.info(f"Active domain found for {provider_id}: {domain}")
|
||||||
content = response.text.lower()
|
cls._cache[provider_id] = {
|
||||||
body_size = len(response.text)
|
'domain': domain,
|
||||||
# Valid pages should be reasonably large and contain expected keywords
|
'last_check': datetime.now().isoformat()
|
||||||
if body_size > 10000 and ('french' in content or 'stream' in content or 'serie' in content or 'anime' in content or 'film' in content or 'telechargement' in content or 'zone' in content):
|
}
|
||||||
logger.info(f"Active domain found for {provider_id}: {domain} ({body_size} bytes)")
|
cls._save_cache()
|
||||||
cls._cache[provider_id] = {
|
return domain
|
||||||
'domain': domain,
|
|
||||||
'last_check': datetime.now().isoformat()
|
|
||||||
}
|
|
||||||
cls._save_cache()
|
|
||||||
return domain
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Domain test failed for {domain}: {e}")
|
logger.debug(f"Domain test failed for {domain}: {e}")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -216,12 +216,8 @@ class WatchlistManager:
|
|||||||
update_check_time = update_last_checked
|
update_check_time = update_last_checked
|
||||||
|
|
||||||
def get_due_items(self) -> List[WatchlistItem]:
|
def get_due_items(self) -> List[WatchlistItem]:
|
||||||
"""Get all items that are due for a check based on current settings"""
|
|
||||||
return self.get_due_for_check(self.settings.check_interval_hours if self.settings else 6)
|
|
||||||
|
|
||||||
def get_due_for_check(self, interval_hours: int) -> List[WatchlistItem]:
|
|
||||||
"""Get all items that are due for a check based on settings"""
|
"""Get all items that are due for a check based on settings"""
|
||||||
interval = timedelta(hours=interval_hours)
|
interval = timedelta(hours=self.settings.check_interval_hours)
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
@@ -238,12 +234,6 @@ class WatchlistManager:
|
|||||||
|
|
||||||
return due_items
|
return due_items
|
||||||
|
|
||||||
def get_settings(self) -> WatchlistSettings:
|
|
||||||
"""Get global watchlist settings"""
|
|
||||||
if self.settings is None:
|
|
||||||
self._load_settings()
|
|
||||||
return self.settings
|
|
||||||
|
|
||||||
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
def update_settings(self, settings: WatchlistSettings) -> WatchlistSettings:
|
||||||
"""Update global watchlist settings"""
|
"""Update global watchlist settings"""
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
const BASE = 'http://127.0.0.1:3000';
|
|
||||||
const opts = { waitUntil: 'domcontentloaded', timeout: 15000 };
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
|
||||||
|
|
||||||
// Obtenir un token via API
|
|
||||||
const apiCtx = await browser.newContext();
|
|
||||||
const apiPage = await apiCtx.newPage();
|
|
||||||
await apiPage.goto(BASE + '/api/auth/login', opts);
|
|
||||||
const token = await apiPage.evaluate(async () => {
|
|
||||||
const res = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: 'roman', password: 'roman123' })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return data.access_token || null;
|
|
||||||
});
|
|
||||||
await apiCtx.close();
|
|
||||||
console.log(`Token obtained: ${token ? token.substring(0, 20) + '...' : 'FAILED'}`);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
console.error('Cannot get token, aborting');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== NON AUTHENTIFIE ==========
|
|
||||||
console.log('\n=== NON AUTHENTIFIE ===');
|
|
||||||
const anonCtx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
||||||
const anon = await anonCtx.newPage();
|
|
||||||
|
|
||||||
const snap = async (p, name, url, wait = 3000) => {
|
|
||||||
try {
|
|
||||||
await p.goto(url, opts);
|
|
||||||
await p.waitForTimeout(wait);
|
|
||||||
await p.screenshot({ path: `/tmp/screenshots/${name}.png`, fullPage: false });
|
|
||||||
console.log(`OK: ${name}`);
|
|
||||||
} catch(e) {
|
|
||||||
console.log(`FAIL: ${name} - ${e.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await snap(anon, 'anon_01_home', `${BASE}/`);
|
|
||||||
await snap(anon, 'anon_02_watchlist', `${BASE}/watchlist`);
|
|
||||||
await snap(anon, 'anon_03_favorites', `${BASE}/favorites`);
|
|
||||||
await snap(anon, 'anon_04_downloads', `${BASE}/downloads`);
|
|
||||||
await snap(anon, 'anon_05_settings', `${BASE}/settings`);
|
|
||||||
await snap(anon, 'anon_06_recommendations', `${BASE}/recommendations`);
|
|
||||||
|
|
||||||
// ========== AUTHENTIFIE (cookie + localStorage) ==========
|
|
||||||
console.log('\n=== AUTHENTIFIE ===');
|
|
||||||
const authCtx = await browser.newContext({
|
|
||||||
viewport: { width: 1440, height: 900 },
|
|
||||||
});
|
|
||||||
// Injecter le token comme cookie AVANT toute navigation
|
|
||||||
await authCtx.addCookies([{
|
|
||||||
name: 'auth_token',
|
|
||||||
value: token,
|
|
||||||
domain: '127.0.0.1',
|
|
||||||
path: '/',
|
|
||||||
sameSite: 'Strict',
|
|
||||||
httpOnly: false,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
const auth = await authCtx.newPage();
|
|
||||||
|
|
||||||
// Injecter dans localStorage au premier chargement
|
|
||||||
await auth.goto(BASE + '/', opts);
|
|
||||||
await auth.evaluate((t) => {
|
|
||||||
localStorage.setItem('auth_token', t);
|
|
||||||
}, token);
|
|
||||||
await auth.waitForTimeout(3000);
|
|
||||||
await auth.screenshot({ path: '/tmp/screenshots/auth_01_home.png', fullPage: false });
|
|
||||||
console.log('OK: auth_01_home');
|
|
||||||
|
|
||||||
await snap(auth, 'auth_02_watchlist', `${BASE}/watchlist`);
|
|
||||||
await snap(auth, 'auth_03_favorites', `${BASE}/favorites`);
|
|
||||||
await snap(auth, 'auth_04_downloads', `${BASE}/downloads`);
|
|
||||||
await snap(auth, 'auth_05_settings', `${BASE}/settings`);
|
|
||||||
await snap(auth, 'auth_06_recommendations', `${BASE}/recommendations`);
|
|
||||||
|
|
||||||
// ========== TESTS FONCTIONNELS ==========
|
|
||||||
console.log('\n=== TESTS FONCTIONNELS ===');
|
|
||||||
|
|
||||||
// Test API: toggle favori
|
|
||||||
const favResult = await auth.evaluate(async (t) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/favorites/toggle', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${t}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return { status: res.status, is_favorite: data.is_favorite };
|
|
||||||
} catch(e) {
|
|
||||||
return { error: e.message };
|
|
||||||
}
|
|
||||||
}, token);
|
|
||||||
console.log(`Favorite toggle: ${JSON.stringify(favResult)}`);
|
|
||||||
|
|
||||||
// Voir les favoris
|
|
||||||
await snap(auth, 'auth_07_favorites_after_add', `${BASE}/favorites`);
|
|
||||||
|
|
||||||
// Test API: ajouter watchlist item
|
|
||||||
const wlResult = await auth.evaluate(async (t) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/watchlist', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${t}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
anime_title: 'Test Screenshot Anime',
|
|
||||||
anime_url: 'https://example.com/anime/1',
|
|
||||||
episode_count: 12,
|
|
||||||
current_episode: 0,
|
|
||||||
status: 'watching'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
return { status: res.status, id: data.id, title: data.anime_title };
|
|
||||||
} catch(e) {
|
|
||||||
return { error: e.message };
|
|
||||||
}
|
|
||||||
}, token);
|
|
||||||
console.log(`Watchlist add: ${JSON.stringify(wlResult)}`);
|
|
||||||
|
|
||||||
// Voir la watchlist
|
|
||||||
await snap(auth, 'auth_08_watchlist_with_item', `${BASE}/watchlist`);
|
|
||||||
|
|
||||||
// Scroller sur la home
|
|
||||||
await auth.goto(`${BASE}/`, opts);
|
|
||||||
await auth.waitForTimeout(2000);
|
|
||||||
await auth.evaluate(() => window.scrollTo(0, 600));
|
|
||||||
await auth.waitForTimeout(1000);
|
|
||||||
await auth.screenshot({ path: '/tmp/screenshots/auth_09_home_scrolled.png', fullPage: false });
|
|
||||||
console.log('OK: auth_09_home_scrolled');
|
|
||||||
|
|
||||||
// ========== NETTOYAGE ==========
|
|
||||||
console.log('\n=== Nettoyage ===');
|
|
||||||
// Retirer le favori de test
|
|
||||||
await auth.evaluate(async (t) => {
|
|
||||||
await fetch('/api/favorites/toggle', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${t}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ content_type: 'anime', anime_id: 'test-screenshot-1', title: 'Test Screenshot Anime' })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Retirer le watchlist item de test
|
|
||||||
if (wlResult.id) {
|
|
||||||
await auth.evaluate(async ({t, id}) => {
|
|
||||||
await fetch(`/api/watchlist/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Authorization': `Bearer ${t}` }
|
|
||||||
});
|
|
||||||
}, { t: token, id: wlResult.id });
|
|
||||||
console.log('Test watchlist item deleted');
|
|
||||||
}
|
|
||||||
console.log('Test favorite removed');
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
console.log('\n=== ALL DONE ===');
|
|
||||||
})();
|
|
||||||
@@ -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,21 +85,21 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
def restore_completed_downloads():
|
def restore_completed_downloads():
|
||||||
"""Restore download tasks: first from the database, then scan for untracked files."""
|
"""Scan downloads directory and restore completed download tasks"""
|
||||||
# Step 1: Load persisted tasks from database
|
|
||||||
download_manager._load_tasks_from_db()
|
|
||||||
|
|
||||||
# Step 2: Scan downloads directory for files not yet tracked in the database
|
|
||||||
download_dir = Path("downloads")
|
download_dir = Path("downloads")
|
||||||
if not download_dir.exists():
|
if not download_dir.exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
video_extensions = {".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm"}
|
||||||
tracked_filenames = {t.filename for t in download_manager.tasks.values()}
|
|
||||||
|
|
||||||
for file_path in download_dir.iterdir():
|
for file_path in download_dir.iterdir():
|
||||||
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
if file_path.is_file() and file_path.suffix.lower() in video_extensions:
|
||||||
@@ -104,11 +107,6 @@ def restore_completed_downloads():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
|
|
||||||
# Skip if already tracked in DB
|
|
||||||
if filename in tracked_filenames:
|
|
||||||
continue
|
|
||||||
|
|
||||||
file_size = file_path.stat().st_size
|
file_size = file_path.stat().st_size
|
||||||
|
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
@@ -128,8 +126,7 @@ def restore_completed_downloads():
|
|||||||
)
|
)
|
||||||
|
|
||||||
download_manager.tasks[task_id] = task
|
download_manager.tasks[task_id] = task
|
||||||
download_manager._save_task_to_db(task)
|
logger.info(f"Restored completed download: {filename}")
|
||||||
logger.info(f"Restored untracked completed download: {filename}")
|
|
||||||
|
|
||||||
|
|
||||||
# Restore completed downloads on startup
|
# Restore completed downloads on startup
|
||||||
|
|||||||
@@ -4,17 +4,10 @@
|
|||||||
"description": "Ohm Stream Downloader - Frontend JavaScript Tests",
|
"description": "Ohm Stream Downloader - Frontend JavaScript Tests",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --minify",
|
|
||||||
"watch:css": "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.60.0"
|
||||||
"@tailwindcss/cli": "^4.2.2",
|
|
||||||
"daisyui": "^5.5.19",
|
|
||||||
"jsdom": "^29.0.0",
|
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"vitest": "^1.0.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,50 +4,55 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
* @see https://playwright.dev/docs/test-configuration
|
* @see https://playwright.dev/docs/test-configuration
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests/e2e',
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
|
||||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
|
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|
||||||
/* Capture screenshot on failure */
|
/* Capture screenshot on failure */
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
|
||||||
/* Video recording on failure */
|
/* Video recording on failure */
|
||||||
video: 'retain-on-failure',
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
projects: [
|
projects: [
|
||||||
|
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: { ...devices['Desktop Chrome'] },
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
dependencies: ['setup'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'uvicorn main:app --host 0.0.0.0 --port 3000',
|
command: 'venv/bin/uvicorn main:app --host 0.0.0.0 --port 3000',
|
||||||
url: 'http://localhost:3000',
|
url: 'http://localhost:3000',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@plugin "daisyui";
|
|
||||||
|
|
||||||
@plugin "daisyui/theme" {
|
|
||||||
name: "ohmstream";
|
|
||||||
default: true;
|
|
||||||
prefersdark: false;
|
|
||||||
color-scheme: dark;
|
|
||||||
--color-base-100: oklch(0.15 0.01 260); /* #1a1c20 - main bg */
|
|
||||||
--color-base-200: oklch(0.18 0.01 260); /* #202327 - card bg */
|
|
||||||
--color-base-300: oklch(0.22 0.01 260); /* #2a2d32 - elevated */
|
|
||||||
--color-base-content: oklch(0.93 0.01 80); /* #eae8e4 - text */
|
|
||||||
--color-primary: oklch(0.72 0.16 65); /* #FF9F1C - orange */
|
|
||||||
--color-primary-content: oklch(0.18 0.02 65); /* #1a1400 */
|
|
||||||
--color-secondary: oklch(0.65 0.12 310); /* #e05faa - magenta */
|
|
||||||
--color-secondary-content: oklch(0.95 0 0);
|
|
||||||
--color-accent: oklch(0.78 0.14 75); /* #FFBF69 - gold */
|
|
||||||
--color-accent-content: oklch(0.18 0.02 75);
|
|
||||||
--color-neutral: oklch(0.25 0.01 260); /* #292b30 */
|
|
||||||
--color-neutral-content: oklch(0.9 0.01 80);
|
|
||||||
--color-info: oklch(0.65 0.15 250); /* #3b7ddd */
|
|
||||||
--color-success: oklch(0.65 0.14 155); /* #2d936c */
|
|
||||||
--color-warning: oklch(0.75 0.16 75); /* #f0a500 */
|
|
||||||
--color-error: oklch(0.6 0.2 25); /* #e63946 */
|
|
||||||
--color-error-content: oklch(0.95 0 0);
|
|
||||||
--radius-selector: 0.5rem;
|
|
||||||
--radius-field: 0.375rem;
|
|
||||||
--radius-box: 0.5rem;
|
|
||||||
--size-selector: 0.25rem;
|
|
||||||
--size-field: 0.25rem;
|
|
||||||
--border: 1px;
|
|
||||||
--depth: 1;
|
|
||||||
--noise: 0;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,7 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
if (!resultsContainer) return;
|
if (!resultsContainer) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resultsContainer.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Recherche en cours...</span></div>';
|
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche en cours...</div>';
|
||||||
|
|
||||||
// If we have a MAL ID, fetch directly by ID, otherwise search by query
|
// If we have a MAL ID, fetch directly by ID, otherwise search by query
|
||||||
let malUrl;
|
let malUrl;
|
||||||
@@ -81,10 +81,10 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
// Only add header and wrapper if we have results
|
// Only add header and wrapper if we have results
|
||||||
if (hasResults) {
|
if (hasResults) {
|
||||||
streamingParts.unshift(
|
streamingParts.unshift(
|
||||||
`<div class="flex items-center gap-2 mb-4 mt-5">
|
`<div class="streaming-results-header">
|
||||||
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
<h3>🎬 Résultats de streaming</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">`
|
<div class="search-results" style="margin-top: 20px;">`
|
||||||
);
|
);
|
||||||
streamingParts.push('</div>');
|
streamingParts.push('</div>');
|
||||||
streamingHtml = streamingParts.join('');
|
streamingHtml = streamingParts.join('');
|
||||||
@@ -109,10 +109,9 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
// MAL found nothing but we have streaming results
|
// MAL found nothing but we have streaming results
|
||||||
if (streamingHtml) {
|
if (streamingHtml) {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="text-center py-12 text-base-content/50 mb-5">
|
<div class="no-results" style="margin-bottom: 20px;">
|
||||||
<i class="fa-solid fa-circle-info text-3xl mb-3 block"></i>
|
<p>ℹ️ Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
||||||
<p>Aucune fiche trouvée sur MyAnimeList pour "${escapeHtml(query)}"</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
<p class="text-xs mt-2 text-base-content/40">
|
|
||||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End")
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +124,9 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
||||||
<p>Aucun résultat trouvé pour "${escapeHtml(query)}"</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
<p class="text-xs mt-2 text-base-content/40">
|
|
||||||
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
Essayez le nom en anglais ou japonais (ex: "Frieren: Beyond Journey's End", "One Piece")
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,10 +137,9 @@ async function searchAnimeDetails(query, malId = null) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching anime details:', error);
|
console.error('Error searching anime details:', error);
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors de la recherche.</p>
|
||||||
<p>Erreur lors de la recherche.</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -179,10 +176,10 @@ async function getProviderSearchResults(query) {
|
|||||||
// Only add header and wrapper if we have results
|
// Only add header and wrapper if we have results
|
||||||
if (hasResults) {
|
if (hasResults) {
|
||||||
htmlParts.unshift(
|
htmlParts.unshift(
|
||||||
`<div class="flex items-center gap-2 mb-4 mt-5">
|
`<div class="streaming-results-header">
|
||||||
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Résultats de streaming</h3>
|
<h3>🎬 Résultats de streaming</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">`
|
<div class="search-results" style="margin-top: 20px;">`
|
||||||
);
|
);
|
||||||
htmlParts.push('</div>');
|
htmlParts.push('</div>');
|
||||||
}
|
}
|
||||||
@@ -240,42 +237,42 @@ function renderAnimeDetails(anime) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-lg">
|
<div class="anime-details-card">
|
||||||
<!-- Header with poster and basic info -->
|
<!-- Header with poster and basic info -->
|
||||||
<div class="flex flex-col md:flex-row gap-4 p-4">
|
<div class="anime-details-header">
|
||||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="w-40 h-56 object-cover rounded-lg shrink-0">` : ''}
|
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-details-poster">` : ''}
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="anime-details-info">
|
||||||
<h2 class="text-xl font-bold">${escapeHtml(anime.title)}</h2>
|
<h2 class="anime-details-title">${escapeHtml(anime.title)}</h2>
|
||||||
${anime.title_english && anime.title_english !== anime.title ? `
|
${anime.title_english && anime.title_english !== anime.title ? `
|
||||||
<p class="text-sm text-base-content/60">${escapeHtml(anime.title_english)}</p>
|
<p class="anime-details-subtitle">${escapeHtml(anime.title_english)}</p>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="anime-details-meta">
|
||||||
${score > 0 ? `<span class="badge badge-warning badge-sm"><i class="fa-solid fa-star"></i> ${score.toFixed(2)}</span>` : ''}
|
${score > 0 ? `<div class="anime-details-rating">★ ${score.toFixed(2)}</div>` : ''}
|
||||||
${rank > 0 ? `<span class="badge badge-outline badge-sm">#${rank}</span>` : ''}
|
${rank > 0 ? `<div class="anime-details-rank">#${rank}</div>` : ''}
|
||||||
${popularity > 0 ? `<span class="badge badge-ghost badge-sm">Popularité #${popularity}</span>` : ''}
|
${popularity > 0 ? `<div class="anime-details-popularity">Popularity #${popularity}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-3 text-sm text-base-content/70">
|
<div class="anime-details-stats">
|
||||||
${anime.episodes ? `<span><i class="fa-solid fa-tv"></i> ${anime.episodes} épisodes</span>` : ''}
|
${anime.episodes ? `<span>📺 ${anime.episodes} épisodes</span>` : ''}
|
||||||
${anime.status ? `<span><i class="fa-solid fa-tower-broadcast"></i> ${translateStatus(anime.status)}</span>` : ''}
|
${anime.status ? `<span>📡 ${translateStatus(anime.status)}</span>` : ''}
|
||||||
${anime.duration ? `<span><i class="fa-solid fa-clock"></i> ${escapeHtml(anime.duration)}</span>` : ''}
|
${anime.duration ? `<span>⏱️ ${escapeHtml(anime.duration)}</span>` : ''}
|
||||||
${anime.year ? `<span><i class="fa-solid fa-calendar"></i> ${anime.year}</span>` : ''}
|
${anime.year ? `<span>📅 ${anime.year}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${studios.length > 0 ? `
|
${studios.length > 0 ? `
|
||||||
<div class="text-sm mt-2 text-base-content/60">
|
<div class="anime-details-studios">
|
||||||
Studio: ${studios.map(s => escapeHtml(s)).join(', ')}
|
Studio: ${studios.map(s => escapeHtml(s)).join(', ')}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mt-3">
|
<div class="anime-details-actions">
|
||||||
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-sm">
|
<a href="${escapeHtml(anime.url)}" target="_blank" class="btn btn-secondary btn-small">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur MAL
|
🔗 Voir sur MAL
|
||||||
</a>
|
</a>
|
||||||
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-success btn-sm">
|
<button onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')" class="btn btn-primary btn-small">
|
||||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
📥 Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,40 +280,39 @@ function renderAnimeDetails(anime) {
|
|||||||
|
|
||||||
<!-- Genres and themes -->
|
<!-- Genres and themes -->
|
||||||
${(genres.length > 0 || themes.length > 0) ? `
|
${(genres.length > 0 || themes.length > 0) ? `
|
||||||
<div class="px-4 pb-3 flex flex-wrap gap-1">
|
<div class="anime-details-tags">
|
||||||
${genres.map(g => `<span class="badge badge-primary badge-outline badge-sm">${escapeHtml(g)}</span>`).join('')}
|
${genres.map(g => `<span class="anime-details-tag genre">${escapeHtml(g)}</span>`).join('')}
|
||||||
${themes.map(t => `<span class="badge badge-accent badge-outline badge-sm">${escapeHtml(t)}</span>`).join('')}
|
${themes.map(t => `<span class="anime-details-tag theme">${escapeHtml(t)}</span>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<!-- Synopsis with translation button -->
|
<!-- Synopsis with translation button -->
|
||||||
${synopsis ? `
|
${synopsis ? `
|
||||||
<div class="px-4 pb-4">
|
<div class="anime-details-section">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
||||||
<h3 class="font-semibold"><i class="fa-solid fa-book"></i> Synopsis</h3>
|
<h3 style="margin: 0;">📖 Synopsis</h3>
|
||||||
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-sm btn-xs">
|
<button onclick="translateSynopsis('${synopsisId}', this)" class="btn btn-secondary btn-small" style="font-size: 12px;">
|
||||||
<i class="fa-solid fa-globe"></i> Traduire en français
|
🌐 Traduire en français
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="${synopsisId}" class="text-sm text-base-content/80 leading-relaxed">${escapeHtml(synopsis)}</p>
|
<p id="${synopsisId}" class="anime-details-synopsis">${escapeHtml(synopsis)}</p>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
<!-- Seasons (Sequel/Prequel) -->
|
<!-- Seasons (Sequel/Prequel) -->
|
||||||
${seasons.length > 0 ? `
|
${seasons.length > 0 ? `
|
||||||
<div class="px-4 pb-4">
|
<div class="anime-details-section">
|
||||||
<h3 class="font-semibold mb-2"><i class="fa-solid fa-tv"></i> Saisons</h3>
|
<h3>📺 Saisons</h3>
|
||||||
<div class="space-y-3">
|
<div class="anime-related-list">
|
||||||
${seasons.map(season => `
|
${seasons.map(season => `
|
||||||
<div>
|
<div class="anime-related-group">
|
||||||
<div class="badge badge-info badge-sm mb-1">${translateRelationType(season.type)}</div>
|
<div class="anime-related-type">${translateRelationType(season.type)}</div>
|
||||||
<div class="space-y-1">
|
<div class="anime-related-items">
|
||||||
${season.entries.map(entry => `
|
${season.entries.map(entry => `
|
||||||
<div class="flex items-center gap-2 p-2 rounded-lg hover:bg-base-300/50 cursor-pointer"
|
<div class="anime-related-item" onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})" style="cursor: pointer;">
|
||||||
onclick="searchAnimeDetails('${escapeHtml(entry.title)}', ${entry.mal_id})">
|
${entry.type ? `<span style="color: #00d9ff; font-size: 11px; margin-right: 8px;">${escapeHtml(entry.type)}</span>` : ''}
|
||||||
${entry.type ? `<span class="badge badge-warning badge-sm shrink-0">${escapeHtml(entry.type)}</span>` : ''}
|
${escapeHtml(entry.title)}
|
||||||
<span class="text-sm">${escapeHtml(entry.title)}</span>
|
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" style="margin-left: auto; color: #888; font-size: 18px; text-decoration: none;" title="Voir sur MyAnimeList">↗</a>` : ''}
|
||||||
${entry.url ? `<a href="${escapeHtml(entry.url)}" target="_blank" class="ml-auto text-base-content/30 hover:text-base-content text-lg" title="Voir sur MyAnimeList" onclick="event.stopPropagation()">↗</a>` : ''}
|
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +332,7 @@ async function loadStreamingResults(query) {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Recherche des sources de streaming...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Recherche des sources de streaming...</div>';
|
||||||
|
|
||||||
// Load providers info
|
// Load providers info
|
||||||
const providersData = await getProvidersInfo();
|
const providersData = await getProvidersInfo();
|
||||||
@@ -361,9 +357,8 @@ async function loadStreamingResults(query) {
|
|||||||
|
|
||||||
if (successfulResults.length === 0) {
|
if (successfulResults.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-triangle-exclamation text-3xl mb-3 block"></i>
|
<p>⚠️ Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
||||||
<p>Aucun résultat de streaming trouvé pour "${escapeHtml(query)}"</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
@@ -371,10 +366,10 @@ async function loadStreamingResults(query) {
|
|||||||
|
|
||||||
// Display results
|
// Display results
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="streaming-results-header">
|
||||||
<h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Disponible sur</h3>
|
<h3>🎬 Disponible sur</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="streaming-results-grid">
|
||||||
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
${successfulResults.map(result => renderStreamingResult(result, query)).join('')}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -382,9 +377,8 @@ async function loadStreamingResults(query) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading streaming results:', error);
|
console.error('Error loading streaming results:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors de la recherche des sources de streaming.</p>
|
||||||
<p>Erreur lors de la recherche des sources de streaming.</p>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -395,84 +389,34 @@ function renderStreamingResult(result, query) {
|
|||||||
const { provider, name, icon, episodes } = result;
|
const { provider, name, icon, episodes } = result;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="streaming-result-card">
|
||||||
<div class="card-body p-4">
|
<div class="streaming-result-header">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<span class="streaming-result-icon">${icon}</span>
|
||||||
<div class="flex items-center gap-2">
|
<span class="streaming-result-name">${escapeHtml(name)}</span>
|
||||||
<span class="text-lg">${icon}</span>
|
<span class="streaming-result-count">${episodes.length} épisodes</span>
|
||||||
<span class="font-semibold text-sm">${escapeHtml(name)}</span>
|
|
||||||
</div>
|
|
||||||
<span class="badge badge-ghost badge-sm">${episodes.length} épisodes</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<select class="select select-bordered select-sm w-full streaming-episode-select" data-provider="${provider}" data-query="${escapeHtml(query)}">
|
|
||||||
<option value="">Sélectionner un épisode</option>
|
|
||||||
${episodes.slice(0, 20).map(ep => `
|
|
||||||
<option value="${escapeHtml(ep.url)}">Épisode ${ep.episode}</option>
|
|
||||||
`).join('')}
|
|
||||||
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="btn btn-primary btn-sm flex-1 streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success btn-sm streaming-download-all-btn"
|
|
||||||
onclick="downloadAllEpisodes(this, '${escapeHtml(query)}', '${escapeHtml(provider)}')"
|
|
||||||
title="Télécharger toute la saison">
|
|
||||||
<i class="fas fa-layer-group"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="#" class="link link-hover text-xs mt-2" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
|
|
||||||
Voir tous les épisodes sur ${escapeHtml(name)} →
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="streaming-result-episodes">
|
||||||
|
<select class="streaming-episode-select" data-provider="${provider}" data-query="${escapeHtml(query)}">
|
||||||
|
<option value="">Sélectionner un épisode</option>
|
||||||
|
${episodes.slice(0, 20).map(ep => `
|
||||||
|
<option value="${escapeHtml(ep.url)}">Épisode ${ep.episode}</option>
|
||||||
|
`).join('')}
|
||||||
|
${episodes.length > 20 ? `<option disabled>... et ${episodes.length - 20} autres</option>` : ''}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn btn-primary btn-small streaming-download-btn" onclick="downloadSelectedEpisode(this)">
|
||||||
|
📥 Télécharger
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="#" class="streaming-result-link" onclick="searchAnimeOnProviders('${escapeHtml(query)}'); return false;">
|
||||||
|
Voir tous les épisodes sur ${escapeHtml(name)} →
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download all episodes from a streaming result card
|
|
||||||
async function downloadAllEpisodes(button, query, provider) {
|
|
||||||
const card = button.closest('.card');
|
|
||||||
const select = card.querySelector('.streaming-episode-select');
|
|
||||||
const totalEps = select.options.length - 1; // exclude disabled options
|
|
||||||
const hasMore = select.querySelector('option[disabled]');
|
|
||||||
|
|
||||||
button.disabled = true;
|
|
||||||
const originalHtml = button.innerHTML;
|
|
||||||
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span>';
|
|
||||||
|
|
||||||
let completed = 0;
|
|
||||||
const promises = [];
|
|
||||||
|
|
||||||
for (const option of select.options) {
|
|
||||||
if (!option.value || option.disabled) continue;
|
|
||||||
promises.push(
|
|
||||||
fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(option.value)}`, { method: 'POST' })
|
|
||||||
.then(r => { completed++; return r; })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
|
||||||
const successCount = results.filter(r => r.status === 'fulfilled').length;
|
|
||||||
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
showToast(`${successCount} épisodes mis en file de téléchargement`);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalHtml;
|
|
||||||
button.disabled = false;
|
|
||||||
}, 4000);
|
|
||||||
|
|
||||||
// Refresh downloads list
|
|
||||||
if (typeof loadDownloads === 'function') {
|
|
||||||
loadDownloads();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download selected episode from streaming results
|
// Download selected episode from streaming results
|
||||||
async function downloadSelectedEpisode(button) {
|
async function downloadSelectedEpisode(button) {
|
||||||
const select = button.parentElement.querySelector('.streaming-episode-select');
|
const select = button.parentElement.querySelector('.streaming-episode-select');
|
||||||
@@ -531,7 +475,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
// Revert to original
|
// Revert to original
|
||||||
synopsisElement.textContent = originalText;
|
synopsisElement.textContent = originalText;
|
||||||
synopsisElement.dataset.translated = 'false';
|
synopsisElement.dataset.translated = 'false';
|
||||||
button.innerHTML = '<i class="fa-solid fa-globe"></i> Traduire en français';
|
button.innerHTML = '🌐 Traduire en français';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +484,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Traduction...';
|
button.innerHTML = '⏳ Traduction...';
|
||||||
synopsisElement.style.opacity = '0.5';
|
synopsisElement.style.opacity = '0.5';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -565,7 +509,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
|
|
||||||
synopsisElement.textContent = data.translatedText;
|
synopsisElement.textContent = data.translatedText;
|
||||||
synopsisElement.dataset.translated = 'true';
|
synopsisElement.dataset.translated = 'true';
|
||||||
button.innerHTML = '<i class="fa-solid fa-rotate"></i> Voir l'original';
|
button.innerHTML = '🔄 Voir l\'original';
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
console.error('Translation API error:', errorData);
|
console.error('Translation API error:', errorData);
|
||||||
@@ -575,12 +519,12 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
console.error('Translation error:', error);
|
console.error('Translation error:', error);
|
||||||
synopsisElement.style.opacity = '1';
|
synopsisElement.style.opacity = '1';
|
||||||
|
|
||||||
// Show user-friendly error using DaisyUI alert styling
|
// Show user-friendly error
|
||||||
const errorMessage = document.createElement('div');
|
const errorMessage = document.createElement('div');
|
||||||
errorMessage.className = 'alert alert-error alert-sm mt-2 text-xs translation-error';
|
errorMessage.style.cssText = 'margin-top: 10px; padding: 10px; background: rgba(255, 107, 107, 0.2); border-radius: 8px; font-size: 12px; color: #ff6b6b;';
|
||||||
errorMessage.innerHTML = `
|
errorMessage.innerHTML = `
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
⚠️ Service de traduction temporairement indisponible.<br>
|
||||||
<span>Service de traduction temporairement indisponible. Essayez à nouveau dans quelques instants.</span>
|
<small>Essayez à nouveau dans quelques instants.</small>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Remove existing error message if any
|
// Remove existing error message if any
|
||||||
@@ -589,6 +533,7 @@ async function translateSynopsis(synopsisId, button) {
|
|||||||
existingError.remove();
|
existingError.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errorMessage.className = 'translation-error';
|
||||||
synopsisElement.parentElement.appendChild(errorMessage);
|
synopsisElement.parentElement.appendChild(errorMessage);
|
||||||
|
|
||||||
// Auto-remove error after 5 seconds
|
// Auto-remove error after 5 seconds
|
||||||
|
|||||||
@@ -102,25 +102,21 @@ function resetLoading(buttonId, originalText) {
|
|||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
const tabs = document.querySelectorAll('.auth-tab');
|
const tabs = document.querySelectorAll('.auth-tab');
|
||||||
const forms = document.querySelectorAll('#loginForm, #registerForm');
|
const forms = document.querySelectorAll('.auth-form');
|
||||||
|
|
||||||
// Remove active states — DaisyUI uses tab-active on tabs, hidden on forms
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
tabs.forEach(t => t.classList.remove('tab-active'));
|
forms.forEach(f => f.classList.remove('active'));
|
||||||
forms.forEach(f => f.classList.add('hidden'));
|
|
||||||
|
|
||||||
if (tab === 'login') {
|
if (tab === 'login') {
|
||||||
tabs[0].classList.add('tab-active');
|
tabs[0].classList.add('active');
|
||||||
document.getElementById('loginForm').classList.remove('hidden');
|
document.getElementById('loginForm').classList.add('active');
|
||||||
} else {
|
} else {
|
||||||
tabs[1].classList.add('tab-active');
|
tabs[1].classList.add('active');
|
||||||
document.getElementById('registerForm').classList.remove('hidden');
|
document.getElementById('registerForm').classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide alerts on tab switch
|
document.getElementById('authError').classList.remove('show');
|
||||||
const authError = document.getElementById('authError');
|
document.getElementById('authSuccess').classList.remove('show');
|
||||||
const authSuccess = document.getElementById('authSuccess');
|
|
||||||
if (authError) authError.classList.add('hidden');
|
|
||||||
if (authSuccess) authSuccess.classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.authUi = {
|
window.authUi = {
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ function displayError(elementId, error, defaultMessage = 'Une erreur est survenu
|
|||||||
}
|
}
|
||||||
|
|
||||||
errorDiv.textContent = message;
|
errorDiv.textContent = message;
|
||||||
errorDiv.classList.remove('hidden');
|
errorDiv.classList.add('show');
|
||||||
|
|
||||||
// Hide success message if visible
|
// Hide success message if visible
|
||||||
const successDiv = document.getElementById(elementId.replace('Error', 'Success'));
|
const successDiv = document.getElementById(elementId.replace('Error', 'Success'));
|
||||||
if (successDiv) {
|
if (successDiv) {
|
||||||
successDiv.classList.add('hidden');
|
successDiv.classList.remove('show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +89,12 @@ function displaySuccess(elementId, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
successDiv.textContent = message;
|
successDiv.textContent = message;
|
||||||
successDiv.classList.remove('hidden');
|
successDiv.classList.add('show');
|
||||||
|
|
||||||
// Hide error message if visible
|
// Hide error message if visible
|
||||||
const errorDiv = document.getElementById(elementId.replace('Success', 'Error'));
|
const errorDiv = document.getElementById(elementId.replace('Success', 'Error'));
|
||||||
if (errorDiv) {
|
if (errorDiv) {
|
||||||
errorDiv.classList.add('hidden');
|
errorDiv.classList.remove('show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ async function loadRecommendations() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Analyse de vos téléchargements...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Analyse de vos téléchargements...</div>';
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/recommendations?limit=12`);
|
const response = await fetch(`${API_BASE}/recommendations?limit=12`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -16,19 +16,18 @@ async function loadRecommendations() {
|
|||||||
console.log('Recommendations response:', data);
|
console.log('Recommendations response:', data);
|
||||||
|
|
||||||
if (data.recommendations && data.recommendations.length > 0) {
|
if (data.recommendations && data.recommendations.length > 0) {
|
||||||
container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${data.recommendations.map(anime =>
|
container.innerHTML = `<div class="recommendations-carousel">${data.recommendations.map(anime =>
|
||||||
renderRecommendationCard(anime)
|
renderRecommendationCard(anime)
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-triangle-exclamation text-3xl mb-3 block"></i>
|
<p>⚠️ Aucune recommandation disponible pour le moment.</p>
|
||||||
<p>Aucune recommandation disponible pour le moment.</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
<p class="text-xs mt-2 text-base-content/40">
|
|
||||||
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
Soit l'API MyAnimeList est inaccessible, soit vous n'avez pas encore de téléchargements.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadRecommendations()">
|
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
🔄 Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -38,12 +37,11 @@ async function loadRecommendations() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading recommendations:', error);
|
console.error('Error loading recommendations:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors du chargement des recommandations.</p>
|
||||||
<p>Erreur lors du chargement des recommandations.</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
<button class="btn btn-secondary btn-small" onclick="loadRecommendations()" style="margin-top: 10px;">
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadRecommendations()">
|
🔄 Réessayer
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -59,7 +57,7 @@ async function loadLatestReleases() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Chargement des dernières sorties...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties...</div>';
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
|
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -67,19 +65,18 @@ async function loadLatestReleases() {
|
|||||||
console.log('Releases response:', data);
|
console.log('Releases response:', data);
|
||||||
|
|
||||||
if (data.releases && data.releases.length > 0) {
|
if (data.releases && data.releases.length > 0) {
|
||||||
container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${data.releases.map(anime =>
|
container.innerHTML = `<div class="releases-carousel">${data.releases.map(anime =>
|
||||||
renderReleaseCard(anime)
|
renderReleaseCard(anime)
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-triangle-exclamation text-3xl mb-3 block"></i>
|
<p>⚠️ Aucune sortie disponible pour le moment.</p>
|
||||||
<p>Aucune sortie disponible pour le moment.</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #888;">
|
||||||
<p class="text-xs mt-2 text-base-content/40">
|
|
||||||
L'API MyAnimeList pourrait être temporairement inaccessible.
|
L'API MyAnimeList pourrait être temporairement inaccessible.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadLatestReleases()">
|
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
🔄 Réessayer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -89,12 +86,11 @@ async function loadLatestReleases() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading releases:', error);
|
console.error('Error loading releases:', error);
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors du chargement des sorties.</p>
|
||||||
<p>Erreur lors du chargement des sorties.</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
<button class="btn btn-secondary btn-small" onclick="loadLatestReleases()" style="margin-top: 10px;">
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadLatestReleases()">
|
🔄 Réessayer
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -104,7 +100,7 @@ async function loadLatestReleases() {
|
|||||||
|
|
||||||
// Load all home content
|
// Load all home content
|
||||||
async function loadHomeContent() {
|
async function loadHomeContent() {
|
||||||
console.log('loadHomeContent() called');
|
console.log('🏠 loadHomeContent() called');
|
||||||
|
|
||||||
const loading = document.getElementById('homeLoading');
|
const loading = document.getElementById('homeLoading');
|
||||||
const recommendationsSection = document.getElementById('recommendationsSection');
|
const recommendationsSection = document.getElementById('recommendationsSection');
|
||||||
@@ -127,13 +123,13 @@ async function loadHomeContent() {
|
|||||||
loadRecommendations(),
|
loadRecommendations(),
|
||||||
loadLatestReleases()
|
loadLatestReleases()
|
||||||
]);
|
]);
|
||||||
console.log('Home content loaded successfully');
|
console.log('✅ Home content loaded successfully');
|
||||||
|
|
||||||
// Show sections if they have content
|
// Show sections if they have content
|
||||||
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
if (recommendationsSection) recommendationsSection.style.display = 'block';
|
||||||
if (releasesSection) releasesSection.style.display = 'block';
|
if (releasesSection) releasesSection.style.display = 'block';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading home content:', error);
|
console.error('❌ Error loading home content:', error);
|
||||||
if (loading) {
|
if (loading) {
|
||||||
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
loading.innerHTML = 'Erreur lors du chargement. Consultez la console pour plus de détails.';
|
||||||
}
|
}
|
||||||
@@ -152,48 +148,44 @@ function renderRecommendationCard(anime) {
|
|||||||
const reason = anime.recommendation_reason || 'Recommandé';
|
const reason = anime.recommendation_reason || 'Recommandé';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm relative">
|
<div class="anime-card-horizontal recommendation-card">
|
||||||
${reason ? `<div class="badge badge-accent badge-sm absolute top-2 left-2 z-10"><i class="fa-solid fa-lightbulb"></i> ${escapeHtml(reason)}</div>` : ''}
|
${reason ? `<div class="recommendation-badge">💡 ${escapeHtml(reason)}</div>` : ''}
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<div class="flex justify-between items-start">
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||||
<h4 class="card-title text-base">${escapeHtml(anime.title)}</h4>
|
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||||
${score > 0 ? `<span class="badge badge-warning badge-sm shrink-0 ml-2"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</span>` : ''}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 mt-1">
|
<div class="anime-card-content">
|
||||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="w-20 h-28 object-cover rounded-lg shrink-0" onerror="this.style.display='none'">` : ''}
|
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 text-sm">
|
<div class="anime-card-info">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="anime-genres">
|
||||||
${genres.slice(0, 3).map(g => `<span class="badge badge-outline badge-sm">${escapeHtml(g)}</span>`).join('')}
|
${genres.slice(0, 3).map(g => `<span class="anime-genre-tag">${escapeHtml(g)}</span>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-base-content/60 text-xs">
|
<div class="anime-card-meta">
|
||||||
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||||
${anime.episodes && anime.status ? ' • ' : ''}
|
${anime.episodes && anime.status ? ' • ' : ''}
|
||||||
${anime.status ? translateStatus(anime.status) : ''}
|
${anime.status ? translateStatus(anime.status) : ''}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${anime.synopsis ? `
|
${anime.synopsis ? `
|
||||||
<details class="collapse collapse-arrow border border-base-300 mt-2 bg-base-300/30">
|
<details class="anime-synopsis">
|
||||||
<summary class="collapse-title text-xs font-medium py-2 min-h-0"><i class="fa-solid fa-book"></i> Synopsis</summary>
|
<summary>📖 Synopsis</summary>
|
||||||
<div class="collapse-content text-xs text-base-content/70">
|
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
</details>
|
||||||
</div>
|
` : ''}
|
||||||
</details>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-2">
|
<div class="anime-card-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> MAL
|
🔗 MAL
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
📥 Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -209,48 +201,44 @@ function renderReleaseCard(anime) {
|
|||||||
const releaseType = anime.release_type || 'Nouveau';
|
const releaseType = anime.release_type || 'Nouveau';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm relative">
|
<div class="anime-card-horizontal release-card">
|
||||||
<div class="badge badge-error badge-sm absolute top-2 left-2 z-10"><i class="fa-solid fa-fire"></i> ${escapeHtml(releaseType)}</div>
|
<div class="release-badge">🔥 ${escapeHtml(releaseType)}</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<div class="flex justify-between items-start">
|
<div class="anime-card-title">${escapeHtml(anime.title)}</div>
|
||||||
<h4 class="card-title text-base">${escapeHtml(anime.title)}</h4>
|
${score > 0 ? `<div class="anime-card-rating">★ ${score.toFixed(1)}</div>` : ''}
|
||||||
${score > 0 ? `<span class="badge badge-warning badge-sm shrink-0 ml-2"><i class="fa-solid fa-star"></i> ${score.toFixed(1)}</span>` : ''}
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 mt-1">
|
<div class="anime-card-content">
|
||||||
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="w-20 h-28 object-cover rounded-lg shrink-0" onerror="this.style.display='none'">` : ''}
|
${imageUrl ? `<img src="${escapeHtml(imageUrl)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 text-sm">
|
<div class="anime-card-info">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="anime-genres">
|
||||||
${genres.slice(0, 3).map(g => `<span class="badge badge-error badge-outline badge-sm">${escapeHtml(g)}</span>`).join('')}
|
${genres.slice(0, 3).map(g => `<span class="anime-genre-tag" style="color: #ff6b6b; background: rgba(255,107,107,0.15);">${escapeHtml(g)}</span>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-base-content/60 text-xs">
|
<div class="anime-card-meta">
|
||||||
${anime.episodes ? `<i class="fa-solid fa-tv"></i> ${anime.episodes} ep` : ''}
|
${anime.episodes ? `📺 ${anime.episodes} ep` : ''}
|
||||||
${anime.episodes && anime.status ? ' • ' : ''}
|
${anime.episodes && anime.status ? ' • ' : ''}
|
||||||
${anime.status ? translateStatus(anime.status) : ''}
|
${anime.status ? translateStatus(anime.status) : ''}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
${anime.synopsis ? `
|
${anime.synopsis ? `
|
||||||
<details class="collapse collapse-arrow border border-base-300 mt-2 bg-base-300/30">
|
<details class="anime-synopsis">
|
||||||
<summary class="collapse-title text-xs font-medium py-2 min-h-0"><i class="fa-solid fa-book"></i> Synopsis</summary>
|
<summary>📖 Synopsis</summary>
|
||||||
<div class="collapse-content text-xs text-base-content/70">
|
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
||||||
<p>${escapeHtml(anime.synopsis.substring(0, 150))}${anime.synopsis.length > 150 ? '...' : ''}</p>
|
</details>
|
||||||
</div>
|
` : ''}
|
||||||
</details>
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-2">
|
<div class="anime-card-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(anime.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> MAL
|
🔗 MAL
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success btn-sm" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
<button class="btn btn-primary btn-small" onclick="searchAnimeOnProviders('${escapeHtml(anime.title)}')">
|
||||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
📥 Télécharger
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -258,11 +246,11 @@ function renderReleaseCard(anime) {
|
|||||||
|
|
||||||
// Get rating color based on score
|
// Get rating color based on score
|
||||||
function getRatingColor(score) {
|
function getRatingColor(score) {
|
||||||
if (score >= 9) return 'text-warning';
|
if (score >= 9) return 'linear-gradient(45deg, #ffd700, #ffed4e)';
|
||||||
if (score >= 8) return 'text-success';
|
if (score >= 8) return 'linear-gradient(45deg, #00ff88, #00d9ff)';
|
||||||
if (score >= 7) return 'text-warning';
|
if (score >= 7) return 'linear-gradient(45deg, #00d9ff, #6c5ce7)';
|
||||||
if (score >= 6) return 'text-warning';
|
if (score >= 6) return 'linear-gradient(45deg, #ffa500, #ff6b6b)';
|
||||||
return 'text-base-content/40';
|
return 'linear-gradient(45deg, #666, #888)';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search anime on providers (redirects to anime tab)
|
// Search anime on providers (redirects to anime tab)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function handleSeriesSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
resultsContainer.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Recherche de séries TV en cours...</span></div>';
|
resultsContainer.innerHTML = '<div class="loading-spinner">Recherche de séries TV en cours...</div>';
|
||||||
|
|
||||||
// Search on series providers using the dedicated endpoint
|
// Search on series providers using the dedicated endpoint
|
||||||
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(query)}&lang=vf`);
|
const response = await fetch(`${API_BASE}/series/search?q=${encodeURIComponent(query)}&lang=vf`);
|
||||||
@@ -25,10 +25,10 @@ async function handleSeriesSearch() {
|
|||||||
if (data.results && data.results['fs7'] && data.results['fs7'].length > 0) {
|
if (data.results && data.results['fs7'] && data.results['fs7'].length > 0) {
|
||||||
const series = data.results['fs7'];
|
const series = data.results['fs7'];
|
||||||
let html = `
|
let html = `
|
||||||
<div class="flex items-center gap-2 mb-4">
|
<div class="streaming-results-header">
|
||||||
<h3 class="text-lg font-semibold"><i class="fa-solid fa-tv"></i> Résultats pour "${escapeHtml(query)}"</h3>
|
<h3>📺 Résultats pour "${escapeHtml(query)}"</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="search-results" style="margin-top: 20px;">
|
||||||
`;
|
`;
|
||||||
|
|
||||||
series.forEach(s => {
|
series.forEach(s => {
|
||||||
@@ -43,27 +43,25 @@ async function handleSeriesSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm" id="series-fs7-${encodeURIComponent(s.url)}">
|
<div class="anime-card" id="series-fs7-${encodeURIComponent(s.url)}">
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<div class="flex justify-between items-start">
|
<div class="anime-card-title">${escapeHtml(s.title)}</div>
|
||||||
<h4 class="font-semibold text-base">${escapeHtml(s.title)}</h4>
|
<div class="anime-card-provider">📺 French Stream</div>
|
||||||
<span class="badge badge-sm badge-ghost"><i class="fa-solid fa-tv"></i> French Stream</span>
|
|
||||||
</div>
|
|
||||||
${coverImage ? `
|
|
||||||
<div class="flex justify-center my-2">
|
|
||||||
<img src="${escapeHtml(coverImage)}" alt="" class="max-w-[200px] rounded-lg" onerror="this.style.display='none'">
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div class="card-actions justify-end mt-2">
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
|
|
||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
|
||||||
<i class="fa-solid fa-arrow-down"></i> Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="episodes-fs7-${encodeURIComponent(s.url)}" class="mt-2"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
${coverImage ? `
|
||||||
|
<div style="text-align: center; margin: 10px 0;">
|
||||||
|
<img src="${escapeHtml(coverImage)}" alt="" style="max-width: 200px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.3);" onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="anime-card-actions">
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(s.url)}', '_blank')">
|
||||||
|
🔗 Voir sur FS7
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodesDirect('${escapeHtml(s.url)}', '${escapeHtml(s.title)}')">
|
||||||
|
📥 Voir les épisodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="episodes-fs7-${encodeURIComponent(s.url)}" style="margin-top: 10px;"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
@@ -72,10 +70,9 @@ async function handleSeriesSearch() {
|
|||||||
resultsContainer.innerHTML = html;
|
resultsContainer.innerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
||||||
<p>Aucune série trouvée pour "${escapeHtml(query)}"</p>
|
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
|
||||||
<p class="text-xs mt-2 opacity-70">
|
|
||||||
Essayez avec un autre titre ou vérifiez l'orthographe
|
Essayez avec un autre titre ou vérifiez l'orthographe
|
||||||
</p>
|
</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -83,127 +80,60 @@ async function handleSeriesSearch() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching series:', error);
|
console.error('Error searching series:', error);
|
||||||
resultsContainer.innerHTML = `
|
resultsContainer.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors de la recherche</p>
|
||||||
<p>Erreur lors de la recherche</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load series episodes directly — shows an inline episode list with download buttons
|
// Load series episodes directly without redirecting to search
|
||||||
async function loadSeriesEpisodesDirect(url, title) {
|
async function loadSeriesEpisodesDirect(url, title) {
|
||||||
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
|
const episodesContainer = document.getElementById(`episodes-fs7-${encodeURIComponent(url)}`);
|
||||||
|
|
||||||
if (!episodesContainer) return;
|
if (!episodesContainer) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
episodesContainer.innerHTML = `
|
episodesContainer.innerHTML = '<div class="loading-spinner">Chargement des épisodes...</div>';
|
||||||
<div class="flex items-center gap-2 py-4">
|
|
||||||
<span class="loading loading-spinner loading-sm text-primary"></span>
|
|
||||||
<span class="text-base-content/60 text-sm">Chargement des épisodes...</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
|
const response = await fetch(`${API_BASE}/anime/episodes?url=${encodeURIComponent(url)}&lang=vf`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.episodes && data.episodes.length > 0) {
|
if (data.episodes && data.episodes.length > 0) {
|
||||||
const totalEps = data.episodes.length;
|
|
||||||
let html = `
|
let html = `
|
||||||
<div class="mt-3 space-y-2">
|
<div style="margin-top: 15px;">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<label style="font-size: 12px; color: #00d9ff; margin-bottom: 5px; display: block;">
|
||||||
<span class="label-text text-xs text-base-content/60">
|
📺 Sélectionner un épisode:
|
||||||
<i class="fas fa-list-ol text-primary"></i> ${totalEps} épisodes disponibles
|
</label>
|
||||||
</span>
|
<select id="select-episodes-${encodeURIComponent(url)}" style="width: 100%; padding: 8px; border-radius: 4px; border: 1px solid rgba(0, 217, 255, 0.3); background: rgba(0, 0, 0, 0.3); color: white;">
|
||||||
<button class="btn btn-xs btn-success gap-1"
|
<option value="">Sélectionner un épisode</option>
|
||||||
onclick="downloadAllSeriesEpisodes(this, '${escapeHtml(url)}', '${escapeHtml(title)}')">
|
${data.episodes.map(ep => `
|
||||||
<i class="fas fa-layer-group"></i> Tout télécharger
|
<option value="${escapeHtml(ep.url)}">Épisode ${escapeHtml(ep.episode)}</option>
|
||||||
</button>
|
`).join('')}
|
||||||
</div>
|
</select>
|
||||||
<div class="max-h-48 overflow-y-auto rounded-lg border border-base-300">
|
<button class="btn btn-primary" style="margin-top: 10px; width: 100%;" onclick="downloadSeriesEpisode('${escapeHtml(url)}', '${escapeHtml(title)}')">
|
||||||
<ul class="divide-y divide-base-300">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;margin-right:4px;">
|
||||||
${data.episodes.map((ep, i) => `
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
<li class="flex items-center justify-between px-3 py-2 hover:bg-base-200/50 transition-colors">
|
</svg>
|
||||||
<span class="text-sm font-medium">Épisode ${escapeHtml(ep.episode)}</span>
|
Télécharger l'épisode
|
||||||
<button class="btn btn-xs btn-outline btn-success gap-1"
|
</button>
|
||||||
hx-post="/api/anime/download?url=${escapeHtml(ep.url)}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i>';this.disabled=true;this.classList.remove('btn-outline','btn-success');this.classList.add('btn-ghost','pointer-events-none');setTimeout(()=>{this.innerHTML='<i class=\'fas fa-download\'></i>';this.disabled=false;this.classList.remove('btn-ghost','pointer-events-none');this.classList.add('btn-outline','btn-success')},4000)}"
|
|
||||||
title="Télécharger l'épisode ${escapeHtml(ep.episode)}">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
`).join('')}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
episodesContainer.innerHTML = html;
|
episodesContainer.innerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
episodesContainer.innerHTML = `
|
episodesContainer.innerHTML = '<div class="no-results" style="margin-top: 10px;">Aucun épisode disponible</div>';
|
||||||
<div class="text-center py-4 text-base-content/50 text-sm">
|
|
||||||
<i class="fas fa-inbox mb-1 block"></i>
|
|
||||||
Aucun épisode disponible
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading episodes:', error);
|
console.error('Error loading episodes:', error);
|
||||||
episodesContainer.innerHTML = `
|
episodesContainer.innerHTML = `<div class="no-results" style="margin-top: 10px; color: #ff6b6b;">Erreur: ${error.message}</div>`;
|
||||||
<div class="alert alert-error alert-sm text-xs">
|
|
||||||
<i class="fas fa-triangle-exclamation"></i>
|
|
||||||
<span>Erreur: ${error.message}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download all series episodes
|
// Download series episode
|
||||||
async function downloadAllSeriesEpisodes(button, url, title) {
|
|
||||||
const container = button.closest('.mt-3');
|
|
||||||
const episodeBtns = container.querySelectorAll('ul button[hx-post*="/api/anime/download"]');
|
|
||||||
|
|
||||||
// Visual feedback: disable button, show spinner
|
|
||||||
button.disabled = true;
|
|
||||||
const originalHtml = button.innerHTML;
|
|
||||||
button.innerHTML = '<span class="loading loading-spinner loading-xs"></span> En cours...';
|
|
||||||
|
|
||||||
let completed = 0;
|
|
||||||
const total = episodeBtns.length;
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
[...episodeBtns].map(btn => {
|
|
||||||
const hxPost = btn.getAttribute('hx-post');
|
|
||||||
const epUrl = hxPost.split('url=')[1];
|
|
||||||
return fetch(`${API_BASE}/anime/download?url=${encodeURIComponent(epUrl)}`, { method: 'POST' })
|
|
||||||
.then(r => {
|
|
||||||
completed++;
|
|
||||||
// Visual: mark episode button as done
|
|
||||||
btn.innerHTML = '<i class="fas fa-check"></i>';
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.classList.remove('btn-outline', 'btn-success');
|
|
||||||
btn.classList.add('btn-ghost', 'pointer-events-none');
|
|
||||||
return r;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
button.innerHTML = '<i class="fas fa-check"></i> Terminé';
|
|
||||||
showToast(`${completed} épisodes de "${title}" mis en file`);
|
|
||||||
|
|
||||||
// Reset button after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalHtml;
|
|
||||||
button.disabled = false;
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download series episode (single - kept for compatibility)
|
|
||||||
async function downloadSeriesEpisode(url, title) {
|
async function downloadSeriesEpisode(url, title) {
|
||||||
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
|
const select = document.getElementById(`select-episodes-${encodeURIComponent(url)}`);
|
||||||
if (!select || !select.value) {
|
if (!select || !select.value) {
|
||||||
showToast('Veuillez sélectionner un épisode', 'warning');
|
alert('Veuillez sélectionner un épisode');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +145,8 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
showToast(`Téléchargement démarré pour "${title}"`);
|
alert(`✅ Téléchargement démarré pour "${title}"`);
|
||||||
|
// Refresh downloads
|
||||||
if (typeof loadDownloads === 'function') {
|
if (typeof loadDownloads === 'function') {
|
||||||
loadDownloads();
|
loadDownloads();
|
||||||
}
|
}
|
||||||
@@ -224,11 +155,11 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
const errorMessage = error.detail
|
const errorMessage = error.detail
|
||||||
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
? (typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail))
|
||||||
: 'Impossible de démarrer le téléchargement';
|
: 'Impossible de démarrer le téléchargement';
|
||||||
showToast(`Erreur : ${errorMessage}`, 'error');
|
alert(`❌ Erreur: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Download error:', error);
|
console.error('Download error:', error);
|
||||||
showToast(`Erreur lors du téléchargement : ${error.message}`, 'error');
|
alert(`❌ Erreur lors du téléchargement: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,4 +167,3 @@ async function downloadSeriesEpisode(url, title) {
|
|||||||
window.handleSeriesSearch = handleSeriesSearch;
|
window.handleSeriesSearch = handleSeriesSearch;
|
||||||
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
|
window.loadSeriesEpisodesDirect = loadSeriesEpisodesDirect;
|
||||||
window.downloadSeriesEpisode = downloadSeriesEpisode;
|
window.downloadSeriesEpisode = downloadSeriesEpisode;
|
||||||
window.downloadAllSeriesEpisodes = downloadAllSeriesEpisodes;
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
/**
|
|
||||||
* Settings page - form handlers for user preferences, filters, and weights.
|
|
||||||
* Loaded on all pages via base.html so functions are available when
|
|
||||||
* the settings section is dynamically loaded via HTMX.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a DaisyUI theme color from computed CSS custom properties.
|
|
||||||
* Falls back to sensible defaults if the theme variable is not found.
|
|
||||||
*/
|
|
||||||
function getThemeColor(varName, fallback) {
|
|
||||||
const style = getComputedStyle(document.documentElement);
|
|
||||||
const value = style.getPropertyValue(varName).trim();
|
|
||||||
return value || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSettings() {
|
|
||||||
const data = {
|
|
||||||
default_lang: document.getElementById('default_lang')?.value,
|
|
||||||
theme: document.getElementById('theme')?.value,
|
|
||||||
download_dir: document.getElementById('download_dir')?.value,
|
|
||||||
};
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
}).then(r => {
|
|
||||||
if (r.ok) showToast('Preferences enregistrees', 'success');
|
|
||||||
}).catch(e => {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFilter(field, value) {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ [field]: value })
|
|
||||||
}).then(r => {
|
|
||||||
if (r.ok) showToast('Filtre mis a jour', 'success');
|
|
||||||
}).catch(e => {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleCategory(field, value) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
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) {
|
|
||||||
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 onWeightModeChange(mode) {
|
|
||||||
const autoInfo = document.getElementById('weight-auto-info');
|
|
||||||
const manualControls = document.getElementById('weight-manual-controls');
|
|
||||||
|
|
||||||
if (mode === 'auto') {
|
|
||||||
if (autoInfo) autoInfo.style.display = 'block';
|
|
||||||
if (manualControls) manualControls.style.display = 'none';
|
|
||||||
loadAutoWeights();
|
|
||||||
} else {
|
|
||||||
if (autoInfo) autoInfo.style.display = 'none';
|
|
||||||
if (manualControls) manualControls.style.display = 'block';
|
|
||||||
updateWeightPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content_weight_mode: mode })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAutoWeights() {
|
|
||||||
const details = document.getElementById('weight-auto-details');
|
|
||||||
if (!details) return;
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings/content-weight', {
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
});
|
|
||||||
if (!r.ok) return;
|
|
||||||
const data = await r.json();
|
|
||||||
const aw = data.anime_weight;
|
|
||||||
const sw = data.series_weight;
|
|
||||||
const ac = data.anime_count;
|
|
||||||
const sc = data.series_count;
|
|
||||||
const total = data.total || 0;
|
|
||||||
|
|
||||||
const primary = getThemeColor('--color-primary', '#6366f1');
|
|
||||||
const secondary = getThemeColor('--color-secondary', '#a3a3a3');
|
|
||||||
const accent = getThemeColor('--color-accent', '#38bdf8');
|
|
||||||
const error = getThemeColor('--color-error', '#f43f5e');
|
|
||||||
const muted = getThemeColor('--color-base-content', '#999');
|
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
details.innerHTML = `<span style="color: ${muted}; opacity: 0.6;">Aucun telechargement detecte. Ratio par defaut : ${aw} anime / ${sw} serie.</span>`;
|
|
||||||
} else {
|
|
||||||
const pctA = total > 0 ? Math.round(ac / total * 100) : 50;
|
|
||||||
const pctS = total > 0 ? Math.round(sc / total * 100) : 50;
|
|
||||||
details.innerHTML = `
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<strong>${ac}</strong> anime${ac > 1 ? 's' : ''} (${pctA}%) — <strong>${sc}</strong> serie${sc > 1 ? 's' : ''} (${pctS}%)
|
|
||||||
</div>
|
|
||||||
<div class="progress w-full" style="height: 8px;">
|
|
||||||
<div class="progress-bar" style="width: 100%; display: flex; border-radius: 4px; overflow: hidden;">
|
|
||||||
<div style="width: ${pctA}%; background: ${primary};"></div>
|
|
||||||
<div style="width: ${pctS}%; background: ${accent};"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 8px; font-size: 12px;">
|
|
||||||
Ratio applique : <strong style="color: ${primary};">${aw}</strong> anime / <strong style="color: ${accent};">${sw}</strong> serie
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const error = getThemeColor('--color-error', '#f43f5e');
|
|
||||||
details.innerHTML = `<span style="color: ${error};">Erreur de chargement</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateWeightPreview() {
|
|
||||||
const awEl = document.getElementById('content_weight_anime_range');
|
|
||||||
const swEl = document.getElementById('content_weight_series_range');
|
|
||||||
const preview = document.getElementById('weight-preview');
|
|
||||||
if (!awEl || !swEl || !preview) return;
|
|
||||||
|
|
||||||
const aw = parseInt(awEl.value) || 0;
|
|
||||||
const sw = parseInt(swEl.value) || 0;
|
|
||||||
const total = aw + sw;
|
|
||||||
|
|
||||||
const primary = getThemeColor('--color-primary', '#6366f1');
|
|
||||||
const secondary = getThemeColor('--color-secondary', '#a3a3a3');
|
|
||||||
const accent = getThemeColor('--color-accent', '#38bdf8');
|
|
||||||
const error = getThemeColor('--color-error', '#f43f5e');
|
|
||||||
|
|
||||||
if (total === 0) {
|
|
||||||
preview.innerHTML = `<span style="color: ${error};">Les deux poids ne peuvent pas etre a 0</span>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pctA = Math.round(aw / total * 100);
|
|
||||||
const pctS = 100 - pctA;
|
|
||||||
|
|
||||||
preview.innerHTML = `
|
|
||||||
<div style="margin-bottom: 6px;">
|
|
||||||
<span style="color: ${primary}; font-weight: 700;">${pctA}%</span> animes /
|
|
||||||
<span style="color: ${accent}; font-weight: 700;">${pctS}%</span> series
|
|
||||||
</div>
|
|
||||||
<div class="progress w-full" style="height: 8px;">
|
|
||||||
<div class="progress-bar" style="width: 100%; display: flex; border-radius: 4px; overflow: hidden;">
|
|
||||||
<div style="width: ${pctA}%; background: ${primary}; transition: width 0.2s;"></div>
|
|
||||||
<div style="width: ${pctS}%; background: ${accent}; transition: width 0.2s;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveManualWeights() {
|
|
||||||
const awEl = document.getElementById('content_weight_anime_range');
|
|
||||||
const swEl = document.getElementById('content_weight_series_range');
|
|
||||||
if (!awEl || !swEl) return;
|
|
||||||
|
|
||||||
const aw = parseInt(awEl.value) || 0;
|
|
||||||
const sw = parseInt(swEl.value) || 0;
|
|
||||||
|
|
||||||
if (aw === 0 && sw === 0) {
|
|
||||||
showToast('Les deux poids ne peuvent pas etre a 0', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/settings', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content_weight_mode: 'manual', content_weight_anime: aw, content_weight_series: sw })
|
|
||||||
});
|
|
||||||
if (r.ok) showToast('Equilibre mis a jour', 'success');
|
|
||||||
} catch (e) {
|
|
||||||
showToast('Erreur: ' + e.message, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message, type) {
|
|
||||||
const event = new CustomEvent('show-toast', { detail: { message, type } });
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize weight display when settings tab content is loaded via HTMX
|
|
||||||
document.addEventListener('htmx:afterSettle', function(evt) {
|
|
||||||
if (evt.detail.target) {
|
|
||||||
const mode = evt.detail.target.querySelector('#content_weight_mode');
|
|
||||||
if (mode && mode.value === 'auto') {
|
|
||||||
loadAutoWeights();
|
|
||||||
} else if (mode && mode.value === 'manual') {
|
|
||||||
updateWeightPreview();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -18,28 +18,30 @@ function renderSeriesRecommendationCard(series) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="anime-card-horizontal recommendation-card">
|
||||||
<div class="badge badge-primary badge-sm absolute top-2 right-2 z-10"><i class="fa-solid fa-music"></i> Série TV populaire</div>
|
<div class="recommendation-badge">🎺 Série TV populaire</div>
|
||||||
|
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<h4 class="card-title text-base">${escapeHtml(series.title)}</h4>
|
<div class="anime-card-title">${escapeHtml(series.title)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-1">
|
<div class="anime-card-content">
|
||||||
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="w-20 h-28 object-cover rounded-lg" onerror="this.style.display='none'">` : ''}
|
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||||
|
|
||||||
<div class="text-sm text-base-content/60">
|
<div class="anime-card-info">
|
||||||
<span class="badge badge-sm badge-ghost"><i class="fa-solid fa-tv"></i> Série TV</span>
|
<div class="anime-card-meta">
|
||||||
|
📺 Série TV
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-3">
|
<div class="anime-card-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
🔗 Voir sur FS7
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||||
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
📥 Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -80,26 +82,28 @@ function renderSeriesReleaseCard(series) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="anime-card-horizontal release-card">
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<h4 class="card-title text-base">${escapeHtml(series.title)}</h4>
|
<div class="anime-card-title">${escapeHtml(series.title)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 mt-1">
|
<div class="anime-card-content">
|
||||||
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="w-20 h-28 object-cover rounded-lg" onerror="this.style.display='none'">` : ''}
|
${coverImage ? `<img src="${escapeHtml(coverImage)}" alt="" class="anime-card-image" onerror="this.style.display='none'">` : ''}
|
||||||
|
|
||||||
<div class="text-sm text-base-content/60">
|
<div class="anime-card-info">
|
||||||
<span class="badge badge-sm badge-warning"><i class="fa-solid fa-tv"></i> Série TV • Nouveau</span>
|
<div class="anime-card-meta">
|
||||||
|
📺 Série TV • Nouveau
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-actions justify-end mt-3">
|
<div class="anime-card-actions">
|
||||||
<button class="btn btn-secondary btn-sm" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
<button class="btn btn-secondary btn-small" onclick="window.open('${escapeHtml(series.url)}', '_blank')">
|
||||||
<i class="fa-solid fa-link"></i> Voir sur FS7
|
🔗 Voir sur FS7
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-success btn-sm" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
<button class="btn btn-primary btn-small" onclick="loadSeriesEpisodes('${escapeHtml(series.url)}', '${escapeHtml(series.title)}')">
|
||||||
<i class="fa-solid fa-arrow-down"></i> Voir les épisodes
|
📥 Voir les épisodes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -111,7 +115,7 @@ async function loadSeriesRecommendations() {
|
|||||||
const container = document.getElementById('seriesRecommendationsList');
|
const container = document.getElementById('seriesRecommendationsList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Chargement des recommandations séries...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Chargement des recommandations séries...</div>';
|
||||||
|
|
||||||
// Search for popular series from all providers (including FS7)
|
// Search for popular series from all providers (including FS7)
|
||||||
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders', 'House of the Dragon', 'The Witcher'];
|
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders', 'House of the Dragon', 'The Witcher'];
|
||||||
@@ -137,16 +141,16 @@ async function loadSeriesRecommendations() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allSeries.length > 0) {
|
if (allSeries.length > 0) {
|
||||||
container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${allSeries.map(series =>
|
container.innerHTML = `<div class="recommendations-carousel">${allSeries.map(series =>
|
||||||
renderSeriesRecommendationCard(series)
|
renderSeriesRecommendationCard(series)
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = '<div class="text-center py-16 text-base-content/50">Aucune recommandation trouvée</div>';
|
container.innerHTML = '<div class="no-results">Aucune recommandation trouvée</div>';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading series recommendations:', error);
|
console.error('Error loading series recommendations:', error);
|
||||||
const container = document.getElementById('seriesRecommendationsList');
|
const container = document.getElementById('seriesRecommendationsList');
|
||||||
if (container) container.innerHTML = '<div class="text-center py-16 text-base-content/50">Erreur lors du chargement</div>';
|
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,23 +160,23 @@ async function loadAnimeReleases() {
|
|||||||
const container = document.getElementById('animeReleasesList');
|
const container = document.getElementById('animeReleasesList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Chargement des dernières sorties anime...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Chargement des dernières sorties anime...</div>';
|
||||||
|
|
||||||
// Use the existing releases API
|
// Use the existing releases API
|
||||||
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
|
const response = await fetch(`${API_BASE}/releases/latest?limit=12`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.releases && data.releases.length > 0) {
|
if (data.releases && data.releases.length > 0) {
|
||||||
container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${data.releases.map(anime =>
|
container.innerHTML = `<div class="recommendations-carousel">${data.releases.map(anime =>
|
||||||
renderReleaseCard(anime)
|
renderReleaseCard(anime)
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = '<div class="text-center py-16 text-base-content/50">Aucune sortie trouvée</div>';
|
container.innerHTML = '<div class="no-results">Aucune sortie trouvée</div>';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading anime releases:', error);
|
console.error('Error loading anime releases:', error);
|
||||||
const container = document.getElementById('animeReleasesList');
|
const container = document.getElementById('animeReleasesList');
|
||||||
if (container) container.innerHTML = '<div class="text-center py-16 text-base-content/50">Erreur lors du chargement</div>';
|
if (container) container.innerHTML = '<div class="no-results">Erreur lors du chargement</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@ async function loadSeriesReleases() {
|
|||||||
const container = document.getElementById('seriesReleasesList');
|
const container = document.getElementById('seriesReleasesList');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Chargement des dernières séries TV...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Chargement des dernières séries TV...</div>';
|
||||||
|
|
||||||
// Search for popular series from all providers (including FS7)
|
// Search for popular series from all providers (including FS7)
|
||||||
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders'];
|
const searchTerms = ['Breaking Bad', 'Game of Thrones', 'Stranger Things', 'The Walking Dead', 'The Last of Us', 'Wednesday', 'Squid Game', 'Peaky Blinders'];
|
||||||
@@ -214,14 +218,14 @@ async function loadSeriesReleases() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allSeries.length > 0) {
|
if (allSeries.length > 0) {
|
||||||
container.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${allSeries.map(series =>
|
container.innerHTML = `<div class="releases-carousel">${allSeries.map(series =>
|
||||||
renderSeriesReleaseCard(series)
|
renderSeriesReleaseCard(series)
|
||||||
).join('')}</div>`;
|
).join('')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<p>Aucune série trouvée</p>
|
<p>Aucune série trouvée</p>
|
||||||
<p class="text-xs mt-2 opacity-70">
|
<p style="font-size: 12px; margin-top: 10px; opacity: 0.7;">
|
||||||
Utilisez l'onglet "Recherche" pour trouver des séries spécifiques
|
Utilisez l'onglet "Recherche" pour trouver des séries spécifiques
|
||||||
</p>
|
</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -231,12 +235,11 @@ async function loadSeriesReleases() {
|
|||||||
const container = document.getElementById('seriesReleasesList');
|
const container = document.getElementById('seriesReleasesList');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors du chargement des séries</p>
|
||||||
<p>Erreur lors du chargement des séries</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
<button class="btn btn-secondary btn-small" onclick="loadSeriesReleases()" style="margin-top: 10px;">
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadSeriesReleases()">
|
🔄 Réessayer
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
|
||||||
</button>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -249,7 +252,7 @@ async function loadProvidersGrid() {
|
|||||||
const container = document.getElementById('providersGrid');
|
const container = document.getElementById('providersGrid');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
container.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md"></span><span class="ml-3 text-base-content/60">Chargement des fournisseurs...</span></div>';
|
container.innerHTML = '<div class="loading-spinner">Chargement des fournisseurs...</div>';
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/providers`);
|
const response = await fetch(`${API_BASE}/providers`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -257,67 +260,65 @@ async function loadProvidersGrid() {
|
|||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
// Section Anime providers
|
// Section Anime providers
|
||||||
html += '<div class="flex items-center gap-2 mt-5 mb-3"><h3 class="text-lg font-semibold"><i class="fa-solid fa-film"></i> Sites Anime</h3></div>';
|
html += '<div class="section-header"><h3 style="margin-top: 20px;">🎬 Sites Anime</h3></div>';
|
||||||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
html += '<div class="search-results">';
|
||||||
|
|
||||||
const animeProviders = Object.entries(data.anime_providers || {});
|
const animeProviders = Object.entries(data.anime_providers || {});
|
||||||
if (animeProviders.length > 0) {
|
if (animeProviders.length > 0) {
|
||||||
animeProviders.forEach(([id, provider]) => {
|
animeProviders.forEach(([id, provider]) => {
|
||||||
const domains = provider.domains || [];
|
const domains = provider.domains || [];
|
||||||
html += `
|
html += `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="anime-card">
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<h4 class="card-title text-base">${provider.icon} ${provider.name}</h4>
|
<div class="anime-card-title">${provider.icon} ${provider.name}</div>
|
||||||
${domains.length > 0 ? `
|
</div>
|
||||||
<div class="text-sm mb-3">
|
${domains.length > 0 ? `
|
||||||
<strong>Domaines:</strong><br>
|
<div class="anime-metadata" style="margin-bottom: 12px;">
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
<strong>Domaines:</strong><br>
|
||||||
${domains.map(d => `<code class="badge badge-ghost badge-sm">${d}</code>`).join('')}
|
${domains.map(d => `<code style="background: rgba(0,217,255,0.1); padding: 2px 6px; border-radius: 4px; margin-right: 4px;">${d}</code>`).join('')}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
<div class="card-actions justify-end">
|
|
||||||
${domains.length > 0 ? `
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="window.open('https://${domains[0]}', '_blank')">
|
|
||||||
<i class="fa-solid fa-link"></i> Visiter le site
|
|
||||||
</button>
|
|
||||||
` : ''}
|
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showProviderSearch('${id}')">
|
|
||||||
<i class="fa-solid fa-magnifying-glass"></i> Rechercher
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="anime-card-actions">
|
||||||
|
${domains.length > 0 ? `
|
||||||
|
<button class="btn btn-primary btn-small" onclick="window.open('https://${domains[0]}', '_blank')">
|
||||||
|
🔗 Visiter le site
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-secondary btn-small" onclick="showProviderSearch('${id}')">
|
||||||
|
🔍 Rechercher
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
html += '<div class="col-span-full text-center py-16 text-base-content/50">Aucun fournisseur anime disponible</div>';
|
html += '<div class="no-results">Aucun fournisseur anime disponible</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Section File hosts
|
// Section File hosts
|
||||||
html += '<div class="flex items-center gap-2 mt-10 mb-3"><h3 class="text-lg font-semibold"><i class="fa-solid fa-floppy-disk"></i> Hébergeurs de fichiers</h3></div>';
|
html += '<div class="section-header" style="margin-top: 40px;"><h3>💾 Hébergeurs de fichiers</h3></div>';
|
||||||
html += '<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">';
|
html += '<div class="search-results">';
|
||||||
|
|
||||||
const fileHosts = Object.entries(data.file_hosts || {});
|
const fileHosts = Object.entries(data.file_hosts || {});
|
||||||
if (fileHosts.length > 0) {
|
if (fileHosts.length > 0) {
|
||||||
fileHosts.forEach(([id, host]) => {
|
fileHosts.forEach(([id, host]) => {
|
||||||
html += `
|
html += `
|
||||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
<div class="anime-card">
|
||||||
<div class="card-body p-4">
|
<div class="anime-card-header">
|
||||||
<h4 class="card-title text-base">${host.icon} ${host.name}</h4>
|
<div class="anime-card-title">${host.icon} ${host.name}</div>
|
||||||
<div class="card-actions justify-end">
|
</div>
|
||||||
<button class="btn btn-secondary btn-sm" onclick="showDownloadInfo()">
|
<div class="anime-card-actions">
|
||||||
<i class="fa-solid fa-download"></i> Télécharger un fichier
|
<button class="btn btn-secondary btn-small" onclick="showDownloadInfo()">
|
||||||
</button>
|
📥 Télécharger un fichier
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
html += '<div class="col-span-full text-center py-16 text-base-content/50">Aucun hébergeur disponible</div>';
|
html += '<div class="no-results">Aucun hébergeur disponible</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
@@ -328,12 +329,11 @@ async function loadProvidersGrid() {
|
|||||||
const container = document.getElementById('providersGrid');
|
const container = document.getElementById('providersGrid');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-16 text-base-content/50">
|
<div class="no-results">
|
||||||
<i class="fa-solid fa-xmark text-3xl mb-3 block"></i>
|
<p>❌ Erreur lors du chargement des fournisseurs</p>
|
||||||
<p>Erreur lors du chargement des fournisseurs</p>
|
<p style="font-size: 12px; margin-top: 10px; color: #ff6b6b;">${error.message}</p>
|
||||||
<p class="text-xs mt-2 text-error">${error.message}</p>
|
<button class="btn btn-secondary btn-small" onclick="loadProvidersGrid()" style="margin-top: 10px;">
|
||||||
<button class="btn btn-secondary btn-sm mt-3" onclick="loadProvidersGrid()">
|
🔄 Réessayer
|
||||||
<i class="fa-solid fa-rotate"></i> Réessayer
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -349,7 +349,7 @@ function showProviderSearch(providerId) {
|
|||||||
|
|
||||||
// Show download info (explains how to download)
|
// Show download info (explains how to download)
|
||||||
function showDownloadInfo() {
|
function showDownloadInfo() {
|
||||||
alert('Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
alert('💡 Pour télécharger un fichier:\n\n1. Utilisez l\'onglet "Recherche"\n2. Entrez le nom de l\'anime/série\n3. Cliquez sur "Télécharger" sur un épisode\n\nOu bien:\n- Copiez directement un lien de téléchargement dans la barre d\'adresse de votre navigateur');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make additional functions available globally
|
// Make additional functions available globally
|
||||||
|
|||||||
@@ -346,10 +346,10 @@ async function handleStartScheduler() {
|
|||||||
try {
|
try {
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('Planificateur démarré !');
|
alert('✅ Planificateur démarré!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting scheduler:', error);
|
console.error('Error starting scheduler:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,10 +360,10 @@ async function handleStopScheduler() {
|
|||||||
try {
|
try {
|
||||||
await stopScheduler();
|
await stopScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('Planificateur arrêté !');
|
alert('✅ Planificateur arrêté!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping scheduler:', error);
|
console.error('Error stopping scheduler:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +376,7 @@ async function handleCheckAll() {
|
|||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking all:', error);
|
console.error('Error checking all:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +394,7 @@ async function handleOpenSettings() {
|
|||||||
document.body.appendChild(modalContainer);
|
document.body.appendChild(modalContainer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,17 +438,17 @@ function updateSchedulerUI(status) {
|
|||||||
|
|
||||||
if (status.next_run) {
|
if (status.next_run) {
|
||||||
const nextRun = new Date(status.next_run);
|
const nextRun = new Date(status.next_run);
|
||||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||||
} else {
|
} else {
|
||||||
// Scheduler running but no next_run yet (just started)
|
// Scheduler running but no next_run yet (just started)
|
||||||
const interval = status.settings?.check_interval_hours || 6;
|
const interval = status.settings?.check_interval_hours || 6;
|
||||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check"></i> En cours<br>Vérification toutes les ${interval}h`;
|
nextRunInfo.innerHTML = `✓ En cours<br>Vérification toutes les ${interval}h`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update buttons if they exist
|
// Update buttons if they exist
|
||||||
if (startBtn) startBtn.style.display = 'inline-block';
|
if (startBtn) startBtn.style.display = 'inline-block';
|
||||||
if (stopBtn) stopBtn.style.display = 'none';
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
nextRunInfo.innerHTML = '<i class="fa-solid fa-pause"></i> Arrêté';
|
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" data-theme="ohmstream">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Ohm Stream Downloader</title>
|
<title>Ohm Stream Downloader</title>
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- CSS -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- CSS: Tailwind (built from input.css via DaisyUI), Font Awesome, Plyr -->
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
|
|
||||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||||
|
|
||||||
<!-- x-cloak: hide elements until Alpine initializes -->
|
<!-- External Libraries -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
/* Inter as default font, system sans-serif fallback */
|
|
||||||
body {
|
|
||||||
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- HTMX (local vendor) -->
|
|
||||||
<script src="/static/vendor/htmx.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Configure HTMX to include auth token in all requests -->
|
<!-- Configure HTMX to include auth token in all requests -->
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('htmx:configRequest', (event) => {
|
document.addEventListener('htmx:configRequest', (event) => {
|
||||||
@@ -38,267 +28,34 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Alpine.js (local vendor, deferred) -->
|
<!-- Legacy JavaScript (Refactored to HTMX/Alpine) -->
|
||||||
<script src="/static/vendor/alpine.min.js" defer></script>
|
|
||||||
|
|
||||||
<!-- Plyr.io JS (CDN) -->
|
|
||||||
<script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
|
|
||||||
|
|
||||||
<!-- Application JS modules -->
|
|
||||||
<script src="/static/js/auth.js?v=1.10" defer></script>
|
<script src="/static/js/auth.js?v=1.10" defer></script>
|
||||||
<script src="/static/js/api.js?v=1.11" defer></script>
|
<script src="/static/js/api.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/utils.js?v=1.11" defer></script>
|
<script src="/static/js/utils.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/downloads.js?v=1.11" defer></script>
|
<script src="/static/js/downloads.js?v=1.11" defer></script>
|
||||||
|
<!-- <script src="/static/js/anime.js?v=1.11" defer></script> -->
|
||||||
|
<!-- <script src="/static/js/anime-details.js?v=1.12" defer></script> -->
|
||||||
|
<!-- <script src="/static/js/series-search.js?v=1.11" defer></script> -->
|
||||||
|
<!-- <script src="/static/js/recommendations.js?v=1.11" defer></script> -->
|
||||||
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
<script src="/static/js/watchlist.js?v=1.11" defer></script>
|
||||||
|
<!-- <script src="/static/js/watchlist-ui.js?v=1.11" defer></script> -->
|
||||||
<script src="/static/js/main.js?v=1.11" defer></script>
|
<script src="/static/js/main.js?v=1.11" defer></script>
|
||||||
<script src="/static/js/settings.js?v=1.0" defer></script>
|
|
||||||
</head>
|
</head>
|
||||||
|
<body x-data="globalAppState">
|
||||||
<body x-data="globalAppState" x-cloak class="min-h-screen bg-base-100 text-base-content">
|
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Toast notification container (fixed position, top-right)
|
|
||||||
============================================================ -->
|
|
||||||
{% include "components/toast_container.html" %}
|
{% include "components/toast_container.html" %}
|
||||||
|
<div class="container">
|
||||||
<!-- ============================================================
|
{% block content %}{% endblock %}
|
||||||
DaisyUI Drawer: wraps the entire page layout.
|
|
||||||
The checkbox (id="ohm-drawer") toggles the mobile sidebar.
|
|
||||||
============================================================ -->
|
|
||||||
<div class="drawer">
|
|
||||||
<input id="ohm-drawer" type="checkbox" class="drawer-toggle" />
|
|
||||||
|
|
||||||
<!-- Page content area -->
|
|
||||||
<div class="drawer-content flex flex-col min-h-screen">
|
|
||||||
|
|
||||||
<!-- ====================================================
|
|
||||||
DaisyUI Navbar (top bar)
|
|
||||||
==================================================== -->
|
|
||||||
<nav class="navbar bg-base-200 border-b border-base-300 sticky top-0 z-30 px-4 lg:px-8">
|
|
||||||
<!-- Mobile menu toggle -->
|
|
||||||
<div class="flex-none lg:hidden">
|
|
||||||
<label for="ohm-drawer" class="btn btn-square btn-ghost" aria-label="Menu">
|
|
||||||
<i class="fa-solid fa-bars text-lg"></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Brand / Logo -->
|
|
||||||
<div class="flex-1 gap-2">
|
|
||||||
<a href="/web" class="btn btn-ghost text-xl gap-2 hover:bg-transparent">
|
|
||||||
<i class="fa-solid fa-bolt text-primary"></i>
|
|
||||||
<span class="font-bold">Ohm Stream</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop navigation tabs (hidden on mobile, shown in drawer instead) -->
|
|
||||||
<div class="hidden lg:flex flex-none gap-1">
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'home' }"
|
|
||||||
@click="activeTab = 'home'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'home' } }))">
|
|
||||||
<i class="fa-solid fa-house text-xs"></i> Accueil
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'anime' }"
|
|
||||||
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } }))">
|
|
||||||
<i class="fa-solid fa-film text-xs"></i> Anime
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'series' }"
|
|
||||||
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } }))">
|
|
||||||
<i class="fa-solid fa-tv text-xs"></i> Séries
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'watchlist' }"
|
|
||||||
@click="activeTab = 'watchlist'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'watchlist' } }))">
|
|
||||||
<i class="fa-solid fa-clipboard-list text-xs"></i> Watchlist
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'downloads' }"
|
|
||||||
@click="activeTab = 'downloads'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'downloads' } }))">
|
|
||||||
<i class="fa-solid fa-download text-xs"></i> Téléchargements
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
:class="{ 'btn-active bg-primary/15 text-primary': activeTab === 'settings' }"
|
|
||||||
@click="activeTab = 'settings'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'settings' } }))">
|
|
||||||
<i class="fa-solid fa-gear text-xs"></i> Paramètres
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User info (desktop) -->
|
|
||||||
<div class="hidden lg:flex flex-none items-center gap-2">
|
|
||||||
<!-- Authenticated state -->
|
|
||||||
<div x-show="isAuthenticated" x-cloak class="flex items-center gap-2">
|
|
||||||
<span class="text-sm text-base-content/70">
|
|
||||||
<i class="fa-solid fa-user text-primary"></i>
|
|
||||||
<strong class="text-primary" x-text="username">-</strong>
|
|
||||||
</span>
|
|
||||||
<button class="btn btn-sm btn-ghost text-error"
|
|
||||||
onclick="removeToken(); isAuthenticated = false"
|
|
||||||
hx-post="/api/auth/logout"
|
|
||||||
hx-on::after-request="window.location.href = '/login'">
|
|
||||||
<i class="fa-solid fa-right-from-bracket"></i> Déconnexion
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Unauthenticated state -->
|
|
||||||
<div x-show="!isAuthenticated" x-cloak>
|
|
||||||
<a href="/login" class="btn btn-sm btn-primary">
|
|
||||||
<i class="fa-solid fa-right-to-bracket"></i> Se connecter
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile: user icon trigger + settings dropdown -->
|
|
||||||
<div class="flex-none lg:hidden">
|
|
||||||
<div x-show="isAuthenticated" x-cloak>
|
|
||||||
<div class="dropdown dropdown-end">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-square btn-ghost">
|
|
||||||
<i class="fa-solid fa-circle-user text-lg text-primary"></i>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box border border-base-300 z-[1] w-56 p-2 shadow-lg mt-2">
|
|
||||||
<li class="menu-title text-xs" x-text="username"></li>
|
|
||||||
<li>
|
|
||||||
<button class="text-error"
|
|
||||||
onclick="removeToken(); isAuthenticated = false"
|
|
||||||
hx-post="/api/auth/logout"
|
|
||||||
hx-on::after-request="window.location.href = '/login'">
|
|
||||||
<i class="fa-solid fa-right-from-bracket"></i> Déconnexion
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div x-show="!isAuthenticated" x-cloak>
|
|
||||||
<a href="/login" class="btn btn-sm btn-primary">
|
|
||||||
<i class="fa-solid fa-right-to-bracket"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- ====================================================
|
|
||||||
Main content block (rendered by child templates)
|
|
||||||
==================================================== -->
|
|
||||||
<main class="flex-1">
|
|
||||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="footer footer-center p-4 bg-base-200 text-base-content/50 border-t border-base-300">
|
|
||||||
<aside class="text-xs">
|
|
||||||
<p>Ohm Stream Downloader — Téléchargez vos animes et séries</p>
|
|
||||||
</aside>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ====================================================
|
|
||||||
DaisyUI Drawer sidebar (mobile navigation)
|
|
||||||
Slides in from the left on mobile (< lg).
|
|
||||||
==================================================== -->
|
|
||||||
<div class="drawer-side z-40">
|
|
||||||
<label for="ohm-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
|
||||||
<aside class="bg-base-200 min-h-full w-64 border-r border-base-300 flex flex-col">
|
|
||||||
|
|
||||||
<!-- Drawer header / brand -->
|
|
||||||
<div class="p-4 border-b border-base-300">
|
|
||||||
<a href="/web" class="flex items-center gap-2 text-xl font-bold" @click="document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-bolt text-primary"></i>
|
|
||||||
<span>Ohm Stream</span>
|
|
||||||
</a>
|
|
||||||
<p class="text-xs text-base-content/50 mt-1">Téléchargez vos vidéos, animes et séries</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile navigation menu -->
|
|
||||||
<ul class="menu p-4 gap-1 flex-1">
|
|
||||||
|
|
||||||
<!-- User info (mobile drawer) -->
|
|
||||||
<li x-show="isAuthenticated" x-cloak class="mb-2">
|
|
||||||
<div class="flex items-center gap-2 px-2 py-1 rounded-lg bg-base-300/50">
|
|
||||||
<i class="fa-solid fa-user text-primary text-sm"></i>
|
|
||||||
<span class="text-sm truncate">
|
|
||||||
<span class="text-base-content/50">Connecté: </span>
|
|
||||||
<strong class="text-primary" x-text="username">-</strong>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li x-show="!isAuthenticated" x-cloak class="mb-2">
|
|
||||||
<a href="/login" class="btn btn-primary btn-sm w-full justify-center">
|
|
||||||
<i class="fa-solid fa-right-to-bracket"></i> Se connecter
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="mt-2">
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'home' }"
|
|
||||||
@click="activeTab = 'home'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'home' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-house w-5 text-center"></i> Accueil
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'anime' }"
|
|
||||||
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-film w-5 text-center"></i> Anime
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'series' }"
|
|
||||||
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-tv w-5 text-center"></i> Séries
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'watchlist' }"
|
|
||||||
@click="activeTab = 'watchlist'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'watchlist' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-clipboard-list w-5 text-center"></i> Watchlist
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'downloads' }"
|
|
||||||
@click="activeTab = 'downloads'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'downloads' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-download w-5 text-center"></i> Téléchargements
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<button class="w-full text-left"
|
|
||||||
:class="{ 'bg-primary text-primary-content rounded-lg': activeTab === 'settings' }"
|
|
||||||
@click="activeTab = 'settings'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'settings' } })); document.getElementById('ohm-drawer').checked = false">
|
|
||||||
<i class="fa-solid fa-gear w-5 text-center"></i> Paramètres
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<!-- Mobile logout -->
|
|
||||||
<li x-show="isAuthenticated" x-cloak class="mt-auto border-t border-base-300 pt-2">
|
|
||||||
<button class="text-error"
|
|
||||||
onclick="removeToken(); isAuthenticated = false"
|
|
||||||
hx-post="/api/auth/logout"
|
|
||||||
hx-on::after-request="window.location.href = '/login'">
|
|
||||||
<i class="fa-solid fa-right-from-bracket w-5 text-center"></i> Déconnexion
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================
|
|
||||||
Alpine.js global state initialization
|
|
||||||
============================================================ -->
|
|
||||||
<script>
|
<script>
|
||||||
|
// Global State initialized when Alpine is ready
|
||||||
document.addEventListener('alpine:init', () => {
|
document.addEventListener('alpine:init', () => {
|
||||||
console.log('Alpine.js initializing...');
|
console.log('Alpine.js initializing...');
|
||||||
|
|
||||||
Alpine.data('globalAppState', () => ({
|
Alpine.data('globalAppState', () => ({
|
||||||
activeTab: 'home',
|
activeTab: 'home',
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
username: '',
|
username: '',
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Auth state listeners
|
|
||||||
window.addEventListener('auth-success', (e) => {
|
window.addEventListener('auth-success', (e) => {
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
this.username = e.detail.username;
|
this.username = e.detail.username;
|
||||||
@@ -307,8 +64,6 @@
|
|||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
this.username = '';
|
this.username = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab switching via custom events (SPA hash routing support)
|
|
||||||
window.addEventListener('set-tab', (e) => {
|
window.addEventListener('set-tab', (e) => {
|
||||||
this.activeTab = e.detail.tab;
|
this.activeTab = e.detail.tab;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,89 +1,85 @@
|
|||||||
<div class="mb-10">
|
<div class="settings-container section-container">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold">Administration</h2>
|
<h2>Administration</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="stats stats-vertical md:stats-horizontal shadow w-full mb-6">
|
<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="stat bg-base-200 border border-base-300 rounded-box">
|
<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 class="stat-title">Utilisateurs</div>
|
<div style="font-size: 2rem; font-weight: 800; color: var(--primary);" id="stat-total-users">{{ users|length }}</div>
|
||||||
<div class="stat-value text-primary">{{ users|length }}</div>
|
<div style="color: var(--text-dim); font-size: 0.85rem;">Utilisateurs</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat bg-base-200 border border-base-300 rounded-box">
|
<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 class="stat-title">Actifs</div>
|
<div style="font-size: 2rem; font-weight: 800; color: var(--accent);" id="stat-active-users">{{ users|selectattr('is_active')|list|length }}</div>
|
||||||
<div class="stat-value text-secondary">{{ users|selectattr('is_active')|list|length }}</div>
|
<div style="color: var(--text-dim); font-size: 0.85rem;">Actifs</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat bg-base-200 border border-base-300 rounded-box">
|
<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 class="stat-title">Admins</div>
|
<div style="font-size: 2rem; font-weight: 800; color: #f0a500;" id="stat-admin-users">{{ users|selectattr('is_admin')|list|length }}</div>
|
||||||
<div class="stat-value text-warning">{{ users|selectattr('is_admin')|list|length }}</div>
|
<div style="color: var(--text-dim); font-size: 0.85rem;">Admins</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="bg-base-200 border border-base-300 rounded-box overflow-hidden">
|
<div style="background: var(--bg-card); border-radius: var(--card-radius); border: 1px solid rgba(255,255,255,0.05); overflow: hidden;">
|
||||||
<div class="px-6 py-5 border-b border-base-300">
|
<div style="padding: 20px 25px; border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||||
<h3 class="font-bold text-primary m-0">Gestion des utilisateurs</h3>
|
<h3 style="margin: 0; color: var(--primary);">Gestion des utilisateurs</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if users %}
|
{% if users %}
|
||||||
<div class="overflow-x-auto">
|
<div style="overflow-x: auto;">
|
||||||
<table class="table table-sm">
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">
|
||||||
<th>Utilisateur</th>
|
<th style="padding: 12px 20px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Utilisateur</th>
|
||||||
<th>Email</th>
|
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Email</th>
|
||||||
<th class="text-center">Statut</th>
|
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Statut</th>
|
||||||
<th class="text-center">Role</th>
|
<th style="padding: 12px 15px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Role</th>
|
||||||
<th>Derniere connexion</th>
|
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Derniere connexion</th>
|
||||||
<th>Inscription</th>
|
<th style="padding: 12px 15px; text-align: left; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Inscription</th>
|
||||||
<th class="text-center">Actions</th>
|
<th style="padding: 12px 20px; text-align: center; color: var(--text-dim); font-size: 0.8rem; text-transform: uppercase;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<tr class="{% if not user.is_active %}opacity-50{% endif %}">
|
<tr style="border-bottom: 1px solid rgba(255,255,255,0.03); {% if not user.is_active %}opacity: 0.5;{% endif %}">
|
||||||
<td>
|
<td style="padding: 12px 20px;">
|
||||||
<div class="font-semibold">{{ user.username }}</div>
|
<div style="font-weight: 600;">{{ user.username }}</div>
|
||||||
{% if user.full_name %}
|
{% if user.full_name %}
|
||||||
<div class="text-xs text-base-content/50">{{ user.full_name }}</div>
|
<div style="font-size: 0.8rem; color: var(--text-dim);">{{ user.full_name }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-base-content/60 text-sm">{{ user.email or '-' }}</td>
|
<td style="padding: 12px 15px; color: var(--text-dim); font-size: 0.9rem;">{{ user.email or '-' }}</td>
|
||||||
<td class="text-center">
|
<td style="padding: 12px 15px; text-align: center;">
|
||||||
{% if user.is_active %}
|
<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 %};">
|
||||||
<span class="badge badge-success badge-sm">Actif</span>
|
{% if user.is_active %}Actif{% else %}Inactif{% endif %}
|
||||||
{% else %}
|
</span>
|
||||||
<span class="badge badge-error badge-sm">Inactif</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td style="padding: 12px 15px; text-align: center;">
|
||||||
{% if user.is_admin %}
|
<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 %};">
|
||||||
<span class="badge badge-primary badge-sm">Admin</span>
|
{% if user.is_admin %}Admin{% else %}User{% endif %}
|
||||||
{% else %}
|
</span>
|
||||||
<span class="badge badge-ghost badge-sm">User</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="text-base-content/50 text-sm">
|
<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 '-' }}
|
{{ user.last_login.strftime('%d/%m/%Y %H:%M') if user.last_login else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-base-content/50 text-sm">
|
<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 '-' }}
|
{{ user.created_at.strftime('%d/%m/%Y') if user.created_at else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center whitespace-nowrap">
|
<td style="padding: 12px 20px; text-align: center; white-space: nowrap;">
|
||||||
{% if user.id != current_user.id %}
|
{% if user.id != current_user.id %}
|
||||||
<button class="btn btn-xs {% if user.is_active %}btn-ghost{% else %}btn-success{% endif %}"
|
<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-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'})"
|
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||||
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
|
title="{% if user.is_active %}Desactiver{% else %}Activer{% endif %}">
|
||||||
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
|
{% if user.is_active %}Desactiver{% else %}Activer{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-xs {% if user.is_admin %}btn-ghost{% else %}btn-success{% endif %}"
|
<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-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'})"
|
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||||
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
|
title="{% if user.is_admin %}Retrograder{% else %}Promouvoir{% endif %}">
|
||||||
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
|
{% if user.is_admin %}Retrograder{% else %}Admin{% endif %}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-xs btn-error"
|
<button class="btn btn-sm btn-danger"
|
||||||
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
|
hx-delete="/api/admin/users/{{ user.id }}" hx-swap="none"
|
||||||
hx-confirm="Supprimer {{ user.username }} ?"
|
hx-confirm="Supprimer {{ user.username }} ?"
|
||||||
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
hx-on::after-request="htmx.ajax('GET', '/api/admin/ui', {target: '#admin-panel-content'})"
|
||||||
@@ -91,7 +87,7 @@
|
|||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-base-content/40 text-xs">Vous</span>
|
<span style="color: var(--text-dim); font-size: 0.8rem;">Vous</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -100,7 +96,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-10 text-center text-base-content/40">Aucun utilisateur</div>
|
<div style="padding: 40px; text-align: center; color: var(--text-dim);">Aucun utilisateur</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
|
{% macro anime_card(anime, in_watchlist=False, lang='vostfr') %}
|
||||||
<div class="card card-compact bg-base-200 shadow-lg hover:shadow-xl transition-all cursor-pointer w-40 shrink-0 group"
|
<div class="hc" id="anime-{{ anime.url | hash }}"
|
||||||
id="anime-{{ anime.url | hash }}"
|
|
||||||
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } })); $nextTick(() => { const input = document.getElementById('animeSearchInput'); if (input) { input.value = '{{ anime.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
||||||
<figure class="relative overflow-hidden rounded-t-lg aspect-[2/3]">
|
<div class="hc-poster">
|
||||||
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/e6e8e6/f15025?text=No+Image' %}
|
{% set poster = anime.cover_image or (anime.metadata.poster_image if anime.metadata else None) or 'https://placehold.co/400x600/161625/00d9ff?text=No+Image' %}
|
||||||
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
|
<img src="{{ poster }}" alt="{{ anime.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
class="w-full h-full object-cover"
|
onerror="this.src='https://placehold.co/400x600/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||||
onerror="this.src='https://placehold.co/400x600/e6e8e6/f15025?text=Error'; this.onerror=null;">
|
|
||||||
{% if anime.metadata and anime.metadata.rating %}
|
{% if anime.metadata and anime.metadata.rating %}
|
||||||
<span class="badge badge-warning badge-sm absolute top-2 left-2 gap-1">
|
<span class="hc-rating"><i class="fas fa-star"></i> {{ anime.metadata.rating }}</span>
|
||||||
<i class="fa-solid fa-star text-[10px]"></i> {{ anime.metadata.rating }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="absolute inset-0 bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
<span class="hc-play"><i class="fas fa-search"></i></span>
|
||||||
<div class="btn btn-circle btn-sm bg-primary/80 border-primary text-primary-content">
|
</div>
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<div class="hc-info">
|
||||||
</div>
|
<span class="hc-src">{{ anime.provider_id or 'Anime' }}</span>
|
||||||
</div>
|
<span class="hc-title">{{ anime.title }}</span>
|
||||||
</figure>
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<span class="badge badge-outline badge-xs">{{ anime.provider_id or 'Anime' }}</span>
|
|
||||||
<p class="text-xs font-semibold truncate mt-1">{{ anime.title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% set accent = "#00d9ff" %}
|
||||||
{% set default_lang = settings.default_lang if settings else 'vostfr' %}
|
{% set default_lang = settings.default_lang if settings else 'vostfr' %}
|
||||||
|
|
||||||
{% set _groups = namespace(items={}) %}
|
{% set _groups = namespace(items={}) %}
|
||||||
@@ -29,136 +30,128 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="flex flex-col gap-4" x-data="{ openDropdown: null }">
|
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||||
{% if _groups.items.values() | list | length > 0 %}
|
{% if _groups.items.values() | list | length > 0 %}
|
||||||
{% for group in _groups.items.values() | list %}
|
{% for group in _groups.items.values() | list %}
|
||||||
{% set first_url = group.providers[0].url %}
|
{% set first_url = group.providers[0].url %}
|
||||||
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||||
<div class="card-body p-5 flex-row gap-5">
|
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
<!-- Poster -->
|
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/00d9ff?text=No+Image' }}"
|
||||||
<figure class="w-28 shrink-0">
|
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
onerror="this.src='https://placehold.co/240x360/161625/00d9ff?text=Error'; this.onerror=null;">
|
||||||
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
</a>
|
||||||
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
<div class="sr-body">
|
||||||
class="rounded-lg w-full aspect-[2/3] object-cover"
|
<div class="sr-top">
|
||||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
<h3 class="sr-title">{{ group.title }}</h3>
|
||||||
</a>
|
{% if group.rating %}
|
||||||
</figure>
|
<span class="sr-rating"><i class="fas fa-star"></i> {{ group.rating }}</span>
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
|
||||||
<!-- Title + rating -->
|
|
||||||
<div class="flex items-baseline gap-3">
|
|
||||||
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
|
||||||
{% if group.rating %}
|
|
||||||
<span class="badge badge-warning badge-sm shrink-0"><i class="fas fa-star"></i> {{ group.rating }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if group.synopsis %}
|
|
||||||
<p class="text-sm text-base-content/60 line-clamp-3">{{ group.synopsis }}</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if group.genres %}
|
{% if group.synopsis %}
|
||||||
<div class="flex flex-wrap gap-1">
|
<p class="sr-synopsis">{{ group.synopsis }}</p>
|
||||||
{% for g in group.genres[:5] %}
|
{% endif %}
|
||||||
<span class="badge badge-ghost badge-sm">{{ g }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Provider badges -->
|
{% if group.genres %}
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="sr-tags">
|
||||||
{% for p in group.providers %}
|
{% for g in group.genres[:5] %}
|
||||||
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
<span class="sr-tag">{{ g }}</span>
|
||||||
class="badge badge-primary badge-outline text-xs uppercase tracking-wider hover:bg-primary hover:text-primary-content hover:border-primary transition-colors cursor-pointer">
|
|
||||||
{{ p.id | upper }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<div class="sr-providers">
|
||||||
<div class="flex flex-wrap gap-2 mt-1">
|
{% for p in group.providers %}
|
||||||
<!-- Watch -->
|
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
{% endfor %}
|
||||||
class="btn btn-sm btn-primary">
|
</div>
|
||||||
<i class="fas fa-play"></i> Regarder
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
<div class="sr-actions">
|
||||||
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
|
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
<div tabindex="0" role="button"
|
<i class="fas fa-play"></i> Regarder
|
||||||
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'"
|
</a>
|
||||||
x-ref="dlToggle-{{ loop.index0 }}">
|
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||||
<span class="btn btn-sm btn-success">
|
<button class="sr-btn sr-btn-dl" @click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
||||||
<i class="fas fa-arrow-down"></i> Télécharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0"
|
|
||||||
class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-300 rounded-xl w-64 border border-base-300 mt-2"
|
|
||||||
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
|
||||||
x-transition:enter="transition ease-out duration-100"
|
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
|
||||||
x-transition:leave="transition ease-in duration-75"
|
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95">
|
|
||||||
<!-- Full season -->
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
|
||||||
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
|
|
||||||
hx-on::after-request="if(event.detail.successful){showToast('Saison complète mise en file'); this.querySelector('.dl-icon').classList.remove('hidden'); this.querySelector('.dl-spinner').classList.add('hidden'); openDropdown = null}">
|
|
||||||
<span class="w-8 h-8 rounded-lg bg-success/20 text-success flex items-center justify-center shrink-0">
|
|
||||||
<i class="fas fa-layer-group dl-icon text-sm"></i>
|
|
||||||
<span class="loading loading-spinner loading-xs dl-spinner hidden"></span>
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col text-left">
|
|
||||||
<span class="text-sm font-medium">Saison complète</span>
|
|
||||||
<span class="text-xs text-base-content/50">Tous les épisodes d'un coup</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="divider my-1 before:bg-base-content/10 after:bg-base-content/10"></div>
|
|
||||||
</li>
|
|
||||||
<!-- Choose episodes -->
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
|
||||||
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
|
||||||
<span class="w-8 h-8 rounded-lg bg-primary/20 text-primary flex items-center justify-center shrink-0">
|
|
||||||
<i class="fas fa-list-ol text-sm"></i>
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col text-left">
|
|
||||||
<span class="text-sm font-medium">Choisir des épisodes</span>
|
|
||||||
<span class="text-xs text-base-content/50">Sélectionnez ce que vous voulez</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Follow -->
|
|
||||||
<button class="btn btn-sm btn-accent btn-outline"
|
|
||||||
hx-post="/api/watchlist"
|
|
||||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.remove('btn-accent','btn-outline');this.classList.add('btn-accent','btn-ghost','pointer-events-none')}">
|
|
||||||
<i class="fas fa-plus"></i> Suivre
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-layer-group"></i> Saison complete
|
||||||
|
</button>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
||||||
|
hx-target="#player-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
||||||
|
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="sr-btn sr-btn-follow"
|
||||||
|
hx-post="/api/watchlist"
|
||||||
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||||
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-20 text-base-content/40">
|
<div class="sr-empty">
|
||||||
<i class="fas fa-search text-6xl mb-5 block opacity-20"></i>
|
<i class="fas fa-search"></i>
|
||||||
<p>Aucun anime trouve pour votre recherche.</p>
|
<p>Aucun anime trouve pour votre recherche.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sr-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.sr-card {
|
||||||
|
display: flex; gap: 20px;
|
||||||
|
background: var(--bg-card); border-radius: var(--card-radius);
|
||||||
|
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||||
|
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||||
|
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sr-top { display: flex; align-items: baseline; gap: 12px; }
|
||||||
|
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.sr-rating { flex-shrink: 0; font-size: 0.8rem; font-weight: 700; color: #ffcc00; }
|
||||||
|
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
|
||||||
|
.sr-tags { display: flex; flex-wrap: wrap; gap: 4px; margin: 0; }
|
||||||
|
.sr-tag { font-size: 0.65rem; font-weight: 600; padding: 2px 8px; border-radius: 4px; background: rgba(255,255,255,0.06); color: var(--text-dim); }
|
||||||
|
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||||
|
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||||
|
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||||
|
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
|
||||||
|
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); }
|
||||||
|
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||||
|
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||||
|
.sr-dropdown { position: relative; }
|
||||||
|
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||||
|
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||||
|
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||||
|
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
|
||||||
|
.sr-poster-link { width: 160px; }
|
||||||
|
.sr-top { justify-content: center; }
|
||||||
|
.sr-tags { justify-content: center; }
|
||||||
|
.sr-providers { justify-content: center; }
|
||||||
|
.sr-actions { justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,61 +1,52 @@
|
|||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="downloads-grid">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="card bg-base-200 border border-base-300 p-4">
|
<div class="download-item status-{{ task.status.value }}">
|
||||||
<!-- Top row: filename + status badge -->
|
<div class="download-info">
|
||||||
<div class="flex justify-between items-center mb-3">
|
<span class="download-name" title="{{ task.filename }}">{{ task.filename }}</span>
|
||||||
<span class="font-medium truncate mr-2" title="{{ task.filename }}">{{ task.filename }}</span>
|
<span class="badge badge-{{ task.status.value }}">{{ task.status | upper }}</span>
|
||||||
<span class="badge
|
|
||||||
{% if task.status == 'downloading' %}badge-info
|
|
||||||
{% elif task.status == 'completed' %}badge-success
|
|
||||||
{% elif task.status == 'failed' %}badge-error
|
|
||||||
{% elif task.status == 'paused' %}badge-warning
|
|
||||||
{% else %}badge-ghost{% endif %}">
|
|
||||||
{{ task.status | upper }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress bar -->
|
<div class="progress-container">
|
||||||
<progress class="progress progress-primary w-full mb-3" value="{{ task.progress }}" max="100"></progress>
|
<div class="progress-bar" style="width: {{ task.progress }}%"></div>
|
||||||
|
</div>
|
||||||
<!-- Meta row: speed, percentage, ETA -->
|
|
||||||
<div class="flex gap-4 text-xs text-base-content/50 mb-3">
|
<div class="download-meta">
|
||||||
<span>{{ task.progress | round(1) }}%</span>
|
<span>{{ task.progress | round(1) }}%</span>
|
||||||
<span>{{ task.speed or '0' }} KB/s</span>
|
<span>{{ task.speed or '0' }} KB/s</span>
|
||||||
<span>{{ task.eta or '' }}</span>
|
<span>{{ task.eta or '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<div class="download-actions">
|
||||||
<div class="flex gap-1 justify-end">
|
|
||||||
{% if task.status == 'downloading' or task.status == 'pending' %}
|
{% if task.status == 'downloading' or task.status == 'pending' %}
|
||||||
<button class="btn btn-circle btn-sm btn-ghost" 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">
|
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 btn-circle btn-sm btn-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">
|
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' %}
|
{% if task.status == 'failed' or task.status == 'cancelled' %}
|
||||||
<button class="btn btn-circle btn-sm btn-warning" hx-post="/api/downloads/{{ task.id }}/retry" hx-swap="none"
|
<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">
|
hx-on::after-request="htmx.trigger('#downloads-container-inner', 'refresh')" title="Relancer">
|
||||||
<i class="fas fa-redo"></i>
|
<i class="fas fa-redo"></i>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if task.status == 'completed' %}
|
{% if task.status == 'completed' %}
|
||||||
<a href="/api/downloads/video/{{ task.id }}" class="btn btn-circle btn-sm btn-success" title="Streamer">
|
<a href="/api/downloads/video/{{ task.id }}" class="btn-icon success" title="Streamer">
|
||||||
<i class="fas fa-play-circle"></i>
|
<i class="fas fa-play-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="/downloads/{{ task.filename }}" class="btn btn-circle btn-sm btn-ghost" download title="Telecharger">
|
<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 btn-circle btn-sm btn-error"
|
<button class="btn-icon danger"
|
||||||
hx-delete="/api/downloads/{{ task.id }}"
|
hx-delete="/api/downloads/{{ task.id }}"
|
||||||
hx-confirm="Supprimer ce telechargement ?"
|
hx-confirm="Supprimer ce telechargement ?"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
@@ -68,8 +59,8 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-16 text-base-content/30">
|
<div class="empty-state" style="text-align: center; padding: 60px 20px; color: var(--text-dim);">
|
||||||
<i class="fas fa-cloud-download-alt text-5xl mb-5 block"></i>
|
<i class="fas fa-cloud-download-alt" style="font-size: 3rem; margin-bottom: 20px; opacity: 0.1; display: block;"></i>
|
||||||
<p>Aucun telechargement en cours</p>
|
<p>Aucun telechargement en cours</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
<div class="mb-10">
|
<div class="section-container">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
<h2>Telechargements <span id="activeDownloadsCount" class="active-downloads-counter" style="display:none;">(0 actif)</span></h2>
|
||||||
Téléchargements
|
<div class="header-actions">
|
||||||
<span id="activeDownloadsCount" class="badge badge-primary badge-sm" style="display:none;">0 actif</span>
|
<button class="btn btn-sm btn-secondary"
|
||||||
</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-ghost"
|
|
||||||
hx-post="/api/downloads/cleanup"
|
hx-post="/api/downloads/cleanup"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-confirm="Nettoyer tous les telechargements termines ?"
|
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')">
|
||||||
<i class="fas fa-broom"></i> Nettoyer termines
|
<i class="fas fa-broom"></i> Nettoyer termines
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error"
|
<button class="btn btn-sm btn-danger"
|
||||||
hx-post="/api/downloads/cancel-all"
|
hx-post="/api/downloads/cancel-all"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-confirm="Annuler tous les telechargements actifs ?"
|
hx-confirm="Annuler tous les telechargements actifs ?"
|
||||||
@@ -26,9 +23,22 @@
|
|||||||
<div id="downloads-container-inner"
|
<div id="downloads-container-inner"
|
||||||
hx-get="/api/downloads?html=1"
|
hx-get="/api/downloads?html=1"
|
||||||
hx-trigger="load, refresh, every 3s"
|
hx-trigger="load, refresh, every 3s"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML">
|
||||||
class="flex justify-center py-8 text-base-content/50">
|
<div class="loading-placeholder">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<div class="spinner"></div> Chargement des telechargements...
|
||||||
<span class="ml-2">Chargement des telechargements...</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section-container { margin-bottom: 40px; }
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -1,205 +1,132 @@
|
|||||||
<div class="card bg-base-200 border border-primary/30 mt-8"
|
<div class="episode-list-container section-container" x-data="{ view: 'grid' }">
|
||||||
x-data="{ view: 'grid', selectedEps: new Set(), selectMode: false, downloadingSeason: false }"
|
<div class="section-header">
|
||||||
id="episode-list-card">
|
<div>
|
||||||
|
<h2 style="border: none; padding: 0; margin-bottom: 5px;">{{ anime_title }}</h2>
|
||||||
<!-- Header -->
|
<span class="badge">{{ episodes|length }} épisodes disponibles</span>
|
||||||
<div class="card-body p-6">
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<h2 class="text-xl font-bold border-none p-0 m-0">{{ anime_title }}</h2>
|
|
||||||
<span class="badge badge-outline">{{ episodes|length }} épisodes disponibles</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
<!-- View toggles -->
|
|
||||||
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'grid'" :class="{ 'btn-primary': view === 'grid' }" title="Grille">
|
|
||||||
<i class="fas fa-th"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-circle btn-sm btn-ghost" @click="view = 'list'" :class="{ 'btn-primary': view === 'list' }" title="Liste">
|
|
||||||
<i class="fas fa-list"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Batch select toggle -->
|
|
||||||
<button class="btn btn-circle btn-sm btn-ghost"
|
|
||||||
@click="selectMode = !selectMode; if(!selectMode) selectedEps.clear()"
|
|
||||||
:class="{ 'btn-accent': selectMode }"
|
|
||||||
title="Sélection multiple">
|
|
||||||
<i class="fas fa-check-double"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Download selected episodes -->
|
|
||||||
<template x-if="selectMode && selectedEps.size > 0">
|
|
||||||
<button class="btn btn-sm btn-success gap-1"
|
|
||||||
@click="downloadSelected()"
|
|
||||||
:disabled="downloadingSeason">
|
|
||||||
<i class="fas fa-download" x-show="!downloadingSeason"></i>
|
|
||||||
<span class="loading loading-spinner loading-xs" x-show="downloadingSeason"></span>
|
|
||||||
<span x-text="selectedEps.size + ' épisode' + (selectedEps.size > 1 ? 's' : '')"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Download full season -->
|
|
||||||
<button class="btn btn-sm btn-secondary gap-1"
|
|
||||||
x-show="!selectMode"
|
|
||||||
:disabled="downloadingSeason"
|
|
||||||
@click="downloadFullSeason()">
|
|
||||||
<i class="fas fa-layer-group" x-show="!downloadingSeason"></i>
|
|
||||||
<span class="loading loading-spinner loading-xs" x-show="downloadingSeason"></span>
|
|
||||||
Saison complète
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Close player -->
|
|
||||||
<button class="btn btn-circle btn-sm btn-error" onclick="document.getElementById('player-container').innerHTML = ''" title="Fermer">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="header-actions" style="display: flex; gap: 10px;">
|
||||||
|
<button class="btn btn-icon" @click="view = 'grid'" :class="{ 'btn-primary': view === 'grid' }">
|
||||||
|
<i class="fas fa-th"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon" @click="view = 'list'" :class="{ 'btn-primary': view === 'list' }">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon danger" onclick="document.getElementById('player-container').innerHTML = ''">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Video player display area -->
|
<!-- Zone d'affichage du player vidéo (Placé en haut pour une meilleure visibilité lors de la sélection) -->
|
||||||
<div id="video-player-display" x-ref="playerArea"></div>
|
<div id="video-player-display"></div>
|
||||||
|
|
||||||
<!-- Episodes content -->
|
<div class="episodes-content" :class="'view-' + view" style="margin-top: 25px;">
|
||||||
{% if episodes %}
|
{% if episodes %}
|
||||||
<!-- Grid View -->
|
{% for ep in episodes %}
|
||||||
<div x-show="view === 'grid'" x-transition class="mt-6">
|
<div class="episode-item">
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 lg:grid-cols-7 gap-3">
|
<div class="ep-number">EP {{ ep.episode_number or loop.index }}</div>
|
||||||
{% for ep in episodes %}
|
<div class="ep-title" title="{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}">
|
||||||
<div class="bg-base-300 rounded-lg p-4 text-center hover:bg-base-100 transition-all border border-transparent hover:border-primary flex flex-col gap-2 relative group"
|
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
|
||||||
:class="{ 'ring-2 ring-accent border-accent': selectMode && selectedEps.has('{{ ep.url }}') }">
|
</div>
|
||||||
<!-- Selection checkbox -->
|
<div class="ep-actions">
|
||||||
<div class="absolute top-2 right-2 z-10 transition-opacity"
|
<button class="btn btn-primary btn-small"
|
||||||
:class="selectMode ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'">
|
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
||||||
<label class="checkbox checkbox-sm checkbox-accent">
|
hx-target="#video-player-display"
|
||||||
<input type="checkbox"
|
hx-swap="innerHTML"
|
||||||
x-model="selectedEps"
|
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
||||||
value="{{ ep.url }}"
|
<i class="fas fa-play"></i> Regarder
|
||||||
@change="$event.target.checked ? selectedEps.add('{{ ep.url }}') : selectedEps.delete('{{ ep.url }}')"
|
</button>
|
||||||
:checked="selectedEps.has('{{ ep.url }}')"
|
<button class="btn btn-secondary btn-icon btn-small"
|
||||||
x-show="selectMode">
|
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
||||||
</label>
|
hx-swap="none"
|
||||||
</div>
|
title="Télécharger cet épisode">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
<div class="text-primary font-bold text-xl">EP {{ ep.episode_number or loop.index }}</div>
|
</button>
|
||||||
{% if ep.title %}
|
</div>
|
||||||
<div class="text-[0.65rem] text-base-content/50 truncate" title="{{ ep.title }}">{{ ep.title }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<button class="btn btn-xs btn-primary w-full"
|
|
||||||
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
|
||||||
hx-target="#video-player-display"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
|
||||||
<i class="fas fa-play"></i> Regarder
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-xs btn-outline btn-success w-full gap-1"
|
|
||||||
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Lancé';this.disabled=true;this.classList.remove('btn-outline','btn-success');this.classList.add('btn-ghost','pointer-events-none');setTimeout(()=>{this.innerHTML='<i class=\'fas fa-download\'></i> Télécharger';this.disabled=false;this.classList.remove('btn-ghost','pointer-events-none');this.classList.add('btn-outline','btn-success')},4000)}"
|
|
||||||
title="Télécharger cet épisode">
|
|
||||||
<i class="fas fa-download"></i> Télécharger
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
|
|
||||||
<!-- List View -->
|
|
||||||
<div x-show="view === 'list'" x-transition class="mt-6">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
{% for ep in episodes %}
|
|
||||||
<div class="flex items-center gap-3 bg-base-300 rounded-lg px-4 py-3 hover:bg-base-100 transition-all group"
|
|
||||||
:class="{ 'ring-2 ring-accent border-accent': selectMode && selectedEps.has('{{ ep.url }}') }">
|
|
||||||
<!-- Selection checkbox -->
|
|
||||||
<div class="shrink-0 transition-opacity"
|
|
||||||
:class="selectMode ? 'opacity-100' : 'opacity-0'">
|
|
||||||
<label class="checkbox checkbox-sm checkbox-accent">
|
|
||||||
<input type="checkbox"
|
|
||||||
x-model="selectedEps"
|
|
||||||
value="{{ ep.url }}"
|
|
||||||
@change="$event.target.checked ? selectedEps.add('{{ ep.url }}') : selectedEps.delete('{{ ep.url }}')"
|
|
||||||
:checked="selectedEps.has('{{ ep.url }}')">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="font-bold text-primary w-12 shrink-0">EP {{ ep.episode_number or loop.index }}</span>
|
|
||||||
<span class="flex-1 truncate text-base-content/80 font-medium text-sm"
|
|
||||||
title="{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}">
|
|
||||||
{{ ep.title or 'Épisode ' ~ (ep.episode_number or loop.index) }}
|
|
||||||
</span>
|
|
||||||
<div class="flex gap-2 shrink-0">
|
|
||||||
<button class="btn btn-xs btn-primary"
|
|
||||||
hx-get="/api/player/embed?url={{ ep.url | urlencode }}"
|
|
||||||
hx-target="#video-player-display"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
onclick="document.getElementById('video-player-display').scrollIntoView({behavior: 'smooth'})">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-xs btn-outline btn-success gap-1"
|
|
||||||
hx-post="/api/anime/download?url={{ ep.url | urlencode }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i>';this.disabled=true;this.classList.remove('btn-outline','btn-success');this.classList.add('btn-ghost','pointer-events-none');setTimeout(()=>{this.innerHTML='<i class=\'fas fa-download\'></i>';this.disabled=false;this.classList.remove('btn-ghost','pointer-events-none');this.classList.add('btn-outline','btn-success')},4000)}"
|
|
||||||
title="Télécharger cet épisode">
|
|
||||||
<i class="fas fa-download"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12 text-base-content/40">
|
<div class="no-results">
|
||||||
<i class="fas fa-exclamation-circle text-3xl mb-3 block"></i>
|
<i class="fas fa-exclamation-circle"></i>
|
||||||
<p>Aucun épisode trouvé pour cette source.</p>
|
<p>Aucun épisode trouvé pour cette source.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<style>
|
||||||
document.addEventListener('alpine:init', () => {
|
.episode-list-container {
|
||||||
Alpine.data('episodeListActions', () => ({
|
margin-top: 30px;
|
||||||
downloadSelected() {
|
background: var(--bg-card);
|
||||||
if (this.selectedEps.size === 0) return;
|
border-radius: var(--card-radius);
|
||||||
this.downloadingSeason = true;
|
padding: 30px;
|
||||||
let completed = 0;
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
const total = this.selectedEps.size;
|
animation: fadeIn 0.3s ease-out;
|
||||||
const urls = [...this.selectedEps];
|
|
||||||
|
|
||||||
Promise.allSettled(urls.map(url =>
|
|
||||||
fetch(`/api/anime/download?url=${encodeURIComponent(url)}`, { method: 'POST' })
|
|
||||||
.then(r => { completed++; return r; })
|
|
||||||
)).then(() => {
|
|
||||||
this.downloadingSeason = false;
|
|
||||||
this.selectedEps.clear();
|
|
||||||
this.selectMode = false;
|
|
||||||
showToast(`${completed} téléchargement${completed > 1 ? 's' : ''} lancé${completed > 1 ? 's' : ''}`);
|
|
||||||
htmx.trigger('#downloads-container-inner', 'refresh');
|
|
||||||
});
|
|
||||||
},
|
|
||||||
downloadFullSeason() {
|
|
||||||
this.downloadingSeason = true;
|
|
||||||
const card = document.getElementById('episode-list-card');
|
|
||||||
const downloadBtns = card.querySelectorAll('[hx-post*="/api/anime/download"]');
|
|
||||||
let completed = 0;
|
|
||||||
const total = downloadBtns.length;
|
|
||||||
|
|
||||||
Promise.allSettled([...downloadBtns].map(btn => {
|
|
||||||
const url = new URLSearchParams(btn.getAttribute('hx-post').split('?')[1]).get('url');
|
|
||||||
return fetch(`/api/anime/download?url=${encodeURIComponent(url)}`, { method: 'POST' })
|
|
||||||
.then(r => { completed++; return r; });
|
|
||||||
})).then(() => {
|
|
||||||
this.downloadingSeason = false;
|
|
||||||
showToast(`${total} épisodes mis en file de téléchargement`);
|
|
||||||
htmx.trigger('#downloads-container-inner', 'refresh');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toast notification helper — uses the Alpine.js toast system in toast_container.html
|
|
||||||
// Already defined globally in settings.js, this is a fallback
|
|
||||||
function showToast(message, type = 'success') {
|
|
||||||
const ev = new CustomEvent('show-toast', { detail: { message, type } });
|
|
||||||
(window.dispatchEvent || document.dispatchEvent).call(window, ev);
|
|
||||||
}
|
}
|
||||||
</script>
|
|
||||||
|
.episodes-content.view-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid .episode-item {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 20px 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid .episode-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: var(--primary);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-grid .ep-title { display: none; }
|
||||||
|
.view-grid .ep-number { font-weight: 800; font-size: 1.2rem; color: var(--primary); }
|
||||||
|
.view-grid .ep-actions { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.view-grid .ep-actions .btn { width: 100%; }
|
||||||
|
|
||||||
|
.episodes-content.view-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-list .episode-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-list .episode-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-list .ep-number { font-weight: 800; width: 60px; color: var(--primary); }
|
||||||
|
.view-list .ep-title { flex: 1; color: var(--text-main); font-weight: 500; }
|
||||||
|
.view-list .ep-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
#video-player-display:not(:empty) {
|
||||||
|
margin: 20px 0 30px 0;
|
||||||
|
padding: 25px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
box-shadow: 0 0 30px rgba(0, 217, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<header>
|
||||||
|
<h1>⚡ Ohm Stream Downloader</h1>
|
||||||
|
<p class="subtitle">Téléchargez vos vidéos, animes et séries depuis vos hébergeurs préférés</p>
|
||||||
|
|
||||||
|
<!-- User info and logout button -->
|
||||||
|
<div id="userInfo" x-show="isAuthenticated" class="auth-panel" x-cloak>
|
||||||
|
<div style="display: flex; align-items: center; gap: 10px;">
|
||||||
|
<span style="color: var(--primary); font-size: 1.2rem;">👤</span>
|
||||||
|
<span style="color: #fff; font-size: 14px;">Connecté en tant que <strong x-text="username" style="color: var(--primary);">-</strong></span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-small"
|
||||||
|
onclick="removeToken(); isAuthenticated = false"
|
||||||
|
hx-post="/api/auth/logout"
|
||||||
|
hx-on::after-request="window.location.href = '/login'">
|
||||||
|
🚪 Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login prompt (shown when not logged in) -->
|
||||||
|
<div id="loginPrompt" x-show="!isAuthenticated" class="auth-panel" style="justify-content: center;" x-cloak>
|
||||||
|
<p style="color: var(--primary); margin: 0;">
|
||||||
|
👋 Bienvenue! <a href="/login" class="btn btn-secondary btn-small" style="margin-left: 10px;">Se connecter</a> pour accéder à toutes les fonctionnalités.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs - Robust navigation -->
|
||||||
|
<nav id="mainTabs" class="tabs">
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'home' }"
|
||||||
|
@click="activeTab = 'home'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'home' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
Accueil
|
||||||
|
</button>
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'anime' }"
|
||||||
|
@click="activeTab = 'anime'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'anime' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Anime
|
||||||
|
</button>
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'series' }"
|
||||||
|
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"></path>
|
||||||
|
</svg>
|
||||||
|
Série
|
||||||
|
</button>
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'watchlist' }"
|
||||||
|
@click="activeTab = 'watchlist'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'watchlist' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2m0 0a2 2 0 00-2 2h10a2 2 0 002-2V7a2 2 0 00-2-2"></path>
|
||||||
|
</svg>
|
||||||
|
Watchlist
|
||||||
|
</button>
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'downloads' }"
|
||||||
|
@click="activeTab = 'downloads'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'downloads' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Téléchargements
|
||||||
|
</button>
|
||||||
|
<button class="tab"
|
||||||
|
:class="{ 'active': activeTab === 'settings' }"
|
||||||
|
@click="activeTab = 'settings'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'settings' } }))">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Paramètres
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
@@ -1,49 +1,36 @@
|
|||||||
<!-- Home Tab -->
|
<div id="tab-home" class="tab-content" x-show="activeTab === 'home'">
|
||||||
<div id="tab-home" x-show="activeTab === 'home'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
|
|
||||||
<!-- Recommendations Section -->
|
<div class="section-container">
|
||||||
<div class="mb-8">
|
<div class="section-header">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<h2>🎯 Recommandé pour vous</h2>
|
||||||
<h2 class="text-xl font-bold">
|
<button class="btn btn-secondary btn-small"
|
||||||
<i class="fa-solid fa-bullseye text-primary"></i> Recommandé pour vous
|
hx-get="/api/recommendations"
|
||||||
</h2>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
hx-get="/api/recommendations"
|
|
||||||
hx-target="#recommendationsList">
|
hx-target="#recommendationsList">
|
||||||
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualiser
|
<i class="fas fa-sync-alt"></i> Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="recommendationsList"
|
<div id="recommendationsList"
|
||||||
hx-get="/api/recommendations"
|
hx-get="/api/recommendations"
|
||||||
hx-trigger="load delay:100ms">
|
hx-trigger="load delay:100ms"
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
class="home-row">
|
||||||
<div class="flex items-center justify-center py-8 w-full">
|
<div class="loading-placeholder"><div class="spinner"></div></div>
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Latest Releases Section -->
|
<div class="section-container">
|
||||||
<div>
|
<div class="section-header">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<h2>🔥 Dernières sorties</h2>
|
||||||
<h2 class="text-xl font-bold">
|
<button class="btn btn-secondary btn-small"
|
||||||
<i class="fa-solid fa-fire text-error"></i> Dernières sorties
|
hx-get="/api/releases/latest"
|
||||||
</h2>
|
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
hx-get="/api/releases/latest"
|
|
||||||
hx-target="#releasesList">
|
hx-target="#releasesList">
|
||||||
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualiser
|
<i class="fas fa-sync-alt"></i> Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="releasesList"
|
<div id="releasesList"
|
||||||
hx-get="/api/releases/latest"
|
hx-get="/api/releases/latest"
|
||||||
hx-trigger="load delay:300ms">
|
hx-trigger="load delay:300ms"
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
class="home-row">
|
||||||
<div class="flex items-center justify-center py-8 w-full">
|
<div class="loading-placeholder"><div class="spinner"></div></div>
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
<div class="flex flex-col items-center justify-center py-16 text-base-content/50">
|
<div class="login-prompt" style="text-align: center; padding: 40px 20px;">
|
||||||
<i class="fa-solid fa-lock text-4xl text-primary mb-4"></i>
|
<i class="fas fa-lock" style="font-size: 2rem; color: #00d9ff; margin-bottom: 15px;"></i>
|
||||||
<p class="text-base">Connectez-vous pour accéder à cette section.</p>
|
<p style="color: #888; font-size: 0.95rem;">Connectez-vous pour accéder à cette section.</p>
|
||||||
<a href="/login" class="btn btn-sm btn-primary mt-4 gap-2">
|
|
||||||
<i class="fa-solid fa-right-to-bracket"></i> Se connecter
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="bg-black rounded-lg border border-base-300 overflow-hidden my-5 p-4"
|
<div class="player-embed-box"
|
||||||
x-data="{
|
x-data="{
|
||||||
initPlayer() {
|
initPlayer() {
|
||||||
if (!this.$refs.player) return;
|
if (!this.$refs.player) return;
|
||||||
@@ -12,27 +12,66 @@
|
|||||||
x-init="initPlayer()">
|
x-init="initPlayer()">
|
||||||
|
|
||||||
{% if is_iframe %}
|
{% if is_iframe %}
|
||||||
<div class="relative w-full" style="padding-bottom: 56.25%; height: 0; overflow: hidden;">
|
<div class="iframe-container">
|
||||||
<iframe src="{{ video_url }}"
|
<iframe src="{{ video_url }}"
|
||||||
allowfullscreen
|
allowfullscreen
|
||||||
webkitallowfullscreen
|
webkitallowfullscreen
|
||||||
mozallowfullscreen
|
mozallowfullscreen></iframe>
|
||||||
class="absolute top-0 left-0 w-full h-full border-0 rounded-t-lg"></iframe>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-base-content/40 mt-3 text-center">
|
<div class="player-info-hint">
|
||||||
<i class="fa-solid fa-lightbulb"></i> Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
|
💡 Lecteur externe utilisé. Les contrôles dépendent de l'hébergeur.
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="w-full rounded-lg overflow-hidden">
|
<div class="video-wrapper">
|
||||||
<video x-ref="player" playsinline controls preload="metadata" class="w-full rounded-lg">
|
<video x-ref="player" playsinline controls preload="metadata">
|
||||||
<source src="{{ video_url }}" type="video/mp4">
|
<source src="{{ video_url }}" type="video/mp4">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex justify-center mt-4">
|
<div class="player-footer-actions">
|
||||||
<a href="{{ video_url }}" class="btn btn-sm btn-ghost" target="_blank">
|
<a href="{{ video_url }}" class="btn btn-sm btn-secondary" target="_blank">
|
||||||
<i class="fas fa-external-link-alt"></i> Ouvrir sur l'hébergeur
|
<i class="fas fa-external-link-alt"></i> Ouvrir sur l'hébergeur
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.player-embed-box {
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 15px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
.iframe-container {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.iframe-container iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.video-wrapper {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.player-info-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.player-footer-actions {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
{% from "components/anime_card.html" import anime_card %}
|
{% from "components/anime_card.html" import anime_card %}
|
||||||
{% from "components/series_card.html" import series_card %}
|
|
||||||
|
|
||||||
{% if recommendations %}
|
{% if recommendations %}
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
{% for anime in recommendations %}
|
||||||
{% for item in recommendations %}
|
{{ anime_card(anime) }}
|
||||||
{% if item.get('content_type') == 'series' %}
|
|
||||||
{{ series_card(item) }}
|
|
||||||
{% else %}
|
|
||||||
{{ anime_card(item) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-base-content/40">
|
<div class="empty-state">
|
||||||
<i class="fa-regular fa-face-meh text-3xl mb-2"></i>
|
<p>Aucune recommandation pour le moment.</p>
|
||||||
<p class="text-sm">Aucune recommandation pour le moment.</p>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
{% from "components/anime_card.html" import anime_card %}
|
{% from "components/anime_card.html" import anime_card %}
|
||||||
{% from "components/series_card.html" import series_card %}
|
|
||||||
|
|
||||||
{% if releases %}
|
{% if releases %}
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
{% for anime in releases %}
|
||||||
{% for item in releases %}
|
{{ anime_card(anime) }}
|
||||||
{% if item.get('content_type') == 'series' %}
|
|
||||||
{{ series_card(item) }}
|
|
||||||
{% else %}
|
|
||||||
{{ anime_card(item) }}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-base-content/40">
|
<div class="empty-state">
|
||||||
<i class="fa-regular fa-face-meh text-3xl mb-2"></i>
|
<p>Aucune sortie récente trouvée.</p>
|
||||||
<p class="text-sm">Aucune sortie récente trouvée.</p>
|
</div>
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
{% macro series_card(series) %}
|
{% macro series_card(series, in_watchlist=False, lang='vf') %}
|
||||||
<div class="card card-compact bg-base-200 shadow-lg hover:shadow-xl transition-all cursor-pointer w-40 shrink-0 group"
|
<div class="ac" id="series-{{ series.url | hash }}">
|
||||||
@click="activeTab = 'series'; window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: 'series' } })); $nextTick(() => { const input = document.getElementById('seriesSearchInput'); if (input) { input.value = '{{ series.title | e }}'; htmx.trigger(input, 'keyup'); } });">
|
<div class="ac-poster">
|
||||||
<figure class="relative overflow-hidden rounded-t-lg aspect-[2/3]">
|
<img src="{{ series.cover_image or 'https://placehold.co/400x600/161625/ff6b6b?text=No+Image' }}"
|
||||||
<img src="{{ series.cover_image or 'https://placehold.co/400x600/202327/FF9F1C?text=No+Image' }}" alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
alt="{{ series.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
class="w-full h-full object-cover"
|
onerror="this.src='https://placehold.co/400x600/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||||
onerror="this.src='https://placehold.co/400x600/202327/FF9F1C?text=Error'; this.onerror=null;">
|
<button class="ac-play"
|
||||||
{% if series.lang %}
|
hx-get="/api/anime/episodes?url={{ series.url | urlencode }}&lang={{ lang }}"
|
||||||
<span class="badge badge-secondary badge-sm absolute top-2 left-2" style="text-transform: uppercase;">{{ series.lang }}</span>
|
hx-target="#player-container" hx-swap="innerHTML">
|
||||||
{% endif %}
|
<i class="fas fa-play"></i>
|
||||||
<div class="absolute inset-0 bg-secondary/20 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
</button>
|
||||||
<div class="btn btn-circle btn-sm bg-secondary/80 border-secondary text-secondary-content">
|
</div>
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<div class="ac-info">
|
||||||
</div>
|
<span class="ac-provider" style="--ac-color: var(--secondary)">{{ series.provider_id | upper if series.provider_id else 'FS7' }}</span>
|
||||||
</div>
|
<h3 class="ac-title" title="{{ series.title }}">{{ series.title }}</h3>
|
||||||
</figure>
|
|
||||||
<div class="card-body p-3">
|
|
||||||
<span class="badge badge-outline badge-xs">{{ series.provider_id or 'FS7' }}</span>
|
|
||||||
<p class="text-xs font-semibold truncate mt-1">{{ series.title }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
{% from "components/series_card.html" import series_card %}
|
|
||||||
|
|
||||||
{% if releases %}
|
|
||||||
<div class="flex gap-4 overflow-x-auto pb-4">
|
|
||||||
{% for item in releases %}
|
|
||||||
{{ series_card(item) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-base-content/40">
|
|
||||||
<i class="fa-regular fa-face-meh text-3xl mb-2"></i>
|
|
||||||
<p class="text-sm">Aucune série récente trouvée.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
{% set accent = "#ff6b6b" %}
|
||||||
{% set default_lang = settings.default_lang if settings else 'vf' %}
|
{% set default_lang = settings.default_lang if settings else 'vf' %}
|
||||||
|
|
||||||
{% set _groups = namespace(items={}) %}
|
{% set _groups = namespace(items={}) %}
|
||||||
@@ -5,12 +6,12 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
{% set _key = item.title | lower | trim %}
|
{% set _key = item.title | lower | trim %}
|
||||||
{% if _key not in _groups.items %}
|
{% if _key not in _groups.items %}
|
||||||
{% set _ = _groups.items.update({
|
{% set _ = _groups.items.update({_key: {
|
||||||
"title": item.title,
|
"title": item.title,
|
||||||
"cover": item.cover_image or "",
|
"cover": item.cover_image or "",
|
||||||
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
"synopsis": (item.metadata.synopsis if item.metadata and item.metadata.synopsis else ""),
|
||||||
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
"providers": [{ "id": item.provider_id or pid, "url": item.url }]
|
||||||
}) %}
|
}}) %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set _existing = _groups.items[_key] %}
|
{% set _existing = _groups.items[_key] %}
|
||||||
{% if not _existing.cover and item.cover_image %}
|
{% if not _existing.cover and item.cover_image %}
|
||||||
@@ -21,124 +22,110 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="flex flex-col gap-4" x-data="{ openDropdown: null }">
|
<div class="sr-list" x-data="{ openDropdown: null }">
|
||||||
{% if _groups.items.values() | list | length > 0 %}
|
{% if _groups.items.values() | list | length > 0 %}
|
||||||
{% for group in _groups.items.values() | list %}
|
{% for group in _groups.items.values() | list %}
|
||||||
{% set first_url = group.providers[0].url %}
|
{% set first_url = group.providers[0].url %}
|
||||||
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors">
|
<div class="sr-card" style="--sr-accent: {{ accent }};">
|
||||||
<div class="card-body p-5 flex-row gap-5">
|
<a class="sr-poster-link" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
<!-- Poster -->
|
<img class="sr-poster-img" src="{{ group.cover or 'https://placehold.co/240x360/161625/ff6b6b?text=No+Image' }}"
|
||||||
<figure class="w-28 shrink-0">
|
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener">
|
onerror="this.src='https://placehold.co/240x360/161625/ff6b6b?text=Error'; this.onerror=null;">
|
||||||
<img src="{{ group.cover or 'https://placehold.co/240x360/29274c/7e52a0?text=No+Image' }}"
|
</a>
|
||||||
alt="{{ group.title }}" loading="lazy" referrerpolicy="no-referrer"
|
<div class="sr-body">
|
||||||
class="rounded-lg w-full aspect-[2/3] object-cover"
|
<h3 class="sr-title">{{ group.title }}</h3>
|
||||||
onerror="this.src='https://placehold.co/240x360/29274c/7e52a0?text=Error'; this.onerror=null;">
|
|
||||||
|
{% if group.synopsis %}
|
||||||
|
<p class="sr-synopsis">{{ group.synopsis }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="sr-providers">
|
||||||
|
{% for p in group.providers %}
|
||||||
|
<a class="sr-provider-badge" href="{{ p.url }}" target="_blank" rel="noopener">{{ p.id | upper }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sr-actions">
|
||||||
|
<a class="sr-btn sr-btn-watch" href="{{ first_url }}" target="_blank" rel="noopener">
|
||||||
|
<i class="fas fa-play"></i> Regarder
|
||||||
</a>
|
</a>
|
||||||
</figure>
|
<div class="sr-dropdown" @click.outside="openDropdown = null">
|
||||||
|
<button class="sr-btn sr-btn-dl" @click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
||||||
<!-- Content -->
|
<i class="fas fa-download"></i> Telecharger <i class="fas fa-chevron-down" style="font-size:0.6rem;margin-left:4px;"></i>
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-2">
|
|
||||||
<!-- Title -->
|
|
||||||
<div class="flex items-baseline gap-3">
|
|
||||||
<h3 class="card-title text-base truncate">{{ group.title }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if group.synopsis %}
|
|
||||||
<p class="text-sm text-base-content/60 line-clamp-3">{{ group.synopsis }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Provider badges -->
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{% for p in group.providers %}
|
|
||||||
<a href="{{ p.url }}" target="_blank" rel="noopener"
|
|
||||||
class="badge badge-primary badge-outline text-xs uppercase tracking-wider hover:bg-primary hover:text-primary-content hover:border-primary transition-colors cursor-pointer">
|
|
||||||
{{ p.id | upper }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="flex flex-wrap gap-2 mt-1">
|
|
||||||
<!-- Watch -->
|
|
||||||
<a href="{{ first_url }}" target="_blank" rel="noopener"
|
|
||||||
class="btn btn-sm btn-primary">
|
|
||||||
<i class="fas fa-play"></i> Regarder
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Download dropdown -->
|
|
||||||
<div class="dropdown dropdown-end" @click.outside="openDropdown = null">
|
|
||||||
<div tabindex="0" role="button"
|
|
||||||
@click.stop="openDropdown = (openDropdown === '{{ first_url | urlencode }}') ? null : '{{ first_url | urlencode }}'">
|
|
||||||
<span class="btn btn-sm btn-success">
|
|
||||||
<i class="fas fa-arrow-down"></i> Télécharger <i class="fas fa-chevron-down text-[0.6rem] ml-1"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0"
|
|
||||||
class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-300 rounded-xl w-64 border border-base-300 mt-2"
|
|
||||||
x-show="openDropdown === '{{ first_url | urlencode }}'"
|
|
||||||
x-transition:enter="transition ease-out duration-100"
|
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
|
||||||
x-transition:leave="transition ease-in duration-75"
|
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95">
|
|
||||||
<!-- Full season -->
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
|
||||||
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::before-request="this.querySelector('.dl-icon').classList.add('hidden'); this.querySelector('.dl-spinner').classList.remove('hidden')"
|
|
||||||
hx-on::after-request="if(event.detail.successful){showToast('Saison complète mise en file'); this.querySelector('.dl-icon').classList.remove('hidden'); this.querySelector('.dl-spinner').classList.add('hidden'); openDropdown = null}">
|
|
||||||
<span class="w-8 h-8 rounded-lg bg-success/20 text-success flex items-center justify-center shrink-0">
|
|
||||||
<i class="fas fa-layer-group dl-icon text-sm"></i>
|
|
||||||
<span class="loading loading-spinner loading-xs dl-spinner hidden"></span>
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col text-left">
|
|
||||||
<span class="text-sm font-medium">Saison complète</span>
|
|
||||||
<span class="text-xs text-base-content/50">Tous les épisodes d'un coup</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="divider my-1 before:bg-base-content/10 after:bg-base-content/10"></div>
|
|
||||||
</li>
|
|
||||||
<!-- Choose episodes -->
|
|
||||||
<li>
|
|
||||||
<button class="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-base-200 transition-colors"
|
|
||||||
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
|
||||||
hx-target="#player-container"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
|
||||||
<span class="w-8 h-8 rounded-lg bg-primary/20 text-primary flex items-center justify-center shrink-0">
|
|
||||||
<i class="fas fa-list-ol text-sm"></i>
|
|
||||||
</span>
|
|
||||||
<div class="flex flex-col text-left">
|
|
||||||
<span class="text-sm font-medium">Choisir des épisodes</span>
|
|
||||||
<span class="text-xs text-base-content/50">Sélectionnez ce que vous voulez</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Follow -->
|
|
||||||
<button class="btn btn-sm btn-accent btn-outline"
|
|
||||||
hx-post="/api/watchlist"
|
|
||||||
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.remove('btn-accent','btn-outline');this.classList.add('btn-accent','btn-ghost','pointer-events-none')}">
|
|
||||||
<i class="fas fa-plus"></i> Suivre
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="sr-dropdown-menu" x-show="openDropdown === '{{ first_url | urlencode }}'" x-transition>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-post="/api/anime/download-season?url={{ first_url | urlencode }}&lang={{ default_lang }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="openDropdown = null">
|
||||||
|
<i class="fas fa-layer-group"></i> Saison complete
|
||||||
|
</button>
|
||||||
|
<button class="sr-dropdown-item"
|
||||||
|
hx-get="/api/anime/episodes?url={{ first_url | urlencode }}&lang={{ default_lang }}&html=1"
|
||||||
|
hx-target="#player-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="openDropdown = null; document.getElementById('player-container').scrollIntoView({behavior: 'smooth'})">
|
||||||
|
<i class="fas fa-list-ol"></i> Choisir des episodes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="sr-btn sr-btn-follow"
|
||||||
|
hx-post="/api/watchlist"
|
||||||
|
hx-vals='{"anime_url": "{{ first_url }}", "anime_title": "{{ group.title }}", "provider_id": "{{ group.providers[0].id }}", "lang": "{{ default_lang }}", "poster_image": "{{ group.cover }}"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="if(event.detail.successful){this.innerHTML='<i class=\'fas fa-check\'></i> Suivi';this.disabled=true;this.classList.add('sr-btn-followed')}">
|
||||||
|
<i class="fas fa-plus"></i> Suivre
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-20 text-base-content/40">
|
<div class="sr-empty">
|
||||||
<i class="fas fa-search text-6xl mb-5 block opacity-20"></i>
|
<i class="fas fa-search"></i>
|
||||||
<p>Aucune serie TV trouvee pour votre recherche.</p>
|
<p>Aucune serie TV trouvee pour votre recherche.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sr-list { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.sr-card {
|
||||||
|
display: flex; gap: 20px;
|
||||||
|
background: var(--bg-card); border-radius: var(--card-radius);
|
||||||
|
padding: 20px; border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.sr-card:hover { border-color: var(--sr-accent); box-shadow: 0 4px 24px rgba(0,0,0,0.4); }
|
||||||
|
.sr-poster-link { flex-shrink: 0; display: block; width: 120px; aspect-ratio: 2/3; border-radius: 8px; overflow: hidden; background: #000; }
|
||||||
|
.sr-poster-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.sr-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sr-title { font-size: 1.1rem; font-weight: 700; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.sr-synopsis { font-size: 0.85rem; color: var(--text-dim); margin: 0; line-height: 1.5; }
|
||||||
|
.sr-providers { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sr-provider-badge { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; padding: 4px 12px; border-radius: 20px; border: 1px solid var(--sr-accent); color: var(--sr-accent); background: transparent; cursor: pointer; transition: var(--transition); letter-spacing: 0.5px; text-decoration: none; }
|
||||||
|
.sr-provider-badge:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
|
||||||
|
.sr-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 8px 16px; border-radius: 8px; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: var(--transition); text-decoration: none; background: transparent; color: var(--text-main); min-height: 34px; }
|
||||||
|
.sr-btn:hover { border-color: rgba(255,255,255,0.2); background: rgba(255,255,255,0.05); }
|
||||||
|
.sr-btn-dl { border-color: var(--secondary); color: var(--secondary); }
|
||||||
|
.sr-btn-dl:hover { background: var(--secondary); color: var(--bg-dark); }
|
||||||
|
.sr-btn-watch { border-color: var(--sr-accent); color: var(--sr-accent); }
|
||||||
|
.sr-btn-watch:hover { background: var(--sr-accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-follow { border-color: var(--accent); color: var(--accent); }
|
||||||
|
.sr-btn-follow:hover { background: var(--accent); color: var(--bg-dark); }
|
||||||
|
.sr-btn-followed { border-color: var(--accent); color: var(--accent); background: rgba(0,255,136,0.1); pointer-events: none; }
|
||||||
|
.sr-dropdown { position: relative; }
|
||||||
|
.sr-dropdown-menu { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: var(--bg-card); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 4px; z-index: 100; box-shadow: 0 8px 32px rgba(0,0,0,0.6); }
|
||||||
|
.sr-dropdown-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 10px 12px; border: none; background: transparent; color: var(--text-main); font-size: 0.8rem; cursor: pointer; border-radius: 6px; transition: var(--transition); text-align: left; }
|
||||||
|
.sr-dropdown-item:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
.sr-empty { text-align: center; padding: 100px 20px; color: var(--text-dim); }
|
||||||
|
.sr-empty i { font-size: 4rem; margin-bottom: 20px; display: block; opacity: 0.2; }
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sr-card { flex-direction: column; align-items: center; text-align: center; gap: 12px; padding: 16px; }
|
||||||
|
.sr-poster-link { width: 160px; }
|
||||||
|
.sr-title { white-space: normal; text-overflow: initial; }
|
||||||
|
.sr-providers { justify-content: center; }
|
||||||
|
.sr-actions { justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,283 +1,229 @@
|
|||||||
<div class="space-y-6">
|
<div class="settings-container section-container">
|
||||||
<!-- Section Title -->
|
<div class="section-header">
|
||||||
<div>
|
<h2>Parametres</h2>
|
||||||
<h2 class="text-2xl font-bold">Paramètres</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Preferences -->
|
<!-- General Preferences -->
|
||||||
<div class="card bg-base-200 border border-base-300">
|
<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="card-body">
|
<h3 style="margin-bottom: 20px; color: var(--primary);">General</h3>
|
||||||
<h3 class="card-title text-lg text-primary">
|
|
||||||
<i class="fa-solid fa-sliders"></i> Général
|
<form id="settings-form" class="settings-form">
|
||||||
</h3>
|
<div class="form-group">
|
||||||
|
<label for="default_lang">Langue par defaut</label>
|
||||||
|
<select name="default_lang" id="default_lang" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
|
||||||
|
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
|
||||||
|
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form id="settings-form" class="space-y-4">
|
<div class="form-group" style="margin-top: 20px;">
|
||||||
<!-- Language -->
|
<label for="theme">Theme</label>
|
||||||
<div class="form-control w-full max-w-xs">
|
<select name="theme" id="theme" class="btn btn-secondary btn-block" style="text-align: left; padding: 12px 15px;">
|
||||||
<label class="label" for="default_lang">
|
<option value="dark" {% if settings.theme == 'dark' %}selected{% endif %}>Sombre (Premium Dark)</option>
|
||||||
<span class="label-text font-semibold">Langue par défaut</span>
|
<option value="light" {% if settings.theme == 'light' %}selected{% endif %}>Clair (Soon)</option>
|
||||||
</label>
|
<option value="oled" {% if settings.theme == 'oled' %}selected{% endif %}>OLED (Noir absolu)</option>
|
||||||
<select name="default_lang" id="default_lang" class="select select-bordered w-full">
|
</select>
|
||||||
<option value="vostfr" {% if settings.default_lang == 'vostfr' %}selected{% endif %}>VOSTFR</option>
|
</div>
|
||||||
<option value="vf" {% if settings.default_lang == 'vf' %}selected{% endif %}>VF</option>
|
|
||||||
</select>
|
<div class="form-group" style="margin-top: 20px;">
|
||||||
|
<label for="download_dir">Repertoire de telechargement</label>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<input type="text" name="download_dir" id="download_dir" value="{{ settings.download_dir }}"
|
||||||
|
class="btn btn-secondary" style="flex: 1; text-align: left; padding: 12px 15px;">
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- Theme -->
|
<button type="submit" class="btn btn-primary" style="margin-top: 20px; width: 100%;" onclick="event.preventDefault(); saveSettings();">
|
||||||
<div class="form-control w-full max-w-xs">
|
<i class="fas fa-save"></i> Enregistrer les preferences
|
||||||
<label class="label" for="theme">
|
</button>
|
||||||
<span class="label-text font-semibold">Thème</span>
|
</form>
|
||||||
</label>
|
|
||||||
<select name="theme" id="theme" class="select select-bordered w-full">
|
|
||||||
<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="oled" {% if settings.theme == 'oled' %}selected{% endif %}>OLED (Noir absolu)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Download Directory -->
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label class="label" for="download_dir">
|
|
||||||
<span class="label-text font-semibold">Répertoire de téléchargement</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="download_dir"
|
|
||||||
id="download_dir"
|
|
||||||
value="{{ settings.download_dir }}"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
>
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text-alt text-base-content/50">Répertoire où les fichiers seront téléchargés (défaut: downloads/)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save Button -->
|
|
||||||
<button type="submit" class="btn btn-primary w-full" onclick="event.preventDefault(); saveSettings();">
|
|
||||||
<i class="fa-solid fa-save"></i> Enregistrer les préférences
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Filters -->
|
<!-- Content Filters -->
|
||||||
<div class="card bg-base-200 border border-base-300">
|
<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="card-body">
|
<h3 style="margin-bottom: 20px; color: var(--primary);">Filtres de contenu</h3>
|
||||||
<h3 class="card-title text-lg text-primary">
|
|
||||||
<i class="fa-solid fa-filter"></i> Filtres de contenu
|
<div class="form-group">
|
||||||
</h3>
|
<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="space-y-4">
|
<div class="form-group" style="margin-top: 15px;">
|
||||||
<!-- Recommendations Filter -->
|
<label for="releases_filter">Dernieres sorties : afficher</label>
|
||||||
<div class="form-control w-full max-w-xs">
|
<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)">
|
||||||
<label class="label" for="recommendations_filter">
|
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Series)</option>
|
||||||
<span class="label-text font-semibold">Recommandé pour vous : afficher</span>
|
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
||||||
</label>
|
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Series uniquement</option>
|
||||||
<select name="recommendations_filter" id="recommendations_filter" class="select select-bordered w-full" onchange="saveFilter('recommendations_filter', this.value)">
|
</select>
|
||||||
<option value="all" {% if settings.recommendations_filter == 'all' %}selected{% endif %}>Les deux (Animes + Séries)</option>
|
|
||||||
<option value="anime" {% if settings.recommendations_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
|
||||||
<option value="series" {% if settings.recommendations_filter == 'series' %}selected{% endif %}>Séries uniquement</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Releases Filter -->
|
|
||||||
<div class="form-control w-full max-w-xs">
|
|
||||||
<label class="label" for="releases_filter">
|
|
||||||
<span class="label-text font-semibold">Dernières sorties : afficher</span>
|
|
||||||
</label>
|
|
||||||
<select name="releases_filter" id="releases_filter" class="select select-bordered w-full" onchange="saveFilter('releases_filter', this.value)">
|
|
||||||
<option value="all" {% if settings.releases_filter == 'all' %}selected{% endif %}>Les deux (Animes + Séries)</option>
|
|
||||||
<option value="anime" {% if settings.releases_filter == 'anime' %}selected{% endif %}>Animes uniquement</option>
|
|
||||||
<option value="series" {% if settings.releases_filter == 'series' %}selected{% endif %}>Séries uniquement</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<div class="card bg-base-200 border border-base-300">
|
<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="card-body">
|
<h3 style="margin-bottom: 20px; color: var(--primary);">Categories</h3>
|
||||||
<h3 class="card-title text-lg text-primary">
|
<p style="color: var(--text-dim); font-size: 13px; margin-bottom: 15px;">Activez ou desactivez les categories. Au moins une doit rester active.</p>
|
||||||
<i class="fa-solid fa-layer-group"></i> Catégories
|
|
||||||
</h3>
|
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||||||
<p class="text-sm text-base-content/60 mb-4">Activez ou désactivez les catégories. Au moins une doit rester active.</p>
|
<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 class="flex gap-4 flex-wrap">
|
<div style="font-weight: 600; font-size: 1.1rem;">Animes</div>
|
||||||
<!-- Anime Toggle -->
|
<div style="font-size: 0.8rem; color: var(--text-dim);">Films et series anime</div>
|
||||||
<div class="form-control">
|
|
||||||
<label class="label cursor-pointer justify-start gap-4 bg-base-300 rounded-lg p-4 border border-base-content/10 flex-1 min-w-[200px]">
|
|
||||||
<div class="flex-1">
|
|
||||||
<span class="font-semibold text-base">Animes</span>
|
|
||||||
<p class="text-xs text-base-content/60">Films et séries animées</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="anime_enabled"
|
|
||||||
class="toggle toggle-primary"
|
|
||||||
{% if settings.anime_enabled %}checked{% endif %}
|
|
||||||
onchange="toggleCategory('anime_enabled', this.checked)"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</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);">
|
||||||
<!-- Series Toggle -->
|
</label>
|
||||||
<div class="form-control">
|
|
||||||
<label class="label cursor-pointer justify-start gap-4 bg-base-300 rounded-lg p-4 border border-base-content/10 flex-1 min-w-[200px]">
|
<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 class="flex-1">
|
<div>
|
||||||
<span class="font-semibold text-base">Séries TV</span>
|
<div style="font-weight: 600; font-size: 1.1rem;">Series TV</div>
|
||||||
<p class="text-xs text-base-content/60">Séries américaines et européennes</p>
|
<div style="font-size: 0.8rem; color: var(--text-dim);">Series americaines et europeennes</div>
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="series_enabled"
|
|
||||||
class="toggle toggle-primary"
|
|
||||||
{% if settings.series_enabled %}checked{% endif %}
|
|
||||||
onchange="toggleCategory('series_enabled', this.checked)"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</div>
|
</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);">
|
||||||
</div>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Weight -->
|
|
||||||
<div class="card bg-base-200 border border-base-300">
|
|
||||||
<div class="card-body">
|
|
||||||
<h3 class="card-title text-lg text-primary">
|
|
||||||
<i class="fa-solid fa-scale-balanced"></i> Équilibre du fil d'actualité
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-base-content/60 mb-4">
|
|
||||||
Définissez la proportion d'animes et de séries affichés dans les recommandations et dernières sorties.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Weight Mode -->
|
|
||||||
<div class="form-control w-full max-w-xs mb-4">
|
|
||||||
<label class="label" for="content_weight_mode">
|
|
||||||
<span class="label-text font-semibold">Mode</span>
|
|
||||||
</label>
|
|
||||||
<select name="content_weight_mode" id="content_weight_mode" class="select select-bordered w-full" onchange="onWeightModeChange(this.value)">
|
|
||||||
<option value="auto" {% if settings.content_weight_mode == 'auto' %}selected{% endif %}>Automatique (basé sur vos téléchargements)</option>
|
|
||||||
<option value="manual" {% if settings.content_weight_mode == 'manual' %}selected{% endif %}>Manuel</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auto mode info -->
|
|
||||||
<div id="weight-auto-info" class="bg-base-300 rounded-lg p-4 border border-base-content/10 mb-4" {% if settings.content_weight_mode != 'auto' %}style="display:none;"{% endif %}>
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<i class="fa-solid fa-chart-pie text-primary"></i>
|
|
||||||
<span class="font-semibold">Analyse de vos téléchargements</span>
|
|
||||||
</div>
|
|
||||||
<div id="weight-auto-details" class="text-sm text-base-content/60">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manual mode controls -->
|
|
||||||
<div id="weight-manual-controls" {% if settings.content_weight_mode != 'manual' %}style="display:none;"{% endif %}>
|
|
||||||
<div class="flex gap-6 items-start flex-wrap">
|
|
||||||
<!-- Anime Weight -->
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold">
|
|
||||||
<i class="fa-solid fa-dragon text-primary"></i> Poids Animes
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="content_weight_anime_range"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
step="1"
|
|
||||||
value="{{ settings.content_weight_anime }}"
|
|
||||||
class="range range-primary range-sm"
|
|
||||||
oninput="document.getElementById('content_weight_anime').value = this.value; updateWeightPreview();"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between text-xs text-base-content/50 px-1 mt-1">
|
|
||||||
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Series Weight -->
|
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text font-semibold">
|
|
||||||
<i class="fa-solid fa-tv text-secondary"></i> Poids Séries
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
id="content_weight_series_range"
|
|
||||||
min="0"
|
|
||||||
max="5"
|
|
||||||
step="1"
|
|
||||||
value="{{ settings.content_weight_series }}"
|
|
||||||
class="range range-secondary range-sm"
|
|
||||||
oninput="document.getElementById('content_weight_series').value = this.value; updateWeightPreview();"
|
|
||||||
>
|
|
||||||
<div class="flex justify-between text-xs text-base-content/50 px-1 mt-1">
|
|
||||||
<span>0</span><span>1</span><span>2</span><span>3</span><span>4</span><span>5</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="hidden" id="content_weight_anime" value="{{ settings.content_weight_anime }}">
|
|
||||||
<input type="hidden" id="content_weight_series" value="{{ settings.content_weight_series }}">
|
|
||||||
|
|
||||||
<!-- Weight Preview -->
|
|
||||||
<div id="weight-preview" class="bg-base-300 rounded-lg p-3 text-center text-sm mt-4"></div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary w-full mt-4" onclick="saveManualWeights()">
|
|
||||||
<i class="fa-solid fa-scale-balanced"></i> Appliquer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Providers Management -->
|
<!-- Providers Management -->
|
||||||
<div class="card bg-base-200 border border-base-300">
|
<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="card-body">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<h3 style="margin: 0; color: var(--primary);">Disponibilite des Fournisseurs</h3>
|
||||||
<h3 class="card-title text-lg text-primary mb-0">
|
<button class="btn btn-secondary btn-small" hx-post="/api/providers/health/check" hx-swap="none"
|
||||||
<i class="fa-solid fa-server"></i> Disponibilité des Fournisseurs
|
hx-on::after-request="htmx.trigger('#tab-settings > div', 'refresh-settings')">
|
||||||
</h3>
|
<i class="fas fa-sync-alt"></i> Forcer verification
|
||||||
<button class="btn btn-sm btn-ghost" hx-post="/api/providers/health/check" hx-swap="none">
|
</button>
|
||||||
<i class="fa-solid fa-arrows-rotate"></i> Forcer vérification
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div class="providers-settings-list" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">
|
||||||
{% for provider in providers %}
|
{% for provider in providers %}
|
||||||
<div class="flex items-center justify-between bg-base-300 rounded-lg p-3 border border-base-content/10">
|
<div class="provider-status-card" style="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;">
|
||||||
<div class="flex items-center gap-3">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
<span class="text-2xl">{{ provider.icon }}</span>
|
<span style="font-size: 1.5rem;">{{ provider.icon }}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">{{ provider.name }}</div>
|
<div style="font-weight: 600;">{{ provider.name }}</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div style="font-size: 0.75rem; display: flex; align-items: center; gap: 5px;">
|
||||||
{% if provider.status == 'up' %}
|
<span class="status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}#aaa{% endif %};"></span>
|
||||||
<span class="badge badge-success badge-xs"></span>
|
<span style="color: {% if provider.status == 'up' %}var(--accent){% elif provider.status == 'down' %}var(--danger){% else %}var(--text-dim){% endif %}; text-transform: uppercase; font-weight: 800;">
|
||||||
<span class="text-xs font-bold text-success">UP</span>
|
{{ provider.status | upper }}
|
||||||
{% elif provider.status == 'down' %}
|
</span>
|
||||||
<span class="badge badge-error badge-xs"></span>
|
|
||||||
<span class="text-xs font-bold text-error">DOWN</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-ghost badge-xs"></span>
|
|
||||||
<span class="text-xs font-bold text-base-content/40">{{ provider.status | upper }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
class="btn btn-sm {% if provider.enabled %}btn-ghost{% else %}btn-primary{% endif %}"
|
|
||||||
|
<button class="btn {% if provider.enabled %}btn-secondary{% else %}btn-accent{% endif %} btn-sm"
|
||||||
hx-post="/api/settings/providers/{{ provider.id }}/toggle"
|
hx-post="/api/settings/providers/{{ provider.id }}/toggle"
|
||||||
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;">
|
||||||
{% if provider.enabled %}Désactiver{% else %}Activer{% endif %}
|
{% if provider.enabled %}Desactiver{% else %}Activer{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
.settings-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 0 5px currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,45 +1,58 @@
|
|||||||
<!-- Toast notification container -->
|
<div id="toast-container"
|
||||||
<div id="toast-container"
|
class="toast-container"
|
||||||
class="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-h-[80vh] overflow-hidden"
|
|
||||||
style="pointer-events: none;"
|
|
||||||
x-data="{ toasts: [] }"
|
x-data="{ toasts: [] }"
|
||||||
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
|
@show-toast.window="toasts.push({ id: Date.now(), message: $event.detail.message, type: $event.detail.type || 'info' }); setTimeout(() => { toasts = toasts.filter(t => t.id !== toasts[0].id) }, 5000)">
|
||||||
|
|
||||||
<template x-for="toast in toasts" :key="toast.id">
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
<div class="alert shadow-lg max-w-sm animate-slide-in"
|
<div class="toast"
|
||||||
style="pointer-events: auto;"
|
:class="'toast-' + toast.type"
|
||||||
:class="{
|
|
||||||
'alert-success': toast.type === 'success',
|
|
||||||
'alert-error': toast.type === 'error',
|
|
||||||
'alert-info': toast.type === 'info'
|
|
||||||
}"
|
|
||||||
x-show="true"
|
x-show="true"
|
||||||
x-transition:enter="transition ease-out duration-300"
|
x-transition:enter="toast-enter"
|
||||||
x-transition:enter-start="opacity-0 translate-x-8"
|
x-transition:leave="toast-leave">
|
||||||
x-transition:enter-end="opacity-100 translate-x-0"
|
<div class="toast-content">
|
||||||
x-transition:leave="transition ease-in duration-200"
|
<i class="fas" :class="{
|
||||||
x-transition:leave-start="opacity-100 translate-x-0"
|
'fa-check-circle': toast.type === 'success',
|
||||||
x-transition:leave-end="opacity-0 translate-x-8">
|
'fa-exclamation-circle': toast.type === 'error',
|
||||||
<i class="fa-solid"
|
'fa-info-circle': toast.type === 'info'
|
||||||
:class="{
|
}"></i>
|
||||||
'fa-circle-check': toast.type === 'success',
|
<span x-text="toast.message"></span>
|
||||||
'fa-circle-exclamation': toast.type === 'error',
|
</div>
|
||||||
'fa-circle-info': toast.type === 'info'
|
<button class="toast-close" @click="toasts = toasts.filter(t => t.id !== toast.id)">
|
||||||
}"></i>
|
<i class="fas fa-times"></i>
|
||||||
<span class="text-sm" x-text="toast.message"></span>
|
|
||||||
<button class="btn btn-ghost btn-xs" @click="toasts = toasts.filter(t => t.id !== toast.id)">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes slide-in {
|
.toast-container {
|
||||||
from { opacity: 0; transform: translateX(100%); }
|
position: fixed;
|
||||||
to { opacity: 1; transform: translateX(0); }
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.animate-slide-in {
|
.toast {
|
||||||
animation: slide-in 0.3s ease-out;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
.toast {
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-left: 4px solid #ccc;
|
||||||
|
}
|
||||||
|
.toast-success { border-left-color: #4caf50; }
|
||||||
|
.toast-error { border-left-color: #f44336; }
|
||||||
|
.toast-info { border-left-color: #2196f3; }
|
||||||
|
.toast-content { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.toast-close { background: none; border: none; color: #aaa; cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,162 +1,491 @@
|
|||||||
{% set status_filter = request.query_params.get('status', 'all') %}
|
{% set status_filter = request.query_params.get('status', 'all') %}
|
||||||
<div class="flex flex-col gap-5" x-data="{ currentFilter: '{{ status_filter }}' }">
|
<div class="watchlist-container" x-data="{ currentFilter: '{{ status_filter }}' }">
|
||||||
<!-- Filter Tabs -->
|
<!-- Filter Tabs -->
|
||||||
<div class="tabs tabs-boxed bg-base-200 p-1">
|
<div class="filter-tabs">
|
||||||
<button class="tab {% if status_filter == 'all' or not status_filter %}tab-active{% endif %}"
|
<button class="filter-tab {{ 'active' if status_filter == 'all' or not status_filter else '' }}"
|
||||||
hx-get="/api/watchlist?status=all"
|
hx-get="/api/watchlist?status=all"
|
||||||
hx-target="#watchlist-items-container"
|
hx-target="#watchlist-items-container"
|
||||||
hx-swap="outerHTML">
|
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
|
<i class="fas fa-list"></i> Tous
|
||||||
</button>
|
</button>
|
||||||
<button class="tab {% if status_filter == 'active' %}tab-active{% endif %}"
|
<button class="filter-tab {{ 'active' if status_filter == 'active' else '' }}"
|
||||||
hx-get="/api/watchlist?status=active"
|
hx-get="/api/watchlist?status=active"
|
||||||
hx-target="#watchlist-items-container"
|
hx-target="#watchlist-items-container"
|
||||||
hx-swap="outerHTML">
|
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
|
<i class="fas fa-play"></i> Actifs
|
||||||
</button>
|
</button>
|
||||||
<button class="tab {% if status_filter == 'paused' %}tab-active{% endif %}"
|
<button class="filter-tab {{ 'active' if status_filter == 'paused' else '' }}"
|
||||||
hx-get="/api/watchlist?status=paused"
|
hx-get="/api/watchlist?status=paused"
|
||||||
hx-target="#watchlist-items-container"
|
hx-target="#watchlist-items-container"
|
||||||
hx-swap="outerHTML">
|
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
|
<i class="fas fa-pause"></i> En pause
|
||||||
</button>
|
</button>
|
||||||
<button class="tab {% if status_filter == 'completed' %}tab-active{% endif %}"
|
<button class="filter-tab {{ 'active' if status_filter == 'completed' else '' }}"
|
||||||
hx-get="/api/watchlist?status=completed"
|
hx-get="/api/watchlist?status=completed"
|
||||||
hx-target="#watchlist-items-container"
|
hx-target="#watchlist-items-container"
|
||||||
hx-swap="outerHTML">
|
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
|
<i class="fas fa-check"></i> Terminés
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Watchlist Items Grid -->
|
<!-- Watchlist Items Grid -->
|
||||||
{% if items and items | length > 0 %}
|
{% if items and items | length > 0 %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="watchlist-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="card bg-base-200 border border-base-300 hover:border-primary transition-colors" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
|
<div class="watchlist-card" id="watchlist-{{ item.id }}" data-item-id="{{ item.id }}">
|
||||||
<div class="card-body p-4 flex-row gap-4">
|
<!-- Poster -->
|
||||||
<!-- Poster -->
|
<div class="watchlist-poster">
|
||||||
<figure class="w-24 shrink-0 relative">
|
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
|
||||||
<img src="{{ item.poster_image or item.cover_image or '/static/img/no-poster.png' }}"
|
alt="{{ item.anime_title }}"
|
||||||
alt="{{ item.anime_title }}"
|
onerror="this.src='/static/img/no-poster.png'">
|
||||||
class="rounded-lg aspect-[2/3] object-cover w-full"
|
<div class="poster-badge {{ item.status }}">
|
||||||
onerror="this.src='/static/img/no-poster.png'">
|
{% if item.status == 'active' %}
|
||||||
<!-- Status badge -->
|
<i class="fas fa-play"></i> Actif
|
||||||
<span class="badge badge-sm absolute top-2 left-2
|
{% elif item.status == 'paused' %}
|
||||||
{% if item.status == 'active' %}badge-success
|
<i class="fas fa-pause"></i> En pause
|
||||||
{% elif item.status == 'paused' %}badge-warning
|
{% elif item.status == 'completed' %}
|
||||||
{% elif item.status == 'completed' %}badge-primary
|
<i class="fas fa-check"></i> Terminé
|
||||||
{% else %}badge-ghost{% endif %}">
|
{% else %}
|
||||||
{% if item.status == 'active' %}
|
<i class="fas fa-archive"></i> Archivé
|
||||||
<i class="fas fa-play"></i> Actif
|
{% endif %}
|
||||||
{% elif item.status == 'paused' %}
|
</div>
|
||||||
<i class="fas fa-pause"></i> Pause
|
{% if item.auto_download %}
|
||||||
{% elif item.status == 'completed' %}
|
<div class="auto-download-badge">
|
||||||
<i class="fas fa-check"></i> Terminé
|
<i class="fas fa-magic"></i> Auto
|
||||||
{% else %}
|
</div>
|
||||||
<i class="fas fa-archive"></i> Archivé
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="watchlist-content">
|
||||||
|
<h3 class="watchlist-title">{{ item.anime_title }}</h3>
|
||||||
|
|
||||||
|
<div class="watchlist-meta">
|
||||||
|
<span class="meta-provider">
|
||||||
|
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-lang">{{ item.lang | upper }}</span>
|
||||||
|
{% if item.quality_preference and item.quality_preference != 'auto' %}
|
||||||
|
<span class="meta-quality">{{ item.quality_preference }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.synopsis %}
|
||||||
|
<p class="watchlist-synopsis">{{ item.synopsis | truncate(150) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="watchlist-stats">
|
||||||
|
<span class="stat">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
Ép. {{ item.last_episode_downloaded }}
|
||||||
|
{% if item.total_episodes %}
|
||||||
|
/ {{ item.total_episodes }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
<!-- Auto-download badge -->
|
{% if item.added_at %}
|
||||||
{% if item.auto_download %}
|
<span class="stat" title="Ajouté le {{ item.added_at.strftime('%d/%m/%Y') }}">
|
||||||
<span class="badge badge-primary badge-sm absolute bottom-2 left-2">
|
<i class="fas fa-calendar"></i>
|
||||||
<i class="fas fa-magic"></i> Auto
|
{{ item.added_at.strftime('%d/%m/%Y') }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</figure>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Actions -->
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-1.5">
|
<div class="watchlist-actions">
|
||||||
<h3 class="font-bold text-sm truncate m-0">{{ item.anime_title }}</h3>
|
<!-- Pause/Resume Toggle -->
|
||||||
|
{% if item.status == 'active' %}
|
||||||
<!-- Meta badges -->
|
<button class="action-btn btn-pause"
|
||||||
<div class="flex flex-wrap gap-1.5 text-[0.7rem]">
|
hx-put="/api/watchlist/{{ item.id }}"
|
||||||
<span class="badge badge-outline badge-sm">
|
hx-vals='{"status": "paused"}'
|
||||||
<i class="fas fa-tv"></i> {{ item.provider_id | upper }}
|
hx-swap="none"
|
||||||
</span>
|
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||||
<span class="badge badge-outline badge-sm badge-ghost">{{ item.lang | upper }}</span>
|
title="Mettre en pause">
|
||||||
{% if item.quality_preference and item.quality_preference != 'auto' %}
|
<i class="fas fa-pause"></i>
|
||||||
<span class="badge badge-outline badge-sm badge-success">{{ item.quality_preference }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Synopsis -->
|
|
||||||
{% if item.synopsis %}
|
|
||||||
<p class="text-xs text-base-content/50 m-0 line-clamp-3">{{ item.synopsis | truncate(150) }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Stats -->
|
|
||||||
<div class="flex flex-wrap gap-3 text-[0.7rem] text-base-content/50">
|
|
||||||
<span class="flex items-center gap-1">
|
|
||||||
<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="flex items-center gap-1" 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="flex gap-1 mt-auto pt-2 border-t border-base-300">
|
|
||||||
<!-- Pause/Resume Toggle -->
|
|
||||||
{% if item.status == 'active' %}
|
|
||||||
<button class="btn btn-circle btn-sm btn-warning"
|
|
||||||
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="btn btn-circle btn-sm btn-success"
|
|
||||||
hx-put="/api/watchlist/{{ item.id }}"
|
|
||||||
hx-vals='{"status": "active"}'
|
|
||||||
hx-swap="none"
|
|
||||||
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
|
||||||
title="Reprendre">
|
|
||||||
<i class="fas fa-play"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Mark as completed -->
|
|
||||||
{% if item.status not in ['completed', 'archived'] %}
|
|
||||||
<button class="btn btn-circle btn-sm btn-ghost"
|
|
||||||
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="btn btn-circle btn-sm btn-error"
|
|
||||||
hx-delete="/api/watchlist/{{ item.id }}"
|
|
||||||
hx-target="#watchlist-{{ item.id }}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
|
|
||||||
title="Supprimer">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
{% elif item.status == 'paused' %}
|
||||||
|
<button class="action-btn btn-resume"
|
||||||
|
hx-put="/api/watchlist/{{ item.id }}"
|
||||||
|
hx-vals='{"status": "active"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||||
|
title="Reprendre">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Mark as completed -->
|
||||||
|
{% if item.status not in ['completed', 'archived'] %}
|
||||||
|
<button class="action-btn btn-complete"
|
||||||
|
hx-put="/api/watchlist/{{ item.id }}"
|
||||||
|
hx-vals='{"status": "completed"}'
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#watchlist-items-container', 'refresh');"
|
||||||
|
title="Marquer comme terminé">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<button class="action-btn btn-delete"
|
||||||
|
hx-delete="/api/watchlist/{{ item.id }}"
|
||||||
|
hx-target="#watchlist-{{ item.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Supprimer '{{ item.anime_title }}' de votre watchlist ?"
|
||||||
|
title="Supprimer">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-16 bg-base-200 rounded-box border border-dashed border-base-300">
|
<div class="watchlist-empty">
|
||||||
<i class="fas fa-inbox text-6xl text-base-content/20 mb-5 block"></i>
|
<i class="fas fa-inbox"></i>
|
||||||
<h3 class="text-lg font-bold mb-2 text-base-content">Votre watchlist est vide</h3>
|
<h3>Votre watchlist est vide</h3>
|
||||||
<p class="text-base-content/50 mb-6">Ajoutez des animes ou séries depuis l'onglet Recherche pour commencer à suivre leurs sorties.</p>
|
<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'})">
|
<button class="btn btn-primary" @click="$dispatch('set-tab', {tab: 'anime'})">
|
||||||
<i class="fas fa-search"></i> Rechercher des animes
|
<i class="fas fa-search"></i> Rechercher des animes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
<div class="mb-10">
|
<div class="section-container">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold flex items-center gap-2">
|
<h2>📋 Ma Watchlist</h2>
|
||||||
<i class="fa-solid fa-clipboard-list"></i> Ma Watchlist
|
<div class="header-actions">
|
||||||
</h2>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
|
<button class="btn btn-sm btn-primary" hx-post="/api/watchlist/check" hx-swap="none">
|
||||||
<i class="fas fa-sync"></i> Vérifier épisodes
|
<i class="fas fa-sync"></i> Vérifier épisodes
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-ghost"
|
<button class="btn btn-sm btn-secondary"
|
||||||
hx-get="/api/watchlist"
|
hx-get="/api/watchlist"
|
||||||
hx-target="#watchlist-items-container">
|
hx-target="#watchlist-items-container">
|
||||||
<i class="fas fa-redo"></i> Actualiser
|
<i class="fas fa-redo"></i> Actualiser
|
||||||
@@ -18,9 +16,34 @@
|
|||||||
<!-- Watchlist items container loaded via HTMX on page load or manual refresh -->
|
<!-- Watchlist items container loaded via HTMX on page load or manual refresh -->
|
||||||
<div id="watchlist-items-container"
|
<div id="watchlist-items-container"
|
||||||
hx-get="/api/watchlist"
|
hx-get="/api/watchlist"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
class="flex justify-center py-8 text-base-content/50">
|
class="watchlist-content">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<div class="loading-placeholder">
|
||||||
<span class="ml-2">Chargement de votre watchlist...</span>
|
<div class="spinner"></div> Chargement de votre watchlist...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.watchlist-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.watchlist-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.watchlist-item:hover { transform: translateY(-3px); border-color: #00d9ff; }
|
||||||
|
.item-poster img { width: 80px; height: 120px; border-radius: 8px; object-fit: cover; }
|
||||||
|
.item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
|
||||||
|
.item-info h3 { font-size: 1rem; margin-bottom: 5px; color: #fff; }
|
||||||
|
.item-meta { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.item-actions { display: flex; gap: 10px; margin-top: 10px; }
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,144 +1,153 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include "components/header.html" %}
|
||||||
|
|
||||||
<!-- Main content - Managed by Alpine state -->
|
<!-- Main content - Managed by Alpine state -->
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
|
|
||||||
{% include "components/home_section.html" %}
|
{% include "components/home_section.html" %}
|
||||||
|
|
||||||
<!-- Anime Tab -->
|
<!-- Nouveaux onglets -->
|
||||||
<div id="tab-anime" x-show="activeTab === 'anime'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
<div id="tab-anime" class="tab-content" x-show="activeTab === 'anime'">
|
||||||
<!-- Anime Search Section -->
|
<!-- Anime Search Section -->
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold">
|
<h2>Rechercher un Anime</h2>
|
||||||
<i class="fa-solid fa-film text-primary"></i> Rechercher un Anime
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<form hx-get="/api/anime/search"
|
<div class="url-form">
|
||||||
hx-target="#animeSearchResults"
|
<form hx-get="/api/anime/search"
|
||||||
hx-indicator="#search-loading"
|
hx-target="#animeSearchResults"
|
||||||
hx-trigger="submit, keyup changed delay:500ms from:#animeSearchInput"
|
hx-indicator="#search-loading"
|
||||||
class="join w-full mb-4">
|
hx-trigger="submit, keyup changed delay:500ms from:#animeSearchInput"
|
||||||
<input type="hidden" name="html" value="1">
|
class="input-group">
|
||||||
<input
|
<input type="hidden" name="html" value="1">
|
||||||
type="text"
|
<input
|
||||||
name="q"
|
type="text"
|
||||||
id="animeSearchInput"
|
name="q"
|
||||||
placeholder="Rechercher un anime (ex: Frieren, One Piece, Naruto...)"
|
id="animeSearchInput"
|
||||||
required
|
placeholder="Rechercher un anime (ex: Frieren, One Piece, Naruto...)"
|
||||||
class="input input-bordered join-item flex-1"
|
required
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn btn-primary join-item">
|
<button type="submit" class="btn btn-primary btn-search">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-[18px] h-[18px]">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:18px;height:18px;">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Rechercher
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="search-loading" class="htmx-indicator flex items-center gap-2 text-primary py-2">
|
<div id="search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--primary);">
|
||||||
<span class="loading loading-spinner loading-sm"></span> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Anime search results -->
|
<!-- Anime search results -->
|
||||||
<div id="animeSearchResults" class="mb-10"></div>
|
<div id="animeSearchResults" style="margin-bottom: 40px;"></div>
|
||||||
|
|
||||||
<!-- Player container for HTMX injections -->
|
<!-- Player container for HTMX injections -->
|
||||||
<div id="player-container"></div>
|
<div id="player-container"></div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||||
|
|
||||||
<!-- Latest Releases Section - Anime only -->
|
<!-- Latest Releases Section - Anime only -->
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold">
|
<h2>Dernieres sorties Anime</h2>
|
||||||
<i class="fa-solid fa-fire text-error"></i> Dernières sorties Anime
|
<button class="btn btn-secondary btn-small"
|
||||||
</h2>
|
hx-get="/api/releases/latest?content_type=anime&html=1"
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
|
||||||
hx-get="/api/releases/latest?content_type=anime&html=1"
|
|
||||||
hx-target="#animeReleasesList">
|
hx-target="#animeReleasesList">
|
||||||
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualiser
|
<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>
|
||||||
|
</svg>
|
||||||
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="animeReleasesList" hx-get="/api/releases/latest?content_type=anime&html=1" 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>
|
||||||
|
|
||||||
<!-- Series Tab -->
|
<div id="tab-series" class="tab-content" x-show="activeTab === 'series'">
|
||||||
<div id="tab-series" x-show="activeTab === 'series'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
<!-- Series Search Section -->
|
<!-- Series Search Section -->
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold">
|
<h2>Rechercher une Serie TV</h2>
|
||||||
<i class="fa-solid fa-tv text-secondary"></i> Rechercher une Série TV
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<form hx-get="/api/series/search"
|
<div class="url-form">
|
||||||
hx-target="#seriesSearchResults"
|
<form hx-get="/api/series/search"
|
||||||
hx-indicator="#series-search-loading"
|
hx-target="#seriesSearchResults"
|
||||||
hx-trigger="submit, keyup changed delay:500ms from:#seriesSearchInput"
|
hx-indicator="#series-search-loading"
|
||||||
class="join w-full mb-4">
|
hx-trigger="submit, keyup changed delay:500ms from:#seriesSearchInput"
|
||||||
<input type="hidden" name="html" value="1">
|
class="input-group">
|
||||||
<input
|
<input type="hidden" name="html" value="1">
|
||||||
type="text"
|
<input
|
||||||
name="q"
|
type="text"
|
||||||
id="seriesSearchInput"
|
name="q"
|
||||||
placeholder="Rechercher une série (ex: Breaking Bad, Game of Thrones...)"
|
id="seriesSearchInput"
|
||||||
required
|
placeholder="Rechercher une serie (ex: Breaking Bad, Game of Thrones...)"
|
||||||
class="input input-bordered join-item flex-1"
|
required
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn btn-primary join-item">
|
<button type="submit" class="btn btn-primary btn-search">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" class="w-[18px] h-[18px]">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:18px;height:18px;">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Rechercher
|
Rechercher
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="series-search-loading" class="htmx-indicator flex items-center gap-2 text-secondary py-2">
|
<div id="series-search-loading" class="htmx-indicator" style="margin-top: 15px; color: var(--secondary);">
|
||||||
<span class="loading loading-spinner loading-sm"></span> Recherche en cours...
|
<div class="spinner"></div> Recherche en cours...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Series search results -->
|
<!-- Series search results -->
|
||||||
<div id="seriesSearchResults" class="mb-10"></div>
|
<div id="seriesSearchResults" style="margin-bottom: 40px;"></div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<hr style="border: none; border-top: 1px solid rgba(255,255,255,0.05); margin: 40px 0;">
|
||||||
|
|
||||||
<!-- Latest Releases Section - Series only -->
|
<!-- Recommendations Section - Series only -->
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="section-header">
|
||||||
<h2 class="text-xl font-bold">
|
<h2>Recommande pour vous</h2>
|
||||||
<i class="fa-solid fa-fire text-error"></i> Dernières sorties Séries TV
|
<button class="btn btn-secondary btn-small"
|
||||||
</h2>
|
hx-get="/api/recommendations?content_type=series&html=1"
|
||||||
<button class="btn btn-sm btn-ghost gap-1.5"
|
hx-target="#seriesRecommendationsList">
|
||||||
hx-get="/api/series/latest?html=1"
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||||
hx-target="#seriesReleasesList">
|
<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>
|
||||||
<i class="fa-solid fa-arrows-rotate text-xs"></i> Actualiser
|
</svg>
|
||||||
|
Actualiser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="seriesReleasesList" hx-get="/api/series/latest?html=1" hx-trigger="load delay:700ms"></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 - Series only -->
|
||||||
|
<div class="section-header" style="margin-top: 40px;">
|
||||||
|
<h2>Dernieres sorties Series TV</h2>
|
||||||
|
<button class="btn btn-secondary btn-small"
|
||||||
|
hx-get="/api/releases/latest?content_type=series&html=1"
|
||||||
|
hx-target="#seriesReleasesList">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:14px;height:14px;">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
|
||||||
<!-- Watchlist Tab -->
|
<div id="tab-watchlist" class="tab-content" x-show="activeTab === 'watchlist'">
|
||||||
<div id="tab-watchlist" x-show="activeTab === 'watchlist'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
{% include "components/watchlist_section.html" %}
|
{% include "components/watchlist_section.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Downloads Tab -->
|
<div id="tab-downloads" class="tab-content" x-show="activeTab === 'downloads'">
|
||||||
<div id="tab-downloads" x-show="activeTab === 'downloads'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
{% include "components/downloads_section.html" %}
|
{% include "components/downloads_section.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<div id="tab-settings" class="tab-content" x-show="activeTab === 'settings'">
|
||||||
<div id="tab-settings" x-show="activeTab === 'settings'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
<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="flex items-center justify-center py-16">
|
<div class="loading-placeholder">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<div class="spinner"></div> Chargement des parametres...
|
||||||
<span class="ml-3 text-base-content/50">Chargement des paramètres...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin Tab -->
|
<div id="tab-admin" class="tab-content" x-show="activeTab === 'admin'">
|
||||||
<div id="tab-admin" x-show="activeTab === 'admin'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
|
|
||||||
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
|
<div id="admin-panel-content" hx-get="/api/admin/ui" hx-trigger="set-tab[detail.tab === 'admin'] from:window" hx-swap="innerHTML">
|
||||||
<div class="flex items-center justify-center py-16">
|
<div class="loading-placeholder">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<div class="spinner"></div> Chargement du panel admin...
|
||||||
<span class="ml-3 text-base-content/50">Chargement du panel admin...</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,148 +1,106 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" data-theme="ohmstream">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Connexion - Ohm Stream Downloader</title>
|
<title>Connexion - Ohm Stream Downloader</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="min-h-screen flex items-center justify-center bg-base-100">
|
<div class="auth-container">
|
||||||
<div class="card w-96 bg-base-200 shadow-2xl">
|
<h1 class="auth-title">🎬 Ohm Stream</h1>
|
||||||
<div class="card-body">
|
|
||||||
<!-- Title -->
|
|
||||||
<h1 class="text-2xl font-bold text-center text-primary">
|
|
||||||
<i class="fa-solid fa-film"></i> Ohm Stream
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<!-- Tab Toggle -->
|
<div class="auth-tabs">
|
||||||
<div class="tabs tabs-boxed bg-base-300 mb-4" role="tablist">
|
<div class="auth-tab active" data-tab="login">Connexion</div>
|
||||||
<button class="tab tab-active auth-tab" role="tab" data-tab="login">Connexion</button>
|
<div class="auth-tab" data-tab="register">Inscription</div>
|
||||||
<button class="tab auth-tab" role="tab" data-tab="register">Inscription</button>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error / Success Alerts -->
|
<div class="auth-error" id="authError" aria-live="polite"></div>
|
||||||
<div id="authError" class="alert alert-error hidden mb-2" role="alert" aria-live="polite">
|
<div class="auth-success" id="authSuccess" aria-live="polite"></div>
|
||||||
<i class="fa-solid fa-circle-exclamation"></i>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
<div id="authSuccess" class="alert alert-success hidden mb-2" role="status" aria-live="polite">
|
|
||||||
<i class="fa-solid fa-circle-check"></i>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form id="loginForm">
|
<form class="auth-form active" id="loginForm">
|
||||||
<div class="form-control mb-3">
|
<div class="form-group">
|
||||||
<label class="label" for="loginUsername">
|
<label for="loginUsername">Nom d'utilisateur</label>
|
||||||
<span class="label-text">Nom d'utilisateur</span>
|
<input
|
||||||
</label>
|
type="text"
|
||||||
<input
|
id="loginUsername"
|
||||||
type="text"
|
placeholder="Entrez votre nom d'utilisateur"
|
||||||
id="loginUsername"
|
required
|
||||||
placeholder="Entrez votre nom d'utilisateur"
|
aria-required="true"
|
||||||
class="input input-bordered w-full"
|
aria-describedby="loginUsernameHelp"
|
||||||
required
|
>
|
||||||
aria-required="true"
|
<span id="loginUsernameHelp" style="display: none;">Champ obligatoire</span>
|
||||||
aria-describedby="loginUsernameHelp"
|
|
||||||
>
|
|
||||||
<label class="label hidden" id="loginUsernameHelp">
|
|
||||||
<span class="label-text-alt text-error">Champ obligatoire</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="loginPassword">
|
|
||||||
<span class="label-text">Mot de passe</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="loginPassword"
|
|
||||||
placeholder="Entrez votre mot de passe"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
required
|
|
||||||
aria-required="true"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button type="submit" id="loginSubmit" class="btn btn-primary w-full">Se connecter</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Register Form -->
|
|
||||||
<form class="hidden" id="registerForm">
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="registerUsername">
|
|
||||||
<span class="label-text">Nom d'utilisateur</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="registerUsername"
|
|
||||||
placeholder="Choisissez un nom d'utilisateur"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
minlength="3"
|
|
||||||
required
|
|
||||||
aria-required="true"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="registerEmail">
|
|
||||||
<span class="label-text">Email (optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="registerEmail"
|
|
||||||
placeholder="votre@email.com"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="registerFullName">
|
|
||||||
<span class="label-text">Nom complet (optionnel)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="registerFullName"
|
|
||||||
placeholder="Votre nom complet"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="registerPassword">
|
|
||||||
<span class="label-text">Mot de passe</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="registerPassword"
|
|
||||||
placeholder="Au moins 6 caractères"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
minlength="6"
|
|
||||||
required
|
|
||||||
aria-required="true"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control mb-3">
|
|
||||||
<label class="label" for="registerPasswordConfirm">
|
|
||||||
<span class="label-text">Confirmer le mot de passe</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="registerPasswordConfirm"
|
|
||||||
placeholder="Confirmez votre mot de passe"
|
|
||||||
class="input input-bordered w-full"
|
|
||||||
minlength="6"
|
|
||||||
required
|
|
||||||
aria-required="true"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button type="submit" id="registerSubmit" class="btn btn-primary w-full">S'inscrire</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Back Link -->
|
|
||||||
<div class="text-center mt-5">
|
|
||||||
<a href="/web" class="btn btn-ghost btn-sm">
|
|
||||||
<i class="fa-solid fa-arrow-left"></i> Retour à l'accueil
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="loginPassword">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="loginPassword"
|
||||||
|
placeholder="Entrez votre mot de passe"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="loginSubmit" class="btn btn-primary btn-block">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Register Form -->
|
||||||
|
<form class="auth-form" id="registerForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="registerUsername">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="registerUsername"
|
||||||
|
placeholder="Choisissez un nom d'utilisateur"
|
||||||
|
minlength="3"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="registerEmail">Email (optionnel)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="registerEmail"
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="registerFullName">Nom complet (optionnel)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="registerFullName"
|
||||||
|
placeholder="Votre nom complet"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="registerPassword">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="registerPassword"
|
||||||
|
placeholder="Au moins 6 caractères"
|
||||||
|
minlength="6"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="registerPasswordConfirm">Confirmer le mot de passe</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="registerPasswordConfirm"
|
||||||
|
placeholder="Confirmez votre mot de passe"
|
||||||
|
minlength="6"
|
||||||
|
required
|
||||||
|
aria-required="true"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="registerSubmit" class="btn btn-primary btn-block">S'inscrire</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 25px;">
|
||||||
|
<a href="/web" class="btn btn-secondary btn-small">← Retour à l'accueil</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,26 +109,6 @@
|
|||||||
<script src="/static/js/auth-api.js"></script>
|
<script src="/static/js/auth-api.js"></script>
|
||||||
<script src="/static/js/auth-ui.js"></script>
|
<script src="/static/js/auth-ui.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Patch displayError / displaySuccess to work with DaisyUI alerts
|
|
||||||
(function () {
|
|
||||||
const origDisplayError = window.displayError;
|
|
||||||
const origDisplaySuccess = window.displaySuccess;
|
|
||||||
|
|
||||||
window.displayError = function (id, message) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
el.querySelector('span').textContent = message || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.displaySuccess = function (id, message) {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (!el) return;
|
|
||||||
el.classList.remove('hidden');
|
|
||||||
el.querySelector('span').textContent = message || '';
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
// Expose setToken from auth.js if available
|
// Expose setToken from auth.js if available
|
||||||
if (typeof window.setToken === 'undefined') {
|
if (typeof window.setToken === 'undefined') {
|
||||||
window.setToken = function(token) {
|
window.setToken = function(token) {
|
||||||
|
|||||||
@@ -1,45 +1,157 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" data-theme="ohmstream">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ filename }} - Ohm Stream Player</title>
|
<title>{{ filename }} - Ohm Stream Player</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css">
|
<style>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #00d9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info .filename {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-info .filesize {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plyr {
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: rgba(0, 217, 255, 0.2);
|
||||||
|
border-color: #00d9ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: linear-gradient(135deg, #00ff88 0%, #00d9ff 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: rgba(255, 71, 87, 0.1);
|
||||||
|
border: 1px solid #ff4757;
|
||||||
|
color: #ff4757;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.video-info { flex-direction: column; align-items: flex-start; }
|
||||||
|
.controls { flex-direction: column; }
|
||||||
|
.btn { width: 100%; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="min-h-screen bg-base-100 p-4 md:p-8">
|
<div class="container">
|
||||||
<div class="max-w-5xl mx-auto">
|
<div class="header">
|
||||||
<!-- Header -->
|
<h1>🎬 Ohm Stream Player</h1>
|
||||||
<div class="text-center mb-6">
|
</div>
|
||||||
<h1 class="text-2xl md:text-3xl font-bold text-primary">
|
|
||||||
<i class="fa-solid fa-film"></i> Ohm Stream Player
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video Info Bar -->
|
<div class="video-info">
|
||||||
<div class="flex justify-between items-center bg-base-200 rounded-box border border-base-300 p-4 mb-4 flex-wrap gap-2">
|
<span class="filename">{{ filename }}</span>
|
||||||
<span class="font-medium text-base-content">{{ filename }}</span>
|
<span class="filesize">{{ "%.2f"|format(file_size / 1024 / 1024) }} MB</span>
|
||||||
<span class="badge badge-ghost">{{ "%.2f"|format(file_size / 1024 / 1024) }} MB</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video Wrapper -->
|
<div class="video-wrapper">
|
||||||
<div class="bg-black rounded-box overflow-hidden">
|
<video id="player" playsinline controls preload="metadata">
|
||||||
<video id="player" playsinline controls preload="metadata">
|
<source src="/stream/{{ filename }}" type="video/mp4">
|
||||||
<source src="/stream/{{ filename }}" type="video/mp4">
|
</video>
|
||||||
</video>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
<div class="controls">
|
||||||
<div class="flex justify-center gap-3 mt-4 flex-wrap">
|
<a href="/web" class="btn">← Retour à l'accueil</a>
|
||||||
<a href="/web" class="btn btn-ghost">
|
<a href="/stream/{{ filename }}" class="btn btn-primary" download>⬇️ Télécharger</a>
|
||||||
<i class="fa-solid fa-arrow-left"></i> Retour
|
|
||||||
</a>
|
|
||||||
<a href="/stream/{{ filename }}" class="btn btn-primary" download>
|
|
||||||
<i class="fa-solid fa-download"></i> Télécharger
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,17 +165,12 @@
|
|||||||
// Error handling
|
// Error handling
|
||||||
player.on('error', (error) => {
|
player.on('error', (error) => {
|
||||||
console.error('Plyr error:', error);
|
console.error('Plyr error:', error);
|
||||||
const wrapper = document.querySelector('.bg-black');
|
const wrapper = document.querySelector('.video-wrapper');
|
||||||
wrapper.innerHTML = `
|
wrapper.innerHTML = `
|
||||||
<div class="alert alert-error m-4">
|
<div class="error-message">
|
||||||
<i class="fa-solid fa-circle-exclamation"></i>
|
Erreur lors de la lecture du flux vidéo.<br>
|
||||||
<div>
|
<a href="/video/{{ task_id }}" style="color: #00d9ff; text-decoration: underline;">Réessayer</a> ou
|
||||||
<p>Erreur lors de la lecture du flux vidéo.</p>
|
<a href="/stream/{{ filename }}" style="color: #00d9ff; text-decoration: underline;" download>Télécharger</a>
|
||||||
<div class="flex gap-2 mt-2">
|
|
||||||
<a href="/video/{{ task_id }}" class="btn btn-sm btn-primary">Réessayer</a>
|
|
||||||
<a href="/stream/{{ filename }}" download class="btn btn-sm btn-ghost">Télécharger</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,90 +1,79 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="fr" data-theme="ohmstream">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Watchlist - Ohm Stream Downloader</title>
|
<title>Watchlist - Ohm Stream Downloader</title>
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-base-100">
|
|
||||||
<!-- Navbar -->
|
|
||||||
<div class="navbar bg-base-200 border-b border-base-300 px-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<a href="/web" class="text-xl font-bold text-primary gap-2">
|
|
||||||
<i class="fa-solid fa-bolt"></i> Ohm Stream
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex-none">
|
|
||||||
<ul class="menu menu-horizontal px-1 gap-1">
|
|
||||||
<li><a href="/web"><i class="fa-solid fa-house"></i> Accueil</a></li>
|
|
||||||
<li><a href="/web#anime"><i class="fa-solid fa-film"></i> Anime</a></li>
|
|
||||||
<li><a href="/web#series"><i class="fa-solid fa-tv"></i> Série</a></li>
|
|
||||||
<li><a href="/web#providers"><i class="fa-solid fa-box"></i> Fournisseurs</a></li>
|
|
||||||
<li><a href="/watchlist" class="active bg-primary text-primary-content rounded-lg"><i class="fa-solid fa-clipboard-list"></i> Watchlist</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
</head>
|
||||||
<div class="max-w-6xl mx-auto px-4 py-6">
|
<body class="watchlist-body">
|
||||||
<!-- Page Header -->
|
<!-- Main Header -->
|
||||||
<div class="flex justify-between items-start flex-wrap gap-4 mb-6">
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
<div>
|
<h1 style="background: linear-gradient(45deg, #00d9ff, #00ff88); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-size: 32px; margin: 0;">⚡ Ohm Stream Downloader</h1>
|
||||||
<h1 class="text-2xl font-bold">
|
<p style="color: #888; font-size: 14px; margin: 5px 0 0;">Téléchargez vos vidéos, animes et séries</p>
|
||||||
<i class="fa-solid fa-clipboard-list text-primary"></i> Ma Watchlist
|
</div>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/60 mt-1">
|
<!-- User Info -->
|
||||||
Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes
|
<div id="userInfo" style="display: none; max-width: 1200px; margin: 0 auto 15px; padding: 10px; background: rgba(0,217,255,0.1); border-radius: 8px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
</p>
|
<span style="color: #00d9ff;">👤 Connecté</span>
|
||||||
</div>
|
<button class="btn-secondary btn-small" onclick="handleLogout()">🚪 Déconnexion</button>
|
||||||
<a href="/web" class="btn btn-ghost btn-sm">
|
</div>
|
||||||
<i class="fa-solid fa-arrow-left"></i> Retour à l'accueil
|
|
||||||
</a>
|
<!-- Tabs -->
|
||||||
|
<div class="tabs" style="max-width: 1200px; margin: 0 auto 20px; display: flex; gap: 5px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 10px;">
|
||||||
|
<button class="tab" onclick="window.location.href='/web'">🏠 Accueil</button>
|
||||||
|
<button class="tab" onclick="window.location.href='/web#anime'">🎬 Anime</button>
|
||||||
|
<button class="tab" onclick="window.location.href='/web#series'">📺 Série</button>
|
||||||
|
<button class="tab" onclick="window.location.href='/web#providers'">📦 Fournisseurs</button>
|
||||||
|
<button class="tab active" onclick="window.location.href='/watchlist'">📋 Watchlist</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="watchlist-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="watchlist-header">
|
||||||
|
<h1>📋 Ma Watchlist</h1>
|
||||||
|
<p>Suivez vos animes préférés et téléchargez automatiquement les nouveaux épisodes</p>
|
||||||
|
<button type="button" class="btn-secondary watchlist-header-back-btn" onclick="window.location.href = '/web'">
|
||||||
|
← Retour à l'accueil
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scheduler Status -->
|
<!-- Scheduler Status -->
|
||||||
<div class="alert bg-base-200 border border-base-300 mb-4" id="schedulerStatus">
|
<div class="scheduler-status" id="schedulerStatus">
|
||||||
<div class="flex-1">
|
<div class="scheduler-status-header">
|
||||||
<div class="flex justify-between items-start flex-wrap gap-3">
|
<div>
|
||||||
<div>
|
<h3>⏰ Planificateur Automatique</h3>
|
||||||
<h3 class="font-semibold text-base-content">
|
<div id="nextRunInfo" class="next-run-info">Chargement...</div>
|
||||||
<i class="fa-solid fa-clock text-primary"></i> Planificateur Automatique
|
</div>
|
||||||
</h3>
|
<div class="scheduler-controls">
|
||||||
<div id="nextRunInfo" class="text-sm text-base-content/60 mt-1">Chargement...</div>
|
<button id="startSchedulerBtn" class="btn-primary btn-small watchlist-btn-small" onclick="handleStartScheduler()" style="display:none;">
|
||||||
</div>
|
▶️ Démarrer
|
||||||
<div class="flex gap-2 flex-wrap">
|
</button>
|
||||||
<button id="startSchedulerBtn" class="btn btn-primary btn-sm hidden" onclick="handleStartScheduler()">
|
<button id="stopSchedulerBtn" class="btn-secondary btn-small watchlist-btn-small" onclick="handleStopScheduler()" style="display:none;">
|
||||||
<i class="fa-solid fa-play"></i> Démarrer
|
⏸️ Arrêter
|
||||||
</button>
|
</button>
|
||||||
<button id="stopSchedulerBtn" class="btn btn-ghost btn-sm hidden" onclick="handleStopScheduler()">
|
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleCheckAll()">
|
||||||
<i class="fa-solid fa-pause"></i> Arrêter
|
🔍 Vérifier tout
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="handleCheckAll()">
|
<button class="btn-secondary btn-small watchlist-btn-small" onclick="handleOpenSettings()">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i> Vérifier tout
|
⚙️ Paramètres
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" onclick="handleOpenSettings()">
|
|
||||||
<i class="fa-solid fa-gear"></i> Paramètres
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Tabs -->
|
<!-- Filter Tabs -->
|
||||||
<div class="tabs tabs-boxed bg-base-200 p-1 mb-4">
|
<div class="filter-tabs">
|
||||||
<button class="tab filter-tab tab-active" onclick="filterWatchlist('all', this)">Tous</button>
|
<button class="filter-tab active" onclick="filterWatchlist('all', this)">Tous</button>
|
||||||
<button class="tab filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
|
<button class="filter-tab" onclick="filterWatchlist('active', this)">Actifs</button>
|
||||||
<button class="tab filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
|
<button class="filter-tab" onclick="filterWatchlist('paused', this)">En pause</button>
|
||||||
<button class="tab filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
|
<button class="filter-tab" onclick="filterWatchlist('completed', this)">Terminés</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Watchlist Items -->
|
<!-- Watchlist Items -->
|
||||||
<div id="watchlistContainer" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div id="watchlistContainer">
|
||||||
<div class="col-span-full text-center py-12">
|
<div class="watchlist-loading">Chargement de la watchlist...</div>
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
||||||
<p class="text-base-content/60 mt-3">Chargement de la watchlist...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,22 +156,22 @@
|
|||||||
|
|
||||||
if (status.running) {
|
if (status.running) {
|
||||||
// Update buttons if they exist
|
// Update buttons if they exist
|
||||||
if (startBtn) startBtn.classList.add('hidden');
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
if (stopBtn) stopBtn.classList.remove('hidden');
|
if (stopBtn) stopBtn.style.display = 'inline-block';
|
||||||
|
|
||||||
if (status.next_run) {
|
if (status.next_run) {
|
||||||
const nextRun = new Date(status.next_run);
|
const nextRun = new Date(status.next_run);
|
||||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check text-success"></i> En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
nextRunInfo.innerHTML = `✓ En cours<br>Prochaine vérification: ${nextRun.toLocaleString('fr-FR')}`;
|
||||||
} else {
|
} else {
|
||||||
// Scheduler running but no next_run yet (just started)
|
// Scheduler running but no next_run yet (just started)
|
||||||
const interval = status.settings?.check_interval_hours || 6;
|
const interval = status.settings?.check_interval_hours || 6;
|
||||||
nextRunInfo.innerHTML = `<i class="fa-solid fa-check text-success"></i> En cours<br>Vérification toutes les ${interval}h`;
|
nextRunInfo.innerHTML = `✓ En cours<br>Vérification toutes les ${interval}h`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Update buttons if they exist
|
// Update buttons if they exist
|
||||||
if (startBtn) startBtn.classList.remove('hidden');
|
if (startBtn) startBtn.style.display = 'inline-block';
|
||||||
if (stopBtn) stopBtn.classList.add('hidden');
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
nextRunInfo.innerHTML = '<i class="fa-solid fa-pause text-base-content/40"></i> Arrêté';
|
nextRunInfo.innerHTML = '⏸️ Arrêté';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,11 +181,11 @@
|
|||||||
async function filterWatchlist(status, tabElement) {
|
async function filterWatchlist(status, tabElement) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
|
|
||||||
// Update tab styles — DaisyUI uses tab-active
|
// Update tab styles
|
||||||
document.querySelectorAll('.filter-tab').forEach(tab => {
|
document.querySelectorAll('.filter-tab').forEach(tab => {
|
||||||
tab.classList.remove('tab-active');
|
tab.classList.remove('active');
|
||||||
});
|
});
|
||||||
tabElement.classList.add('tab-active');
|
tabElement.classList.add('active');
|
||||||
|
|
||||||
// Reload with filter
|
// Reload with filter
|
||||||
await displayWatchlist(status === 'all' ? null : status);
|
await displayWatchlist(status === 'all' ? null : status);
|
||||||
@@ -209,10 +198,10 @@
|
|||||||
try {
|
try {
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('Planificateur démarré !');
|
alert('✅ Planificateur démarré!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error starting scheduler:', error);
|
console.error('Error starting scheduler:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +212,10 @@
|
|||||||
try {
|
try {
|
||||||
await stopScheduler();
|
await stopScheduler();
|
||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
alert('Planificateur arrêté !');
|
alert('✅ Planificateur arrêté!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error stopping scheduler:', error);
|
console.error('Error stopping scheduler:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +228,7 @@
|
|||||||
await loadSchedulerStatus();
|
await loadSchedulerStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking all:', error);
|
console.error('Error checking all:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +246,7 @@
|
|||||||
document.body.appendChild(modalContainer);
|
document.body.appendChild(modalContainer);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading settings:', error);
|
console.error('Error loading settings:', error);
|
||||||
alert(`Erreur : ${error.message}`);
|
alert(`❌ Erreur: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,4 +254,4 @@
|
|||||||
setInterval(loadSchedulerStatus, 30000);
|
setInterval(loadSchedulerStatus, 30000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# Ohm Streaming - Automated Test Report
|
|
||||||
**Date:** 2026-04-09T15:34:39.316Z
|
|
||||||
**Duration:** 62.0s
|
|
||||||
**Base URL:** http://127.0.0.1:3000
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| ✅ Passed | 30 |
|
|
||||||
| ❌ Failed | 0 |
|
|
||||||
| 📊 Total | 30 |
|
|
||||||
| 📊 Pass Rate | 100.0% |
|
|
||||||
|
|
||||||
## All tests passed!
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
- 
|
|
||||||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 709 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 162 KiB |
@@ -1,371 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ohm Streaming - Automated E2E Test Suite
|
|
||||||
* Run: node tests/auto/run_tests.mjs
|
|
||||||
* Output: tests/auto/results/report.md + screenshots/
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
const BASE = 'http://127.0.0.1:3000';
|
|
||||||
const RESULTS_DIR = path.join(import.meta.dirname, 'results');
|
|
||||||
const SCREENSHOT_DIR = path.join(RESULTS_DIR, 'screenshots');
|
|
||||||
|
|
||||||
const CREDS = { username: 'roman', password: 'roman123' };
|
|
||||||
|
|
||||||
// ── Helpers ──
|
|
||||||
const results = { passed: 0, failed: 0, errors: [], duration: 0 };
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
function screenshot(page, name) {
|
|
||||||
const p = path.join(SCREENSHOT_DIR, `${name}.png`);
|
|
||||||
return page.screenshot({ path: p, fullPage: true }).then(() => p);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function test(name, fn) {
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
results.passed++;
|
|
||||||
console.log(` ✅ ${name}`);
|
|
||||||
} catch (err) {
|
|
||||||
results.failed++;
|
|
||||||
const msg = `❌ ${name}: ${err.message}`;
|
|
||||||
results.errors.push(msg);
|
|
||||||
console.error(` ❌ ${name}: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition, message) {
|
|
||||||
if (!condition) throw new Error(message || 'Assertion failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ──
|
|
||||||
(async () => {
|
|
||||||
// Ensure output dirs
|
|
||||||
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
|
||||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
||||||
|
|
||||||
// Collect console errors
|
|
||||||
const consoleErrors = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
|
||||||
});
|
|
||||||
|
|
||||||
// Network error tracking
|
|
||||||
const networkErrors = [];
|
|
||||||
page.on('requestfailed', req => {
|
|
||||||
networkErrors.push(`${req.method()} ${req.url()}: ${req.failure()?.errorText}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n🧪 Ohm Streaming - Automated Test Suite\n');
|
|
||||||
console.log('═══ Phase 1: API Health ═══');
|
|
||||||
|
|
||||||
// ── Phase 1: API Health Checks ──
|
|
||||||
await page.goto(`${BASE}/health`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
await test('GET /health returns 200', async () => {
|
|
||||||
const text = await page.textContent('body');
|
|
||||||
const json = JSON.parse(text);
|
|
||||||
assert(json.status === 'healthy' || json.status === 'ok', `Unexpected status: ${json.status}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('GET / returns landing page', async () => {
|
|
||||||
const resp = await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
assert(resp.status() === 200, `Status ${resp.status()}`);
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
const screenshotPath = await screenshot(page, '01_landing_page');
|
|
||||||
console.log(` 📸 ${screenshotPath}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('GET /login returns login page', async () => {
|
|
||||||
const resp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
assert(resp.status() === 200);
|
|
||||||
await page.waitForTimeout(1500);
|
|
||||||
const screenshotPath = await screenshot(page, '02_login_page');
|
|
||||||
console.log(` 📸 ${screenshotPath}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 2: Authentication ──
|
|
||||||
console.log('\n═══ Phase 2: Authentication ═══');
|
|
||||||
|
|
||||||
await test('Login with valid credentials (roman/roman123)', async () => {
|
|
||||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Use API to login (SPA approach)
|
|
||||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const token = await page.evaluate(async (creds) => {
|
|
||||||
const res = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(creds)
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`Login failed: ${res.status} ${await res.text()}`);
|
|
||||||
return (await res.json()).access_token;
|
|
||||||
}, CREDS);
|
|
||||||
|
|
||||||
assert(token && token.length > 10, 'No valid token received');
|
|
||||||
|
|
||||||
// Inject token into localStorage
|
|
||||||
await page.evaluate((t) => {
|
|
||||||
localStorage.setItem('auth_token', t);
|
|
||||||
}, token);
|
|
||||||
console.log(` 🔑 Token received (${token.substring(0, 20)}...)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('GET /api/auth/me returns user info', async () => {
|
|
||||||
const user = await page.evaluate(async () => {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const res = await fetch('/api/auth/me', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
// Response may be { username, ... } or { user: { username, ... } }
|
|
||||||
const name = user.username || user.user?.username || user.id;
|
|
||||||
assert(name, `No username found in /me response: ${JSON.stringify(user).substring(0, 200)}`);
|
|
||||||
console.log(` 👤 User: ${name} (admin: ${user.is_admin || user.user?.is_admin || false})`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 3: SPA Navigation ──
|
|
||||||
console.log('\n═══ Phase 3: SPA Navigation (/web) ═══');
|
|
||||||
|
|
||||||
const tabs = ['home', 'anime', 'series', 'providers', 'downloads', 'watchlist', 'settings'];
|
|
||||||
|
|
||||||
for (const tab of tabs) {
|
|
||||||
await test(`Navigate to tab: ${tab}`, async () => {
|
|
||||||
await page.goto(`${BASE}/web`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Inject auth
|
|
||||||
await page.evaluate(() => {
|
|
||||||
// Token should already be in localStorage from login test
|
|
||||||
// but let's verify
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) throw new Error('No auth token in localStorage');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch tab using the app's own mechanism
|
|
||||||
await page.evaluate((tabName) => {
|
|
||||||
window.location.hash = tabName;
|
|
||||||
window.dispatchEvent(new CustomEvent('set-tab', { detail: { tab: tabName } }));
|
|
||||||
}, tab);
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
|
|
||||||
// Check no JS errors during navigation
|
|
||||||
const currentErrors = consoleErrors.length;
|
|
||||||
// Just verify page didn't crash
|
|
||||||
const content = await page.textContent('body');
|
|
||||||
assert(content && content.length > 10, `Tab ${tab} rendered empty content`);
|
|
||||||
|
|
||||||
const screenshotPath = await screenshot(page, `03_tab_${tab}`);
|
|
||||||
console.log(` 📸 ${screenshotPath}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 4: API Endpoints ──
|
|
||||||
console.log('\n═══ Phase 4: API Endpoints ═══');
|
|
||||||
|
|
||||||
const apiTests = [
|
|
||||||
{ name: 'GET /api/settings', endpoint: '/api/settings', method: 'GET' },
|
|
||||||
{ name: 'GET /api/favorites', endpoint: '/api/favorites', method: 'GET' },
|
|
||||||
{ name: 'GET /api/watchlist', endpoint: '/api/watchlist', method: 'GET' },
|
|
||||||
{ name: 'GET /api/downloads', endpoint: '/api/downloads', method: 'GET' },
|
|
||||||
{ name: 'GET /api/watchlist/settings', endpoint: '/api/watchlist/settings', method: 'GET' },
|
|
||||||
{ name: 'GET /api/watchlist/stats/summary', endpoint: '/api/watchlist/stats/summary', method: 'GET' },
|
|
||||||
{ name: 'GET /api/providers/health', endpoint: '/api/providers/health', method: 'GET' },
|
|
||||||
{ name: 'GET /api/recommendations', endpoint: '/api/recommendations', method: 'GET' },
|
|
||||||
{ name: 'GET /api/releases/latest', endpoint: '/api/releases/latest', method: 'GET' },
|
|
||||||
{ name: 'GET /api/favorites/stats', endpoint: '/api/favorites/stats', method: 'GET' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const apiTest of apiTests) {
|
|
||||||
await test(`${apiTest.name} returns 200`, async () => {
|
|
||||||
const result = await page.evaluate(async ({ endpoint, method }) => {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method,
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
let body = null;
|
|
||||||
try { body = await res.json(); } catch(e) { /* body stays null */ }
|
|
||||||
return { status: res.status, body };
|
|
||||||
}, apiTest);
|
|
||||||
|
|
||||||
assert(result.status === 200, `${apiTest.name} returned ${result.status}: ${JSON.stringify(result.body).substring(0, 200)}`);
|
|
||||||
// Verify it's valid JSON
|
|
||||||
assert(typeof result.body === 'object', `${apiTest.name} returned non-JSON`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 5: Content Validation ──
|
|
||||||
console.log('\n═══ Phase 5: Content Validation ═══');
|
|
||||||
|
|
||||||
await test('Home tab renders content (not blank)', async () => {
|
|
||||||
await page.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await page.waitForTimeout(3000);
|
|
||||||
const content = await page.textContent('body');
|
|
||||||
assert(content.length > 100, 'Home tab content too short - may be blank');
|
|
||||||
console.log(` 📝 Content length: ${content.length} chars`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('Alpine.js loaded correctly', async () => {
|
|
||||||
const alpineLoaded = await page.evaluate(() => typeof window.Alpine !== 'undefined');
|
|
||||||
assert(alpineLoaded, 'Alpine.js not loaded - x-* directives are dead');
|
|
||||||
console.log(` ⚡ Alpine.js: loaded`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('HTMX loaded correctly', async () => {
|
|
||||||
const htmxLoaded = await page.evaluate(() => typeof window.htmx !== 'undefined');
|
|
||||||
assert(htmxLoaded, 'HTMX not loaded');
|
|
||||||
console.log(` ⚡ HTMX: loaded`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('No critical JS errors in console', async () => {
|
|
||||||
// Filter out non-critical errors (network, extensions)
|
|
||||||
const critical = consoleErrors.filter(e =>
|
|
||||||
!e.includes('favicon') &&
|
|
||||||
!e.includes('net::ERR_CONNECTION') &&
|
|
||||||
!e.includes('404') &&
|
|
||||||
!e.includes('DevTools')
|
|
||||||
);
|
|
||||||
assert(critical.length === 0, `${critical.length} critical JS errors: ${critical.slice(0, 3).join('; ')}`);
|
|
||||||
console.log(` ✨ Console clean (${consoleErrors.length} total, 0 critical)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 6: Search Functionality ──
|
|
||||||
console.log('\n═══ Phase 6: Search Functionality ═══');
|
|
||||||
|
|
||||||
await test('Anime search API works', async () => {
|
|
||||||
const result = await page.evaluate(async () => {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const res = await fetch('/api/anime/search?q=naruto&limit=3', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
return { status: res.status, body: await res.json() };
|
|
||||||
});
|
|
||||||
// Search may return empty if providers are down, but should not error
|
|
||||||
assert(result.status === 200, `Search returned ${result.status}`);
|
|
||||||
console.log(` 🔍 Search results: ${JSON.stringify(result.body).substring(0, 100)}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 7: Responsive Design ──
|
|
||||||
console.log('\n═══ Phase 7: Responsive Design ═══');
|
|
||||||
|
|
||||||
await test('Mobile viewport rendering', async () => {
|
|
||||||
const context = await browser.newContext({
|
|
||||||
viewport: { width: 390, height: 844 },
|
|
||||||
isMobile: true,
|
|
||||||
hasTouch: true,
|
|
||||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15'
|
|
||||||
});
|
|
||||||
const mobilePage = await context.newPage();
|
|
||||||
|
|
||||||
// Re-auth on mobile
|
|
||||||
await mobilePage.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await mobilePage.waitForTimeout(2000);
|
|
||||||
|
|
||||||
const token = await mobilePage.evaluate(async (creds) => {
|
|
||||||
const res = await fetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(creds)
|
|
||||||
});
|
|
||||||
return (await res.json()).access_token;
|
|
||||||
}, CREDS);
|
|
||||||
|
|
||||||
await mobilePage.evaluate((t) => localStorage.setItem('auth_token', t), token);
|
|
||||||
|
|
||||||
await mobilePage.goto(`${BASE}/web#home`, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
||||||
await mobilePage.waitForTimeout(3000);
|
|
||||||
|
|
||||||
const screenshotPath = await mobilePage.screenshot({ path: path.join(SCREENSHOT_DIR, '07_mobile_home.png'), fullPage: true });
|
|
||||||
console.log(` 📸 ${screenshotPath}`);
|
|
||||||
|
|
||||||
// Check for horizontal overflow
|
|
||||||
const overflow = await mobilePage.evaluate(() => {
|
|
||||||
const w = window.innerWidth;
|
|
||||||
return Array.from(document.querySelectorAll('*'))
|
|
||||||
.filter(el => el.getBoundingClientRect().width > w)
|
|
||||||
.length;
|
|
||||||
});
|
|
||||||
assert(overflow === 0, `${overflow} elements overflow horizontally on mobile`);
|
|
||||||
|
|
||||||
await context.close();
|
|
||||||
console.log(` 📱 Mobile: no horizontal overflow`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Phase 8: Settings API ──
|
|
||||||
console.log('\n═══ Phase 8: Settings & Providers ═══');
|
|
||||||
|
|
||||||
await test('GET /api/settings returns valid config', async () => {
|
|
||||||
const settings = await page.evaluate(async () => {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const res = await fetch('/api/settings', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
assert(settings && typeof settings === 'object', 'Settings not an object');
|
|
||||||
console.log(` ⚙️ Settings keys: ${Object.keys(settings).join(', ')}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('GET /api/providers/health check', async () => {
|
|
||||||
const health = await page.evaluate(async () => {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const res = await fetch('/api/providers/health', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
return res.json();
|
|
||||||
});
|
|
||||||
assert(health !== null, 'Provider health returned null');
|
|
||||||
const providerCount = Array.isArray(health) ? health.length : Object.keys(health).length;
|
|
||||||
console.log(` 🏥 Providers checked: ${providerCount}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await browser.close();
|
|
||||||
|
|
||||||
// ── Generate Report ──
|
|
||||||
results.duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
consoleErrors.length = 0;
|
|
||||||
|
|
||||||
const report = `# Ohm Streaming - Automated Test Report
|
|
||||||
**Date:** ${new Date().toISOString()}
|
|
||||||
**Duration:** ${results.duration}s
|
|
||||||
**Base URL:** ${BASE}
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Metric | Value |
|
|
||||||
|--------|-------|
|
|
||||||
| ✅ Passed | ${results.passed} |
|
|
||||||
| ❌ Failed | ${results.failed} |
|
|
||||||
| 📊 Total | ${results.passed + results.failed} |
|
|
||||||
| 📊 Pass Rate | ${((results.passed / (results.passed + results.failed)) * 100).toFixed(1)}% |
|
|
||||||
|
|
||||||
${results.errors.length > 0 ? `## Failed Tests\n\n${results.errors.map((e, i) => `${i + 1}. ${e}`).join('\n')}` : '## All tests passed!'}
|
|
||||||
|
|
||||||
## Screenshots
|
|
||||||
|
|
||||||
${fs.readdirSync(SCREENSHOT_DIR).map(f => `- `).join('\n')}
|
|
||||||
`;
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(RESULTS_DIR, 'report.md'), report);
|
|
||||||
|
|
||||||
console.log('\n═══════════════════════════════════');
|
|
||||||
console.log(` Results: ${results.passed}/${results.passed + results.failed} passed (${results.duration}s)`);
|
|
||||||
console.log(` Report: ${path.join(RESULTS_DIR, 'report.md')}`);
|
|
||||||
console.log(` Screenshots: ${SCREENSHOT_DIR}`);
|
|
||||||
if (results.errors.length > 0) {
|
|
||||||
console.log(`\n Failed tests:`);
|
|
||||||
results.errors.forEach(e => console.log(` ${e}`));
|
|
||||||
}
|
|
||||||
console.log('═══════════════════════════════════\n');
|
|
||||||
|
|
||||||
process.exit(results.failed > 0 ? 1 : 0);
|
|
||||||
})();
|
|
||||||
@@ -25,14 +25,6 @@ from app.favorites import FavoritesManager
|
|||||||
from app.download_manager import DownloadManager
|
from app.download_manager import DownloadManager
|
||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
|
|
||||||
# Import all table models so SQLModel.metadata.create_all creates all tables
|
|
||||||
from app.models.auth import UserTable
|
|
||||||
from app.models.watchlist import WatchlistItemTable, WatchlistSettingsTable
|
|
||||||
from app.models.favorites import FavoriteTable
|
|
||||||
from app.models.sonarr import SonarrMappingTable, SonarrConfigTable
|
|
||||||
from app.models.settings import AppSettingsTable
|
|
||||||
from app.models.download import DownloadTaskTable
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def init_db():
|
def init_db():
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -1,119 +1,93 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { TEST_USER, login } from './helpers';
|
||||||
|
|
||||||
test.describe('Auth Flow', () => {
|
test.describe('Auth Flow', () => {
|
||||||
test('login success - redirects to home and stores token', async ({ page }) => {
|
test('login success - redirects to home and stores token', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await login(page, TEST_USER.username, TEST_USER.password);
|
||||||
|
|
||||||
// Fill login form
|
// Verify redirect to /web
|
||||||
await page.fill('#loginUsername', 'testuser');
|
await expect(page).toHaveURL(/\/web/);
|
||||||
await page.fill('#loginPassword', 'password123');
|
|
||||||
|
// Verify token stored
|
||||||
// Click login button
|
const token = await page.evaluate(() => localStorage.getItem('auth_token'));
|
||||||
await page.click('#loginSubmit');
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
// Wait for redirect or success message
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if redirected or success message shown
|
|
||||||
const currentUrl = page.url();
|
|
||||||
const successMessage = await page.locator('#authSuccess').textContent().catch(() => '');
|
|
||||||
|
|
||||||
// Either redirect happened or success message shown
|
|
||||||
expect(currentUrl.includes('/web') || successMessage.includes('réussie')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login with wrong credentials shows error', async ({ page }) => {
|
test('login with wrong credentials shows error', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
await page.fill('#loginUsername', 'nonexistentuser_xyz');
|
||||||
// Fill login form with wrong credentials
|
|
||||||
await page.fill('#loginUsername', 'nonexistentuser');
|
|
||||||
await page.fill('#loginPassword', 'wrongpassword');
|
await page.fill('#loginPassword', 'wrongpassword');
|
||||||
|
|
||||||
// Click login button
|
const [response] = await Promise.all([
|
||||||
await page.click('#loginSubmit');
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/login')),
|
||||||
|
page.click('#loginSubmit'),
|
||||||
// Wait for error
|
]);
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
// Check error message is displayed
|
|
||||||
const errorVisible = await page.locator('#authError').isVisible().catch(() => false);
|
// Error message should be visible
|
||||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
const errorLocator = page.locator('#authError');
|
||||||
|
await expect(errorLocator).toBeVisible();
|
||||||
// Error should be shown (and NOT be "[object Object]")
|
await expect(errorLocator).toContainText(/incorrect|mot de passe|connexion/i);
|
||||||
expect(errorVisible || errorText.length > 0).toBeTruthy();
|
|
||||||
expect(errorText).not.toContain('[object Object]');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('register new user shows success', async ({ page }) => {
|
test('register new user shows success', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Switch to register tab
|
|
||||||
await page.click('text=Inscription');
|
await page.click('text=Inscription');
|
||||||
|
|
||||||
// Fill register form with unique username
|
const uniqueUsername = `testuser_${Date.now()}`;
|
||||||
const uniqueUsername = 'testuser_' + Date.now();
|
|
||||||
await page.fill('#registerUsername', uniqueUsername);
|
await page.fill('#registerUsername', uniqueUsername);
|
||||||
await page.fill('#registerPassword', 'password123');
|
await page.fill('#registerPassword', 'password123');
|
||||||
await page.fill('#registerPasswordConfirm', 'password123');
|
await page.fill('#registerPasswordConfirm', 'password123');
|
||||||
|
|
||||||
// Click register button
|
const [response] = await Promise.all([
|
||||||
await page.click('#registerSubmit');
|
page.waitForResponse((resp) => resp.url().includes('/api/auth/register')),
|
||||||
|
page.click('#registerSubmit'),
|
||||||
// Wait for success
|
]);
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
expect(response.status()).toBeLessThan(400);
|
||||||
// Check success message
|
|
||||||
const successVisible = await page.locator('#authSuccess').isVisible().catch(() => false);
|
await expect(page.locator('#authSuccess')).toBeVisible();
|
||||||
const successText = await page.locator('#authSuccess').textContent().catch(() => '');
|
await expect(page.locator('#authSuccess')).toContainText(/réussie|succès/i);
|
||||||
|
|
||||||
// Success should be shown
|
|
||||||
expect(successVisible || successText.includes('réussie')).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('password mismatch shows validation error', async ({ page }) => {
|
test('password mismatch shows validation error', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Switch to register tab
|
|
||||||
await page.click('text=Inscription');
|
await page.click('text=Inscription');
|
||||||
|
|
||||||
// Fill register form with mismatching passwords
|
|
||||||
await page.fill('#registerUsername', 'testuser');
|
await page.fill('#registerUsername', 'testuser');
|
||||||
await page.fill('#registerPassword', 'password123');
|
await page.fill('#registerPassword', 'password123');
|
||||||
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
await page.fill('#registerPasswordConfirm', 'differentpassword');
|
||||||
|
|
||||||
// Click register button
|
|
||||||
await page.click('#registerSubmit');
|
await page.click('#registerSubmit');
|
||||||
|
|
||||||
// Wait for error
|
await expect(page.locator('#authError')).toBeVisible();
|
||||||
await page.waitForTimeout(1000);
|
await expect(page.locator('#authError')).toContainText(/correspondent|match/i);
|
||||||
|
|
||||||
// Check error message
|
|
||||||
const errorText = await page.locator('#authError').textContent().catch(() => '');
|
|
||||||
|
|
||||||
// Should show password mismatch error
|
|
||||||
expect(errorText).toContain('correspondent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('login button shows loading state during request', async ({ page }) => {
|
test('login button shows loading state during request', async ({ page }) => {
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
|
|
||||||
// Get button and check initial state
|
|
||||||
const button = page.locator('#loginSubmit');
|
const button = page.locator('#loginSubmit');
|
||||||
const initialText = await button.textContent();
|
const initialText = await button.textContent();
|
||||||
|
|
||||||
// Fill form and click
|
await page.fill('#loginUsername', TEST_USER.username);
|
||||||
await page.fill('#loginUsername', 'testuser');
|
await page.fill('#loginPassword', TEST_USER.password);
|
||||||
await page.fill('#loginPassword', 'password123');
|
|
||||||
|
// Start the click but don't await it fully — we want to observe the loading state
|
||||||
// Click and immediately check loading state
|
const clickPromise = button.click();
|
||||||
await button.click();
|
|
||||||
|
// Poll briefly for loading state
|
||||||
// Check loading state (should change text or be disabled)
|
let sawLoading = false;
|
||||||
await page.waitForTimeout(100);
|
for (let i = 0; i < 10; i++) {
|
||||||
const buttonText = await button.textContent();
|
const text = await button.textContent();
|
||||||
const isDisabled = await button.isDisabled().catch(() => false);
|
const disabled = await button.isDisabled();
|
||||||
|
if (text !== initialText || disabled) {
|
||||||
// Button should either show loading text or be disabled
|
sawLoading = true;
|
||||||
expect(buttonText !== initialText || isDisabled).toBeTruthy();
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
await clickPromise;
|
||||||
|
expect(sawLoading).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||