diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 182209a..9ce6e96 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -54,15 +54,22 @@ def get_base_manifest(user_settings: UserSettings | None = None): } +# Cache catalog definitions for 1 hour (3600s) # Cache catalog definitions for 1 hour (3600s) @alru_cache(maxsize=1000, ttl=3600) async def fetch_catalogs(token: str | None = None, settings_str: str | None = None): if not token: return [] - user_settings = decode_settings(settings_str) if settings_str else None - credentials = await resolve_user_credentials(token) + + if settings_str: + user_settings = decode_settings(settings_str) + elif credentials.get("settings"): + user_settings = UserSettings(**credentials["settings"]) + else: + user_settings = None + stremio_service = StremioService( username=credentials.get("username") or "", password=credentials.get("password") or "", @@ -85,7 +92,18 @@ async def _manifest_handler(response: Response, token: str | None, settings_str: # Cache manifest for 1 day (86400 seconds) response.headers["Cache-Control"] = "public, max-age=86400" - user_settings = decode_settings(settings_str) if settings_str else None + user_settings = None + if settings_str: + user_settings = decode_settings(settings_str) + elif token: + try: + creds = await resolve_user_credentials(token) + if creds.get("settings"): + user_settings = UserSettings(**creds["settings"]) + except Exception: + # Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs) + pass + base_manifest = get_base_manifest(user_settings) if token: diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index 299b926..0834d24 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -5,7 +5,7 @@ from redis import exceptions as redis_exceptions from app.core.config import settings -from app.core.settings import CatalogConfig, UserSettings, encode_settings, get_default_settings +from app.core.settings import CatalogConfig, UserSettings, get_default_settings from app.services.catalog_updater import refresh_catalogs_for_credentials from app.services.stremio_service import StremioService from app.services.token_store import token_store @@ -15,6 +15,8 @@ class TokenRequest(BaseModel): + watchly_username: str | None = Field(default=None, description="Watchly account (user/id)") + watchly_password: str | None = Field(default=None, description="Watchly account password") username: str | None = Field(default=None, description="Stremio username or email") password: str | None = Field(default=None, description="Stremio password") authKey: str | None = Field(default=None, description="Existing Stremio auth key") @@ -77,34 +79,95 @@ async def _verify_credentials_or_raise(payload: dict) -> str: @router.post("/", response_model=TokenResponse) async def create_token(payload: TokenRequest, request: Request) -> TokenResponse: - username = payload.username.strip() if payload.username else None - password = payload.password - auth_key = payload.authKey.strip() if payload.authKey else None + # Stremio Credentials + stremio_username = payload.username.strip() if payload.username else None + stremio_password = payload.password + stremio_auth_key = payload.authKey.strip() if payload.authKey else None + + # Watchly Credentials (The new flow) + watchly_username = payload.watchly_username.strip() if payload.watchly_username else None + watchly_password = payload.watchly_password + rpdb_key = payload.rpdb_key.strip() if payload.rpdb_key else None - if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): - auth_key = auth_key[1:-1].strip() + if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): + stremio_auth_key = stremio_auth_key[1:-1].strip() + + # Construct Settings + default_settings = get_default_settings() - if username and not password: - raise HTTPException(status_code=400, detail="Password is required when a username is provided.") + user_settings = UserSettings( + language=payload.language or default_settings.language, + catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, + rpdb_key=rpdb_key, + excluded_movie_genres=payload.excluded_movie_genres, + excluded_series_genres=payload.excluded_series_genres, + ) - if password and not username: + # Logic to handle "Update Mode" (Watchly credentials only) + is_update_mode = (watchly_username and watchly_password) and not ( + stremio_username or stremio_password or stremio_auth_key + ) + + if is_update_mode: + # User is trying to update settings using only Watchly credentials + # We must retrieve their existing Stremio credentials from the store + temp_payload_for_derivation = { + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": None, + "password": None, + "authKey": None, + } + derived_token = token_store.derive_token(temp_payload_for_derivation) + existing_data = await token_store.get_payload(derived_token) + + if not existing_data: + raise HTTPException( + status_code=404, + detail="Account not found. Please start as a New User to connect Stremio.", + ) + + # Hydrate Stremio credentials from existing data + stremio_username = existing_data.get("username") + stremio_password = existing_data.get("password") + stremio_auth_key = existing_data.get("authKey") + + # Regular Validation Logic + if stremio_username and not stremio_password: + raise HTTPException(status_code=400, detail="Stremio password is required when username is provided.") + + if stremio_password and not stremio_username: raise HTTPException( status_code=400, - detail="Username/email is required when a password is provided.", + detail="Stremio username/email is required when password is provided.", ) - if not auth_key and not (username and password): + if not stremio_auth_key and not (stremio_username and stremio_password): raise HTTPException( status_code=400, - detail="Provide either a Stremio auth key or both username and password.", + detail="Provide either a Stremio auth key or both Stremio username and password.", + ) + + # if creating a new account, check if the Watchly ID is already taken. + if watchly_username and not is_update_mode: + derived_token = token_store.derive_token( + {"watchly_username": watchly_username, "watchly_password": watchly_password} ) + if await token_store.get_payload(derived_token): + raise HTTPException( + status_code=409, + detail="This Watchly ID is already in use. Please choose a different one or log in as an Existing User.", # noqa: E501 + ) - # We only store credentials in Redis, settings go into URL + # Payload to store includes BOTH Watchly and Stremio credentials + User Settings payload_to_store = { - "username": username, - "password": password, - "authKey": auth_key, + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": stremio_username, + "password": stremio_password, + "authKey": stremio_auth_key, + "settings": user_settings.model_dump(), } verified_auth_key = await _verify_credentials_or_raise(payload_to_store) @@ -125,20 +188,6 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse detail="Token storage is temporarily unavailable. Please try again once Redis is reachable.", ) from exc - # Construct Settings - default_settings = get_default_settings() - - user_settings = UserSettings( - language=payload.language or default_settings.language, - catalogs=payload.catalogs if payload.catalogs else default_settings.catalogs, - rpdb_key=rpdb_key, - excluded_movie_genres=payload.excluded_movie_genres, - excluded_series_genres=payload.excluded_series_genres, - ) - - # encode_settings now includes the "settings:" prefix - encoded_settings = encode_settings(user_settings) - if created: try: await refresh_catalogs_for_credentials( @@ -153,8 +202,8 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse ) from exc base_url = settings.HOST_NAME - # New URL structure - manifest_url = f"{base_url}/{encoded_settings}/{token}/manifest.json" + # New URL structure (Settings stored in Token) + manifest_url = f"{base_url}/{token}/manifest.json" expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None @@ -165,26 +214,64 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse ) +@router.post("/verify", status_code=200) +async def verify_user(payload: TokenRequest): + """Verify if a Watchly user exists.""" + watchly_username = payload.watchly_username.strip() if payload.watchly_username else None + watchly_password = payload.watchly_password + + if not watchly_username or not watchly_password: + raise HTTPException(status_code=400, detail="Watchly username and password required.") + + payload_to_derive = { + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": None, + "password": None, + "authKey": None, + } + + token = token_store.derive_token(payload_to_derive) + exists = await token_store.get_payload(token) + + if not exists: + raise HTTPException(status_code=404, detail="Account not found.") + + return {"found": True, "token": token, "settings": exists.get("settings")} + + @router.delete("/", status_code=200) async def delete_token(payload: TokenRequest): """Delete a token based on provided credentials.""" - username = payload.username.strip() if payload.username else None - password = payload.password - auth_key = payload.authKey.strip() if payload.authKey else None - - if auth_key and auth_key.startswith('"') and auth_key.endswith('"'): - auth_key = auth_key[1:-1].strip() - - if not auth_key and not (username and password): + # Stremio Credentials + stremio_username = payload.username.strip() if payload.username else None + stremio_password = payload.password + stremio_auth_key = payload.authKey.strip() if payload.authKey else None + + # Watchly Credentials + watchly_username = payload.watchly_username.strip() if payload.watchly_username else None + watchly_password = payload.watchly_password + + if stremio_auth_key and stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): + stremio_auth_key = stremio_auth_key[1:-1].strip() + + # Need either Watchly creds OR Stremio creds (for legacy) + if ( + not (watchly_username and watchly_password) + and not stremio_auth_key + and not (stremio_username and stremio_password) + ): raise HTTPException( status_code=400, - detail="Provide either a Stremio auth key or both username and password to delete account.", + detail="Provide Watchly credentials (or Stremio credentials for legacy accounts) to delete account.", ) payload_to_derive = { - "username": username, - "password": password, - "authKey": auth_key, + "watchly_username": watchly_username, + "watchly_password": watchly_password, + "username": stremio_username, + "password": stremio_password, + "authKey": stremio_auth_key, } try: diff --git a/app/services/token_store.py b/app/services/token_store.py index cf0e8e9..96d3098 100644 --- a/app/services/token_store.py +++ b/app/services/token_store.py @@ -63,19 +63,31 @@ def _format_key(self, hashed_token: str) -> str: def _normalize_payload(self, payload: dict[str, Any]) -> dict[str, Any]: return { + "watchly_username": (payload.get("watchly_username") or "").strip() or None, + "watchly_password": payload.get("watchly_password") or None, "username": (payload.get("username") or "").strip() or None, "password": payload.get("password") or None, "authKey": (payload.get("authKey") or "").strip() or None, "includeWatched": bool(payload.get("includeWatched", False)), + "settings": payload.get("settings"), } def _derive_token_value(self, payload: dict[str, Any]) -> str: - canonical = { - "username": payload.get("username") or "", - "password": payload.get("password") or "", - "authKey": payload.get("authKey") or "", - "includeWatched": bool(payload.get("includeWatched", False)), - } + # Prioritize Watchly credentials for stable token generation + if payload.get("watchly_username"): + canonical = { + "watchly_username": payload.get("watchly_username"), + "watchly_password": payload.get("watchly_password") or "", + } + else: + # Legacy fallback + canonical = { + "username": payload.get("username") or "", + "password": payload.get("password") or "", + "authKey": payload.get("authKey") or "", + "includeWatched": bool(payload.get("includeWatched", False)), + } + serialized = json.dumps(canonical, sort_keys=True, separators=(",", ":")) secret = settings.TOKEN_SALT.encode("utf-8") return hmac.new(secret, serialized.encode("utf-8"), hashlib.sha256).hexdigest() diff --git a/app/utils.py b/app/utils.py index afc3cbc..822cbaf 100644 --- a/app/utils.py +++ b/app/utils.py @@ -55,4 +55,5 @@ async def resolve_user_credentials(token: str) -> dict[str, Any]: "password": password, "authKey": auth_key, "includeWatched": include_watched, + "settings": payload.get("settings"), } diff --git a/static/index.html b/static/index.html index 1c4785c..97a0aee 100644 --- a/static/index.html +++ b/static/index.html @@ -18,12 +18,23 @@ colors: { slate: { 850: '#151f32', + 900: '#0f172a', + 950: '#020617', }, stremio: { DEFAULT: '#3b2667', hover: '#4e3286', border: '#573b94' } + }, + animation: { + 'fade-in': 'fadeIn 0.3s ease-out forwards', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0', transform: 'translateY(10px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + } } } } @@ -34,366 +45,468 @@ background-color: #020617; background-image: radial-gradient(circle at 50% 0%, rgba(30, 58, 138, 0.15), transparent 50%); } - - /* Custom scrollbar for webkit */ - ::-webkit-scrollbar { - width: 8px; + /* Custom scrollbar */ + ::-webkit-scrollbar { width: 8px; } + ::-webkit-scrollbar-track { background: transparent; } + ::-webkit-scrollbar-thumb { background: #334155; border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: #475569; } + + .nav-item.active { + @apply bg-blue-600/10 text-blue-400 border-l-2 border-blue-400; } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: #334155; - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: #475569; + .nav-item.disabled { + @apply opacity-50 cursor-not-allowed pointer-events-none; } - -
+ - -
-
-
-
- Watchly -
-

+ +
+ Watchly +

Watchly

+
+ + + + +
+
+ + + +
+
+

Welcome + to Watchly

+

Get personalized Stremio recommendations based on your unique + taste.

+
+ +
+ + + +
+
+ + + + +
+ -
+ + + + +

+ -
- - -
- + + - - - - -
-

© 2024 Watchly. All rights reserved.

- - - Source Code -
+ -
- - + + - diff --git a/static/script.js b/static/script.js index 2310cab..ff6b3df 100644 --- a/static/script.js +++ b/static/script.js @@ -7,162 +7,353 @@ const defaultCatalogs = [ let catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); -// Genre Constants (TMDB) +// Genre Constants const MOVIE_GENRES = [ - { id: '28', name: 'Action' }, - { id: '12', name: 'Adventure' }, - { id: '16', name: 'Animation' }, - { id: '35', name: 'Comedy' }, - { id: '80', name: 'Crime' }, - { id: '99', name: 'Documentary' }, - { id: '18', name: 'Drama' }, - { id: '10751', name: 'Family' }, - { id: '14', name: 'Fantasy' }, - { id: '36', name: 'History' }, - { id: '27', name: 'Horror' }, - { id: '10402', name: 'Music' }, - { id: '9648', name: 'Mystery' }, - { id: '10749', name: 'Romance' }, - { id: '878', name: 'Science Fiction' }, - { id: '10770', name: 'TV Movie' }, - { id: '53', name: 'Thriller' }, - { id: '10752', name: 'War' }, - { id: '37', name: 'Western' } + { id: '28', name: 'Action' }, { id: '12', name: 'Adventure' }, { id: '16', name: 'Animation' }, { id: '35', name: 'Comedy' }, { id: '80', name: 'Crime' }, { id: '99', name: 'Documentary' }, { id: '18', name: 'Drama' }, { id: '10751', name: 'Family' }, { id: '14', name: 'Fantasy' }, { id: '36', name: 'History' }, { id: '27', name: 'Horror' }, { id: '10402', name: 'Music' }, { id: '9648', name: 'Mystery' }, { id: '10749', name: 'Romance' }, { id: '878', name: 'Science Fiction' }, { id: '10770', name: 'TV Movie' }, { id: '53', name: 'Thriller' }, { id: '10752', name: 'War' }, { id: '37', name: 'Western' } ]; const SERIES_GENRES = [ - { id: '10759', name: 'Action & Adventure' }, - { id: '16', name: 'Animation' }, - { id: '35', name: 'Comedy' }, - { id: '80', name: 'Crime' }, - { id: '99', name: 'Documentary' }, - { id: '18', name: 'Drama' }, - { id: '10751', name: 'Family' }, - { id: '10762', name: 'Kids' }, - { id: '9648', name: 'Mystery' }, - { id: '10763', name: 'News' }, - { id: '10764', name: 'Reality' }, - { id: '10765', name: 'Sci-Fi & Fantasy' }, - { id: '10766', name: 'Soap' }, - { id: '10767', name: 'Talk' }, - { id: '10768', name: 'War & Politics' }, - { id: '37', name: 'Western' } + { id: '10759', name: 'Action & Adventure' }, { id: '16', name: 'Animation' }, { id: '35', name: 'Comedy' }, { id: '80', name: 'Crime' }, { id: '99', name: 'Documentary' }, { id: '18', name: 'Drama' }, { id: '10751', name: 'Family' }, { id: '10762', name: 'Kids' }, { id: '9648', name: 'Mystery' }, { id: '10763', name: 'News' }, { id: '10764', name: 'Reality' }, { id: '10765', name: 'Sci-Fi & Fantasy' }, { id: '10766', name: 'Soap' }, { id: '10767', name: 'Talk' }, { id: '10768', name: 'War & Politics' }, { id: '37', name: 'Western' } ]; // DOM Elements const configForm = document.getElementById('configForm'); -const authMethod = document.getElementById('authMethod'); -const credentialsFields = document.getElementById('credentialsFields'); -const authKeyField = document.getElementById('authKeyField'); const catalogList = document.getElementById('catalogList'); const movieGenreList = document.getElementById('movieGenreList'); const seriesGenreList = document.getElementById('seriesGenreList'); const errorMessage = document.getElementById('errorMessage'); -const successMessage = document.getElementById('successMessage'); const submitBtn = document.getElementById('submitBtn'); -const btnText = submitBtn.querySelector('.btn-text'); -const loader = submitBtn.querySelector('.loader'); const stremioLoginBtn = document.getElementById('stremioLoginBtn'); const stremioLoginText = document.getElementById('stremioLoginText'); -const manualAuthContainer = document.getElementById('manualAuthContainer'); -const orDivider = document.getElementById('orDivider'); const languageSelect = document.getElementById('languageSelect'); -const rpdbKeyInput = document.getElementById('rpdbKey'); +const generateIdBtn = document.getElementById('generateIdBtn'); +const watchlyUsername = document.getElementById('watchlyUsername'); +const watchlyPassword = document.getElementById('watchlyPassword'); +const toggleStremioManual = document.getElementById('toggleStremioManual'); +const stremioManualFields = document.getElementById('stremioManualFields'); +const manualContinueBtn = document.getElementById('manualContinueBtn'); +const configNextBtn = document.getElementById('configNextBtn'); +const catalogsNextBtn = document.getElementById('catalogsNextBtn'); +const successResetBtn = document.getElementById('successResetBtn'); +const deleteAccountBtn = document.getElementById('deleteAccountBtn'); + +const navItems = { + welcome: document.getElementById('nav-welcome'), + login: document.getElementById('nav-login'), + config: document.getElementById('nav-config'), + catalogs: document.getElementById('nav-catalogs'), + install: document.getElementById('nav-install') +}; + +const sections = { + welcome: document.getElementById('sect-welcome'), + watchlyLogin: document.getElementById('sect-watchly-login'), + login: document.getElementById('sect-login'), + config: document.getElementById('sect-config'), + catalogs: document.getElementById('sect-catalogs'), + install: document.getElementById('sect-install'), + success: document.getElementById('sect-success') +}; + +// Welcome & Watchly Login Elements +const btnNewUser = document.getElementById('btn-new-user'); +const btnExistingUser = document.getElementById('btn-existing-user'); +const btnWatchlyLoginSubmit = document.getElementById('btn-watchly-login-submit'); +const backToWelcome = document.getElementById('back-to-welcome'); +const existingWatchlyUser = document.getElementById('existing-watchly-user'); +const existingWatchlyPass = document.getElementById('existing-watchly-pass'); + // Initialize document.addEventListener('DOMContentLoaded', () => { - initializeAuthMethodToggle(); + // Start at Welcome + switchSection('welcome'); // ensure welcome is visible + initializeWelcomeFlow(); + + initializeNavigation(); initializeCatalogList(); initializeLanguageSelect(); initializeGenreLists(); initializeFormSubmission(); initializeSuccessActions(); initializePasswordToggles(); - initializeAuthHelp(); initializeStremioLogin(); initializeFooter(); + + // Watchly ID Generator + if (generateIdBtn && watchlyUsername) { + generateIdBtn.addEventListener('click', () => { + const randomId = 'user-' + Math.random().toString(36).substring(2, 9); + watchlyUsername.value = randomId; + }); + } + + // Manual Stremio Toggle + if (toggleStremioManual && stremioManualFields) { + toggleStremioManual.addEventListener('click', () => { + stremioManualFields.classList.toggle('hidden'); + toggleStremioManual.textContent = stremioManualFields.classList.contains('hidden') + ? "I prefer to enter credentials manually" + : "Hide manual credentials"; + }); + } + + // Manual Continue Button + if (manualContinueBtn) { + manualContinueBtn.addEventListener('click', () => { + const user = document.getElementById('username').value.trim(); + const pass = document.getElementById('password').value; + const key = document.getElementById('authKey').value.trim(); + + if ((user && pass) || key) { + unlockNavigation(); + switchSection('config'); + } else { + showError('stremioAuthSection', 'Please enter your credentials or auth key first.'); + } + }); + } + + // Next Buttons + if (configNextBtn) configNextBtn.addEventListener('click', () => switchSection('catalogs')); + if (catalogsNextBtn) catalogsNextBtn.addEventListener('click', () => switchSection('install')); + + // Reset Buttons + document.getElementById('resetBtn')?.addEventListener('click', resetApp); + if (successResetBtn) successResetBtn.addEventListener('click', resetApp); }); -// Genre Lists -function initializeGenreLists() { - renderGenreList(movieGenreList, MOVIE_GENRES, 'movie-genre'); - renderGenreList(seriesGenreList, SERIES_GENRES, 'series-genre'); + +// Welcome Flow Logic +function initializeWelcomeFlow() { + if (btnNewUser) { + btnNewUser.addEventListener('click', () => { + // NEW USER FLOW + navItems.login.classList.remove('disabled'); // Unlock Login + + // Reset "Save & Install" UI to default (Create Mode) + if (watchlyUsername) { + watchlyUsername.value = ''; + watchlyUsername.removeAttribute('readonly'); + // watchlyUsername.classList.remove('opacity-50', 'cursor-not-allowed'); // optional styling + } + if (watchlyPassword) watchlyPassword.value = ''; + + // Show Generate Button + if (generateIdBtn) generateIdBtn.classList.remove('hidden'); + + // Update Headers/Text + const installHeader = document.querySelector('#sect-install h2'); + const installDesc = document.querySelector('#sect-install p'); + if (installHeader) installHeader.textContent = "Save & Install"; + if (installDesc) installDesc.textContent = "Create your Watchly account to secure your settings."; + + const btnText = document.querySelector('#submitBtn .btn-text'); + if (btnText) btnText.textContent = "Generate & Install"; + + // Go to Stremio Step + switchSection('login'); + }); + } + + if (btnExistingUser) { + btnExistingUser.addEventListener('click', () => { + switchSection('watchlyLogin'); + // Ensure sidebar is reset visually + Object.values(navItems).forEach(el => el.classList.remove('active')); + }); + } + + if (backToWelcome) { + backToWelcome.addEventListener('click', () => { + switchSection('welcome'); + }); + } + + if (btnWatchlyLoginSubmit) { + btnWatchlyLoginSubmit.addEventListener('click', async () => { + const wUser = existingWatchlyUser.value.trim(); + const wPass = existingWatchlyPass.value; + + if (!wUser || !wPass) { + alert("Please enter your Watchly ID and Password."); + return; + } + + const originalText = btnWatchlyLoginSubmit.textContent; + btnWatchlyLoginSubmit.textContent = "Verifying..."; + btnWatchlyLoginSubmit.disabled = true; + + try { + const res = await fetch('/tokens/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ watchly_username: wUser, watchly_password: wPass }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || "Account not found or invalid credentials."); + } + + const data = await res.json(); + + // POPULATE SETTINGS + if (data.settings) { + const s = data.settings; + if (s.language && languageSelect) languageSelect.value = s.language; + if (s.rpdb_key && document.getElementById('rpdbKey')) document.getElementById('rpdbKey').value = s.rpdb_key; + + // Genres (Checked = Excluded) + document.querySelectorAll('input[name="movie-genre"]').forEach(cb => cb.checked = false); + document.querySelectorAll('input[name="series-genre"]').forEach(cb => cb.checked = false); + + if (s.excluded_movie_genres) s.excluded_movie_genres.forEach(id => { + const cb = document.querySelector(`input[name="movie-genre"][value="${id}"]`); + if (cb) cb.checked = true; + }); + if (s.excluded_series_genres) s.excluded_series_genres.forEach(id => { + const cb = document.querySelector(`input[name="series-genre"][value="${id}"]`); + if (cb) cb.checked = true; + }); + + // Catalogs + if (s.catalogs && Array.isArray(s.catalogs)) { + s.catalogs.forEach(remote => { + const local = catalogs.find(c => c.id === remote.id); + if (local) { + local.enabled = remote.enabled; + if (remote.name) local.name = remote.name; + } + }); + renderCatalogList(); + } + } + + // EXISTING USER FLOW (Success) + navItems.config.classList.remove('disabled'); + navItems.catalogs.classList.remove('disabled'); + navItems.install.classList.remove('disabled'); + + // Hide Login Nav + navItems.login.style.display = 'none'; + + if (watchlyUsername) { + watchlyUsername.value = wUser; + watchlyUsername.setAttribute('readonly', 'true'); + } + if (watchlyPassword) watchlyPassword.value = wPass; + if (generateIdBtn) generateIdBtn.classList.add('hidden'); + + const installHeader = document.querySelector('#sect-install h2'); + const installDesc = document.querySelector('#sect-install p'); + if (installHeader) installHeader.textContent = "Update Account"; + if (installDesc) installDesc.textContent = "Your settings will be updated for this account."; + const btnText = document.querySelector('#submitBtn .btn-text'); + if (btnText) btnText.textContent = "Update & Re-Install"; + + switchSection('config'); + + } catch (error) { + alert(error.message); + } finally { + btnWatchlyLoginSubmit.textContent = originalText; + btnWatchlyLoginSubmit.disabled = false; + } + }); + } } -function renderGenreList(container, genres, namePrefix) { - container.innerHTML = genres.map(genre => ` - - `).join(''); + +// Navigation Logic +function initializeNavigation() { + Object.keys(navItems).forEach(key => { + navItems[key].addEventListener('click', () => { + if (!navItems[key].classList.contains('disabled')) { + switchSection(key); + } + }); + }); } -// Language Selection -async function initializeLanguageSelect() { - try { +function unlockNavigation() { + Object.values(navItems).forEach(el => el.classList.remove('disabled')); +} - const languagesResponse = await fetch('/api/languages'); - if (!languagesResponse.ok) throw new Error('Failed to fetch languages'); +function switchSection(sectionKey) { + // Hide all sections + Object.values(sections).forEach(el => { + if (el) el.classList.add('hidden'); + }); - const languages = await languagesResponse.json(); + // Show target section + if (sections[sectionKey]) { + sections[sectionKey].classList.remove('hidden'); + } - // Sort: English first, then alphabetical by English name - languages.sort((a, b) => { - if (a.iso_639_1 === 'en') return -1; - if (b.iso_639_1 === 'en') return 1; - return a.english_name.localeCompare(b.english_name); - }); + // Update Nav UI Logic + // Reset all nav items + Object.values(navItems).forEach(el => el.classList.remove('active', 'bg-blue-600/10', 'text-blue-400', 'border-l-2', 'border-blue-400')); - languageSelect.innerHTML = languages.map(lang => { - const code = lang.iso_639_1; - // Construct label: "English (US)" or just "English" if name is empty? - // The example showed "name": "" for Bislama. - const label = lang.name ? lang.name : lang.english_name; - const fullLabel = lang.name && lang.name !== lang.english_name - ? `${lang.english_name} (${lang.name})` - : lang.english_name; + // Activate current if exists in nav + if (navItems[sectionKey]) { + navItems[sectionKey].classList.add('active'); + } +} - return ``; - }).join(''); - } catch (err) { - console.error('Failed to load languages:', err); - // Fallback - languageSelect.innerHTML = ''; +function resetApp() { + if (configForm) configForm.reset(); + clearErrors(); + + // Reset Navigation is now Back to Welcome + switchSection('welcome'); + + // Lock Navs + Object.keys(navItems).forEach(key => { + if (key !== 'login') navItems[key].classList.add('disabled'); // Login is always enabled technically, but we hide it via switchSection('welcome') + }); + // Actually, we should probably disable 'login' too until they choose New/Existing User? + // But our nav click logic handles that. If we are at 'welcome', the sidebar is visible but inactive. + + // Reset Stremio State + if (stremioManualFields) { + stremioManualFields.classList.add('hidden'); + if (toggleStremioManual) toggleStremioManual.textContent = "I prefer to enter credentials manually"; } + setStremioLoggedOutState(); + + // Reset catalogs + catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); + renderCatalogList(); + + // Show Form + if (configForm) configForm.classList.remove('hidden'); + if (sections.success) sections.success.classList.add('hidden'); } + // Stremio Login Logic function initializeStremioLogin() { - // Check for auth key in URL (from callback) const urlParams = new URLSearchParams(window.location.search); const authKey = urlParams.get('key') || urlParams.get('authKey'); if (authKey) { - // Logged in state + // Logged In -> Unlock and move to config setStremioLoggedInState(authKey); + unlockNavigation(); + switchSection('config'); - // Remove query param from URL without reload + // Remove query param const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname; window.history.replaceState({ path: newUrl }, '', newUrl); } - // Handle login button click if (stremioLoginBtn) { stremioLoginBtn.addEventListener('click', () => { if (stremioLoginBtn.getAttribute('data-action') === 'logout') { - // Handle Logout - setStremioLoggedOutState(); + resetApp(); // Logout effectively resets the app flow } else { - // Handle Login let appHost = window.APP_HOST; if (!appHost || appHost.includes('
- -
-
${escapeHtml(cat.name)} - - ${isRenamable ? ` - - ` : ''} + + ${isRenamable ? `` : ''}
- -
${escapeHtml(cat.description || '')}
- -
- `; - if (isRenamable) { - setupRenameLogic(item, cat); - } + if (isRenamable) setupRenameLogic(item, cat); - // Setup switch toggle const switchInput = item.querySelector('.switch input'); switchInput.addEventListener('change', (e) => { cat.enabled = e.target.checked; - // Update disabled styling - if (cat.enabled) { - item.classList.remove('opacity-50'); - } else { - item.classList.add('opacity-50'); - } + if (cat.enabled) item.classList.remove('opacity-50'); + else item.classList.add('opacity-50'); }); - // Setup sort buttons - const moveUpBtn = item.querySelector('.move-up'); - const moveDownBtn = item.querySelector('.move-down'); - - moveUpBtn.addEventListener('click', (e) => { - e.preventDefault(); - moveCatalogUp(index); - }); - - moveDownBtn.addEventListener('click', (e) => { - e.preventDefault(); - moveCatalogDown(index); - }); + item.querySelector('.move-up').addEventListener('click', (e) => { e.preventDefault(); moveCatalogUp(index); }); + item.querySelector('.move-down').addEventListener('click', (e) => { e.preventDefault(); moveCatalogDown(index); }); return item; } @@ -385,16 +626,11 @@ function setupRenameLogic(item, cat) { const nameInput = item.querySelector('.catalog-name-input'); const renameBtn = item.querySelector('.rename-btn'); - // Create edit action buttons dynamically (Tailwind styled) const editActions = document.createElement('div'); - editActions.className = 'edit-actions hidden absolute right-1 top-1/2 -translate-y-1/2 flex gap-1 bg-slate-900 pl-2 z-10'; // hidden by default + editActions.className = 'edit-actions hidden absolute right-1 top-1/2 -translate-y-1/2 flex gap-1 bg-slate-900 pl-2 z-10'; editActions.innerHTML = ` - - + + `; nameContainer.appendChild(editActions); @@ -405,160 +641,55 @@ function setupRenameLogic(item, cat) { nameContainer.classList.add('editing'); nameText.classList.add('hidden'); nameInput.classList.remove('hidden'); - editActions.classList.remove('hidden'); // Show actions - editActions.classList.add('flex'); - renameBtn.classList.add('invisible'); + editActions.classList.remove('hidden'); editActions.classList.add('flex'); + if (renameBtn) renameBtn.classList.add('invisible'); nameInput.focus(); - const len = nameInput.value.length; - nameInput.setSelectionRange(len, len); } - function saveEdit() { const newName = nameInput.value.trim(); - if (newName) { - cat.name = newName; - nameText.textContent = newName; - nameInput.value = newName; - } else { - nameInput.value = cat.name; // Revert if empty - } + if (newName) { cat.name = newName; nameText.textContent = newName; nameInput.value = newName; } + else { nameInput.value = cat.name; } closeEdit(); } - - function cancelEdit() { - nameInput.value = cat.name; // Revert value - closeEdit(); - } - + function cancelEdit() { nameInput.value = cat.name; closeEdit(); } function closeEdit() { nameContainer.classList.remove('editing'); nameInput.classList.add('hidden'); - editActions.classList.add('hidden'); - editActions.classList.remove('flex'); + editActions.classList.add('hidden'); editActions.classList.remove('flex'); nameText.classList.remove('hidden'); - renameBtn.classList.remove('invisible'); + if (renameBtn) renameBtn.classList.remove('invisible'); } - - renameBtn.addEventListener('click', (e) => { - e.preventDefault(); - enableEdit(); - }); - - saveBtn.addEventListener('click', (e) => { - e.preventDefault(); - saveEdit(); - }); - - cancelBtn.addEventListener('click', (e) => { - e.preventDefault(); - cancelEdit(); - }); - + if (renameBtn) renameBtn.addEventListener('click', (e) => { e.preventDefault(); enableEdit(); }); + saveBtn.addEventListener('click', (e) => { e.preventDefault(); saveEdit(); }); + cancelBtn.addEventListener('click', (e) => { e.preventDefault(); cancelEdit(); }); nameInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveEdit(); - } else if (e.key === 'Escape') { - cancelEdit(); - } + if (e.key === 'Enter') { e.preventDefault(); saveEdit(); } + else if (e.key === 'Escape') { cancelEdit(); } }); } - -// Form Submission -function initializeFormSubmission() { - configForm.addEventListener('submit', async (e) => { - e.preventDefault(); - - const authMethodValue = authMethod.value; - const username = document.getElementById('username')?.value.trim(); - const password = document.getElementById('password')?.value; - const authKey = document.getElementById('authKey')?.value.trim(); - const language = document.getElementById('languageSelect').value; - const rpdbKey = document.getElementById('rpdbKey').value.trim(); - - // Get excluded genres - const excludedMovieGenres = Array.from(document.querySelectorAll('input[name="movie-genre"]:checked')).map(cb => cb.value); - const excludedSeriesGenres = Array.from(document.querySelectorAll('input[name="series-genre"]:checked')).map(cb => cb.value); - - // Validation - if (authMethodValue === 'credentials') { - if (!username || !password) { - showError('Please provide both email and password.'); - return; - } - } else { - if (!authKey) { - showError('Please provide your Stremio auth key.'); - return; - } - } - - // Prepare catalog configs - const catalogConfigs = catalogs.map(cat => ({ - id: cat.id, - name: cat.name, - enabled: cat.enabled - })); - - // Prepare payload - const payload = { - catalogs: catalogConfigs, - language: language, - rpdb_key: rpdbKey || null, - excluded_movie_genres: excludedMovieGenres, - excluded_series_genres: excludedSeriesGenres - }; - - if (authMethodValue === 'credentials') { - payload.username = username; - payload.password = password; - } else { - payload.authKey = authKey; - } - - // Submit - setLoading(true); - hideError(); - - try { - const response = await fetch('/tokens/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || 'Failed to create token'); - } - - showSuccess(data.manifestUrl); - } catch (error) { - showError(error.message || 'An error occurred. Please try again.'); - } finally { - setLoading(false); - } - }); -} - - -// Success Actions +// Delete & Success Helpers function initializeSuccessActions() { - const installDesktopBtn = document.getElementById('installDesktopBtn'); - const installWebBtn = document.getElementById('installWebBtn'); const copyBtn = document.getElementById('copyBtn'); - const resetBtn = document.getElementById('resetBtn'); + if (copyBtn) { + copyBtn.addEventListener('click', async () => { + const urlText = document.getElementById('addonUrl').textContent; + try { + await navigator.clipboard.writeText(urlText); + const originalText = copyBtn.innerHTML; + copyBtn.innerHTML = 'Copied!'; + setTimeout(() => { copyBtn.innerHTML = originalText; }, 2000); + } catch (err) { } + }); + } + const installDesktopBtn = document.getElementById('installDesktopBtn'); if (installDesktopBtn) { installDesktopBtn.addEventListener('click', () => { const url = document.getElementById('addonUrl').textContent; window.location.href = `stremio://${url.replace(/^https?:\/\//, '')}`; }); } - + const installWebBtn = document.getElementById('installWebBtn'); if (installWebBtn) { installWebBtn.addEventListener('click', () => { const url = document.getElementById('addonUrl').textContent; @@ -566,104 +697,32 @@ function initializeSuccessActions() { }); } - if (copyBtn) { - copyBtn.addEventListener('click', async () => { - const url = document.getElementById('addonUrl').textContent; - try { - await navigator.clipboard.writeText(url); - const originalText = copyBtn.innerHTML; - copyBtn.innerHTML = ` - - Copied! - `; - setTimeout(() => { - copyBtn.innerHTML = originalText; - }, 2000); - } catch (err) { - showError('Failed to copy URL'); - } - }); - } - - if (resetBtn) { - resetBtn.addEventListener('click', () => { - configForm.reset(); - catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); - renderCatalogList(); - configForm.classList.remove('hidden'); - configForm.style.display = ''; // Clear inline style if any - successMessage.classList.add('hidden'); - successMessage.style.display = ''; - hideError(); - - if (stremioLoginBtn.getAttribute('data-action') === 'logout') { - setStremioLoggedOutState(); - } - }); - } - - const deleteAccountBtn = document.getElementById('deleteAccountBtn'); if (deleteAccountBtn) { deleteAccountBtn.addEventListener('click', async () => { - if (!confirm('Are you sure you want to delete your settings? This will remove your credentials from the server and stop your addons from working.')) { + if (!confirm('Are you sure you want to delete your settings? This is irreversible.')) return; + const wUser = document.getElementById("watchlyUsername").value.trim(); + const wPass = document.getElementById("watchlyPassword").value; + const sUser = document.getElementById("username").value.trim(); + const sPass = document.getElementById("password").value; + const sAuthKey = document.getElementById("authKey").value.trim(); + + if (!sAuthKey && (!sUser || !sPass) && (!wUser || !wPass)) { + showError('generalError', "We can't identify the account to delete. Please login or provide keys."); return; } - const authMethodValue = authMethod.value; - const username = document.getElementById('username')?.value.trim(); - const password = document.getElementById('password')?.value; - const authKey = document.getElementById('authKey')?.value.trim(); - - // Validation - if (authMethodValue === 'credentials') { - if (!username || !password) { - showError('Please provide both email and password to delete your account.'); - return; - } - } else { - if (!authKey) { - showError('Please provide your Stremio auth key to delete your account.'); - return; - } - } - - const payload = {}; - if (authMethodValue === 'credentials') { - payload.username = username; - payload.password = password; - } else { - payload.authKey = authKey; - } - setLoading(true); - hideError(); - try { - const response = await fetch('/tokens/', { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.detail || 'Failed to delete account'); - } - - alert('Settings deleted successfully.'); - // Clear form - configForm.reset(); - if (stremioLoginBtn.getAttribute('data-action') === 'logout') { - setStremioLoggedOutState(); - } - catalogs = JSON.parse(JSON.stringify(defaultCatalogs)); - renderCatalogList(); - - } catch (err) { - showError(err.message || 'Failed to delete account. Please try again.'); + const payload = { + watchly_username: wUser, watchly_password: wPass, + username: sUser, password: sPass, authKey: sAuthKey + }; + const res = await fetch('/tokens/', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); + if (!res.ok) throw new Error((await res.json()).detail || 'Failed to delete'); + alert('Account deleted.'); + resetApp(); + } catch (e) { + showError('generalError', e.message); } finally { setLoading(false); } @@ -671,51 +730,66 @@ function initializeSuccessActions() { } } -// UI Helpers function setLoading(loading) { + if (!submitBtn) return; + const btnText = submitBtn.querySelector('.btn-text'); + const loader = submitBtn.querySelector('.loader'); submitBtn.disabled = loading; if (loading) { - btnText.classList.add('hidden'); // Use class hidden - loader.classList.remove('hidden'); + if (btnText) btnText.classList.add('hidden'); + if (loader) loader.classList.remove('hidden'); } else { - btnText.classList.remove('hidden'); - loader.classList.add('hidden'); + if (btnText) btnText.classList.remove('hidden'); + if (loader) loader.classList.add('hidden'); } } -function showError(message) { - const msgContent = errorMessage.querySelector('.message-content') || errorMessage; - // Check if message-content span exists (it does in new HTML) - if (errorMessage.querySelector('.message-content')) { - errorMessage.querySelector('.message-content').textContent = message; +function showError(target, message) { + if (target === 'generalError') { + const errEl = document.getElementById('errorMessage'); + if (errEl) { + errEl.querySelector('.message-content').textContent = message; + errEl.classList.remove('hidden'); + } else { alert(message); } + } else if (target === 'stremioAuthSection') { + // Fallback since we don't have a specific error div anymore + alert(message); + // Or highlight fields + document.getElementById('stremioManualFields').classList.remove('hidden'); } else { - errorMessage.textContent = message; + const el = document.getElementById(target); + if (el) { + el.classList.add('border-red-500'); + el.focus(); + } } - errorMessage.classList.remove('hidden'); - errorMessage.classList.add('flex'); } -function hideError() { - errorMessage.classList.add('hidden'); - errorMessage.classList.remove('flex'); +function clearErrors() { + const errEl = document.getElementById('errorMessage'); + if (errEl) errEl.classList.add('hidden'); + document.querySelectorAll('.border-red-500').forEach(e => e.classList.remove('border-red-500')); } -function showSuccess(manifestUrl) { - configForm.classList.add('hidden'); // Use classes - successMessage.classList.remove('hidden'); - document.getElementById('addonUrl').textContent = manifestUrl; +function showSuccess(url) { + // Hide form entirely by hiding the active section + Object.values(sections).forEach(s => { if (s) s.classList.add('hidden') }); + + // Show Success Section + if (sections.success) { + sections.success.classList.remove('hidden'); + document.getElementById('addonUrl').textContent = url; + } } function escapeHtml(text) { + if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } -// Footer Year function initializeFooter() { - const yearSpan = document.getElementById('currentYear'); - if (yearSpan) { - yearSpan.textContent = new Date().getFullYear(); - } + const y = document.getElementById('currentYear'); + if (y) y.textContent = new Date().getFullYear(); }