From eb75e7a56cc39e31dc9a022b47959bc4894bfdf5 Mon Sep 17 00:00:00 2001 From: raccommode Date: Mon, 25 May 2026 09:53:26 -0400 Subject: [PATCH 01/11] Fix provider following sync --- app/api/following.py | 101 ++++++++++++++++++++++++++++++++++++++-- app/main.py | 26 ++++------- static/discover.js | 4 +- static/following.html | 6 +-- static/following.js | 100 +++++++++++++++++++++++++++++++-------- static/watch.js | 2 - tests/test_following.py | 64 +++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 48 deletions(-) create mode 100644 tests/test_following.py diff --git a/app/api/following.py b/app/api/following.py index d9492e2..8bce102 100644 --- a/app/api/following.py +++ b/app/api/following.py @@ -2,9 +2,13 @@ API Router: Following management """ +import asyncio +import os + from fastapi import APIRouter, HTTPException from ..logger import logger +from ..providers.base import ProviderError router = APIRouter(prefix="/api", tags=["following"]) @@ -12,15 +16,17 @@ _chaturbate_api = None _auth_service = None _db = None +_provider_registry = None _DEFAULT_RETENTION_DAYS = 30 _MAX_RETENTION_DAYS = 365 -def init(chaturbate_api, auth_service, db): - global _chaturbate_api, _auth_service, _db +def init(chaturbate_api, auth_service, db, provider_registry=None): + global _chaturbate_api, _auth_service, _db, _provider_registry _chaturbate_api = chaturbate_api _auth_service = auth_service _db = db + _provider_registry = provider_registry async def _get_default_retention_days() -> int: @@ -117,8 +123,70 @@ async def get_following(): @router.post("/following/sync") async def sync_following(): """ - Force re-sync followed models from Chaturbate. + Force re-sync followed models from every connected provider that supports + remote follow sync. Kept on the legacy route for the Following page and + older clients. """ + if not _db: + raise HTTPException(status_code=503, detail="Database not initialized") + + if _provider_registry: + timeout = float(os.getenv("PSTREAMREC_FOLLOW_SYNC_TIMEOUT", "45") or "45") + results = [] + total_synced = 0 + + for provider in _provider_registry.all(): + caps = getattr(provider, "capabilities", None) + if not caps or not getattr(caps, "can_sync_following", False): + continue + + source_type = getattr(provider, "source_type", "") or "" + display_name = getattr(provider, "display_name", source_type) or source_type + try: + items = await asyncio.wait_for(provider.sync_following(), timeout=timeout) + synced = await _store_provider_following(source_type, items or []) + total_synced += synced + results.append({ + "sourceType": source_type, + "displayName": display_name, + "synced": synced, + "status": "ok", + }) + except asyncio.TimeoutError: + logger.warning("Provider following sync timeout", source_type=source_type, timeout=timeout) + results.append({ + "sourceType": source_type, + "displayName": display_name, + "synced": 0, + "status": "timeout", + "detail": f"Sync timed out after {int(timeout)}s", + }) + except ProviderError as exc: + results.append({ + "sourceType": source_type, + "displayName": display_name, + "synced": 0, + "status": "error", + "detail": str(exc), + }) + except Exception as exc: + logger.error("Provider following sync failed", source_type=source_type, error=str(exc), exc_info=True) + results.append({ + "sourceType": source_type, + "displayName": display_name, + "synced": 0, + "status": "error", + "detail": str(exc), + }) + + await _db.reconcile_model_sources_from_followed() + ok_count = sum(1 for item in results if item.get("status") == "ok") + return { + "synced": total_synced, + "results": results, + "message": f"Synced {total_synced} followed models from {ok_count} provider(s)", + } + if not _auth_service or not _chaturbate_api: raise HTTPException(status_code=503, detail="Services not initialized") @@ -152,6 +220,7 @@ async def sync_following(): is_online=is_online, viewers=model.get("viewers", 0), thumbnail_url=thumb, + source_type="chaturbate", room_status=model.get("room_status"), ) synced_usernames.add(model["username"]) @@ -162,6 +231,32 @@ async def sync_following(): return {"synced": len(models), "message": f"Synced {len(models)} followed models"} +async def _store_provider_following(source_type: str, items: list[dict]) -> int: + synced_usernames = set() + for item in items: + username = item.get("username") + if not username: + continue + thumbnail = item.get("thumbnail_url") or item.get("thumbnail") + is_online = bool(item.get("is_online", item.get("isOnline", False))) + if source_type == "chaturbate" and not is_online and thumbnail and "roomimg.stream.highwebmedia.com" in thumbnail: + thumbnail = None + await _db.upsert_followed_model( + username=username, + display_name=item.get("display_name") or username, + is_online=is_online, + viewers=int(item.get("viewers") or 0), + thumbnail_url=thumbnail, + source_type=source_type, + room_status=item.get("room_status") or item.get("roomStatus"), + ) + synced_usernames.add(username) + + if source_type == "chaturbate": + await _db.remove_unfollowed(synced_usernames, source_type=source_type) + return len(synced_usernames) + + @router.post("/following/{username}/track") async def track_followed_model(username: str): """ diff --git a/app/main.py b/app/main.py index 5615e87..5ba3b93 100644 --- a/app/main.py +++ b/app/main.py @@ -1525,7 +1525,10 @@ async def provider_sync_following(source_type: str): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) try: - items = await provider.sync_following() + timeout = float(os.getenv("PSTREAMREC_FOLLOW_SYNC_TIMEOUT", "45") or "45") + items = await asyncio.wait_for(provider.sync_following(), timeout=timeout) + except asyncio.TimeoutError: + raise HTTPException(status_code=504, detail=f"{provider.display_name}: sync timeout") except ProviderError as exc: raise HTTPException(status_code=400, detail=str(exc)) @@ -3600,32 +3603,19 @@ async def update_recording_settings(body: dict): @app.post("/api/chaturbate/follow/{username}") async def follow_model_on_chaturbate(username: str): """Follow a model on Chaturbate""" - if not chaturbate_api: - raise HTTPException(status_code=503, detail="Chaturbate API not initialized") - success = await chaturbate_api.follow_model(username) - if success: - return {"success": True, "message": f"Now following {username}"} - raise HTTPException(status_code=400, detail=f"Failed to follow {username}") + return await provider_follow("chaturbate", username) @app.post("/api/chaturbate/unfollow/{username}") async def unfollow_model_on_chaturbate(username: str): """Unfollow a model on Chaturbate""" - if not chaturbate_api: - raise HTTPException(status_code=503, detail="Chaturbate API not initialized") - success = await chaturbate_api.unfollow_model(username) - if success: - return {"success": True, "message": f"Unfollowed {username}"} - raise HTTPException(status_code=400, detail=f"Failed to unfollow {username}") + return await provider_unfollow("chaturbate", username) @app.get("/api/chaturbate/is-following/{username}") async def is_following_model(username: str): """Check if following a model on Chaturbate""" - if not chaturbate_api: - return {"isFollowing": False} - is_following = await chaturbate_api.is_following(username) - return {"isFollowing": is_following} + return await provider_is_following("chaturbate", username) # ============================================ @@ -4261,7 +4251,7 @@ async def startup_event(): # Wire up API routers auth_router.init(cb_auth, flaresolverr) discover_router.init(cb_api, db, provider_registry) - following_router.init(cb_api, cb_auth, db) + following_router.init(cb_api, cb_auth, db, provider_registry) # Set authenticated resolver for chaturbate from .resolvers.chaturbate import set_chaturbate_api diff --git a/static/discover.js b/static/discover.js index 414a469..eae4b3e 100644 --- a/static/discover.js +++ b/static/discover.js @@ -254,9 +254,7 @@ async function toggleFollowOnCard(username, sourceType, btn) { if (!username || btn.classList.contains('busy')) return; var key = sourceKey(username, sourceType); var wasFollowing = followedSet.has(key); - var base = sourceType === 'cam4' - ? '/api/cam4' - : (sourceType === 'chaturbate' ? '/api/chaturbate' : '/api/providers/' + encodeURIComponent(sourceType || 'chaturbate')); + var base = '/api/providers/' + encodeURIComponent(sourceType || 'chaturbate'); var endpoint = base + (wasFollowing ? '/unfollow/' : '/follow/') + encodeURIComponent(username); btn.classList.add('busy'); diff --git a/static/following.html b/static/following.html index eca67da..ee037fa 100644 --- a/static/following.html +++ b/static/following.html @@ -15,10 +15,10 @@
- + diff --git a/static/following.js b/static/following.js index fe89d11..288e276 100644 --- a/static/following.js +++ b/static/following.js @@ -34,20 +34,43 @@ function renderPlatformBadge(sourceType) { // State let trackedModels = new Set(); let isLoggedIn = false; +let followingProviders = []; + +async function fetchJsonWithTimeout(url, options, timeoutMs) { + timeoutMs = timeoutMs || 45000; + var controller = window.AbortController ? new AbortController() : null; + var timer = controller ? setTimeout(function() { controller.abort(); }, timeoutMs) : null; + try { + var requestOptions = options || {}; + if (controller) requestOptions.signal = controller.signal; + var res = await fetch(url, requestOptions); + var data = await res.json().catch(function() { return {}; }); + return { ok: res.ok, status: res.status, data: data }; + } finally { + if (timer) clearTimeout(timer); + } +} // ============================================ -// Check Chaturbate login status +// Check provider login status // ============================================ -async function checkChaturbateStatus() { +async function loadFollowingProviders() { try { - var res = await fetch('/api/chaturbate/status'); + var res = await fetch('/api/providers', { cache: 'no-store' }); if (res.ok) { var data = await res.json(); - return data.isLoggedIn === true; + followingProviders = (data.providers || []).filter(function(provider) { + var caps = provider.capabilities || {}; + return caps.can_sync_following === true; + }); + return followingProviders.some(function(provider) { + return provider.status && provider.status.isLoggedIn === true; + }); } } catch (e) { - // Chaturbate status API not available + console.error('Error loading provider status:', e); } + followingProviders = []; return false; } @@ -247,29 +270,65 @@ async function trackFollowedModel(username, btn) { // Sync following list // ============================================ async function syncFollowing() { + var options = arguments[0] || {}; + var silent = options.silent === true; var syncBtn = document.getElementById('syncBtn'); var syncIcon = document.getElementById('syncIcon'); - syncBtn.classList.add('syncing'); - syncIcon.style.animation = 'spin 1s linear infinite'; + if (syncBtn) syncBtn.classList.add('syncing'); + if (syncIcon) syncIcon.style.animation = 'spin 1s linear infinite'; try { - var res = await fetch('/api/following/sync', { method: 'POST' }); - if (res.ok) { - showNotification('Following list synced!', 'success'); - // Reload the page data - var models = await loadFollowing(); - renderFollowing(models); - updateLastSynced(); + if (!followingProviders.length) { + await loadFollowingProviders(); + } + + var targets = followingProviders.filter(function(provider) { + var caps = provider.capabilities || {}; + return caps.can_sync_following === true && provider.status && provider.status.isLoggedIn === true; + }); + + if (!targets.length) { + if (!silent) showNotification('No connected provider to sync', 'error'); + return []; + } + + var synced = 0; + var failed = []; + for (var i = 0; i < targets.length; i += 1) { + var provider = targets[i]; + try { + var result = await fetchJsonWithTimeout( + '/api/providers/' + encodeURIComponent(provider.sourceType) + '/following/sync', + { method: 'POST' }, + 45000 + ); + if (result.ok) { + synced += Number(result.data.synced || 0); + } else { + failed.push(provider.displayName || provider.sourceType); + } + } catch (e) { + failed.push(provider.displayName || provider.sourceType); + } + } + + var models = await loadFollowing(); + renderFollowing(models); + if (failed.length) { + if (!silent) showNotification('Sync incomplete: ' + failed.join(', '), 'error'); } else { - showNotification('Failed to sync following list', 'error'); + if (!silent) showNotification('Following list synced!', 'success'); + updateLastSynced(); } + return models; } catch (e) { console.error('Error syncing following:', e); - showNotification('Connection error', 'error'); + if (!silent) showNotification('Connection error', 'error'); + return []; } finally { - syncBtn.classList.remove('syncing'); - syncIcon.style.animation = ''; + if (syncBtn) syncBtn.classList.remove('syncing'); + if (syncIcon) syncIcon.style.animation = ''; } } @@ -376,7 +435,7 @@ window.addEventListener('DOMContentLoaded', function() { var syncControls = document.getElementById('syncControls'); // Load data - Promise.all([loadTrackedModels(), checkChaturbateStatus()]).then(function(results) { + Promise.all([loadTrackedModels(), loadFollowingProviders()]).then(function(results) { isLoggedIn = results[1]; loadingState.style.display = 'none'; @@ -400,6 +459,9 @@ window.addEventListener('DOMContentLoaded', function() { loadFollowing().then(function(models) { renderFollowing(models); + if (!models.length) { + syncFollowing({ silent: true }); + } }); } }).catch(function(e) { diff --git a/static/watch.js b/static/watch.js index 85764f2..4431482 100644 --- a/static/watch.js +++ b/static/watch.js @@ -524,8 +524,6 @@ async function retryStream() { // ============================================ function followBasePath() { // Route vers le bon service selon la plateforme. - if (currentSourceType === 'cam4') return '/api/cam4'; - if (currentSourceType === 'chaturbate') return '/api/chaturbate'; return '/api/providers/' + encodeURIComponent(currentSourceType || 'chaturbate'); } diff --git a/tests/test_following.py b/tests/test_following.py new file mode 100644 index 0000000..134e4d6 --- /dev/null +++ b/tests/test_following.py @@ -0,0 +1,64 @@ +import unittest + +from app.api import following +from app.providers.base import ProviderCapabilities + + +class _Provider: + def __init__(self, source_type, items): + self.source_type = source_type + self.display_name = source_type.upper() + self.capabilities = ProviderCapabilities(can_sync_following=True) + self.items = items + + async def sync_following(self): + return self.items + + +class _Registry: + def __init__(self, providers): + self.providers = providers + + def all(self): + return self.providers + + +class _DB: + def __init__(self): + self.upserts = [] + self.removed = [] + self.reconciled = False + + async def upsert_followed_model(self, **kwargs): + self.upserts.append(kwargs) + + async def remove_unfollowed(self, current_usernames, source_type="chaturbate"): + self.removed.append((set(current_usernames), source_type)) + + async def reconcile_model_sources_from_followed(self): + self.reconciled = True + return 0 + + +class FollowingSyncTests(unittest.IsolatedAsyncioTestCase): + async def asyncTearDown(self): + following.init(None, None, None, None) + + async def test_legacy_sync_uses_provider_registry_and_preserves_source(self): + db = _DB() + registry = _Registry([ + _Provider("chaturbate", [ + {"username": "alice", "display_name": "Alice", "is_online": True, "viewers": 12}, + ]), + _Provider("cam4", [ + {"username": "bella", "display_name": "Bella", "is_online": False, "viewers": 0}, + ]), + ]) + following.init(None, None, db, registry) + + result = await following.sync_following() + + self.assertEqual(2, result["synced"]) + self.assertEqual(["chaturbate", "cam4"], [item["source_type"] for item in db.upserts]) + self.assertEqual([({"alice"}, "chaturbate")], db.removed) + self.assertTrue(db.reconciled) From e6efc0ac0ba3ee99c9273d381db625ed705b6efc Mon Sep 17 00:00:00 2001 From: raccommode Date: Mon, 25 May 2026 09:57:33 -0400 Subject: [PATCH 02/11] Add beta Docker image publishing --- .github/workflows/docker-publish.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e81d7ab..afad7e7 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - beta jobs: build-and-push: @@ -21,6 +22,13 @@ jobs: - name: Compute version (YEAR.WEEK.N) id: version run: | + if [ "${GITHUB_REF_NAME}" = "beta" ]; then + VERSION="beta" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Computed beta image tag: $VERSION" + exit 0 + fi + YEAR=$(date -u +%Y) WEEK=$(date -u +%V) # Remove leading zero for clean version number @@ -60,7 +68,7 @@ jobs: images: ghcr.io/raccommode/p-streamrec tags: | type=raw,value=${{ steps.version.outputs.version }} - type=raw,value=latest + type=raw,value=latest,enable=${{ github.ref_name == 'main' }} - name: Build and push uses: docker/build-push-action@v5 @@ -76,6 +84,7 @@ jobs: cache-to: type=gha,mode=max - name: Create GitHub Release + if: github.ref_name == 'main' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.version }} @@ -83,6 +92,7 @@ jobs: generate_release_notes: true - name: Sync Umbrel app version + if: github.ref_name == 'main' env: VERSION: ${{ steps.version.outputs.version }} run: | @@ -93,6 +103,7 @@ jobs: grep "^version:" "$APP_YML" - name: Commit Umbrel version bump + if: github.ref_name == 'main' env: VERSION: ${{ steps.version.outputs.version }} run: | From 6da9a7427100a4361e4484cf16e3a38a6744eae6 Mon Sep 17 00:00:00 2001 From: raccommode Date: Mon, 25 May 2026 10:04:52 -0400 Subject: [PATCH 03/11] Use beta version image tags --- .github/workflows/docker-publish.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index afad7e7..b83a685 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -22,13 +22,6 @@ jobs: - name: Compute version (YEAR.WEEK.N) id: version run: | - if [ "${GITHUB_REF_NAME}" = "beta" ]; then - VERSION="beta" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Computed beta image tag: $VERSION" - exit 0 - fi - YEAR=$(date -u +%Y) WEEK=$(date -u +%V) # Remove leading zero for clean version number @@ -42,6 +35,9 @@ jobs: N=$(( LATEST + 1 )) VERSION="${PREFIX}.${N}" + if [ "${GITHUB_REF_NAME}" = "beta" ]; then + VERSION="${VERSION}-beta" + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Computed version: $VERSION" @@ -69,6 +65,7 @@ jobs: tags: | type=raw,value=${{ steps.version.outputs.version }} type=raw,value=latest,enable=${{ github.ref_name == 'main' }} + type=raw,value=beta,enable=${{ github.ref_name == 'beta' }} - name: Build and push uses: docker/build-push-action@v5 From 21e6db2efbd55e6195892410cfc3e42d486a29f0 Mon Sep 17 00:00:00 2001 From: raccommode Date: Mon, 25 May 2026 10:41:47 -0400 Subject: [PATCH 04/11] Fix proxied HLS live playback --- app/main.py | 54 ++++++++++++++++++++++++++++++++++++++--- static/model.html | 1 + static/watch.html | 2 +- static/watch.js | 26 +++++++++++++++++--- tests/test_hls_proxy.py | 54 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 8 deletions(-) diff --git a/app/main.py b/app/main.py index 5ba3b93..00a3376 100644 --- a/app/main.py +++ b/app/main.py @@ -507,6 +507,7 @@ async def chunks(): provider_registry = create_provider_registry(db, output_dir=OUTPUT_DIR) SOURCE_TYPES = provider_registry.source_types() _HLS_PROXY_CACHE: dict[str, dict] = {} +_HLS_PROXY_REVERSE: dict[tuple, str] = {} _HLS_PROXY_TTL_SECONDS = int(os.getenv("PSTREAMREC_HLS_PROXY_TTL_SECONDS", "900")) @@ -687,7 +688,21 @@ def _prune_hls_proxy_cache() -> None: if entry.get("expires_at", 0) <= now ] for token in expired: - _HLS_PROXY_CACHE.pop(token, None) + entry = _HLS_PROXY_CACHE.pop(token, None) + cache_key = entry.get("cache_key") if entry else None + if cache_key and _HLS_PROXY_REVERSE.get(cache_key) == token: + _HLS_PROXY_REVERSE.pop(cache_key, None) + + +def _hls_proxy_cache_key( + url: str, + headers: Optional[dict[str, str]], + suffix: str, +) -> tuple: + header_items = tuple( + sorted((str(key), str(value)) for key, value in (headers or {}).items()) + ) + return (url, header_items, suffix) def _hls_proxy_path_suffix(url: str) -> str: @@ -709,15 +724,25 @@ def _register_hls_proxy_url( suffix: Optional[str] = None, ) -> str: _prune_hls_proxy_cache() - token = secrets.token_urlsafe(24) + resolved_suffix = suffix if suffix is not None else _hls_proxy_path_suffix(url) + cache_key = _hls_proxy_cache_key(url, headers, resolved_suffix) now = time.time() + existing_token = _HLS_PROXY_REVERSE.get(cache_key) + existing = _HLS_PROXY_CACHE.get(existing_token or "") + if existing and existing.get("expires_at", 0) > now: + existing["expires_at"] = now + max(60, _HLS_PROXY_TTL_SECONDS) + return f"/api/proxy/hls/{existing_token}{resolved_suffix}" + + token = secrets.token_urlsafe(24) _HLS_PROXY_CACHE[token] = { "url": url, "headers": dict(headers or {}), + "cache_key": cache_key, "created_at": now, "expires_at": now + max(60, _HLS_PROXY_TTL_SECONDS), } - return f"/api/proxy/hls/{token}{suffix if suffix is not None else _hls_proxy_path_suffix(url)}" + _HLS_PROXY_REVERSE[cache_key] = token + return f"/api/proxy/hls/{token}{resolved_suffix}" def _register_cached_hls_body(url: str, body: bytes, content_type: str = "") -> str: @@ -833,6 +858,7 @@ def proxy_url(raw_uri: str) -> str: return _register_hls_proxy_url(urljoin(base_url, raw_uri), headers=headers) media_sequence_written = False + pending_program_date_time: Optional[str] = None rewritten = [] for line in (text or "").splitlines(): stripped = line.strip() @@ -840,8 +866,28 @@ def proxy_url(raw_uri: str) -> str: rewritten.append(line) continue if stripped.startswith("#"): + upper = stripped.upper() + # Hls.js can chase LL-HLS parts/preload hints faster than a + # server-side proxy can refresh provider tokens. Keep the stable + # full segments and rewrite the playlist as ordinary live HLS. + if ( + upper.startswith("#EXT-X-SERVER-CONTROL:") + or upper.startswith("#EXT-X-PART-INF:") + or upper.startswith("#EXT-X-PART:") + or upper.startswith("#EXT-X-PRELOAD-HINT:") + or upper.startswith("#EXT-X-RENDITION-REPORT:") + ): + continue + if upper.startswith("#EXT-X-PROGRAM-DATE-TIME:"): + pending_program_date_time = line + continue + if upper.startswith("#EXTINF:"): + if pending_program_date_time: + rewritten.append(pending_program_date_time) + pending_program_date_time = None + rewritten.append(line) + continue if live_sequence is not None: - upper = stripped.upper() if upper.startswith("#EXT-X-PLAYLIST-TYPE:") or upper == "#EXT-X-ENDLIST": continue if upper.startswith("#EXT-X-MEDIA-SEQUENCE:"): diff --git a/static/model.html b/static/model.html index ebaaa51..f538766 100644 --- a/static/model.html +++ b/static/model.html @@ -1280,6 +1280,7 @@

⚙️ Model Sett debug: false, enableWorker: false, capLevelToPlayerSize: false, // Don't limit quality to player size + lowLatencyMode: false, }); // HLS error handling diff --git a/static/watch.html b/static/watch.html index 2c0f4ca..71387ee 100644 --- a/static/watch.html +++ b/static/watch.html @@ -10,7 +10,7 @@ - +
diff --git a/static/watch.js b/static/watch.js index 4431482..8973ee3 100644 --- a/static/watch.js +++ b/static/watch.js @@ -334,10 +334,10 @@ function startStreamWithUrl(streamUrl) { }, { once: true }); video.load(); startMutedAutoplay(video); - } else if (Hls.isSupported()) { + } else if (window.Hls && window.Hls.isSupported()) { hlsPlayer = new Hls({ enableWorker: true, - lowLatencyMode: true, + lowLatencyMode: false, backBufferLength: 90, }); hlsPlayer.loadSource(streamUrl); @@ -351,7 +351,14 @@ function startStreamWithUrl(streamUrl) { }); hlsPlayer.on(Hls.Events.ERROR, function(event, data) { if (data.fatal) { - console.error('HLS fatal error:', data.type); + console.error('HLS fatal error: ' + [ + data.type, + data.details || '', + data.reason || '', + data.error ? data.error.message : '', + data.response ? data.response.code : '', + data.url || '' + ].filter(Boolean).join(' | ')); scheduleStatusRefreshAfterStreamProblem(); switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: @@ -379,6 +386,19 @@ function startStreamWithUrl(streamUrl) { }, { once: true }); video.load(); startMutedAutoplay(video); + } else { + console.error('HLS playback is not supported in this browser'); + stopStream(false); + var offlineOverlay = document.getElementById('offlineOverlay'); + var offlineIcon = document.getElementById('offlineIcon'); + var offlineTitle = document.getElementById('offlineTitle'); + var offlineText = document.getElementById('offlineText'); + var retryBtn = document.getElementById('retryBtn'); + if (offlineIcon) offlineIcon.innerHTML = '⚠'; + if (offlineTitle) offlineTitle.textContent = 'Live player unavailable'; + if (offlineText) offlineText.textContent = 'This browser cannot load the HLS player. Check the network connection and retry.'; + if (retryBtn) retryBtn.style.display = 'inline-flex'; + if (offlineOverlay) offlineOverlay.style.display = 'flex'; } } diff --git a/tests/test_hls_proxy.py b/tests/test_hls_proxy.py index bfed0b9..23fc4fe 100644 --- a/tests/test_hls_proxy.py +++ b/tests/test_hls_proxy.py @@ -7,9 +7,11 @@ class HlsProxyTests(unittest.TestCase): def setUp(self): main._HLS_PROXY_CACHE.clear() + main._HLS_PROXY_REVERSE.clear() def tearDown(self): main._HLS_PROXY_CACHE.clear() + main._HLS_PROXY_REVERSE.clear() def test_rewrite_playlist_proxies_segments_and_keys(self): rewritten = main._rewrite_hls_playlist( @@ -32,6 +34,40 @@ def test_rewrite_playlist_proxies_segments_and_keys(self): for entry in main._HLS_PROXY_CACHE.values(): self.assertEqual({"Cookie": "session=secret"}, entry["headers"]) + def test_rewrite_playlist_strips_low_latency_hints(self): + rewritten = main._rewrite_hls_playlist( + "#EXTM3U\n" + "#EXT-X-VERSION:6\n" + "#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=2.430000\n" + "#EXT-X-PART-INF:PART-TARGET=0.800000\n" + "#EXT-X-MAP:URI=\"init.m4s\"\n" + "#EXT-X-PART:DURATION=0.8,URI=\"part-1.m4s\",INDEPENDENT=YES\n" + "#EXT-X-PROGRAM-DATE-TIME:2026-05-25T14:34:51.627+00:00\n" + "#EXTINF:1.6,\n" + "segment-1.m4s\n" + "#EXT-X-PROGRAM-DATE-TIME:2026-05-25T14:34:53.227+00:00\n" + "#EXT-X-PART:DURATION=0.8,URI=\"part-orphan.m4s\",INDEPENDENT=YES\n" + "#EXT-X-PRELOAD-HINT:TYPE=PART,URI=\"part-2.m4s\"\n" + "#EXT-X-RENDITION-REPORT:URI=\"variant.m3u8\",LAST-MSN=1,LAST-PART=0\n", + "https://cdn.example.test/live/playlist.m3u8", + ) + + self.assertNotIn("#EXT-X-SERVER-CONTROL", rewritten) + self.assertNotIn("#EXT-X-PART-INF", rewritten) + self.assertNotIn("#EXT-X-PART:", rewritten) + self.assertNotIn("#EXT-X-PRELOAD-HINT", rewritten) + self.assertNotIn("#EXT-X-RENDITION-REPORT", rewritten) + self.assertIn("#EXT-X-MAP", rewritten) + self.assertIn("2026-05-25T14:34:51.627+00:00", rewritten) + self.assertNotIn("2026-05-25T14:34:53.227+00:00", rewritten) + self.assertEqual(2, rewritten.count("/api/proxy/hls/")) + urls = {entry["url"] for entry in main._HLS_PROXY_CACHE.values()} + self.assertIn("https://cdn.example.test/live/init.m4s", urls) + self.assertIn("https://cdn.example.test/live/segment-1.m4s", urls) + self.assertNotIn("https://cdn.example.test/live/part-1.m4s", urls) + self.assertNotIn("https://cdn.example.test/live/part-orphan.m4s", urls) + self.assertNotIn("https://cdn.example.test/live/part-2.m4s", urls) + def test_proxy_suffix_maps_pts_segments_to_ts_for_ffmpeg(self): proxied = main._register_hls_proxy_url("https://cdn.example.test/live/chunk.pts") @@ -42,6 +78,24 @@ def test_proxy_suffix_maps_segment_flag_to_mp4_for_ffmpeg(self): self.assertTrue(proxied.endswith(".mp4")) + def test_proxy_reuses_token_for_same_upstream_asset(self): + first = main._register_hls_proxy_url( + "https://cdn.example.test/live/segment-1.m4s", + headers={"Referer": "https://example.test"}, + ) + second = main._register_hls_proxy_url( + "https://cdn.example.test/live/segment-1.m4s", + headers={"Referer": "https://example.test"}, + ) + different_headers = main._register_hls_proxy_url( + "https://cdn.example.test/live/segment-1.m4s", + headers={"Referer": "https://other.example.test"}, + ) + + self.assertEqual(first, second) + self.assertNotEqual(first, different_headers) + self.assertEqual(2, len(main._HLS_PROXY_CACHE)) + def test_hls_segment_header_variants_retry_without_cookie(self): variants = main._hls_segment_header_variants({ "User-Agent": "UA", From f07c5d66478a387f8fc5f03bbf1e56f36d98fa81 Mon Sep 17 00:00:00 2001 From: raccommode Date: Sat, 30 May 2026 01:05:28 -0400 Subject: [PATCH 05/11] Prepare beta provider and media updates --- .github/workflows/docker-publish.yml | 34 +- README.md | 13 +- app/api/auth.py | 8 +- app/api/cam4.py | 112 +- app/api/discover.py | 10 +- app/api/following.py | 398 ++- app/browser_recorder.py | 137 +- app/core/database.py | 474 ++- app/ffmpeg_runner.py | 24 +- app/main.py | 1531 ++++++++-- app/providers/base.py | 11 + app/providers/browser.py | 2869 ++++++++++++++---- app/providers/builtin.py | 148 +- app/providers/registry.py | 28 +- app/providers/ytdlp.py | 173 +- app/services/cam4_auth.py | 35 +- app/services/cam4_source.py | 99 +- app/services/chaturbate_api.py | 14 +- app/services/chaturbate_auth.py | 41 +- app/tasks/monitor.py | 1 + docker-compose.yml | 2 +- p-streamrec-store-p-streamrec/umbrel-app.yml | 2 +- static/app.js | 823 ----- static/discover.html | 2 +- static/discover.js | 39 +- static/following.html | 46 +- static/following.js | 514 +++- static/header.html | 2 +- static/index.html | 732 ----- static/media.html | 240 ++ static/media.js | 859 ++++++ static/settings.html | 20 +- static/settings.js | 670 ++-- static/styles.css | 1494 ++++++++- static/watch.html | 2 +- static/watch.js | 187 +- static/wiki.html | 2 +- tests/test_browser_recorder.py | 22 + tests/test_discover.py | 2 +- tests/test_ffmpeg_runner.py | 13 - tests/test_following.py | 173 +- tests/test_following_static.py | 68 + tests/test_hls_proxy.py | 93 +- tests/test_media_library.py | 180 ++ tests/test_provider_credentials.py | 150 + tests/test_providers.py | 806 ++++- tests/test_settings_static.py | 134 + 47 files changed, 10096 insertions(+), 3341 deletions(-) delete mode 100644 static/app.js delete mode 100644 static/index.html create mode 100644 static/media.html create mode 100644 static/media.js create mode 100644 tests/test_following_static.py create mode 100644 tests/test_media_library.py create mode 100644 tests/test_provider_credentials.py create mode 100644 tests/test_settings_static.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b83a685..81a9a5c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,14 +1,42 @@ -name: Build, Push & Release +name: Fast checks / manual publish on: push: branches: - main - beta + workflow_dispatch: + inputs: + platforms: + description: Docker platforms to publish + required: true + default: linux/amd64,linux/arm64 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + quick-checks: + if: github.event_name == 'push' + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Fast syntax checks + run: | + python3 -m compileall -q app tests + sh -n docker/entrypoint.sh + build-and-push: + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + timeout-minutes: 45 permissions: contents: write packages: write @@ -17,7 +45,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - name: Compute version (YEAR.WEEK.N) id: version @@ -72,7 +100,7 @@ jobs: with: context: . push: true - platforms: linux/amd64,linux/arm64 + platforms: ${{ inputs.platforms }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | diff --git a/README.md b/README.md index 062a8f0..a6e7872 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,6 @@ | MyFreeCams | ✅ | ✅ | ✅ | Local | | LiveJasmin | ✅ | ✅ | ✅ | Local | | CamSoda | ✅ | ✅ | ✅ | Local | -| Streamate | ✅ | ✅ | ✅ | Local | -| Flirt4Free | ✅ | ✅ | ✅ | Local | | Cams.com | ✅ | ✅ | ✅ | Local | | Xcams | ✅ | ✅ | ✅ | Local | @@ -72,7 +70,7 @@ services: restart: unless-stopped p-streamrec: - image: ghcr.io/raccommode/p-streamrec:latest + image: ${PSTREAMREC_IMAGE:-ghcr.io/raccommode/p-streamrec:latest} depends_on: - flaresolverr environment: @@ -96,12 +94,21 @@ docker run -d --name p-streamrec \ **Access:** `http://localhost:8080` +For local image testing without changing the default image: + +```bash +docker build -t p-streamrec:local . +PSTREAMREC_IMAGE=p-streamrec:local HOST_PORT=2727 docker compose up -d --force-recreate +``` + ## Configuration | Variable | Default | Description | |----------|---------|-------------| | `OUTPUT_DIR` | `/data` | Recordings folder | | `PORT` | `8080` | Web interface port | +| `HOST_PORT` | `8080` | Host port used by Docker Compose | +| `PSTREAMREC_IMAGE` | `ghcr.io/raccommode/p-streamrec:latest` | Docker Compose image override for local builds | | `FFMPEG_PATH` | `ffmpeg` | Path to FFmpeg | | `RECORDING_RANGE_CHUNK_SIZE` | `8388608` | Max bytes returned for open-ended replay Range requests | | `CB_RESOLVER_ENABLED` | `true` | Enable Chaturbate support | diff --git a/app/api/auth.py b/app/api/auth.py index 9bbf2af..d0cd6e7 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -31,6 +31,7 @@ async def chaturbate_login(req: LoginRequest): raise HTTPException(status_code=503, detail="Auth service not initialized") result = await _auth_service.login(req.username, req.password) + if not result.get("success"): raise HTTPException(status_code=401, detail=result.get("error", "Login failed")) @@ -40,9 +41,14 @@ async def chaturbate_login(req: LoginRequest): @router.get("/status") async def chaturbate_status(): """Get Chaturbate auth status""" - status = {} if _auth_service: status = _auth_service.get_status() + else: + status = { + "isLoggedIn": False, + "username": None, + "lastError": "Auth service not initialized", + } # Live check FlareSolverr (no cache) so UI reflects current state + error message if _flaresolverr: diff --git a/app/api/cam4.py b/app/api/cam4.py index a686a8e..1c5ca03 100644 --- a/app/api/cam4.py +++ b/app/api/cam4.py @@ -1,9 +1,5 @@ """ -API Router: CAM4 session management + followed models. - -Login programmatique via POST /rest/v2.0/login (endpoint interne CAM4). Les -cookies retournés sont persistés par CAM4AuthService et réutilisés pour les -requêtes authentifiées (favorites, etc.). +API Router: CAM4 authentication and favorites. """ from __future__ import annotations @@ -68,93 +64,95 @@ def _require_auth(): @router.get("/is-following/{username}") async def cam4_is_following(username: str): - from ..services import cam4_source auth_user, cookies = _require_auth() - result = await cam4_source.is_favorite(auth_user, username, cookies) - return {"isFollowing": bool(result)} + from ..services import cam4_source + + try: + is_favorite = await cam4_source.is_favorite(auth_user, username, cookies) + except Exception as e: + logger.error("CAM4 is-following failed", username=username, error=str(e)) + raise HTTPException(status_code=502, detail=f"CAM4 check failed: {e}") + return {"isFollowing": bool(is_favorite), "localOnly": False} @router.post("/follow/{username}") async def cam4_follow(username: str): - from ..services import cam4_source auth_user, cookies = _require_auth() + from ..services import cam4_source + if _db is None: + raise HTTPException(status_code=503, detail="Database not initialized") result = await cam4_source.follow(auth_user, username, cookies) if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("error", "Follow failed")) - - # Mise à jour immédiate de la table followed_models pour que le modèle - # apparaisse sur la page /following sans attendre un sync. - if _db is not None: - try: - status = await cam4_source.check_status(username) - thumb = None - if status.get("hls_source"): - thumb = f"https://snapshots.xcdnpro.com/thumbnails/{username}" - await _db.upsert_followed_model( - username=username, - display_name=username, - is_online=bool(status.get("is_online")), - viewers=int(status.get("viewers") or 0), - thumbnail_url=thumb, - source_type="cam4", - room_status=status.get("room_status"), - ) - await _db.reconcile_model_sources_from_followed() - except Exception as e: - logger.debug("CAM4 upsert après follow échec", username=username, error=str(e)) - return result + raise HTTPException(status_code=400, detail=result.get("error", "CAM4 follow failed")) + try: + status = await cam4_source.check_status(username) + except Exception as e: + logger.debug("CAM4 follow status check failed", username=username, error=str(e)) + status = {} + thumb = status.get("thumbnail") + await _db.upsert_followed_model( + username=username, + display_name=username, + is_online=bool(status.get("is_online")), + viewers=int(status.get("viewers") or 0), + thumbnail_url=thumb, + source_type="cam4", + room_status=status.get("room_status"), + ) + await _db.reconcile_model_sources_from_followed() + return {"success": True, "localOnly": False, "action": "follow"} @router.post("/unfollow/{username}") async def cam4_unfollow(username: str): - from ..services import cam4_source auth_user, cookies = _require_auth() + from ..services import cam4_source + if _db is None: + raise HTTPException(status_code=503, detail="Database not initialized") result = await cam4_source.unfollow(auth_user, username, cookies) if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("error", "Unfollow failed")) - - if _db is not None: - try: - await _db.delete_followed_model(username, source_type="cam4") - except Exception as e: - logger.debug("CAM4 delete après unfollow échec", username=username, error=str(e)) - return result + raise HTTPException(status_code=400, detail=result.get("error", "CAM4 unfollow failed")) + await _db.delete_followed_model(username, source_type="cam4") + return {"success": True, "localOnly": False, "action": "unfollow"} @router.post("/following/sync") async def cam4_sync_following(): - """Synchronise les favoris CAM4 du compte connecté vers la DB locale - (table followed_models, source_type=cam4).""" + """Synchronise les favoris CAM4 distants dans le cache local.""" if _auth_service is None or _db is None: - raise HTTPException(status_code=503, detail="Services CAM4 non initialisés") - + raise HTTPException(status_code=503, detail="CAM4 auth/database non initialisé") status = _auth_service.get_status() if not status.get("isLoggedIn"): raise HTTPException(status_code=401, detail="CAM4 session absente") from ..services import cam4_source - cookies = _auth_service.get_cookies() + try: - items = await cam4_source.list_followed(cookies) + items = await cam4_source.list_followed(_auth_service.get_cookies()) except Exception as e: - logger.error("CAM4 list_followed échec", error=str(e), exc_info=True) - raise HTTPException(status_code=502, detail=f"Échec CAM4 list_followed: {e}") + logger.error("CAM4 following sync failed", error=str(e)) + raise HTTPException(status_code=502, detail=f"CAM4 sync failed: {e}") synced = set() for item in items: + username = item.get("username") + if not username: + continue await _db.upsert_followed_model( - username=item["username"], - display_name=item.get("display_name") or item["username"], + username=username, + display_name=item.get("display_name") or username, is_online=bool(item.get("is_online", False)), viewers=int(item.get("viewers") or 0), thumbnail_url=item.get("thumbnail"), source_type="cam4", room_status=item.get("room_status"), ) - synced.add(item["username"]) - await _db.reconcile_model_sources_from_followed() + synced.add(username) - # On ne supprime PAS les follows CAM4 absents: sans état fiable de la page - # /favorites (redirection silencieuse si la session expire), un scrape - # raté vidrait la DB. L'utilisateur peut supprimer manuellement. - return {"synced": len(synced), "message": f"CAM4: {len(synced)} favoris synchronisés"} + await _db.reconcile_model_sources_from_followed() + await _db.remove_unfollowed(synced, source_type="cam4") + return { + "synced": len(synced), + "localOnly": False, + "message": f"CAM4: {len(synced)} favoris synchronisés", + } diff --git a/app/api/discover.py b/app/api/discover.py index fcddf22..2b06443 100644 --- a/app/api/discover.py +++ b/app/api/discover.py @@ -242,11 +242,12 @@ def _filter_list(result: Optional[Dict[str, Any]], source_type: str) -> List[Dic item["source_type"] = item.get("source_type") or source_type item_caps = capabilities.get(item["source_type"]) stream_available = bool(getattr(item_caps, "can_stream", True)) + record_available = bool(getattr(item_caps, "can_record", stream_available)) if not explicit_source and not stream_available: continue item["stream_available"] = stream_available - item["record_available"] = bool(getattr(item_caps, "can_record", stream_available)) - item["can_follow"] = bool(getattr(item_caps, "can_follow", False)) + item["record_available"] = record_available + item["can_follow"] = bool(getattr(item_caps, "can_follow", False) or stream_available or record_available) out.append(item) return out @@ -299,7 +300,10 @@ def _filter_list(result: Optional[Dict[str, Any]], source_type: str) -> List[Dic if not explicit_source and sort_mode == "viewers": positive_items = [item for item in ranked_items if int(item.get("viewers") or 0) > 0] if positive_items: - ranked_items = positive_items + ranked_items = [ + item for item in ranked_items + if int(item.get("viewers") or 0) > 0 + ] if sort_mode == "newest": ranked_items.sort(key=lambda m: (m.get("age") or 99)) else: diff --git a/app/api/following.py b/app/api/following.py index 8bce102..f5feda6 100644 --- a/app/api/following.py +++ b/app/api/following.py @@ -2,10 +2,11 @@ API Router: Following management """ +import json import asyncio -import os +from typing import Optional -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, Query from ..logger import logger from ..providers.base import ProviderError @@ -19,6 +20,15 @@ _provider_registry = None _DEFAULT_RETENTION_DAYS = 30 _MAX_RETENTION_DAYS = 365 +_PRIVATE_ROOM_STATUSES = { + "private", + "group", + "password_protected", + "password protected", + "hidden", + "true_private", + "private_spy", +} def init(chaturbate_api, auth_service, db, provider_registry=None): @@ -40,45 +50,270 @@ async def _get_default_retention_days() -> int: return _DEFAULT_RETENTION_DAYS +def _provider_source_type(provider) -> str: + return str(getattr(provider, "source_type", "") or "").strip().lower() + + +def _provider_display_name(provider, source_type: str) -> str: + return getattr(provider, "display_name", None) or source_type or "Unknown" + + +def _registered_source_types() -> Optional[set[str]]: + if not _provider_registry: + return None + return { + source_type + for provider in _provider_registry.all() + for source_type in [_provider_source_type(provider)] + if source_type + } + + +async def _provider_login_summary(provider) -> dict: + source_type = _provider_source_type(provider) + summary = { + "isLoggedIn": False, + "username": None, + "lastError": None, + "hasCookies": False, + "hasLocalStorage": False, + "hasSession": False, + "hasSavedSessionData": False, + "hasSavedCredentials": False, + "credentialsUpdatedAt": None, + } + + caps = getattr(provider, "capabilities", None) + if not getattr(caps, "can_login", False): + summary["accountDisabled"] = True + return summary + + auth = getattr(provider, "auth", None) + if auth is not None and hasattr(auth, "get_status"): + try: + status = auth.get_status() or {} + summary.update({ + "isLoggedIn": bool(status.get("isLoggedIn")), + "username": status.get("username"), + "lastError": status.get("lastError"), + "hasCookies": bool(status.get("hasCookies") or status.get("isLoggedIn")), + }) + summary["hasSession"] = bool(summary["isLoggedIn"]) + summary["hasSavedSessionData"] = bool(summary["hasCookies"]) + except Exception as exc: + logger.debug("Provider auth status unavailable", source_type=source_type, error=str(exc)) + + if _db and source_type: + try: + row = await _db.get_provider_session(source_type) + except Exception as exc: + logger.debug("Provider session status unavailable", source_type=source_type, error=str(exc)) + row = None + if row: + has_cookies = _stored_json_has_items(row.get("session_cookies")) + has_local_storage = _stored_json_has_items(row.get("local_storage")) + has_saved_session_data = bool(summary.get("hasCookies") or has_cookies or has_local_storage) + summary["isLoggedIn"] = bool(summary["isLoggedIn"] or (row.get("is_logged_in") and has_saved_session_data)) + summary["username"] = summary["username"] or row.get("username") or row.get("credential_username") + summary["lastError"] = summary["lastError"] or row.get("last_error") + summary["hasCookies"] = bool(summary["hasCookies"] or has_cookies) + summary["hasLocalStorage"] = has_local_storage + summary["hasSavedSessionData"] = has_saved_session_data + summary["hasSession"] = bool(summary["isLoggedIn"]) + summary["hasSavedCredentials"] = bool( + row.get("credential_username") and row.get("credential_password") + ) + summary["credentialsUpdatedAt"] = row.get("credentials_updated_at") + + return summary + + +def _stored_json_has_items(raw_value: object) -> bool: + if not raw_value: + return False + try: + parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value + except Exception: + return False + if isinstance(parsed, list): + return any(bool(item) for item in parsed) + if isinstance(parsed, dict): + return bool(parsed) + return False + + +def _provider_capabilities(provider) -> dict: + caps = getattr(provider, "capabilities", None) + if not caps: + return {} + return { + "can_login": bool(getattr(caps, "can_login", False)), + "can_follow": bool(getattr(caps, "can_follow", False)), + "can_sync_following": bool(getattr(caps, "can_sync_following", False)), + "can_discover": bool(getattr(caps, "can_discover", False)), + "can_stream": bool(getattr(caps, "can_stream", True)), + "can_record": bool(getattr(caps, "can_record", True)), + "uses_browser": bool(getattr(caps, "uses_browser", False)), + "uses_ytdlp": bool(getattr(caps, "uses_ytdlp", False)), + } + + +def _source_type_for_model(model: dict) -> str: + return str(model.get("source_type") or model.get("platform") or "chaturbate").strip().lower() + + +def _is_private_model(model: dict) -> bool: + return (model.get("room_status") or model.get("roomStatus") or "").lower() in _PRIVATE_ROOM_STATUSES + + +def _model_viewers(model: dict) -> int: + if not bool(model.get("is_online")) or _is_private_model(model): + return 0 + try: + return int(model.get("viewers") or 0) + except (TypeError, ValueError): + return 0 + + +def _model_status_rank(model: dict) -> int: + if bool(model.get("is_online")) and not _is_private_model(model): + return 0 + if _is_private_model(model): + return 1 + return 2 + + +def _sort_following_models(models: list[dict]) -> list[dict]: + return sorted( + models, + key=lambda model: ( + -_model_viewers(model), + _model_status_rank(model), + _source_type_for_model(model), + str(model.get("username") or model.get("name") or "").lower(), + ), + ) + + +def _provider_counts(models: list[dict]) -> dict: + online = [m for m in models if bool(m.get("is_online")) or _is_private_model(m)] + return { + "totalCount": len(models), + "onlineCount": len(online), + "offlineCount": len(models) - len(online), + } + + +async def _build_provider_summaries(followed: list[dict], any_logged_in: bool) -> tuple[list[dict], dict, bool]: + by_source: dict[str, list[dict]] = {} + for model in followed: + by_source.setdefault(_source_type_for_model(model), []).append(model) + + providers = [] + per_source_logins = {} + seen_sources = set() + + if _provider_registry: + for provider in _provider_registry.all(): + source_type = _provider_source_type(provider) + if not source_type: + continue + status = await _provider_login_summary(provider) + caps = _provider_capabilities(provider) + models = by_source.get(source_type, []) + seen_sources.add(source_type) + per_source_logins[source_type] = bool(status.get("isLoggedIn")) + any_logged_in = any_logged_in or bool(status.get("isLoggedIn")) + providers.append({ + "sourceType": source_type, + "displayName": _provider_display_name(provider, source_type), + "capabilities": caps, + "status": status, + **_provider_counts(models), + }) + + if not _provider_registry: + for source_type, models in sorted(by_source.items()): + if source_type in seen_sources: + continue + per_source_logins[source_type] = per_source_logins.get(source_type, False) + providers.append({ + "sourceType": source_type, + "displayName": source_type.capitalize(), + "capabilities": {}, + "status": { + "isLoggedIn": False, + "username": None, + "lastError": None, + "hasCookies": False, + "hasSavedCredentials": False, + "credentialsUpdatedAt": None, + }, + **_provider_counts(models), + }) + + return providers, per_source_logins, any_logged_in + + @router.get("/following") async def get_following(): """Returns all followed models (Chaturbate + CAM4 + autres plugins) avec statut online et flag isTracked.""" try: - # Login status par source (utilisé par le frontend pour afficher les - # bons boutons "se connecter" / "synchroniser"). + # Login status par source (fallback legacy si le registre provider + # n'est pas encore initialisé). per_source_logins: dict = {} any_logged_in = False - if _auth_service: + if _auth_service and not _provider_registry: cb_status = _auth_service.get_status() per_source_logins["chaturbate"] = bool(cb_status.get("isLoggedIn")) any_logged_in = per_source_logins["chaturbate"] or any_logged_in # CAM4 status (lazy import pour éviter les cycles) - try: - from .. import main as app_main # type: ignore - cam4_svc = getattr(app_main, "cam4_auth_service", None) - if cam4_svc is not None: - per_source_logins["cam4"] = bool(cam4_svc.get_status().get("isLoggedIn")) - any_logged_in = per_source_logins["cam4"] or any_logged_in - except Exception: - pass + if not _provider_registry: + try: + from .. import main as app_main # type: ignore + cam4_svc = getattr(app_main, "cam4_auth_service", None) + if cam4_svc is not None: + per_source_logins["cam4"] = bool(cam4_svc.get_status().get("isLoggedIn")) + any_logged_in = per_source_logins["cam4"] or any_logged_in + except Exception: + pass # Lire le cache local de tous les follows (toutes sources confondues) followed = [] tracked_map = {} if _db: try: + registered_sources = _registered_source_types() followed = await _db.get_all_followed() tracked_models = await _db.get_all_models() - tracked_map = {m["username"]: m for m in tracked_models} + if registered_sources is not None: + followed = [ + item for item in followed + if _source_type_for_model(item) in registered_sources + ] + tracked_models = [ + item for item in tracked_models + if _source_type_for_model(item) in registered_sources + ] + tracked_map = { + ( + m["username"], + _source_type_for_model(m), + ): m + for m in tracked_models + } except Exception as e: logger.warning("DB read failed in /api/following", error=str(e)) followed = [] tracked_map = {} for model in followed: - tracked = tracked_map.get(model["username"]) + model_source = _source_type_for_model(model) + tracked = tracked_map.get((model["username"], model_source)) + if not tracked and model_source == "chaturbate": + tracked = tracked_map.get((model["username"], "")) model["isTracked"] = tracked is not None model["is_recording"] = bool(tracked and tracked.get("is_recording")) # Surface cached room_status for UI (distinguer Private d'Offline) @@ -91,6 +326,13 @@ async def get_following(): or "chaturbate" ) + followed = _sort_following_models(followed) + providers, registry_logins, any_logged_in = await _build_provider_summaries(followed, any_logged_in) + per_source_logins.update(registry_logins) + by_provider = {} + for model in followed: + by_provider.setdefault(_source_type_for_model(model), []).append(model) + online = [m for m in followed if m.get("is_online")] offline = [m for m in followed if not m.get("is_online")] @@ -102,7 +344,9 @@ async def get_following(): "offlineCount": len(offline), "isLoggedIn": any_logged_in, "perSource": per_source_logins, - "message": None if any_logged_in else "Login required to view followed models", + "providers": providers, + "byProvider": by_provider, + "message": None, } except Exception as e: # Ne jamais renvoyer 500 sur cet endpoint : le front s'affiche mieux avec une @@ -123,112 +367,91 @@ async def get_following(): @router.post("/following/sync") async def sync_following(): """ - Force re-sync followed models from every connected provider that supports - remote follow sync. Kept on the legacy route for the Following page and - older clients. + Sync all providers that expose a verified remote following API. + Providers without remote sync keep their local follows untouched. """ if not _db: raise HTTPException(status_code=503, detail="Database not initialized") if _provider_registry: - timeout = float(os.getenv("PSTREAMREC_FOLLOW_SYNC_TIMEOUT", "45") or "45") results = [] total_synced = 0 - for provider in _provider_registry.all(): caps = getattr(provider, "capabilities", None) - if not caps or not getattr(caps, "can_sync_following", False): + if not getattr(caps, "can_sync_following", False): continue - - source_type = getattr(provider, "source_type", "") or "" - display_name = getattr(provider, "display_name", source_type) or source_type + source_type = _provider_source_type(provider) + display_name = _provider_display_name(provider, source_type) try: - items = await asyncio.wait_for(provider.sync_following(), timeout=timeout) + items = await asyncio.wait_for(provider.sync_following(), timeout=60) synced = await _store_provider_following(source_type, items or []) total_synced += synced results.append({ "sourceType": source_type, "displayName": display_name, "synced": synced, - "status": "ok", + "success": True, }) except asyncio.TimeoutError: - logger.warning("Provider following sync timeout", source_type=source_type, timeout=timeout) + logger.warning("Provider following sync timeout", source_type=source_type) results.append({ "sourceType": source_type, "displayName": display_name, "synced": 0, - "status": "timeout", - "detail": f"Sync timed out after {int(timeout)}s", + "success": False, + "error": "Sync timeout", }) except ProviderError as exc: + logger.warning("Provider following sync failed", source_type=source_type, error=str(exc)) results.append({ "sourceType": source_type, "displayName": display_name, "synced": 0, - "status": "error", - "detail": str(exc), + "success": False, + "error": str(exc), }) except Exception as exc: - logger.error("Provider following sync failed", source_type=source_type, error=str(exc), exc_info=True) + logger.error("Provider following sync error", source_type=source_type, error=str(exc), exc_info=True) results.append({ "sourceType": source_type, "displayName": display_name, "synced": 0, - "status": "error", - "detail": str(exc), + "success": False, + "error": str(exc), }) - await _db.reconcile_model_sources_from_followed() - ok_count = sum(1 for item in results if item.get("status") == "ok") + if not results: + return { + "synced": 0, + "results": [], + "localOnly": True, + "message": "No provider exposes remote following sync; local follows only", + } return { "synced": total_synced, "results": results, - "message": f"Synced {total_synced} followed models from {ok_count} provider(s)", + "localOnly": False, + "message": f"Synced {total_synced} remote follows", } - if not _auth_service or not _chaturbate_api: - raise HTTPException(status_code=503, detail="Services not initialized") - + if not _chaturbate_api or not _auth_service: + raise HTTPException(status_code=503, detail="Chaturbate API not initialized") status = _auth_service.get_status() if not status.get("isLoggedIn"): - raise HTTPException(status_code=401, detail="Login required") - - # Fetch from Chaturbate - models = await _chaturbate_api.get_followed_models() - - if not models: - # Diagnose the reason so users know what to fix - cookies = _auth_service.api.auth.get_cookies() if hasattr(_auth_service, 'api') else {} - if not _chaturbate_api.auth.get_cookies().get("sessionid"): - reason = "Chaturbate session expired — please log in again from Settings" - else: - reason = "Chaturbate returned 0 followed models (session may be invalid or rate-limited)" - return {"synced": 0, "message": reason} - - # Upsert all models (preserves old thumbnail_url via COALESCE when new value is None) - synced_usernames = set() - for model in models: - thumb = model.get("thumbnail_url") - is_online = model.get("is_online", False) - # For offline models with only a fallback URL, pass None so COALESCE keeps the old thumbnail - if not is_online and thumb and "roomimg.stream.highwebmedia.com" in thumb: - thumb = None - await _db.upsert_followed_model( - username=model["username"], - display_name=model.get("display_name"), - is_online=is_online, - viewers=model.get("viewers", 0), - thumbnail_url=thumb, - source_type="chaturbate", - room_status=model.get("room_status"), - ) - synced_usernames.add(model["username"]) - - # Remove models no longer followed - await _db.remove_unfollowed(synced_usernames) - - return {"synced": len(models), "message": f"Synced {len(models)} followed models"} + raise HTTPException(status_code=401, detail="Chaturbate session absente") + try: + items = await _chaturbate_api.get_followed_models() + except Exception as exc: + logger.error("Chaturbate following sync failed", error=str(exc), exc_info=True) + raise HTTPException(status_code=502, detail=f"Chaturbate sync failed: {exc}") + synced = await _store_provider_following("chaturbate", items or []) + await _db.reconcile_model_sources_from_followed() + return { + "synced": synced, + "results": [{"sourceType": "chaturbate", "displayName": "Chaturbate", "synced": synced, "success": True}], + "localOnly": False, + "message": f"Chaturbate: {synced} follows synced", + } async def _store_provider_following(source_type: str, items: list[dict]) -> int: @@ -252,26 +475,33 @@ async def _store_provider_following(source_type: str, items: list[dict]) -> int: ) synced_usernames.add(username) - if source_type == "chaturbate": - await _db.remove_unfollowed(synced_usernames, source_type=source_type) + await _db.remove_unfollowed(synced_usernames, source_type=source_type) return len(synced_usernames) @router.post("/following/{username}/track") -async def track_followed_model(username: str): +async def track_followed_model( + username: str, + source_type: Optional[str] = Query(None, alias="source_type"), + source: Optional[str] = Query(None), +): """ Add a followed model to P-StreamRec models table for recording. """ if not _db: raise HTTPException(status_code=503, detail="Database not initialized") + requested_source = (source_type or source or "").strip().lower() or None + if requested_source and _provider_registry and requested_source not in (_registered_source_types() or set()): + raise HTTPException(status_code=404, detail=f"Source '{requested_source}' non disponible") + # Check if already tracked - existing = await _db.get_model(username) + existing = await _db.get_model(username, source_type=requested_source) if existing: return {"message": f"{username} is already tracked", "alreadyTracked": True} - followed = await _db.get_followed_model(username) - source_type = (followed or {}).get("source_type") or "chaturbate" + followed = await _db.get_followed_model(username, source_type=requested_source) + source_type = (followed or {}).get("source_type") or requested_source or "chaturbate" # Add to models table await _db.add_or_update_model( diff --git a/app/browser_recorder.py b/app/browser_recorder.py index bec5c23..81416d2 100644 --- a/app/browser_recorder.py +++ b/app/browser_recorder.py @@ -2,6 +2,7 @@ import asyncio import base64 +import json import os import queue import threading @@ -16,6 +17,17 @@ from .providers.browser import DEFAULT_USER_AGENT +def _browser_launch_args() -> list[str]: + return [ + "--autoplay-policy=no-user-gesture-required", + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--disable-setuid-sandbox", + "--lang=en-US,en", + "--no-sandbox", + ] + + def _decode_base64_payload(raw: str) -> bytes: raw = (raw or "").strip().rsplit(",", 1)[-1] raw = "".join(raw.split()) @@ -44,6 +56,7 @@ def __init__( record: bool = True, browser_root: Optional[Path] = None, file_extension: str = "webm", + session_store=None, ): self.id = session_id self.source_type = source_type @@ -54,6 +67,7 @@ def __init__( self.name = display_name or person or session_id self.record = bool(record) self.browser_root = browser_root + self.session_store = session_store self.file_extension = (file_extension or "webm").strip(".").lower() or "webm" self.created_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") self.start_time = time.time() @@ -74,6 +88,74 @@ def __init__( self._subscribers: list[queue.Queue[bytes]] = [] self._first_chunk: Optional[bytes] = None + async def _apply_browser_stealth(self, context) -> None: + try: + await context.add_init_script( + """ + (() => { + try { Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); } catch {} + try { Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); } catch {} + try { Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); } catch {} + try { window.chrome = window.chrome || { runtime: {} }; } catch {} + })(); + """ + ) + except Exception: + pass + + async def _restore_provider_state(self, context) -> None: + await self._apply_browser_stealth(context) + if not self.session_store: + return + try: + state = await self.session_store.get(self.source_type) + except Exception as exc: + logger.debug("Browser capture provider state unavailable", source_type=self.source_type, error=str(exc)) + return + + cookies = state.get("cookies") or [] + if cookies: + try: + await context.add_cookies(cookies) + except Exception as exc: + logger.debug("Browser capture cookie restore failed", source_type=self.source_type, error=str(exc)) + + local_storage = state.get("localStorage") or [] + if local_storage: + try: + await context.add_init_script( + """ + (() => { + const origins = __PSTREAMREC_ORIGINS__; + const originState = origins.find((entry) => entry && entry.origin === window.location.origin); + if (!originState || !Array.isArray(originState.localStorage)) return; + for (const item of originState.localStorage) { + if (!item || !item.name || typeof item.value !== 'string') continue; + try { window.localStorage.setItem(item.name, item.value); } catch {} + } + })(); + """.replace("__PSTREAMREC_ORIGINS__", json.dumps(local_storage)) + ) + except Exception as exc: + logger.debug("Browser capture localStorage restore failed", source_type=self.source_type, error=str(exc)) + + async def _save_provider_state(self, context) -> None: + if not self.session_store or self.bytes_written <= 0: + return + try: + stored = await self.session_store.get(self.source_type) + state = await context.storage_state() + await self.session_store.save( + self.source_type, + username=stored.get("username") or stored.get("credential_username"), + is_logged_in=bool(stored.get("is_logged_in")), + cookies=state.get("cookies") or [], + local_storage=state.get("origins") or [], + last_error=None, + ) + except Exception as exc: + logger.debug("Browser capture provider state save failed", source_type=self.source_type, error=str(exc)) + def start(self) -> None: self._thread = threading.Thread( target=lambda: asyncio.run(self._run()), @@ -149,6 +231,39 @@ def _write_chunk(self, data: bytes) -> None: self._publish_chunk(data) self._ready_evt.set() + async def _dismiss_entry_prompts(self, page) -> None: + try: + clicked = await page.evaluate( + """ + () => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + }; + const textOf = (el) => (el.innerText || el.textContent || el.getAttribute('aria-label') || '') + .replace(/\\s+/g, ' ') + .trim(); + const accept = /(i'?m\\s+over\\s+18|i\\s+am\\s+over\\s+18|enter|accept|agree|continue|got\\s+it)/i; + const reject = /(exit|privacy|terms|parental|record-keeping|support|log\\s*in|sign\\s*up|create\\s+free)/i; + const controls = Array.from(document.querySelectorAll('button, [role="button"], a')); + for (const el of controls) { + const text = textOf(el); + if (visible(el) && accept.test(text) && !reject.test(text)) { + el.click(); + return true; + } + } + return false; + } + """ + ) + if clicked: + await page.wait_for_timeout(1200) + except Exception: + return + async def _run(self) -> None: browser = None context = None @@ -160,7 +275,7 @@ async def _run(self) -> None: self.records_dir_for_person.mkdir(parents=True, exist_ok=True) async with async_playwright() as playwright: - launch_args = ["--autoplay-policy=no-user-gesture-required"] + launch_args = _browser_launch_args() if self.browser_root: user_data_dir = self.browser_root / self.source_type user_data_dir.mkdir(parents=True, exist_ok=True) @@ -182,6 +297,7 @@ async def _run(self) -> None: viewport={"width": 1280, "height": 720}, ) page = await context.new_page() + await self._restore_provider_state(context) async def receive_chunk(_source, payload): raw = "" @@ -201,11 +317,17 @@ async def receive_chunk(_source, payload): await page.expose_binding("__pstreamrecChunk", receive_chunk) await page.goto(self.page_url, wait_until="domcontentloaded", timeout=45000) - await page.wait_for_timeout(12000) + for _ in range(4): + await self._dismiss_entry_prompts(page) + await page.wait_for_timeout(800) + await page.wait_for_timeout(5000) try: await page.mouse.click(640, 360) except Exception: pass + for _ in range(3): + await self._dismiss_entry_prompts(page) + await page.wait_for_timeout(700) await page.wait_for_timeout(3000) await page.evaluate( @@ -292,6 +414,7 @@ async def receive_chunk(_source, payload): finally: try: if context: + await self._save_provider_state(context) await context.close() except Exception: pass @@ -346,7 +469,7 @@ async def _run(self) -> None: self.records_dir_for_person.mkdir(parents=True, exist_ok=True) async with async_playwright() as playwright: - launch_args = ["--autoplay-policy=no-user-gesture-required"] + launch_args = _browser_launch_args() if self.browser_root: user_data_dir = self.browser_root / self.source_type user_data_dir.mkdir(parents=True, exist_ok=True) @@ -368,6 +491,7 @@ async def _run(self) -> None: viewport={"width": 1280, "height": 720}, ) page = await context.new_page() + await self._restore_provider_state(context) cdp = await context.new_cdp_session(page) @@ -408,6 +532,7 @@ def receive_frame(event) -> None: finally: try: if context: + await self._save_provider_state(context) await context.close() except Exception: pass @@ -422,11 +547,12 @@ def receive_frame(event) -> None: class BrowserCaptureManager: - def __init__(self, base_output_dir: str): + def __init__(self, base_output_dir: str, session_store=None): self.base_output_dir = Path(base_output_dir) self.sessions_root = self.base_output_dir / "browser-sessions" self.records_root = self.base_output_dir / "records" self.browser_root = self.base_output_dir / "provider-browser" + self.session_store = session_store self._lock = threading.Lock() self._sessions: Dict[str, BrowserCaptureSession] = {} @@ -470,7 +596,8 @@ def start_session( person=person, display_name=display_name, record=record, - browser_root=None, + browser_root=self.browser_root, + session_store=self.session_store, ) session.capture_mode = (capture_mode or "media_recorder").strip().lower() self._sessions[session_id] = session diff --git a/app/core/database.py b/app/core/database.py index 5957ea4..26d6902 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -16,6 +16,10 @@ def __init__(self, db_path: Path): self.db_path = db_path self._initialized = False + @staticmethod + def _normalize_source_type(source_type: Optional[str]) -> str: + return (source_type or "").strip().lower() or "chaturbate" + def _connect(self): """Ouvre une connexion aiosqlite avec timeout de verrou. @@ -47,7 +51,7 @@ async def initialize(self): # Table pour les modèles et leur statut await db.execute(""" CREATE TABLE IF NOT EXISTS models ( - username TEXT PRIMARY KEY, + username TEXT NOT NULL, display_name TEXT, is_online BOOLEAN DEFAULT 0, is_recording BOOLEAN DEFAULT 0, @@ -58,9 +62,35 @@ async def initialize(self): auto_record BOOLEAN DEFAULT 1, record_quality TEXT DEFAULT 'best', retention_days INTEGER DEFAULT 30, - source_type TEXT DEFAULT 'chaturbate', + source_type TEXT NOT NULL DEFAULT 'chaturbate', room_status TEXT, created_at INTEGER, + updated_at INTEGER, + PRIMARY KEY(username, source_type) + ) + """) + + # Fiches locales enrichies pour la médiathèque. Ces informations + # restent séparées des réglages de stream dans models. + await db.execute(""" + CREATE TABLE IF NOT EXISTS media_profiles ( + username TEXT PRIMARY KEY, + display_name TEXT, + first_name TEXT, + last_name TEXT, + age INTEGER, + address TEXT, + city TEXT, + region TEXT, + postal_code TEXT, + country TEXT, + aliases TEXT, + tags TEXT, + notes TEXT, + social_urls TEXT, + stream_urls TEXT, + profile_urls TEXT, + created_at INTEGER, updated_at INTEGER ) """) @@ -111,15 +141,16 @@ async def initialize(self): # Table pour les modèles suivis sur Chaturbate await db.execute(""" CREATE TABLE IF NOT EXISTS followed_models ( - username TEXT PRIMARY KEY, + username TEXT NOT NULL, display_name TEXT, is_online BOOLEAN DEFAULT 0, viewers INTEGER DEFAULT 0, thumbnail_url TEXT, last_seen_online_at INTEGER, synced_at INTEGER, - source_type TEXT DEFAULT 'chaturbate', - room_status TEXT + source_type TEXT NOT NULL DEFAULT 'chaturbate', + room_status TEXT, + PRIMARY KEY(username, source_type) ) """) @@ -160,8 +191,8 @@ async def initialize(self): ) """) - # Sessions generiques par provider: cookies/localStorage seulement, - # jamais les mots de passe. + # Sessions generiques par provider. Credentials are local-only and + # allow reconnect retries after provider sessions expire. await db.execute(""" CREATE TABLE IF NOT EXISTS provider_sessions ( source_type TEXT PRIMARY KEY, @@ -169,6 +200,9 @@ async def initialize(self): is_logged_in BOOLEAN DEFAULT 0, session_cookies TEXT, local_storage TEXT, + credential_username TEXT, + credential_password TEXT, + credentials_updated_at INTEGER, last_login_at INTEGER, last_error TEXT, updated_at INTEGER @@ -181,6 +215,16 @@ async def initialize(self): ON models(is_online, username) """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_models_source_username + ON models(source_type, username) + """) + + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_followed_source_username + ON followed_models(source_type, username) + """) + await db.execute(""" CREATE INDEX IF NOT EXISTS idx_recordings_username ON recordings(username, created_at DESC) @@ -188,6 +232,7 @@ async def initialize(self): # Migrations idempotentes pour schémas existants await self._migrate_schema(db) + await self._migrate_provider_identity_keys(db) await db.commit() @@ -212,6 +257,9 @@ async def _migrate_schema(self, db): ("models", "room_status", "TEXT"), ("followed_models", "source_type", "TEXT DEFAULT 'chaturbate'"), ("followed_models", "room_status", "TEXT"), + ("provider_sessions", "credential_username", "TEXT"), + ("provider_sessions", "credential_password", "TEXT"), + ("provider_sessions", "credentials_updated_at", "INTEGER"), ] for table, column, ddl in migrations: try: @@ -220,6 +268,118 @@ async def _migrate_schema(self, db): except Exception: # La colonne existe déjà - SQLite lève "duplicate column" pass + + async def _migrate_provider_identity_keys(self, db): + """Upgrade legacy username-only tables to provider-aware identities.""" + + async def table_pk_columns(table: str) -> list[str]: + cursor = await db.execute(f"PRAGMA table_info({table})") + rows = await cursor.fetchall() + return [ + row[1] + for row in sorted( + [row for row in rows if int(row[5] or 0) > 0], + key=lambda item: int(item[5] or 0), + ) + ] + + async def rebuild_models() -> None: + await db.execute("ALTER TABLE models RENAME TO models_legacy_provider_identity") + await db.execute(""" + CREATE TABLE models ( + username TEXT NOT NULL, + display_name TEXT, + is_online BOOLEAN DEFAULT 0, + is_recording BOOLEAN DEFAULT 0, + viewers INTEGER DEFAULT 0, + thumbnail_path TEXT, + thumbnail_updated_at INTEGER, + last_check_at INTEGER, + auto_record BOOLEAN DEFAULT 1, + record_quality TEXT DEFAULT 'best', + retention_days INTEGER DEFAULT 30, + source_type TEXT NOT NULL DEFAULT 'chaturbate', + room_status TEXT, + created_at INTEGER, + updated_at INTEGER, + PRIMARY KEY(username, source_type) + ) + """) + await db.execute(""" + INSERT OR REPLACE INTO models ( + username, display_name, is_online, is_recording, viewers, + thumbnail_path, thumbnail_updated_at, last_check_at, + auto_record, record_quality, retention_days, source_type, + room_status, created_at, updated_at + ) + SELECT + username, display_name, is_online, is_recording, viewers, + thumbnail_path, thumbnail_updated_at, last_check_at, + auto_record, record_quality, retention_days, + COALESCE(NULLIF(source_type, ''), 'chaturbate'), + room_status, created_at, updated_at + FROM models_legacy_provider_identity + WHERE username IS NOT NULL AND username != '' + """) + await db.execute("DROP TABLE models_legacy_provider_identity") + + async def rebuild_followed() -> None: + await db.execute("ALTER TABLE followed_models RENAME TO followed_models_legacy_provider_identity") + await db.execute(""" + CREATE TABLE followed_models ( + username TEXT NOT NULL, + display_name TEXT, + is_online BOOLEAN DEFAULT 0, + viewers INTEGER DEFAULT 0, + thumbnail_url TEXT, + last_seen_online_at INTEGER, + synced_at INTEGER, + source_type TEXT NOT NULL DEFAULT 'chaturbate', + room_status TEXT, + PRIMARY KEY(username, source_type) + ) + """) + await db.execute(""" + INSERT OR REPLACE INTO followed_models ( + username, display_name, is_online, viewers, thumbnail_url, + last_seen_online_at, synced_at, source_type, room_status + ) + SELECT + username, display_name, is_online, viewers, thumbnail_url, + last_seen_online_at, synced_at, + COALESCE(NULLIF(source_type, ''), 'chaturbate'), + room_status + FROM followed_models_legacy_provider_identity + WHERE username IS NOT NULL AND username != '' + """) + await db.execute("DROP TABLE followed_models_legacy_provider_identity") + + try: + if await table_pk_columns("models") != ["username", "source_type"]: + await rebuild_models() + logger.info("Migration: models utilise username+source_type") + except Exception as exc: + logger.warning("Migration provider-aware models échouée", error=str(exc)) + + try: + if await table_pk_columns("followed_models") != ["username", "source_type"]: + await rebuild_followed() + logger.info("Migration: followed_models utilise username+source_type") + except Exception as exc: + logger.warning("Migration provider-aware followed_models échouée", error=str(exc)) + + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_models_online + ON models(is_online, username) + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_models_source_username + ON models(source_type, username) + """) + await db.execute(""" + CREATE INDEX IF NOT EXISTS idx_followed_source_username + ON followed_models(source_type, username) + """) async def add_or_update_model( self, @@ -234,7 +394,7 @@ async def add_or_update_model( await self.initialize() now = int(datetime.now().timestamp()) - source_type = (source_type or "").strip().lower() or None + source_type = self._normalize_source_type(source_type) async with self._connect() as db: await db.execute(""" @@ -243,17 +403,16 @@ async def add_or_update_model( retention_days, source_type, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(username) DO UPDATE SET + ON CONFLICT(username, source_type) DO UPDATE SET display_name = COALESCE(?, display_name), auto_record = ?, record_quality = ?, retention_days = ?, - source_type = COALESCE(?, source_type), updated_at = ? """, ( username, display_name, auto_record, record_quality, - retention_days, source_type or "chaturbate", now, now, - display_name, auto_record, record_quality, retention_days, source_type, now + retention_days, source_type, now, now, + display_name, auto_record, record_quality, retention_days, now )) await db.commit() @@ -296,6 +455,7 @@ async def update_model_status( is_recording: bool = False, thumbnail_path: Optional[str] = None, room_status: Optional[str] = None, + source_type: Optional[str] = None, ): """Met à jour le statut d'un modèle""" await self.initialize() @@ -318,23 +478,42 @@ async def update_model_status( placeholders = ', '.join(f"{k} = ?" for k in update_fields.keys()) values = list(update_fields.values()) + [username] - - await db.execute( - f"UPDATE models SET {placeholders} WHERE username = ?", - values - ) + if source_type: + values.append(self._normalize_source_type(source_type)) + await db.execute( + f"UPDATE models SET {placeholders} WHERE username = ? AND source_type = ?", + values, + ) + else: + await db.execute( + f"UPDATE models SET {placeholders} WHERE username = ?", + values, + ) await db.commit() - async def get_model(self, username: str) -> Optional[Dict[str, Any]]: + async def get_model( + self, username: str, source_type: Optional[str] = None + ) -> Optional[Dict[str, Any]]: """Récupère les informations d'un modèle""" await self.initialize() async with self._connect() as db: db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT * FROM models WHERE username = ?", - (username,) - ) + if source_type: + cursor = await db.execute( + "SELECT * FROM models WHERE username = ? AND source_type = ?", + (username, self._normalize_source_type(source_type)), + ) + else: + cursor = await db.execute( + """ + SELECT * FROM models + WHERE username = ? + ORDER BY CASE WHEN source_type = 'chaturbate' THEN 0 ELSE 1 END, source_type + LIMIT 1 + """, + (username,), + ) row = await cursor.fetchone() if row: @@ -365,15 +544,190 @@ async def get_models_for_auto_record(self) -> List[Dict[str, Any]]: rows = await cursor.fetchall() return [dict(row) for row in rows] - async def delete_model(self, username: str): + async def delete_model(self, username: str, source_type: Optional[str] = None): """Supprime un modèle""" await self.initialize() async with self._connect() as db: - await db.execute("DELETE FROM models WHERE username = ?", (username,)) + if source_type: + await db.execute( + "DELETE FROM models WHERE username = ? AND source_type = ?", + (username, self._normalize_source_type(source_type)), + ) + else: + await db.execute("DELETE FROM models WHERE username = ?", (username,)) await db.commit() - logger.info("Modèle supprimé", username=username) + logger.info("Modèle supprimé", username=username, source_type=source_type) + + @staticmethod + def _json_list(value: Any) -> str: + if value is None: + return "[]" + if isinstance(value, str): + values = [line.strip() for line in value.splitlines() if line.strip()] + elif isinstance(value, list): + values = [str(item).strip() for item in value if str(item).strip()] + else: + values = [] + return json.dumps(values, ensure_ascii=False) + + @staticmethod + def _decode_json_list(value: Any) -> List[str]: + if not value: + return [] + if isinstance(value, list): + return [str(item) for item in value] + try: + decoded = json.loads(value) + if isinstance(decoded, list): + return [str(item) for item in decoded if str(item).strip()] + except Exception: + pass + return [line.strip() for line in str(value).splitlines() if line.strip()] + + def _format_media_profile_row(self, row: Dict[str, Any]) -> Dict[str, Any]: + data = dict(row) + data["social_urls"] = self._decode_json_list(data.get("social_urls")) + data["stream_urls"] = self._decode_json_list(data.get("stream_urls")) + data["profile_urls"] = self._decode_json_list(data.get("profile_urls")) + return data + + async def get_media_profile(self, username: str) -> Optional[Dict[str, Any]]: + """Récupère la fiche locale d'un profil média.""" + await self.initialize() + + async with self._connect() as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute( + "SELECT * FROM media_profiles WHERE username = ? LIMIT 1", + (username,), + ) + row = await cursor.fetchone() + return self._format_media_profile_row(dict(row)) if row else None + + async def get_all_media_profiles(self) -> List[Dict[str, Any]]: + """Récupère toutes les fiches locales média.""" + await self.initialize() + + async with self._connect() as db: + db.row_factory = aiosqlite.Row + cursor = await db.execute("SELECT * FROM media_profiles ORDER BY username") + rows = await cursor.fetchall() + return [self._format_media_profile_row(dict(row)) for row in rows] + + async def upsert_media_profile(self, username: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Crée ou met à jour les informations enrichies d'un profil média.""" + await self.initialize() + + now = int(datetime.now().timestamp()) + age = data.get("age") + try: + age = int(age) if age not in (None, "") else None + except (TypeError, ValueError): + age = None + + payload = { + "display_name": data.get("display_name"), + "first_name": data.get("first_name"), + "last_name": data.get("last_name"), + "age": age, + "address": data.get("address"), + "city": data.get("city"), + "region": data.get("region"), + "postal_code": data.get("postal_code"), + "country": data.get("country"), + "aliases": data.get("aliases"), + "tags": data.get("tags"), + "notes": data.get("notes"), + "social_urls": self._json_list(data.get("social_urls")), + "stream_urls": self._json_list(data.get("stream_urls")), + "profile_urls": self._json_list(data.get("profile_urls")), + } + + async with self._connect() as db: + await db.execute(""" + INSERT INTO media_profiles ( + username, display_name, first_name, last_name, age, + address, city, region, postal_code, country, + aliases, tags, notes, social_urls, stream_urls, profile_urls, + created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(username) DO UPDATE SET + display_name = ?, + first_name = ?, + last_name = ?, + age = ?, + address = ?, + city = ?, + region = ?, + postal_code = ?, + country = ?, + aliases = ?, + tags = ?, + notes = ?, + social_urls = ?, + stream_urls = ?, + profile_urls = ?, + updated_at = ? + """, ( + username, + payload["display_name"], + payload["first_name"], + payload["last_name"], + payload["age"], + payload["address"], + payload["city"], + payload["region"], + payload["postal_code"], + payload["country"], + payload["aliases"], + payload["tags"], + payload["notes"], + payload["social_urls"], + payload["stream_urls"], + payload["profile_urls"], + now, + now, + payload["display_name"], + payload["first_name"], + payload["last_name"], + payload["age"], + payload["address"], + payload["city"], + payload["region"], + payload["postal_code"], + payload["country"], + payload["aliases"], + payload["tags"], + payload["notes"], + payload["social_urls"], + payload["stream_urls"], + payload["profile_urls"], + now, + )) + await db.commit() + + profile = await self.get_media_profile(username) + return profile or {"username": username} + + async def delete_media_profile(self, username: str) -> None: + """Supprime la fiche locale d'un profil média.""" + await self.initialize() + + async with self._connect() as db: + await db.execute("DELETE FROM media_profiles WHERE username = ?", (username,)) + await db.commit() + + async def delete_recordings_for_username(self, username: str) -> int: + """Supprime tous les enregistrements DB associés à un profil.""" + await self.initialize() + + async with self._connect() as db: + cursor = await db.execute("DELETE FROM recordings WHERE username = ?", (username,)) + await db.commit() + return cursor.rowcount or 0 async def update_all_models_retention_days(self, retention_days: int) -> int: """Apply one retention window to every tracked model.""" @@ -683,7 +1037,7 @@ async def save_provider_session( is_logged_in = ?, session_cookies = COALESCE(?, session_cookies), local_storage = COALESCE(?, local_storage), - last_login_at = COALESCE(?, last_login_at), + last_login_at = ?, last_error = ?, updated_at = ? """, ( @@ -694,6 +1048,40 @@ async def save_provider_session( )) await db.commit() + async def save_provider_credentials( + self, + source_type: str, + username: str, + password: str, + ) -> None: + await self.initialize() + source_type = (source_type or "").strip().lower() + username = (username or "").strip() + if not source_type: + raise ValueError("source_type is required") + if not username: + raise ValueError("username is required") + now = int(datetime.now().timestamp()) + + async with self._connect() as db: + await db.execute(""" + INSERT INTO provider_sessions ( + source_type, username, credential_username, + credential_password, credentials_updated_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(source_type) DO UPDATE SET + username = ?, + credential_username = ?, + credential_password = ?, + credentials_updated_at = ?, + updated_at = ? + """, ( + source_type, username, username, password, now, now, + username, username, password, now, now, + )) + await db.commit() + async def get_provider_session(self, source_type: str) -> Optional[Dict[str, Any]]: await self.initialize() source_type = (source_type or "").strip().lower() @@ -733,6 +1121,7 @@ async def upsert_followed_model( """Add or update a followed model""" await self.initialize() now = int(datetime.now().timestamp()) + source_type = self._normalize_source_type(source_type) async with self._connect() as db: await db.execute(""" @@ -742,21 +1131,20 @@ async def upsert_followed_model( room_status ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(username) DO UPDATE SET + ON CONFLICT(username, source_type) DO UPDATE SET display_name = COALESCE(?, display_name), is_online = ?, viewers = ?, thumbnail_url = COALESCE(?, thumbnail_url), last_seen_online_at = CASE WHEN ? THEN ? ELSE last_seen_online_at END, synced_at = ?, - source_type = ?, room_status = ? """, ( username, display_name, is_online, viewers, thumbnail_url, now if is_online else None, now, source_type, room_status, display_name, is_online, viewers, thumbnail_url, - is_online, now, now, source_type, room_status, + is_online, now, now, room_status, )) await db.commit() @@ -772,17 +1160,30 @@ async def get_all_followed(self) -> List[Dict[str, Any]]: rows = await cursor.fetchall() return [dict(row) for row in rows] - async def get_followed_model(self, username: str) -> Optional[Dict[str, Any]]: - """Récupère un followed_model par username (sans filtre source_type). + async def get_followed_model( + self, username: str, source_type: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """Récupère un followed_model par username et source_type optionnel. Utilisé pour résoudre la plateforme d'un modèle qui n'est pas dans `tracked_models` mais dans la liste des favoris.""" await self.initialize() async with self._connect() as db: db.row_factory = aiosqlite.Row - cursor = await db.execute( - "SELECT * FROM followed_models WHERE username = ? LIMIT 1", - (username,), - ) + if source_type: + cursor = await db.execute( + "SELECT * FROM followed_models WHERE username = ? AND source_type = ? LIMIT 1", + (username, self._normalize_source_type(source_type)), + ) + else: + cursor = await db.execute( + """ + SELECT * FROM followed_models + WHERE username = ? + ORDER BY CASE WHEN source_type = 'chaturbate' THEN 0 ELSE 1 END, source_type + LIMIT 1 + """, + (username,), + ) row = await cursor.fetchone() return dict(row) if row else None @@ -804,7 +1205,7 @@ async def delete_followed_model( if source_type: await db.execute( "DELETE FROM followed_models WHERE username = ? AND source_type = ?", - (username, source_type), + (username, self._normalize_source_type(source_type)), ) else: await db.execute( @@ -819,6 +1220,7 @@ async def remove_unfollowed( """Remove followed models no longer in the followed list (scoped par source_type pour ne pas toucher les autres sources).""" await self.initialize() + source_type = self._normalize_source_type(source_type) async with self._connect() as db: cursor = await db.execute( diff --git a/app/ffmpeg_runner.py b/app/ffmpeg_runner.py index a486e12..f44eb74 100644 --- a/app/ffmpeg_runner.py +++ b/app/ffmpeg_runner.py @@ -19,7 +19,6 @@ _LLHLS_QUALITY_LADDER = [(360, 0), (480, 1), (540, 2), (720, 3), (1080, 4)] _TS_PACKET_SIZE = 188 _CHATURBATE_HLS_HOST_SUFFIXES = ("chaturbate.com", "highwebmedia.com", "mmcdn.com") -_STREAMATE_HLS_HOST_SUFFIXES = ("naiadsystems.com",) _CHATURBATE_HLS_HEADERS = ( "Referer: https://chaturbate.com/\r\n" "Origin: https://chaturbate.com\r\n" @@ -49,17 +48,6 @@ def _is_chaturbate_hls_url(input_url: str) -> bool: ) -def _is_streamate_hls_url(input_url: str) -> bool: - try: - hostname = (urlparse(input_url).hostname or "").lower().rstrip(".") - except Exception: - return False - return any( - hostname == suffix or hostname.endswith(f".{suffix}") - for suffix in _STREAMATE_HLS_HOST_SUFFIXES - ) - - def _is_local_hls_proxy_url(input_url: str) -> bool: try: parsed = urlparse(input_url) @@ -144,10 +132,10 @@ def _build_ffmpeg_command( "-y", ] - if not (_is_streamate_hls_url(input_url) or _is_local_hls_proxy_url(input_url)): - # Options de reconnexion pour stabilité. Streamate/NaiadSystems HLS - # and the local HLS proxy serve short-lived playlists/segments; - # reconnecting at EOF can stall with an empty recording. + if not _is_local_hls_proxy_url(input_url): + # Options de reconnexion pour stabilité. The local HLS proxy serves + # short-lived playlists/segments; reconnecting at EOF can stall with + # an empty recording. cmd.extend([ "-reconnect", "1", "-reconnect_at_eof", "1", @@ -678,6 +666,10 @@ def start_session( return sess + def get_session(self, session_id: str) -> Optional[FFmpegSession]: + with self._lock: + return self._sessions.get(session_id) + def _finalize_session_locked(self, sess: FFmpegSession, join_timeout: float = 0.2): if sess.process: sess.exit_returncode = sess.process.poll() diff --git a/app/main.py b/app/main.py index 00a3376..69af30a 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,14 @@ import re +import mimetypes from pathlib import Path -from typing import Optional, Tuple -from urllib.parse import quote, urljoin, urlparse +from typing import Any, Optional, Tuple +from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse import os import asyncio import aiohttp import json import queue +import shutil import subprocess import sys import time @@ -52,6 +54,7 @@ ResolvedStream, create_provider_registry, ) +from .providers.sessions import ProviderSessionStore from .api import auth as auth_router from .api import cam4 as cam4_router from .api import discover as discover_router @@ -71,6 +74,30 @@ FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191") RECORDING_RANGE_CHUNK_SIZE = int(os.getenv("RECORDING_RANGE_CHUNK_SIZE", str(8 * 1024 * 1024))) MEDIA_IMPORTS_ENABLED = os.getenv("PSTREAMREC_MEDIA_IMPORTS", "false").lower() in {"1", "true", "yes"} +MEDIA_LIBRARY_VIDEO_EXTENSIONS = SUPPORTED_VIDEO_EXTENSIONS | {".ts"} +MEDIA_LIBRARY_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"} +MEDIA_LIBRARY_AUDIO_EXTENSIONS = {".mp3", ".m4a", ".aac", ".ogg", ".wav", ".flac"} +MEDIA_LIBRARY_EXTENSIONS = ( + MEDIA_LIBRARY_VIDEO_EXTENSIONS + | MEDIA_LIBRARY_IMAGE_EXTENSIONS + | MEDIA_LIBRARY_AUDIO_EXTENSIONS +) +MEDIA_LIBRARY_BROWSER_PLAYABLE_VIDEO_EXTENSIONS = {".mp4", ".m4v", ".mov", ".webm"} +MEDIA_LIBRARY_TEMP_SUFFIXES = ( + ".tmp", + ".part", + ".partial", + ".download", + ".crdownload", +) +IVS_PLAYER_ASSET_BASE_URL = "https://player.live-video.net/1.4.1" +IVS_PLAYER_ASSETS = { + "amazon-ivs-player.min.js": "application/javascript", + "amazon-ivs-worker.min.js": "application/javascript", + "amazon-ivs-wasmworker.min.js": "application/javascript", + "amazon-ivs-wasmworker.min.wasm": "application/wasm", +} +_IVS_PLAYER_ASSET_CACHE: dict[str, dict[str, object]] = {} # Docker constants DOCKER_SOCKET = '/var/run/docker.sock' @@ -261,7 +288,7 @@ async def log_requests(request: Request, call_next): @app.middleware("http") async def _no_cache_static(request: Request, call_next): response = await call_next(request) - no_cache_paths = {"/", "/discover", "/following", "/recordings", "/settings", "/wiki"} + no_cache_paths = {"/", "/discover", "/following", "/recordings", "/media", "/settings", "/wiki"} if request.url.path.startswith("/static/") or request.url.path in no_cache_paths: response.headers["Cache-Control"] = "no-store, must-revalidate" response.headers["Pragma"] = "no-cache" @@ -389,6 +416,292 @@ async def range_file_stream(): return FileResponse(str(file_path), media_type=media_type, headers=base_headers) +def _media_library_kind(path: Path) -> Optional[str]: + ext = path.suffix.lower() + if ext in MEDIA_LIBRARY_IMAGE_EXTENSIONS: + return "image" + if ext in MEDIA_LIBRARY_VIDEO_EXTENSIONS: + return "video" + if ext in MEDIA_LIBRARY_AUDIO_EXTENSIONS: + return "audio" + return None + + +def _is_media_library_file(path: Path) -> bool: + if not path.is_file(): + return False + name = path.name + if name.startswith(".") or any(name.endswith(suffix) for suffix in MEDIA_LIBRARY_TEMP_SUFFIXES): + return False + if any(part.startswith(".") for part in path.parts): + return False + return _media_library_kind(path) is not None + + +def _quote_url_path(path_value: str) -> str: + return "/".join(quote(part, safe="") for part in Path(path_value).parts) + + +def _content_disposition(filename: str, disposition: str = "inline") -> str: + fallback = re.sub(r'[^A-Za-z0-9._ -]+', "_", filename).strip() or "media" + return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{quote(filename)}' + + +def _resolve_library_media_path(username: str, file_path: str) -> Path: + if ( + not username + or ".." in username + or "/" in username + or "\\" in username + or "\x00" in username + or "\x00" in file_path + or "\\" in file_path + ): + raise HTTPException(status_code=400, detail="Chemin média invalide") + + relative = Path(file_path) + if relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts): + raise HTTPException(status_code=400, detail="Chemin média invalide") + + records_root = (OUTPUT_DIR / "records").resolve() + profile_root = (records_root / username).resolve() + candidate = (profile_root / relative).resolve() + + if not candidate.is_relative_to(profile_root) or not candidate.is_relative_to(records_root): + raise HTTPException(status_code=403, detail="Chemin média non autorisé") + if _media_library_kind(candidate) is None: + raise HTTPException(status_code=400, detail="Format média non supporté") + return candidate + + +def _validate_media_profile_username(username: str) -> str: + username = (username or "").strip() + if ( + not username + or username in {".", ".."} + or "/" in username + or "\\" in username + or "\x00" in username + ): + raise HTTPException(status_code=400, detail="Profil média invalide") + return username + + +def _media_profile_dir(username: str) -> Path: + username = _validate_media_profile_username(username) + records_root = (OUTPUT_DIR / "records").resolve() + profile_dir = (records_root / username).resolve() + if not profile_dir.is_relative_to(records_root): + raise HTTPException(status_code=403, detail="Profil média non autorisé") + return profile_dir + + +def _list_media_profile_folders() -> set[str]: + records_root = OUTPUT_DIR / "records" + if not records_root.exists(): + return set() + + profiles: set[str] = set() + for child in records_root.iterdir(): + if not child.is_dir() or child.name.startswith("."): + continue + try: + _validate_media_profile_username(child.name) + except HTTPException: + continue + profiles.add(child.name) + return profiles + + +def _media_library_url(username: str, relative_path: str, download: bool = False) -> str: + url = f"/streams/library/{quote(username, safe='')}/{_quote_url_path(relative_path)}" + if download: + return f"{url}?download=1" + return url + + +def _model_for_media_profile(username: str, models: list[dict]) -> Optional[dict]: + selected = None + for model in models: + if model.get("username") != username: + continue + if selected is None: + selected = model + if (model.get("source_type") or model.get("sourceType")) == "chaturbate": + return model + return selected + + +def _media_profile_formatted(profile: Optional[dict]) -> dict: + profile = profile or {} + return { + "displayName": profile.get("display_name") or "", + "firstName": profile.get("first_name") or "", + "lastName": profile.get("last_name") or "", + "age": profile.get("age"), + "address": profile.get("address") or "", + "city": profile.get("city") or "", + "region": profile.get("region") or "", + "postalCode": profile.get("postal_code") or "", + "country": profile.get("country") or "", + "aliases": profile.get("aliases") or "", + "tags": profile.get("tags") or "", + "notes": profile.get("notes") or "", + "socialUrls": profile.get("social_urls") or [], + "streamUrls": profile.get("stream_urls") or [], + "profileUrls": profile.get("profile_urls") or [], + } + + +async def _media_profile_payload(username: str) -> dict: + username = _validate_media_profile_username(username) + profile_dir = _media_profile_dir(username) + profile = await db.get_media_profile(username) + model = await db.get_model(username) + source_type = await _infer_source_type(username, model) if model else "chaturbate" + default_quality = await _get_default_record_quality() + default_retention = await _get_default_retention_days() + + return { + "username": username, + "folderExists": profile_dir.exists() and profile_dir.is_dir(), + "deleteUrl": f"/api/media-profiles/{quote(username, safe='')}", + **_media_profile_formatted(profile), + "sourceType": source_type, + "source_type": source_type, + "autoRecord": bool(model.get("auto_record", False)) if model else False, + "recordQuality": (model or {}).get("record_quality") or default_quality, + "retentionDays": (model or {}).get("retention_days", default_retention), + } + + +def _media_library_placeholder_title(filename: str) -> str: + title = Path(filename).stem.replace("_", " ").replace("-", " ").strip() + return re.sub(r"\s+", " ", title) or filename + + +def _recording_thumb_url(username: str, rec: Optional[dict], path: Path) -> Optional[str]: + thumb_value = (rec or {}).get("thumbnail_path") + if thumb_value: + thumb_path = Path(thumb_value) + if thumb_path.exists(): + return f"/api/recording-thumbnail/{quote(username, safe='')}/{quote(thumb_path.name, safe='')}" + + generated = OUTPUT_DIR / "thumbnails" / username / f"{path.stem}.jpg" + if generated.exists(): + return f"/api/recording-thumbnail/{quote(username, safe='')}/{quote(generated.name, safe='')}" + return None + + +def _media_library_stats(items: list[dict]) -> dict: + stats = { + "total": len(items), + "videos": 0, + "images": 0, + "audio": 0, + "totalSize": 0, + } + for item in items: + stats["totalSize"] += int(item.get("size") or 0) + if item.get("type") == "video": + stats["videos"] += 1 + elif item.get("type") == "image": + stats["images"] += 1 + elif item.get("type") == "audio": + stats["audio"] += 1 + return stats + + +async def _scan_media_library_items() -> list[dict]: + from .core.utils import format_bytes + + records_root = OUTPUT_DIR / "records" + if not records_root.exists(): + return [] + + items: list[dict] = [] + for profile_dir in sorted(records_root.iterdir(), key=lambda p: p.name.lower()): + if not profile_dir.is_dir() or profile_dir.name.startswith("."): + continue + + username = profile_dir.name + profile_root = profile_dir.resolve() + recordings = await db.get_recordings(username) + recordings_by_path: dict[str, dict] = {} + recordings_by_filename: dict[str, dict] = {} + + for rec in recordings: + filename = rec.get("filename") + if filename: + recordings_by_filename.setdefault(filename, rec) + for key in ("file_path", "mp4_path", "playable_path"): + value = rec.get(key) + if not value: + continue + try: + recordings_by_path.setdefault(str(Path(value).resolve()), rec) + except Exception: + continue + + for media_path in profile_dir.rglob("*"): + if not _is_media_library_file(media_path): + continue + try: + resolved = media_path.resolve() + if not resolved.is_relative_to(profile_root): + continue + stat = media_path.stat() + relative_path = media_path.relative_to(profile_dir).as_posix() + except Exception: + continue + + kind = _media_library_kind(media_path) + if not kind: + continue + + rec = recordings_by_path.get(str(resolved)) + if rec is None and len(Path(relative_path).parts) == 1: + rec = recordings_by_filename.get(media_path.name) + + url = _media_library_url(username, relative_path) + thumbnail = url if kind == "image" else _recording_thumb_url(username, rec, media_path) + created_at = int((rec or {}).get("created_at") or stat.st_mtime) + duration_seconds = int((rec or {}).get("duration_seconds") or 0) + recording_id = (rec or {}).get("recording_id") + item_id_seed = f"{username}\0{relative_path}" + item_id = hashlib.sha256(item_id_seed.encode("utf-8")).hexdigest()[:16] + + items.append({ + "id": item_id, + "recordingId": recording_id, + "username": username, + "filename": media_path.name, + "relativePath": relative_path, + "title": (rec or {}).get("title") or _media_library_placeholder_title(media_path.name), + "type": kind, + "extension": media_path.suffix.lower().lstrip("."), + "mimeType": mimetypes.guess_type(media_path.name)[0] or ( + _recording_media_type(media_path.name) if kind == "video" else "application/octet-stream" + ), + "size": stat.st_size, + "sizeFormatted": format_bytes(stat.st_size), + "createdAt": created_at, + "modifiedAt": int(stat.st_mtime), + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "duration": duration_seconds, + "durationStr": _format_duration_label(duration_seconds) if duration_seconds else "", + "url": url, + "downloadUrl": _media_library_url(username, relative_path, download=True), + "deleteUrl": f"/api/media-library/{quote(username, safe='')}/{_quote_url_path(relative_path)}", + "thumbnail": thumbnail, + "browserPlayable": kind != "video" or media_path.suffix.lower() in MEDIA_LIBRARY_BROWSER_PLAYABLE_VIDEO_EXTENSIONS, + "isImported": bool(rec and rec.get("media_kind") == "import"), + "isRecording": bool(rec and (rec.get("media_kind") or "recording") != "import"), + }) + + return items + + # Route protégée pour les enregistrements @app.api_route("/streams/records/{username}/{filename}", methods=["GET", "HEAD"]) async def serve_recording_protected(request: Request, username: str, filename: str): @@ -450,6 +763,41 @@ async def serve_imported_media(request: Request, recording_id: str, download: bo return await _serve_video_file_with_ranges(request, file_path, file_path.name) + +@app.api_route("/streams/library/{username}/{file_path:path}", methods=["GET", "HEAD"]) +async def serve_library_media(request: Request, username: str, file_path: str, download: bool = False): + """Sert un fichier média présent dans /records// avec validation de chemin.""" + media_path = _resolve_library_media_path(username, file_path) + if not media_path.exists() or not media_path.is_file(): + raise HTTPException(status_code=404, detail="Média introuvable") + + kind = _media_library_kind(media_path) + media_type = mimetypes.guess_type(media_path.name)[0] or ( + _recording_media_type(media_path.name) if kind == "video" else "application/octet-stream" + ) + + if download: + return FileResponse( + str(media_path), + media_type=media_type, + headers={ + "Content-Disposition": _content_disposition(media_path.name, "attachment"), + "Cache-Control": "private, max-age=0", + }, + ) + + if kind == "video": + return await _serve_video_file_with_ranges(request, media_path, media_path.name) + + return FileResponse( + str(media_path), + media_type=media_type, + headers={ + "Content-Disposition": _content_disposition(media_path.name), + "Cache-Control": "public, max-age=3600", + }, + ) + # Mount pour les sessions HLS live uniquement app.mount("/streams/sessions", StaticFiles(directory=str(OUTPUT_DIR / "sessions")), name="streams_sessions") app.mount("/streams/thumbnails", StaticFiles(directory=str(OUTPUT_DIR / "thumbnails")), name="streams_thumbnails") @@ -494,6 +842,7 @@ async def chunks(): # Database SQLite DB_FILE = OUTPUT_DIR / "streamrec.db" db = Database(DB_FILE) +browser_capture_manager.session_store = ProviderSessionStore(db) media_import_manager: Optional[MediaImportManager] = None # Chaturbate API (initialized at startup) @@ -509,6 +858,8 @@ async def chunks(): _HLS_PROXY_CACHE: dict[str, dict] = {} _HLS_PROXY_REVERSE: dict[tuple, str] = {} _HLS_PROXY_TTL_SECONDS = int(os.getenv("PSTREAMREC_HLS_PROXY_TTL_SECONDS", "900")) +_HLS_MOUFLON_PSCH_RE = re.compile(r"^#EXT-X-MOUFLON:PSCH:([^:]+):(.+)$", re.IGNORECASE) +_HLS_MOUFLON_URI_RE = re.compile(r"^#EXT-X-MOUFLON:URI:(https?://.+)$", re.IGNORECASE) def _parse_auto_record_users_env(raw_value: Optional[str]) -> Tuple[list[str], int]: @@ -584,11 +935,14 @@ async def _import_auto_record_users_from_env() -> dict[str, int]: } +_SOURCE_TYPE_ALIASES = {} + + def _normalize_source_type(source_type: Optional[str]) -> Optional[str]: value = (source_type or "").strip().lower() if not value or value == "auto": return None - return value + return _SOURCE_TYPE_ALIASES.get(value, value) def _available_source_types() -> set[str]: @@ -650,6 +1004,10 @@ def _supports_browser_capture(source_type: str) -> bool: return (source_type or "").strip().lower() in _BROWSER_CAPTURE_SOURCES +async def _ensure_browser_capture_session(source_type: str) -> None: + return + + def _provider_error_detail(source_type: str, target: str, exc: Exception) -> str: if isinstance(exc, ProviderInteractionRequired): return f"{source_type}/{target}: interaction manuelle requise (CAPTCHA/2FA/challenge)" @@ -718,6 +1076,16 @@ def _hls_proxy_path_suffix(url: str) -> str: return "" +def _url_with_missing_query_params(url: str, params: dict[str, str]) -> str: + parsed = urlparse(url or "") + pairs = parse_qsl(parsed.query, keep_blank_values=True) + existing = {key for key, _ in pairs} + for key, value in params.items(): + if key not in existing and value: + pairs.append((key, value)) + return urlunparse(parsed._replace(query=urlencode(pairs))) + + def _register_hls_proxy_url( url: str, headers: Optional[dict[str, str]] = None, @@ -857,8 +1225,17 @@ def _rewrite_hls_playlist( def proxy_url(raw_uri: str) -> str: return _register_hls_proxy_url(urljoin(base_url, raw_uri), headers=headers) + def proxy_absolute_url(url: str) -> str: + return _register_hls_proxy_url(url, headers=headers) + media_sequence_written = False pending_program_date_time: Optional[str] = None + pending_mouflon_uri: Optional[str] = None + pending_variant = False + skip_mouflon_full_segment = False + variant_index = 0 + mouflon_keys: list[tuple[str, str]] = [] + has_mouflon_parts = "#EXT-X-MOUFLON:URI:" in (text or "") and "#EXT-X-PART:" in (text or "") rewritten = [] for line in (text or "").splitlines(): stripped = line.strip() @@ -867,21 +1244,49 @@ def proxy_url(raw_uri: str) -> str: continue if stripped.startswith("#"): upper = stripped.upper() - # Hls.js can chase LL-HLS parts/preload hints faster than a - # server-side proxy can refresh provider tokens. Keep the stable - # full segments and rewrite the playlist as ordinary live HLS. + psch_match = _HLS_MOUFLON_PSCH_RE.match(stripped) + if psch_match: + mouflon_keys.append((psch_match.group(1).strip(), psch_match.group(2).strip())) + rewritten.append(line) + continue + uri_match = _HLS_MOUFLON_URI_RE.match(stripped) + if uri_match: + pending_mouflon_uri = uri_match.group(1).strip() + continue + # Hls.js can chase generic LL-HLS parts/preload hints faster than + # a server-side proxy can refresh provider tokens. Stripchat's + # Mouflon playlists are different: the full media.mp4 placeholders + # 404, while the part URLs in the preceding Mouflon tags are valid. if ( upper.startswith("#EXT-X-SERVER-CONTROL:") or upper.startswith("#EXT-X-PART-INF:") - or upper.startswith("#EXT-X-PART:") - or upper.startswith("#EXT-X-PRELOAD-HINT:") or upper.startswith("#EXT-X-RENDITION-REPORT:") ): continue + if upper.startswith("#EXT-X-PART:"): + if has_mouflon_parts and pending_mouflon_uri: + duration_match = re.search(r"DURATION=([0-9.]+)", stripped, re.IGNORECASE) + rewritten.append(f"#EXTINF:{duration_match.group(1) if duration_match else '0.5'},") + rewritten.append(proxy_absolute_url(pending_mouflon_uri)) + pending_mouflon_uri = None + continue + if upper.startswith("#EXT-X-PRELOAD-HINT:"): + pending_mouflon_uri = None + continue + if upper.startswith("#EXT-X-MOUFLON:EXT-REF:"): + continue + if upper.startswith("#EXT-X-STREAM-INF:"): + pending_variant = True + rewritten.append(line) + continue if upper.startswith("#EXT-X-PROGRAM-DATE-TIME:"): pending_program_date_time = line continue if upper.startswith("#EXTINF:"): + if has_mouflon_parts: + skip_mouflon_full_segment = True + pending_program_date_time = None + continue if pending_program_date_time: rewritten.append(pending_program_date_time) pending_program_date_time = None @@ -902,7 +1307,23 @@ def proxy_url(raw_uri: str) -> str: ) rewritten.append(line) continue - rewritten.append(proxy_url(stripped)) + raw_uri = pending_mouflon_uri or stripped + pending_mouflon_uri = None + if skip_mouflon_full_segment: + skip_mouflon_full_segment = False + continue + absolute_url = urljoin(base_url, raw_uri) + if pending_variant: + query_keys = {key for key, _ in parse_qsl(urlparse(absolute_url).query, keep_blank_values=True)} + if mouflon_keys and "pkey" not in query_keys: + version, pkey = mouflon_keys[min(variant_index, len(mouflon_keys) - 1)] + absolute_url = _url_with_missing_query_params( + absolute_url, + {"playlistType": "lowLatency", "psch": version, "pkey": pkey}, + ) + variant_index += 1 + pending_variant = False + rewritten.append(proxy_absolute_url(absolute_url)) if live_sequence is not None and not media_sequence_written: insert_at = 1 if rewritten and rewritten[0].strip() == "#EXTM3U" else 0 rewritten.insert(insert_at, f"#EXT-X-MEDIA-SEQUENCE:{live_sequence}") @@ -918,6 +1339,10 @@ def _proxied_stream_url(stream: ResolvedStream) -> str: return stream.url +def _watch_stream_payload(stream: ResolvedStream) -> dict: + return {"streamUrl": _proxied_stream_url(stream)} + + def _local_proxy_url_for_ffmpeg(url: str) -> str: if (url or "").startswith("/api/proxy/hls/"): port = os.getenv("PORT", "8080") @@ -1002,6 +1427,71 @@ async def _index_browser_capture_recording(session) -> None: ) +async def _index_ffmpeg_recording(session) -> None: + paths = [] + try: + paths = [Path(path) for path in session._recording_paths_for_cleanup()] + except Exception: + record_path = getattr(session, "record_path", None) + if record_path: + paths = [Path(record_path)] + + if not paths: + return + + try: + from app.tasks.monitor import get_video_duration, generate_recording_thumbnail + + for record_path in paths: + if not record_path.exists() or not record_path.is_file(): + continue + file_size = record_path.stat().st_size + duration_seconds = await get_video_duration(record_path, FFMPEG_PATH) + if not duration_seconds: + duration_seconds = max(0, int(time.time() - getattr(session, "start_time", time.time()))) + if duration_seconds < MIN_RECORDING_SECONDS and file_size < MIN_RECORDING_BYTES: + logger.info( + "Recording trop court, indexation ignorée", + session_id=session.id, + person=session.person, + file=record_path.name, + duration_seconds=duration_seconds, + file_size=file_size, + ) + continue + thumbnail_path = await generate_recording_thumbnail( + record_path, + OUTPUT_DIR, + session.person, + FFMPEG_PATH, + ) + await db.add_or_update_recording( + username=session.person, + filename=record_path.name, + file_path=str(record_path), + file_size=file_size, + recording_id=f"{session.person}_{record_path.stem}", + duration_seconds=duration_seconds, + thumbnail_path=thumbnail_path, + is_converted=False, + ) + logger.info( + "Recording FFmpeg indexée", + session_id=session.id, + person=session.person, + file=record_path.name, + duration_seconds=duration_seconds, + file_size=file_size, + ) + except Exception as exc: + logger.warning( + "Indexation recording FFmpeg échouée", + session_id=getattr(session, "id", None), + person=getattr(session, "person", None), + error=str(exc), + ) + + async def _remux_browser_recording(record_path: Path) -> bool: if record_path.suffix.lower() not in {".webm", ".mp4"}: return False @@ -1092,8 +1582,21 @@ class StartBody(BaseModel): class ProviderLoginBody(BaseModel): - username: str - password: str + username: Optional[str] = None + password: Optional[str] = None + + +class ProviderSessionBody(BaseModel): + username: Optional[str] = None + cookies: Optional[Any] = None + cookieHeader: Optional[str] = None + localStorage: Optional[Any] = None + origins: Optional[Any] = None + storageState: Optional[dict[str, Any]] = None + userAgent: Optional[str] = None + user_agent: Optional[str] = None + xBc: Optional[str] = None + xbc: Optional[str] = None class ModelVolumeBody(BaseModel): @@ -1131,6 +1634,12 @@ async def recordings_page(): return FileResponse(str(STATIC_DIR / "recordings.html")) +@app.get("/media") +async def media_page(): + """Media page - file library for recordings folders""" + return FileResponse(str(STATIC_DIR / "media.html")) + + @app.get("/settings") async def settings_page(): """Settings page""" @@ -1149,12 +1658,6 @@ async def watch_page(username: str): return FileResponse(str(STATIC_DIR / "watch.html")) -@app.get("/dashboard") -async def dashboard_page(): - """Legacy dashboard (old index.html)""" - return FileResponse(str(STATIC_DIR / "index.html")) - - @app.get("/login") async def login_page(): """Page de connexion""" @@ -1422,6 +1925,45 @@ async def model_page(): return FileResponse(str(STATIC_DIR / "model.html")) +@app.api_route("/vendor/{asset_name}", methods=["GET", "HEAD"]) +async def amazon_ivs_player_asset(asset_name: str, request: Request): + media_type = IVS_PLAYER_ASSETS.get(asset_name) + if not media_type: + raise HTTPException(status_code=404, detail="Unknown vendor asset") + + now = time.time() + cached = _IVS_PLAYER_ASSET_CACHE.get(asset_name) or {} + cached_body = cached.get("body") + cached_at = float(cached.get("cached_at") or 0) + cache_headers = {"Cache-Control": "public, max-age=86400"} + if isinstance(cached_body, bytes) and now - cached_at < 86400: + if request.method == "HEAD": + return Response(status_code=200, media_type=media_type, headers=cache_headers) + return Response(content=cached_body, media_type=media_type, headers=cache_headers) + + try: + async with aiohttp_client_session(timeout=aiohttp.ClientTimeout(total=20)) as session: + async with session.get( + f"{IVS_PLAYER_ASSET_BASE_URL}/{asset_name}", + headers={"User-Agent": "Mozilla/5.0"}, + allow_redirects=True, + **aiohttp_request_kwargs(), + ) as resp: + if resp.status >= 400: + raise HTTPException(status_code=502, detail="IVS player asset unavailable") + body = await resp.read() + except HTTPException: + raise + except Exception as exc: + logger.error("Erreur chargement asset IVS", asset=asset_name, error=str(exc), exc_info=True) + raise HTTPException(status_code=502, detail="IVS player asset unavailable") + + _IVS_PLAYER_ASSET_CACHE[asset_name] = {"body": body, "cached_at": now} + if request.method == "HEAD": + return Response(status_code=200, media_type=media_type, headers=cache_headers) + return Response(content=body, media_type=media_type, headers=cache_headers) + + @app.api_route("/api/proxy/hls/{token_path:path}", methods=["GET", "HEAD"]) async def hls_proxy(token_path: str, request: Request): token = (token_path or "").split("/", 1)[0].split(".", 1)[0] @@ -1497,25 +2039,122 @@ async def _provider_login_state(source_type: str) -> dict: except Exception: return {"isLoggedIn": False, "username": None, "lastError": "Unknown provider"} + if not getattr(getattr(provider, "capabilities", None), "can_login", False): + return { + "isLoggedIn": False, + "username": None, + "lastError": None, + "lastLoginAt": None, + "hasCookies": False, + "hasLocalStorage": False, + "hasSession": False, + "hasSavedSessionData": False, + "hasSavedCredentials": False, + "accountDisabled": True, + } + auth = getattr(provider, "auth", None) if auth is not None: try: - return auth.get_status() + status = auth.get_status() + row = await db.get_provider_session(source_type) + has_saved_credentials = bool(row and row.get("credential_username") and row.get("credential_password")) + has_local_storage = _stored_json_has_items(row.get("local_storage") if row else None) + has_saved_session_data = bool(status.get("hasCookies") or has_local_storage) + is_logged_in = bool(status.get("isLoggedIn")) + status["hasSavedCredentials"] = has_saved_credentials + status["credentialsUpdatedAt"] = row.get("credentials_updated_at") if has_saved_credentials else None + status["hasLocalStorage"] = has_local_storage + status["hasSavedSessionData"] = has_saved_session_data + status["hasSession"] = bool(is_logged_in and (status.get("hasCookies") or has_local_storage or is_logged_in)) + if not is_logged_in: + status["lastLoginAt"] = None + if not status.get("username") and row: + status["username"] = row.get("credential_username") + return status except Exception: pass row = await db.get_provider_session(source_type) if not row: - return {"isLoggedIn": False, "username": None, "lastError": None, "hasCookies": False} + return { + "isLoggedIn": False, + "username": None, + "lastError": None, + "hasCookies": False, + "hasLocalStorage": False, + "hasSession": False, + "hasSavedSessionData": False, + "hasSavedCredentials": False, + } + has_saved_credentials = bool(row.get("credential_username") and row.get("credential_password")) + has_cookies = _stored_json_has_items(row.get("session_cookies")) + has_local_storage = _stored_json_has_items(row.get("local_storage")) + has_saved_session_data = bool(has_cookies or has_local_storage) + is_logged_in = bool(row.get("is_logged_in")) and bool(has_cookies or has_local_storage) return { - "isLoggedIn": bool(row.get("is_logged_in")), - "username": row.get("username"), + "isLoggedIn": is_logged_in, + "username": row.get("username") or row.get("credential_username"), "lastError": row.get("last_error"), - "lastLoginAt": row.get("last_login_at"), - "hasCookies": bool(row.get("session_cookies")), + "lastLoginAt": row.get("last_login_at") if is_logged_in else None, + "hasCookies": has_cookies, + "hasLocalStorage": has_local_storage, + "hasSession": is_logged_in, + "hasSavedSessionData": has_saved_session_data, + "hasSavedCredentials": has_saved_credentials, + "credentialsUpdatedAt": row.get("credentials_updated_at") if has_saved_credentials else None, } +def _stored_json_has_items(raw_value: object) -> bool: + if not raw_value: + return False + try: + parsed = json.loads(raw_value) if isinstance(raw_value, str) else raw_value + except Exception: + return False + if isinstance(parsed, list): + return any(bool(item) for item in parsed) + if isinstance(parsed, dict): + return bool(parsed) + return False + +async def _saved_provider_credentials(source_type: str) -> Optional[tuple[str, str]]: + row = await db.get_provider_session(source_type) + if not row: + return None + username = (row.get("credential_username") or "").strip() + password = row.get("credential_password") or "" + if not username or not password: + return None + return username, password + + +async def _login_with_saved_provider_credentials(source_type: str) -> bool: + credentials = await _saved_provider_credentials(source_type) + if not credentials: + return False + username, password = credentials + result = await _provider_for(source_type).login(username, password) + return bool(result.get("success")) + + +async def _ensure_saved_provider_login(provider, source_type: str) -> None: + try: + logged_in = await _login_with_saved_provider_credentials(source_type) + except ProviderInteractionRequired: + raise HTTPException( + status_code=409, + detail=f"{provider.display_name}: session navigateur verifiee requise", + ) + except ProviderAuthError: + raise HTTPException(status_code=401, detail=f"{provider.display_name}: connexion requise") + except ProviderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not logged_in: + raise HTTPException(status_code=401, detail=f"{provider.display_name}: connexion requise") + + @app.get("/api/providers") async def list_providers(): providers = [] @@ -1544,14 +2183,102 @@ async def provider_login(source_type: str, body: ProviderLoginBody): if source_type not in _available_source_types(): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) + if not getattr(provider.capabilities, "can_login", False): + raise HTTPException(status_code=400, detail=f"{provider.display_name}: account connection unsupported") + username = (body.username or "").strip() + password = body.password or "" + saved_credentials = False + if password: + if not username: + raise HTTPException(status_code=400, detail="Username is required") + await db.save_provider_credentials(source_type, username, password) + saved_credentials = True + else: + credentials = await _saved_provider_credentials(source_type) + if not credentials: + raise HTTPException(status_code=400, detail="Username and password are required") + username, password = credentials + saved_credentials = True try: - result = await provider.login(body.username, body.password) + result = await provider.login(username, password) except ProviderInteractionRequired as exc: - raise HTTPException(status_code=409, detail=str(exc)) + return JSONResponse( + status_code=409, + content={"success": False, "savedCredentials": saved_credentials, "detail": str(exc)}, + ) except ProviderError as exc: - raise HTTPException(status_code=400, detail=str(exc)) + return JSONResponse( + status_code=400, + content={"success": False, "savedCredentials": saved_credentials, "detail": str(exc)}, + ) + if not result.get("success"): + return JSONResponse( + status_code=401, + content={ + "success": False, + "savedCredentials": saved_credentials, + "detail": result.get("error", "Login failed"), + }, + ) + result["savedCredentials"] = saved_credentials + return result + + +def _provider_session_payload(body: ProviderSessionBody) -> tuple[Optional[list[dict[str, Any]]], list[dict[str, Any]]]: + storage_state = body.storageState if isinstance(body.storageState, dict) else {} + cookies = body.cookies if body.cookies is not None else storage_state.get("cookies") + local_storage = ( + body.localStorage + if body.localStorage is not None + else body.origins + if body.origins is not None + else storage_state.get("origins") + ) + + if cookies is not None and not isinstance(cookies, list): + raise HTTPException(status_code=400, detail="Session cookies must be a JSON array or cookie header") + normalized_cookies = [ + cookie + for cookie in (cookies or []) + if isinstance(cookie, dict) and cookie.get("name") and cookie.get("value") is not None + ] + if local_storage is None: + local_storage = [] + if not isinstance(local_storage, list): + raise HTTPException(status_code=400, detail="localStorage/origins must be a JSON array") + normalized_storage = [entry for entry in local_storage if isinstance(entry, dict)] + return normalized_cookies or None, normalized_storage + + +@app.post("/api/providers/{source_type}/session") +async def provider_import_session(source_type: str, body: ProviderSessionBody): + source_type = _normalize_source_type(source_type) or "" + if source_type not in _available_source_types(): + raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") + provider = _provider_for(source_type) + if not getattr(provider.capabilities, "can_login", False): + raise HTTPException(status_code=400, detail=f"{provider.display_name}: session import unsupported") + cookies, local_storage = _provider_session_payload(body) + cookie_header = (body.cookieHeader or "").strip() + if not cookies and not cookie_header and not local_storage: + raise HTTPException(status_code=400, detail="No valid session data provided") + username = (body.username or "").strip() or None + try: + result = await provider.import_session( + username=username, + cookie_header=cookie_header or None, + cookies=cookies, + local_storage=local_storage, + user_agent=(body.userAgent or body.user_agent or None), + x_bc=(body.xBc or body.xbc or None), + ) + except ProviderError as exc: + return JSONResponse(status_code=400, content={"success": False, "detail": str(exc)}) if not result.get("success"): - raise HTTPException(status_code=401, detail=result.get("error", "Login failed")) + return JSONResponse( + status_code=401, + content={"success": False, "detail": result.get("error", "Session import failed")}, + ) return result @@ -1570,34 +2297,51 @@ async def provider_sync_following(source_type: str): if source_type not in _available_source_types(): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) + if not getattr(provider.capabilities, "can_sync_following", False): + raise HTTPException( + status_code=400, + detail=f"{provider.display_name}: remote following sync unsupported; local follows only", + ) try: - timeout = float(os.getenv("PSTREAMREC_FOLLOW_SYNC_TIMEOUT", "45") or "45") - items = await asyncio.wait_for(provider.sync_following(), timeout=timeout) + try: + items = await asyncio.wait_for(provider.sync_following(), timeout=60) + except ProviderAuthError: + await _ensure_saved_provider_login(provider, source_type) + items = await asyncio.wait_for(provider.sync_following(), timeout=60) except asyncio.TimeoutError: - raise HTTPException(status_code=504, detail=f"{provider.display_name}: sync timeout") + raise HTTPException(status_code=504, detail=f"{provider.display_name}: following sync timeout") + except ProviderInteractionRequired as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except ProviderAuthError as exc: + raise HTTPException(status_code=401, detail=str(exc)) except ProviderError as exc: raise HTTPException(status_code=400, detail=str(exc)) - synced = set() + synced_usernames = set() for item in items or []: username = item.get("username") if not username: continue + thumbnail = item.get("thumbnail_url") or item.get("thumbnail") await db.upsert_followed_model( username=username, display_name=item.get("display_name") or username, is_online=bool(item.get("is_online", item.get("isOnline", False))), viewers=int(item.get("viewers") or 0), - thumbnail_url=item.get("thumbnail") or item.get("thumbnail_url"), + thumbnail_url=thumbnail, source_type=source_type, room_status=item.get("room_status") or item.get("roomStatus"), ) - synced.add(username) + synced_usernames.add(username) - if source_type == "chaturbate": - await db.remove_unfollowed(synced, source_type=source_type) - await db.reconcile_model_sources_from_followed() - return {"synced": len(synced), "message": f"{provider.display_name}: {len(synced)} follows synchronises"} + await db.remove_unfollowed(synced_usernames, source_type=source_type) + repaired_sources = await db.reconcile_model_sources_from_followed() + return { + "synced": len(synced_usernames), + "sourceType": source_type, + "repairedSources": repaired_sources, + "message": f"{provider.display_name}: {len(synced_usernames)} follows synced", + } @app.get("/api/providers/{source_type}/is-following/{username}") @@ -1606,15 +2350,29 @@ async def provider_is_following(source_type: str, username: str): if source_type not in _available_source_types(): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) - remote = False - try: - if provider.capabilities.can_follow: - remote = bool(await provider.is_following(username)) - except Exception: - remote = False - local = await db.get_followed_model(username) + local = await db.get_followed_model(username, source_type=source_type) local_match = bool(local and (local.get("source_type") or "chaturbate") == source_type) - return {"isFollowing": remote or local_match} + remote_match = False + remote_checked = False + if getattr(provider.capabilities, "can_sync_following", False): + try: + remote_match = bool(await provider.is_following(username)) + remote_checked = True + except ProviderAuthError: + try: + await _ensure_saved_provider_login(provider, source_type) + remote_match = bool(await provider.is_following(username)) + remote_checked = True + except HTTPException: + remote_checked = False + except ProviderError: + remote_checked = False + return { + "isFollowing": bool(remote_match or local_match), + "localOnly": not getattr(provider.capabilities, "can_sync_following", False), + "localFollowing": local_match, + "remoteFollowing": remote_match if remote_checked else None, + } @app.post("/api/providers/{source_type}/follow/{username}") @@ -1623,13 +2381,24 @@ async def provider_follow(source_type: str, username: str): if source_type not in _available_source_types(): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) - if provider.capabilities.can_follow: - result = await provider.follow(username) - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("error", "Follow failed")) + if getattr(provider.capabilities, "can_sync_following", False): + try: + try: + result = await provider.follow(username) + except ProviderAuthError: + await _ensure_saved_provider_login(provider, source_type) + result = await provider.follow(username) + except ProviderInteractionRequired as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except ProviderAuthError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + except ProviderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not result.get("success", True): + return JSONResponse(status_code=400, content=result) + result = {**result, "localOnly": False, "action": result.get("action") or "follow"} else: - result = {"success": True, "localOnly": True} - + result = {"success": True, "localOnly": True, "action": "follow"} status = ProviderStatus(False, source_type=source_type) try: status = await _provider_status(source_type, username) @@ -1654,12 +2423,24 @@ async def provider_unfollow(source_type: str, username: str): if source_type not in _available_source_types(): raise HTTPException(status_code=404, detail=f"Source '{source_type}' non disponible") provider = _provider_for(source_type) - if provider.capabilities.can_follow: - result = await provider.unfollow(username) - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("error", "Unfollow failed")) + if getattr(provider.capabilities, "can_sync_following", False): + try: + try: + result = await provider.unfollow(username) + except ProviderAuthError: + await _ensure_saved_provider_login(provider, source_type) + result = await provider.unfollow(username) + except ProviderInteractionRequired as exc: + raise HTTPException(status_code=409, detail=str(exc)) + except ProviderAuthError as exc: + raise HTTPException(status_code=401, detail=str(exc)) + except ProviderError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + if not result.get("success", True): + return JSONResponse(status_code=400, content=result) + result = {**result, "localOnly": False, "action": result.get("action") or "unfollow"} else: - result = {"success": True, "localOnly": True} + result = {"success": True, "localOnly": True, "action": "unfollow"} await db.delete_followed_model(username, source_type=source_type) return result @@ -1680,19 +2461,20 @@ async def api_start(body: StartBody): logger.error("Champ 'target' vide dans la requête") raise HTTPException(status_code=400, detail="Champ 'target' requis") + requested_source = _normalize_source_type(body.source_type) model_settings = None if not target.startswith("http://") and not target.startswith("https://"): model_lookup_username = (body.person or target).strip() if model_lookup_username: try: - model_settings = await db.get_model(model_lookup_username) + model_settings = await db.get_model(model_lookup_username, source_type=requested_source) except Exception: model_settings = None # Si c'est un auto-start, vérifier que auto_record est activé dans la DB if body.auto_start: username = body.person or target - model = model_settings or await db.get_model(username) + model = model_settings or await db.get_model(username, source_type=requested_source) if model: auto_record = bool(model.get('auto_record', True)) if not auto_record: @@ -1713,7 +2495,6 @@ async def api_start(body: StartBody): max_height = await _get_recording_height_for_quality(record_quality) # Determine source type - requested_source = _normalize_source_type(body.source_type) stype = requested_source or await _infer_source_type(person or target, model_settings) if target.startswith("http://") or target.startswith("https://"): url_source = _source_type_from_url(target) @@ -1746,6 +2527,7 @@ async def api_start(body: StartBody): person = slugify(person) logger.subsection(f"Démarrage capture navigateur '{effective_source}'") try: + await _ensure_browser_capture_session(effective_source) sess = _start_browser_capture( effective_source, target, @@ -1767,6 +2549,9 @@ async def api_start(body: StartBody): except RuntimeError as e: logger.error("Session browser capture impossible", person=person, error=str(e)) raise HTTPException(status_code=409, detail=str(e)) + except ProviderError as e: + logger.error("Session browser capture refusee", person=person, error=str(e)) + raise HTTPException(status_code=400, detail=_provider_error_detail(effective_source, target, e)) except Exception as e: logger.critical("Erreur création browser capture", exc_info=True, person=person, error=str(e)) raise HTTPException(status_code=500, detail=f"Erreur serveur: {str(e)}") @@ -1858,7 +2643,10 @@ async def api_status(): @app.post("/api/stop/{session_id}") async def api_stop(session_id: str): + ffmpeg_session = manager.get_session(session_id) ok = manager.stop_session(session_id) + if ok and ffmpeg_session: + await _index_ffmpeg_recording(ffmpeg_session) if not ok: browser_session = browser_capture_manager.get(session_id) ok = browser_capture_manager.stop_session(session_id) @@ -2128,16 +2916,16 @@ async def api_process_restart(session_id: str): @app.get("/api/model/{username}/status") async def get_model_status(username: str, source: Optional[str] = None): """Récupère le statut d'un modèle via le registre provider.""" - model = await db.get_model(username) + source_type = _normalize_source_type(source) + model = await db.get_model(username, source_type=source_type) if not model or not model.get('source_type'): - followed = await db.get_followed_model(username) + followed = await db.get_followed_model(username, source_type=source_type) if followed and followed.get('source_type'): if not model: model = followed else: model = {**model, 'source_type': followed['source_type']} - source_type = _normalize_source_type(source) if not source_type: source_type = await _infer_source_type(username, model) @@ -2204,7 +2992,8 @@ async def get_model_stream(username: str, source: Optional[str] = None): try: model = None try: - model = await db.get_model(username) + requested_source = _normalize_source_type(source) + model = await db.get_model(username, source_type=requested_source) except Exception: pass source_type = _normalize_source_type(source) @@ -2217,8 +3006,10 @@ async def get_model_stream(username: str, source: Optional[str] = None): detail=f"Source '{source_type}' non disponible", ) if _supports_browser_capture(source_type): + status = None person = slugify(username) try: + await _ensure_browser_capture_session(source_type) sess = _start_browser_capture( source_type, username, @@ -2240,9 +3031,9 @@ async def get_model_stream(username: str, source: Optional[str] = None): "isOnline": True, "sourceType": source_type, "roomStatus": "public", - "viewers": 0, - "tags": [], - "thumbnail": None, + "viewers": int(status.viewers or 0) if status else 0, + "tags": list(status.tags or []) if status else [], + "thumbnail": status.thumbnail if status else None, } try: max_height = await _get_max_recording_height() @@ -2255,7 +3046,7 @@ async def get_model_stream(username: str, source: Optional[str] = None): return { "username": username, - "streamUrl": _proxied_stream_url(resolved), + **_watch_stream_payload(resolved), "isOnline": True, "sourceType": source_type, "roomStatus": resolved.room_status, @@ -2319,58 +3110,6 @@ async def get_thumbnail(username: str): ) -@app.get("/api/dashboard") -async def get_dashboard(): - """ - Endpoint optimisé qui retourne TOUTES les données depuis le cache SQLite - Ultra-rapide car tout est pré-calculé par la tâche de monitoring - """ - try: - # Récupérer tous les modèles depuis SQLite (déjà avec statut à jour) - models = await db.get_all_models() - - # Récupérer les sessions actives - active_sessions = manager.list_status() - - # Formater les données pour le frontend - models_info = [] - - for model in models: - username = model['username'] - source_type = await _infer_source_type(username, model) - - # Récupérer le nombre d'enregistrements depuis SQLite - recordings_count = await db.get_recordings_count(username) - - model_info = { - "username": username, - "isOnline": bool(model.get('is_online', False)), - "isRecording": bool(model.get('is_recording', False)), - "viewers": model.get('viewers', 0), - "thumbnail": f"/api/thumbnail/{username}", - "recordingsCount": recordings_count, - "recordQuality": model.get('record_quality', 'best'), - "retentionDays": model.get('retention_days', 30), - "autoRecord": bool(model.get('auto_record', True)), - "roomStatus": model.get('room_status'), - "sourceType": source_type, - "source_type": source_type, - } - - models_info.append(model_info) - - # Retourner tout d'un coup - return { - "models": models_info, - "sessions": active_sessions, - "timestamp": int(time.time() * 1000) - } - - except Exception as e: - logger.error("Erreur dashboard", error=str(e), exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) - - def _format_duration_label(duration_seconds: int) -> str: hours = duration_seconds // 3600 minutes = (duration_seconds % 3600) // 60 @@ -2593,6 +3332,442 @@ async def rescan_media_imports(): } +@app.get("/api/media-library") +async def get_media_library( + username: Optional[str] = None, + kind: str = "all", + search: str = "", + sort: str = "newest", + limit: int = 1000, + offset: int = 0, +): + """Liste les médias présents dans les dossiers records.""" + from .core.utils import format_bytes + + all_items = await _scan_media_library_items() + folder_profiles = _list_media_profile_folders() + media_profiles = await db.get_all_media_profiles() + all_models = await db.get_all_models() + media_profiles_by_username = { + profile["username"]: profile + for profile in media_profiles + if profile.get("username") + } + + profile_stats: dict[str, dict] = {} + for item in all_items: + profile = item["username"] + if profile not in profile_stats: + profile_stats[profile] = { + "username": profile, + "total": 0, + "videos": 0, + "images": 0, + "audio": 0, + "totalSize": 0, + } + stats = profile_stats[profile] + stats["total"] += 1 + stats["totalSize"] += int(item.get("size") or 0) + if item.get("type") == "video": + stats["videos"] += 1 + elif item.get("type") == "image": + stats["images"] += 1 + elif item.get("type") == "audio": + stats["audio"] += 1 + + if int(item.get("createdAt") or 0) >= int(stats.get("latestAt") or 0): + stats["latestAt"] = int(item.get("createdAt") or 0) + stats["latestTitle"] = item.get("title") or item.get("filename") or "" + stats["latestType"] = item.get("type") or "" + stats["thumbnail"] = item.get("thumbnail") + stats["previewUrl"] = item.get("url") + + profile_names = set(profile_stats) + profile_names.update(folder_profiles) + profile_names.update(media_profiles_by_username) + profile_names.update(model["username"] for model in all_models if model.get("username")) + + profiles = [] + for profile_name in profile_names: + profile = profile_stats.setdefault(profile_name, { + "username": profile_name, + "total": 0, + "videos": 0, + "images": 0, + "audio": 0, + "totalSize": 0, + "latestAt": None, + "latestTitle": "", + "latestType": "", + "thumbnail": None, + "previewUrl": None, + }) + metadata = media_profiles_by_username.get(profile_name) + model = _model_for_media_profile(profile_name, all_models) + formatted_metadata = _media_profile_formatted(metadata) + source_type = await _infer_source_type(profile_name, model) if model else "chaturbate" + profiles.append({ + **profile, + **formatted_metadata, + "displayName": formatted_metadata["displayName"] or (model or {}).get("display_name") or profile_name, + "folderExists": profile_name in folder_profiles, + "empty": int(profile.get("total") or 0) == 0, + "autoRecord": bool((model or {}).get("auto_record", False)), + "recordQuality": (model or {}).get("record_quality", "best"), + "retentionDays": (model or {}).get("retention_days", 30), + "sourceType": source_type, + "source_type": source_type, + "deleteUrl": f"/api/media-profiles/{quote(profile_name, safe='')}", + "totalSizeFormatted": format_bytes(profile["totalSize"]), + }) + profiles.sort(key=lambda item: ( + -int(item["total"] > 0), + -int(item.get("latestAt") or 0), + item["displayName"].lower(), + item["username"].lower(), + )) + + normalized_kind = (kind or "all").strip().lower() + kind_aliases = { + "photos": "image", + "photo": "image", + "images": "image", + "videos": "video", + "audio": "audio", + "all": "all", + "tous": "all", + } + normalized_kind = kind_aliases.get(normalized_kind, normalized_kind) + if normalized_kind not in {"all", "video", "image", "audio"}: + normalized_kind = "all" + + filtered = all_items + if username: + filtered = [item for item in filtered if item["username"] == username] + if normalized_kind != "all": + filtered = [item for item in filtered if item["type"] == normalized_kind] + if search: + query = search.strip().lower() + if query: + filtered = [ + item for item in filtered + if query in item["filename"].lower() + or query in item["title"].lower() + or query in item["username"].lower() + or query in item["relativePath"].lower() + ] + + sort_key = (sort or "newest").strip().lower() + reverse = True + if sort_key == "oldest": + key_func = lambda item: item["createdAt"] + reverse = False + elif sort_key == "largest": + key_func = lambda item: item["size"] + elif sort_key == "smallest": + key_func = lambda item: item["size"] + reverse = False + elif sort_key == "name": + key_func = lambda item: (item["title"].lower(), item["filename"].lower()) + reverse = False + else: + key_func = lambda item: item["createdAt"] + + filtered = sorted(filtered, key=key_func, reverse=reverse) + total = len(filtered) + limit = max(1, min(int(limit or 1000), 5000)) + offset = max(0, int(offset or 0)) + page_items = filtered[offset:offset + limit] + + library_stats = _media_library_stats(all_items) + filtered_stats = _media_library_stats(filtered) + return { + "items": page_items, + "total": total, + "limit": limit, + "offset": offset, + "hasMore": offset + limit < total, + "profiles": profiles, + "stats": { + **filtered_stats, + "totalSizeFormatted": format_bytes(filtered_stats["totalSize"]), + }, + "libraryStats": { + **library_stats, + "totalSizeFormatted": format_bytes(library_stats["totalSize"]), + }, + } + + +@app.get("/api/media-profiles/{username}") +async def get_media_profile(username: str): + """Retourne la fiche enrichie et les réglages stream d'un profil média.""" + username = _validate_media_profile_username(username) + profile_dir = _media_profile_dir(username) + profile = await db.get_media_profile(username) + model = await db.get_model(username) + recordings = await db.get_recordings(username) + if not profile_dir.exists() and not profile and not model and not recordings: + raise HTTPException(status_code=404, detail="Profil introuvable") + return await _media_profile_payload(username) + + +@app.put("/api/media-profiles/{username}") +async def update_media_profile(username: str, body: dict): + """Met à jour les informations locales et les réglages stream d'un profil.""" + username = _validate_media_profile_username(username) + requested_source = _normalize_source_type( + body.get("sourceType") or body.get("source_type") + ) or "chaturbate" + if requested_source not in _available_source_types(): + raise HTTPException( + status_code=400, + detail=f"Source '{requested_source}' non disponible", + ) + + profile_data = { + "display_name": body.get("displayName") or body.get("display_name") or "", + "first_name": body.get("firstName") or body.get("first_name") or "", + "last_name": body.get("lastName") or body.get("last_name") or "", + "age": body.get("age"), + "address": body.get("address") or "", + "city": body.get("city") or "", + "region": body.get("region") or "", + "postal_code": body.get("postalCode") or body.get("postal_code") or "", + "country": body.get("country") or "", + "aliases": body.get("aliases") or "", + "tags": body.get("tags") or "", + "notes": body.get("notes") or "", + "social_urls": body.get("socialUrls") or body.get("social_urls") or [], + "stream_urls": body.get("streamUrls") or body.get("stream_urls") or [], + "profile_urls": body.get("profileUrls") or body.get("profile_urls") or [], + } + await db.upsert_media_profile(username, profile_data) + + existing = await db.get_model(username, source_type=requested_source) + retention_days = _normalize_retention_days( + body.get("retentionDays"), + (existing or {}).get("retention_days", await _get_default_retention_days()), + ) + record_quality = ( + body.get("recordQuality") + or (existing or {}).get("record_quality") + or await _get_default_record_quality() + ) + auto_record = bool(body.get("autoRecord", (existing or {}).get("auto_record", False))) + + await db.add_or_update_model( + username=username, + display_name=profile_data["display_name"] or username, + auto_record=auto_record, + record_quality=record_quality, + retention_days=retention_days, + source_type=requested_source, + ) + + return { + "success": True, + "profile": await _media_profile_payload(username), + } + + +def _assert_media_profile_not_active(username: str): + for session in _all_recording_statuses(): + if session.get("person") == username and session.get("running"): + raise HTTPException( + status_code=403, + detail="Impossible de supprimer un profil en cours d'enregistrement.", + ) + + +@app.delete("/api/media-profiles/{username}") +async def delete_media_profile(username: str): + """Supprime la fiche, les enregistrements DB et le dossier records du profil.""" + username = _validate_media_profile_username(username) + profile_dir = _media_profile_dir(username) + _assert_media_profile_not_active(username) + + profile = await db.get_media_profile(username) + model = await db.get_model(username) + recordings = await db.get_recordings(username) + folder_exists = profile_dir.exists() + if not folder_exists and not profile and not model and not recordings: + raise HTTPException(status_code=404, detail="Profil introuvable") + if folder_exists and not profile_dir.is_dir(): + raise HTTPException(status_code=400, detail="Le profil records n'est pas un dossier") + + for rec in recordings: + recording_id = rec.get("recording_id") + if recording_id: + await db.delete_playback_position(recording_id) + removed_recordings = await db.delete_recordings_for_username(username) + await db.delete_model(username) + await db.delete_media_profile(username) + + folder_deleted = False + if folder_exists: + shutil.rmtree(profile_dir) + folder_deleted = True + + thumb_dir = OUTPUT_DIR / "thumbnails" / username + thumbs_deleted = False + try: + if thumb_dir.exists() and thumb_dir.is_dir() and _path_is_inside_output(thumb_dir): + shutil.rmtree(thumb_dir) + thumbs_deleted = True + except OSError as e: + logger.warning("Suppression miniatures profil impossible", username=username, error=str(e)) + + logger.info( + "Profil média supprimé", + username=username, + folder_deleted=folder_deleted, + removed_recordings=removed_recordings, + ) + return { + "success": True, + "username": username, + "folderDeleted": folder_deleted, + "thumbnailsDeleted": thumbs_deleted, + "removedRecordings": removed_recordings, + } + + +def _resolved_path_key(value: Optional[str]) -> Optional[str]: + if not value: + return None + try: + return str(Path(value).resolve()) + except Exception: + return None + + +def _path_is_inside_output(path: Path) -> bool: + try: + return path.resolve().is_relative_to(OUTPUT_DIR.resolve()) + except Exception: + return False + + +async def _find_recording_for_media_path(username: str, media_path: Path, relative_path: str) -> Optional[dict]: + target_key = _resolved_path_key(str(media_path)) + recordings = await db.get_recordings(username) + for rec in recordings: + for key in ("file_path", "mp4_path", "playable_path"): + if target_key and _resolved_path_key(rec.get(key)) == target_key: + return rec + if len(Path(relative_path).parts) == 1 and rec.get("filename") == media_path.name: + return rec + return None + + +def _assert_media_not_active(username: str, media_path: Path): + target_key = _resolved_path_key(str(media_path)) + for session in _all_recording_statuses(): + if not (session.get("person") == username and session.get("running")): + continue + + record_path = session.get("record_path") or "" + active_key = _resolved_path_key(record_path) + if active_key and target_key and active_key == target_key: + raise HTTPException( + status_code=403, + detail="Impossible de supprimer un média en cours d'enregistrement.", + ) + if record_path and Path(record_path).stem == media_path.stem: + raise HTTPException( + status_code=403, + detail="Impossible de supprimer un média en cours d'enregistrement.", + ) + + +async def _delete_media_library_record(username: str, media_path: Path, relative_path: str) -> dict: + rec = await _find_recording_for_media_path(username, media_path, relative_path) + if rec and rec.get("media_kind") == "import": + deleted = await remove_import_record( + db, + rec, + reason="media_library_delete", + delete_original=True, + ) + if not deleted: + raise HTTPException(status_code=404, detail="Média introuvable") + return { + "success": True, + "message": "Média supprimé", + "deletedFiles": [media_path.name], + "removedRecord": True, + } + + paths_to_delete: list[Path] = [media_path] + removed_record = False + recording_id = None + + if rec: + removed_record = True + recording_id = rec.get("recording_id") + for key in ("file_path", "mp4_path", "playable_path", "thumbnail_path"): + value = rec.get(key) + if value: + paths_to_delete.append(Path(value)) + + generated_thumb = OUTPUT_DIR / "thumbnails" / username / f"{media_path.stem}.jpg" + paths_to_delete.append(generated_thumb) + + deleted_files = [] + seen = set() + for path in paths_to_delete: + path_key = _resolved_path_key(str(path)) + if not path_key or path_key in seen: + continue + seen.add(path_key) + if not _path_is_inside_output(path): + continue + try: + if path.exists() and path.is_file(): + path.unlink() + deleted_files.append(path.name) + except OSError as e: + logger.warning("Suppression média impossible", path=str(path), error=str(e)) + + if rec: + if recording_id: + await db.delete_recording_by_id(recording_id) + await db.delete_playback_position(recording_id) + else: + await db.delete_recording(username, rec.get("filename") or media_path.name) + + if not deleted_files and not removed_record: + raise HTTPException(status_code=404, detail="Média introuvable") + + return { + "success": True, + "message": "Média supprimé", + "deletedFiles": deleted_files, + "removedRecord": removed_record, + } + + +@app.delete("/api/media-library/{username}/{file_path:path}") +async def delete_media_library_item(username: str, file_path: str): + """Supprime un média depuis la bibliothèque locale records.""" + media_path = _resolve_library_media_path(username, file_path) + if not media_path.exists() or not media_path.is_file(): + raise HTTPException(status_code=404, detail="Média introuvable") + + _assert_media_not_active(username, media_path) + result = await _delete_media_library_record(username, media_path, file_path) + logger.info( + "Média bibliothèque supprimé", + username=username, + file=file_path, + deleted_files=result.get("deletedFiles", []), + removed_record=result.get("removedRecord"), + ) + return result + + @app.get("/api/all-recordings") async def get_all_recordings( page: int = 1, @@ -2779,8 +3954,18 @@ async def add_model(model: dict): if not username: raise HTTPException(status_code=400, detail="Username requis") + requested_source = _normalize_source_type( + model.get("sourceType") or model.get("source_type") + ) + source_type = requested_source or await _infer_source_type(username) + if source_type not in _available_source_types(): + raise HTTPException( + status_code=400, + detail=f"Source '{source_type}' non disponible", + ) + # Vérifier si le modèle existe déjà - existing = await db.get_model(username) + existing = await db.get_model(username, source_type=source_type) if existing: raise HTTPException(status_code=409, detail="Modèle déjà existant") @@ -2793,16 +3978,6 @@ async def add_model(model: dict): record_quality = await _get_default_record_quality() else: record_quality = 'best' - requested_source = _normalize_source_type( - model.get("sourceType") or model.get("source_type") - ) - source_type = requested_source or await _infer_source_type(username) - if source_type not in _available_source_types(): - raise HTTPException( - status_code=400, - detail=f"Source '{source_type}' non disponible", - ) - if "retentionDays" in model and model.get("retentionDays") is not None: retention_days = _normalize_retention_days(model.get("retentionDays")) else: @@ -2834,8 +4009,17 @@ async def add_model(model: dict): @app.put("/api/models/{username}") async def update_model(username: str, model_data: dict): """Met à jour les paramètres d'un modèle dans SQLite""" + requested_source = _normalize_source_type( + model_data.get('sourceType') or model_data.get('source_type') + ) + if requested_source and requested_source not in _available_source_types(): + raise HTTPException( + status_code=400, + detail=f"Source '{requested_source}' non disponible", + ) + # Vérifier si le modèle existe - existing = await db.get_model(username) + existing = await db.get_model(username, source_type=requested_source) if not existing: raise HTTPException(status_code=404, detail="Modèle introuvable") @@ -2847,14 +4031,7 @@ async def update_model(username: str, model_data: dict): else: retention_days = existing.get("retention_days", 30) - requested_source = _normalize_source_type( - model_data.get('sourceType') or model_data.get('source_type') - ) - if requested_source and requested_source not in _available_source_types(): - raise HTTPException( - status_code=400, - detail=f"Source '{requested_source}' non disponible", - ) + source_type = requested_source or existing.get("source_type") or "chaturbate" # Mettre à jour dans SQLite await db.add_or_update_model( @@ -2862,11 +4039,11 @@ async def update_model(username: str, model_data: dict): auto_record=model_data.get('autoRecord', existing.get('auto_record', True)), record_quality=model_data.get('recordQuality', existing.get('record_quality', 'best')), retention_days=retention_days, - source_type=requested_source, + source_type=source_type, ) # Récupérer le modèle mis à jour - updated = await db.get_model(username) + updated = await db.get_model(username, source_type=source_type) return { "success": True, @@ -2882,15 +4059,16 @@ async def update_model(username: str, model_data: dict): @app.delete("/api/models/{username}") -async def delete_model(username: str): +async def delete_model(username: str, source: Optional[str] = None): """Supprime un modèle de SQLite""" + source_type = _normalize_source_type(source) # Vérifier si le modèle existe - existing = await db.get_model(username) + existing = await db.get_model(username, source_type=source_type) if not existing: raise HTTPException(status_code=404, detail="Modèle introuvable") # Supprimer de SQLite - await db.delete_model(username) + await db.delete_model(username, source_type=source_type) # Récupérer la liste mise à jour all_models = await db.get_all_models() @@ -3671,15 +4849,6 @@ async def is_following_model(username: str): @app.patch("/api/models/{username}/auto-record") async def toggle_auto_record(username: str, body: dict): """Toggle auto-record for a model""" - existing = await db.get_model(username) - if not existing: - raise HTTPException(status_code=404, detail="Model not found") - - auto_record = body.get("autoRecord") - if auto_record is None: - raise HTTPException(status_code=400, detail="autoRecord field required") - - new_auto = bool(auto_record) requested_source = _normalize_source_type( body.get("sourceType") or body.get("source_type") ) @@ -3688,6 +4857,16 @@ async def toggle_auto_record(username: str, body: dict): status_code=400, detail=f"Source '{requested_source}' non disponible", ) + existing = await db.get_model(username, source_type=requested_source) + if not existing: + raise HTTPException(status_code=404, detail="Model not found") + + auto_record = body.get("autoRecord") + if auto_record is None: + raise HTTPException(status_code=400, detail="autoRecord field required") + + source_type = requested_source or existing.get("source_type") or "chaturbate" + new_auto = bool(auto_record) was_auto = bool(existing.get("auto_record", False)) record_quality = existing.get("record_quality", "best") # On the off -> on transition, apply the global default resolution so the @@ -3700,13 +4879,13 @@ async def toggle_auto_record(username: str, body: dict): auto_record=new_auto, record_quality=record_quality, retention_days=existing.get("retention_days", 30), - source_type=requested_source, + source_type=source_type, ) return { "success": True, "autoRecord": new_auto, "recordQuality": record_quality, - "sourceType": requested_source or existing.get("source_type") or "chaturbate", + "sourceType": source_type, } @@ -3984,8 +5163,9 @@ async def auto_record_task(): if is_recording: continue # Déjà en cours + source_hint = model.get("source_type") or "chaturbate" # Vérifier le statut depuis le cache SQLite (mis à jour par monitor) - cached_status = await db.get_model(username) + cached_status = await db.get_model(username, source_type=source_hint) if cached_status and cached_status.get('is_online'): # Modèle en ligne: résoudre le flux HLS @@ -4007,6 +5187,7 @@ async def auto_record_task(): if _supports_browser_capture(source_type): logger.background_task("auto-record", "Modèle WebRTC en ligne détecté", username=username) try: + await _ensure_browser_capture_session(source_type) sess = _start_browser_capture( source_type, username, @@ -4027,6 +5208,11 @@ async def auto_record_task(): task="auto-record", username=username, error=str(e)) + except ProviderError as e: + logger.warning("Browser capture refusee", + task="auto-record", + username=username, + error=str(e)) else: try: resolved = await _resolve_stream( @@ -4208,6 +5394,7 @@ async def sync_cam4_following_task(cam4_auth): continue items = await cam4_source.list_followed(cam4_auth.get_cookies()) + synced = set() for item in items: await db.upsert_followed_model( username=item["username"], @@ -4218,6 +5405,8 @@ async def sync_cam4_following_task(cam4_auth): source_type="cam4", room_status=item.get("room_status"), ) + synced.add(item["username"]) + await db.remove_unfollowed(synced, source_type="cam4") if items: repaired = await db.reconcile_model_sources_from_followed() logger.debug("CAM4 following synced", count=len(items), task="cam4-sync") @@ -4273,6 +5462,19 @@ async def startup_event(): # Initialize Chaturbate auth service cb_auth = ChaturbateAuthService(db, flaresolverr) await cb_auth.initialize() + if CHATURBATE_USERNAME and CHATURBATE_PASSWORD: + try: + result = await cb_auth.login(CHATURBATE_USERNAME, CHATURBATE_PASSWORD) + if result.get("success"): + logger.info("Chaturbate auto-login succeeded", username=CHATURBATE_USERNAME) + else: + logger.warning( + "Chaturbate auto-login failed", + username=CHATURBATE_USERNAME, + error=result.get("error"), + ) + except Exception as exc: + logger.warning("Chaturbate auto-login error", username=CHATURBATE_USERNAME, error=str(exc)) # Initialize CAM4 auth service (cookie-based session) global cam4_auth_service @@ -4306,15 +5508,6 @@ async def startup_event(): # Wire CAM4 router avec auth service cam4_router.init(cam4_auth_service, db) - # Auto-login if env vars are set - if CHATURBATE_USERNAME and CHATURBATE_PASSWORD: - logger.info("Auto-login Chaturbate", username=CHATURBATE_USERNAME) - result = await cb_auth.login(CHATURBATE_USERNAME, CHATURBATE_PASSWORD) - if result.get("success"): - logger.success("Chaturbate auto-login successful") - else: - logger.warning("Chaturbate auto-login failed", error=result.get("error")) - # Démarrer les tâches de fond asyncio.create_task(monitor_models_task( db, @@ -4326,13 +5519,13 @@ async def startup_event(): )) asyncio.create_task(ffmpeg_watchdog_task()) asyncio.create_task(auto_record_task()) + asyncio.create_task(sync_following_task(cb_api, cb_auth)) + asyncio.create_task(sync_cam4_following_task(cam4_auth_service)) asyncio.create_task(cleanup_old_recordings_task()) asyncio.create_task(auto_convert_recordings_task(db, OUTPUT_DIR, manager, FFMPEG_PATH)) if MEDIA_IMPORTS_ENABLED: global media_import_manager media_import_manager = MediaImportManager(db, OUTPUT_DIR, FFMPEG_PATH) asyncio.create_task(media_imports_task(media_import_manager)) - asyncio.create_task(sync_following_task(cb_api, cb_auth)) - asyncio.create_task(sync_cam4_following_task(cam4_auth_service)) logger.info("Background tasks démarrés", - tasks=["monitor", "ffmpeg-watchdog", "auto-record", "cleanup", "convert", "following-sync", "cam4-sync"]) + tasks=["monitor", "ffmpeg-watchdog", "auto-record", "following-sync", "cam4-sync", "cleanup", "convert"]) diff --git a/app/providers/base.py b/app/providers/base.py index 8c91591..2d7a961 100644 --- a/app/providers/base.py +++ b/app/providers/base.py @@ -135,6 +135,17 @@ async def list_live_models(self, **kwargs) -> dict[str, Any]: async def login(self, username: str, password: str) -> dict[str, Any]: raise ProviderError(f"Connexion non supportee pour {self.display_name or self.source_type}") + async def import_session( + self, + username: Optional[str] = None, + cookie_header: Optional[str] = None, + cookies: Optional[list[dict[str, Any]]] = None, + local_storage: Optional[list[dict[str, Any]]] = None, + user_agent: Optional[str] = None, + x_bc: Optional[str] = None, + ) -> dict[str, Any]: + raise ProviderError(f"Import de session non supporte pour {self.display_name or self.source_type}") + async def logout(self) -> dict[str, Any]: if self.session_store: await self.session_store.clear(self.source_type) diff --git a/app/providers/browser.py b/app/providers/browser.py index 7d08d4c..120f487 100644 --- a/app/providers/browser.py +++ b/app/providers/browser.py @@ -7,20 +7,23 @@ import re import time from pathlib import Path -from typing import Iterable, Optional +from typing import Any, Iterable, Optional from urllib.parse import quote_plus, unquote, urlencode, urljoin, urlparse import aiohttp +from yarl import URL from ..core.http_client import aiohttp_client_session, aiohttp_request_kwargs from ..logger import logger from .base import ( BaseProvider, ProviderCapabilities, + ProviderAuthError, ProviderError, ProviderInteractionRequired, ProviderOfflineError, ProviderPrivateError, + ProviderStatus, ResolvedStream, ) from .sessions import ProviderSessionStore @@ -30,6 +33,24 @@ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) +STRIPCHAT_BASE_URL = "https://stripchat.com" +STRIPCHAT_API_BASE = f"{STRIPCHAT_BASE_URL}/api/front" +STRIPCHAT_FRONT_VERSION = os.getenv("PSTREAMREC_STRIPCHAT_FRONT_VERSION", "11.7.28") +STRIPCHAT_LOGIN_PATHS = ( + "/auth/login", + "/v3/auth/login", + "/v2/auth/login", + "/login", +) +STRIPCHAT_HLS_HOSTS = ( + "doppiocdn.net", + "doppiocdn.com", + "doppiocdn.org", + "doppiocdn.live", + "doppiocdn.media", +) +STRIPCHAT_PLAYBACK_KEY = os.getenv("PSTREAMREC_STRIPCHAT_PLAYBACK_KEY", "fncnu6utiWqsDLk8") +BONGACAMS_BASE_URL = "https://bongacams.com" _MEDIA_URL_RE = re.compile( r"https?:\\?/\\?/[^\s\"'<>]+?\.(?:m3u8|mpd)(?:\?[^\s\"'<>]*)?", re.IGNORECASE, @@ -38,7 +59,19 @@ r"""["']hlsUrl["']\s*:\s*["']([^"']+)["']""", re.IGNORECASE, ) -_INTERACTION_RE = re.compile(r"captcha|2fa|two-factor|cloudflare|verify you are human", re.IGNORECASE) +_INTERACTION_RE = re.compile( + r"captcha|hcaptcha|recaptcha|turnstile|2fa|two-factor|cloudflare|cloudfront|request blocked|verify you are human", + re.IGNORECASE, +) +_AUTH_REQUIRED_RE = re.compile( + r"\b(log\s*in|login|sign\s*in|signin)\b|please\s+log\s+in|auth(?:entication)?\s+required", + re.IGNORECASE, +) +_LOGIN_FAILED_RE = re.compile( + r"invalid|incorrect|wrong|failed|try again|not recognized|not recognised|" + r"could not log|unable to log|password.*required|username.*required", + re.IGNORECASE, +) _OFFLINE_RE = re.compile(r"offline|not currently online|not live|away", re.IGNORECASE) _PRIVATE_RE = re.compile(r"private|ticket show|group show|premium", re.IGNORECASE) _ANCHOR_RE = re.compile(r"[^>]*)>(?P.*?)", re.IGNORECASE | re.DOTALL) @@ -99,12 +132,15 @@ "profile", "root", "search", + "settings", "show", "shows", "signup", "standard", + "store", "stream", "streams", + "subscriptions", "video", "videos", "viewer", @@ -151,6 +187,7 @@ "male", "members", "models", + "my", "new", "online", "performers", @@ -159,10 +196,14 @@ "privacy.php", "profile", "search", + "settings", "sex-cam", "signup", + "store", + "subscriptions", "support", "terms", + "tos", "trans", "transgender", "videos", @@ -180,6 +221,8 @@ def extract_media_urls(text: str) -> list[str]: out = [] for match in _MEDIA_URL_RE.finditer(text or ""): url = _clean_media_url(match.group(0)) + if not _is_probable_media_url(url): + continue if url not in seen: seen.add(url) out.append(url) @@ -197,17 +240,6 @@ def extract_hls_url_fields(text: str) -> list[str]: return out -def streamate_hls_manifest(payload: object) -> Optional[str]: - if not isinstance(payload, dict): - return None - formats = payload.get("formats") if isinstance(payload.get("formats"), dict) else {} - hls_format = formats.get("mp4-hls") if isinstance(formats.get("mp4-hls"), dict) else {} - manifest = str(hls_format.get("manifest") or "").strip() - if manifest.startswith("http://") or manifest.startswith("https://"): - return manifest - return None - - def _parse_attrs(raw: str) -> dict[str, str]: attrs: dict[str, str] = {} for match in _ATTR_RE.finditer(raw or ""): @@ -507,8 +539,15 @@ def _subject_from_context(context: str) -> str: return "" +def _is_probable_media_url(value: str) -> bool: + lower = (value or "").lower() + if "/ping.m3u8" in lower: + return False + return ".m3u8" in lower or ".mpd" in lower + + class BrowserCaptureProvider(BaseProvider): - capabilities = ProviderCapabilities(can_login=True, uses_browser=True) + capabilities = ProviderCapabilities(can_follow=True, uses_browser=True) def __init__( self, @@ -520,6 +559,8 @@ def __init__( browser_root: Optional[Path] = None, login_templates: Optional[Iterable[str]] = None, discover_templates: Optional[Iterable[str]] = None, + can_login: bool = False, + can_remote_follow: Optional[bool] = None, can_stream: bool = True, can_record: bool = True, ): @@ -532,7 +573,9 @@ def __init__( self.login_templates = tuple(login_templates or self.url_templates) self.discover_templates = tuple(discover_templates or ()) self.capabilities = ProviderCapabilities( - can_login=True, + can_login=can_login, + can_follow=True, + can_sync_following=False, can_discover=bool(self.discover_templates), can_stream=can_stream, can_record=can_record, @@ -540,6 +583,15 @@ def __init__( ) self._discover_cache: dict[tuple, tuple[float, dict]] = {} + def _browser_args(self) -> list[str]: + return [ + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + "--disable-setuid-sandbox", + "--lang=en-US,en", + "--no-sandbox", + ] + def canonical_url(self, target: str) -> str: target = (target or "").strip() if target.startswith("http://") or target.startswith("https://"): @@ -608,6 +660,18 @@ async def list_live_models(self, **kwargs) -> dict[str, object]: if cached and time.monotonic() - cached[0] < ttl: return dict(cached[1]) + if self.source_type == "stripchat": + api_result = await self._stripchat_list_live_models_api( + page=page, + limit=limit, + search=search, + gender=gender, + tags=tags, + ) + if api_result is not None: + self._discover_cache[cache_key] = (time.monotonic(), api_result) + return dict(api_result) + urls = self.discover_urls(page, search, gender, tags) items: list[dict[str, object]] = [] for page_url in urls: @@ -674,12 +738,8 @@ async def list_live_models(self, **kwargs) -> dict[str, object]: async def resolve_stream( self, target: str, max_height: Optional[int] = None ) -> ResolvedStream: - if self.source_type == "streamate": - stream = await self._resolve_streamate_hls(target, max_height=max_height) - if stream: - return stream - if self.source_type == "flirt4free": - stream = await self._resolve_flirt4free_hls(target) + if self.source_type == "stripchat": + stream = await self._resolve_stripchat_public_hls(target, max_height=max_height) if stream: return stream urls = self.candidate_urls(target) @@ -688,73 +748,6 @@ async def resolve_stream( return stream return await self._resolve_from_browser(urls, target) - async def _resolve_streamate_hls( - self, - target: str, - max_height: Optional[int] = None, - ) -> Optional[ResolvedStream]: - del max_height - username = self._username_from_url(target) if target.startswith(("http://", "https://")) else (target or "").strip() - username = (username or "").strip("@/ ") - if not re.match(r"^[A-Za-z0-9_.-]{2,64}$", username): - return None - - page_url = self.canonical_url(username) - manifest_probe_url = f"https://manifest-server.naiadsystems.com/live/s:{quote_plus(username)}.json" - cookie_header = await self._cookie_header(None) - headers = self._stream_headers(page_url, cookie_header) - headers["Accept"] = "application/json,text/plain,*/*" - - try: - async with aiohttp_client_session(timeout=aiohttp.ClientTimeout(total=20)) as session: - async with session.get( - manifest_probe_url, - headers=headers, - allow_redirects=True, - **aiohttp_request_kwargs(), - ) as resp: - if resp.status == 404: - raise ProviderOfflineError(f"Aucun flux public Streamate pour {username}") - if resp.status in (401, 403): - raise ProviderInteractionRequired("Streamate demande une session ou une interaction") - if resp.status >= 400: - return None - payload = await resp.json(content_type=None) - manifest_url = streamate_hls_manifest(payload) - if not manifest_url: - return None - async with session.get( - manifest_url, - headers=headers, - allow_redirects=True, - **aiohttp_request_kwargs(), - ) as resp: - text = await resp.text(errors="ignore") - if resp.status >= 400 or "#EXTM3U" not in text: - return None - except (ProviderOfflineError, ProviderInteractionRequired): - raise - except Exception as exc: - logger.debug( - "Streamate manifest probe failed", - source_type=self.source_type, - username=username, - error=str(exc), - ) - return None - - return ResolvedStream( - url=manifest_url, - headers=self._stream_headers(page_url, cookie_header), - source_type=self.source_type, - is_live=True, - room_status="public", - viewers=0, - tags=[], - thumbnail=None, - title=None, - ) - async def _resolve_from_http(self, urls: list[str]) -> Optional[ResolvedStream]: headers = { "User-Agent": DEFAULT_USER_AGENT, @@ -848,13 +841,15 @@ async def _fetch_discover_html_browser(self, page_url: str, search: str = "") -> headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} user_data_dir = self.browser_root / self.source_type user_data_dir.mkdir(parents=True, exist_ok=True) + browser_user_agent = await self._stored_browser_user_agent() async with async_playwright() as playwright: context = await playwright.chromium.launch_persistent_context( str(user_data_dir), headless=headless, - user_agent=DEFAULT_USER_AGENT, + user_agent=browser_user_agent, viewport={"width": 1280, "height": 900}, + args=self._browser_args(), ) try: await self._restore_cookies(context) @@ -923,16 +918,14 @@ def _parse_discover_models(self, html_text: str, page_url: str) -> list[dict[str specialized_parser = { "camsoda": self._parse_camsoda_models, "cams": self._parse_cams_models, - "flirt4free": self._parse_flirt4free_models, "livejasmin": self._parse_livejasmin_models, "myfreecams": self._parse_myfreecams_models, "stripchat": self._parse_stripchat_models, - "streamate": self._parse_streamate_models, "xcams": self._parse_xcams_models, }.get(self.source_type) if specialized_parser: items.extend(specialized_parser(html_text, page_url)) - if items or self.source_type in {"camsoda", "cams", "flirt4free", "livejasmin", "myfreecams", "streamate", "xcams"}: + if items or self.source_type in {"camsoda", "cams", "livejasmin", "myfreecams", "xcams"}: return items for match in _ANCHOR_RE.finditer(html_text or ""): attrs = _parse_attrs(match.group("attrs")) @@ -979,36 +972,88 @@ def _parse_stripchat_models(self, html_text: str, page_url: str) -> list[dict[st payloads = self._embedded_json_payloads(html_text) models: list[dict[str, object]] = [] for payload in payloads: + models.extend(self._stripchat_models_from_payload(payload)) + items = [item for model in models if (item := self._stripchat_model_item(model))] + return self._dedupe_discover_models(items) + + def _stripchat_models_from_payload(self, payload: object) -> list[dict[str, object]]: + models: list[dict[str, object]] = [] + if isinstance(payload, dict): + profile_model = self._stripchat_profile_model(payload) + if profile_model: + models.append(profile_model) + direct_models = payload.get("models") + if isinstance(direct_models, list): + models.extend(model for model in direct_models if isinstance(model, dict)) for block in self._find_dicts_with_key(payload, "models"): block_models = block.get("models") if isinstance(block_models, list): models.extend(model for model in block_models if isinstance(model, dict)) - items: list[dict[str, object]] = [] + elif isinstance(payload, list): + models.extend(model for model in payload if isinstance(model, dict)) + return self._dedupe_stripchat_models(models) + + def _stripchat_profile_model(self, payload: dict[str, object]) -> Optional[dict[str, object]]: + item = payload.get("item") + if isinstance(item, dict) and item.get("username"): + return dict(item) + user_block = payload.get("user") + if isinstance(user_block, dict): + inner = user_block.get("user") + if isinstance(inner, dict) and inner.get("username"): + model = dict(inner) + for key in ("isInFavorites", "tags", "lastTagsAliases", "tagGroups"): + if key in user_block: + model[key] = user_block[key] + cam = payload.get("cam") + if isinstance(cam, dict): + if cam.get("streamName"): + model["streamName"] = cam.get("streamName") + if cam.get("isCamAvailable") is not None: + model["isOnline"] = bool(cam.get("isCamAvailable")) + return model + return None + + def _dedupe_stripchat_models(self, models: list[dict[str, object]]) -> list[dict[str, object]]: + by_key: dict[str, dict[str, object]] = {} for model in models: - username = str(model.get("username") or "").strip() - if not username: + username = str(model.get("username") or model.get("login") or "").strip().lower() + model_id = str(model.get("id") or model.get("streamName") or "").strip() + key = username or model_id + if not key: continue - status = str(model.get("status") or "").lower() - if status and status != "public": - room_status = status - else: - room_status = "public" - tags = self._stripchat_tags(model) - thumbnail = self._stripchat_thumbnail(model) - items.append({ - "username": username, - "display_name": username, - "thumbnail": thumbnail, - "viewers": int(model.get("viewersCount") or model.get("viewers") or 0), - "subject": str(model.get("groupShowTopic") or ""), - "age": None, - "gender": str(model.get("genderGroup") or model.get("gender") or "").lower(), - "is_online": bool(model.get("isOnline", model.get("isLive", True))), - "tags": tags, - "room_status": room_status, - "source_type": self.source_type, - }) - return self._dedupe_discover_models(items) + existing = by_key.get(key) + if existing: + for field, value in model.items(): + if value not in (None, "", [], {}): + existing.setdefault(field, value) + continue + by_key[key] = dict(model) + return list(by_key.values()) + + def _stripchat_model_item(self, model: dict[str, object]) -> Optional[dict[str, object]]: + username = str(model.get("username") or model.get("login") or "").strip() + if not username: + return None + status = str(model.get("status") or "").lower() + room_status = status if status and status != "public" else "public" + try: + viewers = int(model.get("viewersCount") or model.get("viewers") or model.get("usersCount") or 0) + except (TypeError, ValueError): + viewers = 0 + return { + "username": username, + "display_name": str(model.get("name") or username), + "thumbnail": self._stripchat_thumbnail(model), + "viewers": viewers, + "subject": str(model.get("groupShowTopic") or model.get("offlineStatus") or ""), + "age": model.get("age") if isinstance(model.get("age"), int) else None, + "gender": str(model.get("genderGroup") or model.get("gender") or "").lower(), + "is_online": bool(model.get("isOnline", model.get("isLive", status == "public"))), + "tags": self._stripchat_tags(model), + "room_status": room_status, + "source_type": self.source_type, + } def _stripchat_tags(self, model: dict[str, object]) -> list[str]: gender = str(model.get("gender") or model.get("broadcastGender") or model.get("genderGroup") or "").strip() @@ -1057,7 +1102,7 @@ def _stripchat_thumbnail(self, model: dict[str, object]) -> Optional[str]: timestamp = str(model.get("snapshotTimestamp") or model.get("verifiedSnapshotTimestamp") or "").strip() if model_id and timestamp: return f"https://img.doppiocdn.net/snapshot/{model_id}/{timestamp}" - for key in ("previewUrlThumbSmall", "avatarUrl"): + for key in ("previewUrlThumbSmall", "previewUrlThumbBig", "previewUrl", "avatarUrl", "avatarUrlThumb"): value = str(model.get(key) or "").strip() if not value: continue @@ -1067,6 +1112,97 @@ def _stripchat_thumbnail(self, model: dict[str, object]) -> Optional[str]: return f"https://img.doppiocdn.net{value}" return None + async def _stripchat_list_live_models_api( + self, + page: int, + limit: int, + gender: Optional[str], + search: str, + tags: list[str], + ) -> Optional[dict[str, object]]: + try: + if search: + profile = await self._stripchat_model_by_username(search) + item = self._stripchat_model_item(profile) + items = [item] if item and item.get("is_online") else [] + items = self._stripchat_filter_items(items, search="", tags=tags) + return { + "models": items[:limit], + "total": len(items), + "page": page, + "limit": limit, + "total_pages": 1, + } + + request_limit = min(max(limit, 24), 100) + payload = await self._stripchat_api_json( + "GET", + "/v2/models", + params={ + "primaryTag": self._stripchat_primary_tag(gender, tags), + "limit": request_limit, + "offset": max(0, (page - 1) * limit), + }, + referer=f"{STRIPCHAT_BASE_URL}/girls", + ) + except Exception as exc: + logger.debug("Stripchat public model API failed", error=str(exc)) + return None + + items = [item for model in self._stripchat_models_from_payload(payload) if (item := self._stripchat_model_item(model))] + items = self._stripchat_filter_items(items, search=search, tags=tags) + items.sort(key=lambda item: int(item.get("viewers") or 0), reverse=True) + total = len(items) + if isinstance(payload, dict) and not search and not tags: + try: + total = max(total, int(payload.get("totalCount") or 0)) + except (TypeError, ValueError): + pass + total_pages = max(1, (total + limit - 1) // limit) + if search or tags: + start = (page - 1) * limit + page_items = items[start:start + limit] + else: + page_items = items[:limit] + return { + "models": page_items, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + } + + def _stripchat_filter_items( + self, + items: list[dict[str, object]], + search: str = "", + tags: Optional[list[str]] = None, + ) -> list[dict[str, object]]: + tags = [str(tag).lower() for tag in (tags or []) if str(tag).strip()] + if search: + lowered = search.lower() + items = [ + item for item in items + if lowered in str(item.get("username") or "").lower() + or lowered in str(item.get("display_name") or "").lower() + ] + if tags: + items = [ + item for item in items + if all(tag in [str(value).lower() for value in (item.get("tags") or [])] for tag in tags) + ] + return items + + def _stripchat_primary_tag(self, gender: Optional[str], tags: list[str]) -> str: + values = {str(gender or "").lower(), *(tag.lower() for tag in tags or [])} + if values & {"male", "men", "man"}: + return "men" + if values & {"trans", "transgender", "tranny"}: + return "trans" + if values & {"couple", "couples", "group"}: + return "couples" + return "girls" + def _parse_camsoda_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: items: list[dict[str, object]] = [] for match in re.finditer( @@ -1098,258 +1234,6 @@ def _parse_camsoda_models(self, html_text: str, page_url: str) -> list[dict[str, }) return items - def _parse_flirt4free_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: - home_models = self._flirt4free_homepage_models(html_text) - if home_models: - items = [] - for model in home_models: - username = str(model.get("model_seo_name") or model.get("model_name") or "").strip() - if not username: - continue - room_status = str(model.get("room_status") or "").strip().lower() - is_public = str(model.get("room_status_char") or "").upper() == "O" or "open" in room_status - if not is_public: - continue - category_tags = [ - model.get("category_name"), - model.get("category_name_2"), - model.get("category_name_3"), - model.get("login_group_title"), - model.get("credit_tier"), - model.get("languages"), - model.get("country_code"), - "hd" if str(model.get("is_high_quality") or "") == "1" else "", - "new" if str(model.get("is_new") or "") not in {"", "0"} else "", - "fetish" if str(model.get("is_fetish") or "").upper() == "Y" else "", - ] - tags = _normalize_tags(category_tags) - image_id = str(model.get("sample_image_id") or "").strip() - thumbnail = None - if image_id: - thumbnail = f"https://cdn5-images.vscdns.com/images/photos2/{image_id[-3:]}/{image_id}/small.jpg" - items.append({ - "username": username, - "display_name": str(model.get("display") or username), - "thumbnail": thumbnail, - "viewers": _parse_count(str(model.get("viewer_count") or model.get("viewers") or 0)), - "subject": str(model.get("scheduled_info", {}).get("title") or "") if isinstance(model.get("scheduled_info"), dict) else "", - "age": _parse_count(str(model.get("age") or 0)) or None, - "gender": "female", - "is_online": True, - "tags": tags or ["female", "public"], - "room_status": "public", - "source_type": self.source_type, - }) - if items: - return self._dedupe_discover_models(items) - - items: list[dict[str, object]] = [] - for match in re.finditer( - r'[^>]*class\s*=\s*["\'][^"\']*model-container-home[^"\']*["\'][^>]*)>(?P.*?)(?=]*class\s*=\s*["\'][^"\']*model-container-home||\Z)', - html_text or "", - re.IGNORECASE | re.DOTALL, - ): - attrs = _parse_attrs(match.group("attrs")) - body = match.group("body") or "" - username_match = re.search(r'href\s*=\s*["\'][^"\']*[?&]model=([^"&\']+)', body, re.IGNORECASE) - if not username_match: - continue - username = unquote(username_match.group(1)).strip() - title_match = re.search(r']*\balt\s*=\s*["\']([^"\']+)["\']', body, re.IGNORECASE) - display_name = html.unescape(title_match.group(1)).strip() if title_match else username.replace("-", " ") - tags = ["female"] - class_value = attrs.get("class") or "" - if "partyRoom" in class_value: - tags.append("group") - if "openRoom" in class_value: - tags.append("public") - if "tip-controlled" in body or "lovense" in body.lower(): - tags.append("toy") - if "new-model" in body or " new " in f" {class_value.lower()} ": - tags.append("new") - items.append({ - "username": username, - "display_name": display_name, - "thumbnail": _first_image(body, page_url), - "viewers": _viewer_count(body), - "subject": "", - "age": None, - "gender": "female", - "is_online": True, - "tags": _normalize_tags(tags), - "room_status": "public", - "source_type": self.source_type, - }) - if items: - return items - for match in re.finditer( - r'[^>]*class\s*=\s*["\'][^"\']*model-container[^"\']*["\'][^>]*)>(?P.*?)(?=]*class\s*=\s*["\'][^"\']*model-container||\Z)', - html_text or "", - re.IGNORECASE | re.DOTALL, - ): - body = match.group("body") or "" - href_match = re.search(r'href\s*=\s*["\']/models/bios/([^/"\']+)/about\.php', body, re.IGNORECASE) - if not href_match: - continue - slug = unquote(href_match.group(1)).strip() - if not slug: - continue - title_match = re.search(r']*\balt\s*=\s*["\']([^"\']+)["\']', body, re.IGNORECASE) - display_name = html.unescape(title_match.group(1)).strip() if title_match else slug.replace("-", " ") - category_tags = [ - html.unescape(category).strip() - for category in re.findall(r'title\s*=\s*["\']Category\s+([^"\']+)["\']', body, re.IGNORECASE) - ] - subject = _strip_html(body)[:240] - tags = _normalize_tags(category_tags + _subject_keyword_tags(subject) + ["female"]) - items.append({ - "username": slug, - "display_name": display_name, - "thumbnail": _first_image(body, page_url), - "viewers": _viewer_count(body), - "subject": "", - "age": None, - "gender": "female", - "is_online": True, - "tags": tags, - "room_status": "public", - "source_type": self.source_type, - }) - if not items: - for match in _ANCHOR_RE.finditer(html_text or ""): - attrs = _parse_attrs(match.group("attrs")) - href = attrs.get("href") or "" - href_match = re.search(r"/models/([^/\"']+)\.html?$", href, re.IGNORECASE) - if not href_match: - continue - username = unquote(href_match.group(1)).strip() - body = match.group("body") or "" - if not username or username.lower() in _RESERVED_PROFILE_SEGMENTS: - continue - items.append({ - "username": username, - "display_name": attrs.get("title") or _strip_html(body) or username, - "thumbnail": _first_image(body, page_url), - "viewers": _viewer_count(body), - "subject": "", - "age": None, - "gender": "female", - "is_online": True, - "tags": ["female"], - "room_status": "public", - "source_type": self.source_type, - }) - return items - - async def _resolve_flirt4free_hls(self, target: str) -> Optional[ResolvedStream]: - slug = self._flirt4free_slug(target) - if not slug: - return None - - pages = list(self.discover_urls(1, None, None, None)) - if not pages: - pages = ["https://www.flirt4free.com/live/girls/"] - pages.append(f"https://www.flirt4free.com/search?q={quote_plus(slug)}") - - matched: Optional[dict[str, object]] = None - referer = "https://www.flirt4free.com/live/girls/" - for page_url in pages: - html_text = await self._fetch_discover_html(page_url) - if not html_text: - continue - for model in self._flirt4free_homepage_models(html_text): - if self._flirt4free_model_matches(model, slug): - matched = model - referer = page_url - break - if matched: - break - if not matched: - return None - - if str(matched.get("video_blocked") or "0") == "1" or str(matched.get("is_hls") or "") != "1": - raise ProviderPrivateError("Flirt4Free ne fournit pas de HLS public pour ce modele") - room_status = str(matched.get("room_status") or "").lower() - if str(matched.get("room_status_char") or "").upper() != "O" and "open" not in room_status: - raise ProviderPrivateError("Le modele Flirt4Free n'est pas en room publique") - - model_id = str(matched.get("model_id") or "").strip() - video_host = str(matched.get("video_host") or "").strip() - if not model_id or not video_host: - return None - - stream_data = await self._fetch_flirt4free_stream_data(model_id, video_host, referer) - media_rows = [] - data = stream_data.get("data") if isinstance(stream_data, dict) else {} - if isinstance(data, dict): - for key in ("llhls", "hls"): - rows = data.get(key) - if isinstance(rows, list): - media_rows.extend(row for row in rows if isinstance(row, dict)) - - headers = self._stream_headers(referer, await self._provider_cookie_header()) - for row in media_rows: - stream_url = str(row.get("url") or "").strip().replace("\\/", "/") - if stream_url.startswith("//"): - stream_url = "https:" + stream_url - if not stream_url: - continue - if await self._is_valid_playlist(stream_url, headers): - tags = _normalize_tags([ - matched.get("category_name"), - matched.get("category_name_2"), - matched.get("category_name_3"), - matched.get("login_group_title"), - matched.get("credit_tier"), - "hd" if str(matched.get("is_high_quality") or "") == "1" else "", - "public", - ]) - return ResolvedStream( - url=stream_url, - headers=headers, - source_type=self.source_type, - is_live=True, - room_status="public", - viewers=_parse_count(str(matched.get("viewer_count") or matched.get("viewers") or 0)), - tags=tags, - thumbnail=None, - title=str(matched.get("display") or slug), - ) - raise ProviderOfflineError("Aucun HLS Flirt4Free public valide") - - async def _fetch_flirt4free_stream_data(self, model_id: str, video_host: str, referer: str) -> dict[str, object]: - headers = { - "User-Agent": DEFAULT_USER_AGENT, - "Accept": "application/json,text/plain,*/*", - "Referer": referer, - } - cookie_header = await self._provider_cookie_header() - if cookie_header: - headers["Cookie"] = cookie_header - url = "https://www.flirt4free.com/ws/chat/get-stream-urls.php?" + urlencode({ - "model_id": model_id, - "video_host": video_host, - }) - timeout = aiohttp.ClientTimeout(total=15) - async with aiohttp_client_session(timeout=timeout) as session: - async with session.get(url, headers=headers, **aiohttp_request_kwargs()) as resp: - if resp.status >= 400: - raise ProviderOfflineError(f"Flirt4Free stream endpoint HTTP {resp.status}") - payload = await resp.json(content_type=None) - return payload if isinstance(payload, dict) else {} - - async def _is_valid_playlist(self, stream_url: str, headers: dict[str, str]) -> bool: - timeout = aiohttp.ClientTimeout(total=12) - try: - async with aiohttp_client_session(timeout=timeout) as session: - async with session.get(stream_url, headers=headers, **aiohttp_request_kwargs()) as resp: - if resp.status >= 400: - return False - head = await resp.content.read(256) - except Exception: - return False - return head.lstrip().startswith(b"#EXTM3U") or b" str: if not self.session_store: return "" @@ -1358,115 +1242,1579 @@ async def _provider_cookie_header(self) -> str: except Exception: return "" - def _flirt4free_homepage_models(self, html_text: str) -> list[dict[str, object]]: - source = html_text or "" - marker = source.find("window.__homePageData__") - if marker >= 0: - source = source[marker:] - array_text = self._extract_js_array_after_key(source, "models") - if not array_text: - return [] - array_text = re.sub(r",\s*([\]}])", r"\1", array_text) + async def _provider_session_state(self) -> dict[str, Any]: + if not self.session_store: + return {} try: - data = json.loads(array_text) + return await self.session_store.get(self.source_type) except Exception: + return {} + + async def _provider_session_username(self) -> Optional[str]: + state = await self._provider_session_state() + username = str(state.get("username") or state.get("credential_username") or "").strip() + return username or None + + async def _provider_has_session(self) -> bool: + state = await self._provider_session_state() + return bool(state.get("cookies") or state.get("localStorage") or state.get("is_logged_in")) + + async def sync_following(self) -> list[dict[str, object]]: + if self.source_type == "stripchat": + return await self._stripchat_sync_following_http() + if self.source_type == "bongacams": + return await self._bongacams_sync_following() + return await super().sync_following() + + async def follow(self, username: str) -> dict[str, object]: + if self.source_type == "stripchat": + try: + return await self._stripchat_follow_http(username, follow=True) + except ProviderAuthError: + raise + except ProviderError as exc: + logger.debug("Stripchat HTTP follow failed", username=username, error=str(exc)) + try: + return await self._stripchat_browser_follow_action(username, follow=True) + except ProviderAuthError: + raise + except ProviderError as browser_exc: + return {"success": False, "error": str(browser_exc)} + if self.source_type == "bongacams": + return await self._bongacams_follow_action(username, follow=True) + return await super().follow(username) + + async def unfollow(self, username: str) -> dict[str, object]: + if self.source_type == "stripchat": + try: + return await self._stripchat_follow_http(username, follow=False) + except ProviderAuthError: + raise + except ProviderError as exc: + logger.debug("Stripchat HTTP unfollow failed", username=username, error=str(exc)) + try: + return await self._stripchat_browser_follow_action(username, follow=False) + except ProviderAuthError: + raise + except ProviderError as browser_exc: + return {"success": False, "error": str(browser_exc)} + if self.source_type == "bongacams": + return await self._bongacams_follow_action(username, follow=False) + return await super().unfollow(username) + + async def is_following(self, username: str) -> bool: + if self.source_type == "stripchat": + try: + payload = await self._stripchat_api_json( + "GET", + f"/v2/models/username/{quote_plus(username)}/cam", + auth_required=False, + referer=self.canonical_url(username), + ) + value = self._stripchat_find_value(payload, {"isinfavorites"}) + if value is not None: + return bool(value) + except Exception: + pass + try: + items = await self.sync_following() + except Exception: + return False + needle = username.strip().lower() + return any(str(item.get("username") or "").lower() == needle for item in items) + if self.source_type == "bongacams": + if await self._local_provider_is_following(username): + return True + return await self._bongacams_is_following_browser(username) + return await super().is_following(username) + + async def _local_provider_following(self) -> list[dict[str, object]]: + db = getattr(self.session_store, "db", None) if self.session_store else None + if not db or not hasattr(db, "get_all_followed"): return [] - return [item for item in data if isinstance(item, dict)] - - def _extract_js_array_after_key(self, text: str, key: str) -> str: - key_match = re.search(rf"['\"]{re.escape(key)}['\"]\s*:\s*\[", text or "") - if not key_match: - return "" - start = key_match.end() - 1 - depth = 0 - quote = "" - escaped = False - for idx in range(start, len(text)): - ch = text[idx] - if quote: - if escaped: - escaped = False - elif ch == "\\": - escaped = True - elif ch == quote: - quote = "" - continue - if ch in {"'", '"'}: - quote = ch - elif ch == "[": - depth += 1 - elif ch == "]": - depth -= 1 - if depth == 0: - return text[start:idx + 1] - return "" - - def _flirt4free_slug(self, target: str) -> str: - raw = (target or "").strip() - if raw.startswith(("http://", "https://")): - username = self._username_from_url(raw) or raw.rstrip("/").rsplit("/", 1)[-1] - else: - username = raw - username = re.sub(r"\.html?$", "", username) - return username.strip().lower().replace("_", "-") - - def _flirt4free_model_matches(self, model: dict[str, object], slug: str) -> bool: - candidates = [ - model.get("model_seo_name"), - str(model.get("model_name") or "").replace("_", "-"), - str(model.get("display") or "").replace(" ", "-"), - ] - normalized = {str(value or "").strip().lower().replace("_", "-") for value in candidates} - return slug in normalized - - def _parse_xcams_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: + rows = await db.get_all_followed() items: list[dict[str, object]] = [] - for match in re.finditer( - r'[^>]*data-testid\s*=\s*["\']model-card["\'][^>]*)>(?P.*?)(?=]*data-testid\s*=\s*["\']model-card["\']||\Z)', - html_text or "", - re.IGNORECASE | re.DOTALL, - ): - attrs = _parse_attrs(match.group("attrs")) - body = match.group("body") or "" - username = (attrs.get("data-cammer-nickname") or "").strip() - if not username: - profile_match = re.search(r'href\s*=\s*["\']/profile/([^/"\']+)/', body, re.IGNORECASE) - username = unquote(profile_match.group(1)).strip() if profile_match else "" + for row in rows or []: + source_type = str(row.get("source_type") or "chaturbate").strip().lower() + if source_type != self.source_type: + continue + username = str(row.get("username") or "").strip() if not username: continue - tags = ["female"] - if "models__features--toy" in body or "toy--icon" in body: - tags.append("toy") - if "models__labels__item--new" in body: - tags.append("new") - if "chat-mode models__labels__item--free" in body or re.search(r">\s*FREE\s*<", body, re.IGNORECASE): - tags.append("free") - thumbnail = _first_image(body, page_url) items.append({ "username": username, - "display_name": username, - "thumbnail": thumbnail, - "viewers": _viewer_count(body), - "subject": "", - "age": None, - "gender": "female", - "is_online": True, - "tags": _normalize_tags(tags), - "room_status": "public", + "display_name": row.get("display_name") or username, + "thumbnail": row.get("thumbnail_url") or row.get("thumbnail"), + "viewers": int(row.get("viewers") or 0), + "is_online": bool(row.get("is_online")), + "room_status": row.get("room_status"), "source_type": self.source_type, }) return items - def _parse_cams_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: - data = self._next_data_json(html_text) - compressed = self._find_first_dict(data, "compressedWonResponse") if data else None - if not compressed: - return [] - mapping = compressed.get("mapping") or [] - rows = compressed.get("models") or [] - items: list[dict[str, object]] = [] - for row in rows: - if not isinstance(row, list): + async def _local_provider_is_following(self, username: str) -> bool: + db = getattr(self.session_store, "db", None) if self.session_store else None + if not db or not hasattr(db, "get_followed_model"): + return False + try: + row = await db.get_followed_model(username, source_type=self.source_type) + except TypeError: + row = await db.get_followed_model(username) + source_type = str((row or {}).get("source_type") or "chaturbate").strip().lower() + return bool(row and source_type == self.source_type) + + async def _bongacams_sync_following(self) -> list[dict[str, object]]: + return await self._local_provider_following() + + async def _bongacams_follow_action(self, username: str, follow: bool) -> dict[str, object]: + username = (username or "").strip() + if not username: + return {"success": False, "error": "Username is required"} + + remote_required = os.getenv("PSTREAMREC_BONGACAMS_REMOTE_FOLLOW_REQUIRED", "").lower() in {"1", "true", "yes"} + if await self._provider_has_session(): + try: + return await self._bongacams_browser_follow_action(username, follow=follow) + except ProviderError as exc: + logger.debug( + "BongaCams remote follow action failed; keeping local follow state", + username=username, + follow=follow, + error=str(exc), + ) + if remote_required: + return {"success": False, "error": str(exc)} + except Exception as exc: + logger.debug( + "BongaCams remote follow action crashed; keeping local follow state", + username=username, + follow=follow, + error=str(exc), + ) + if remote_required: + return {"success": False, "error": str(exc)} + + return { + "success": True, + "localOnly": True, + "provider": "bongacams", + "username": username, + "action": "follow" if follow else "unfollow", + } + + async def _bongacams_is_following_browser(self, username: str) -> bool: + if not await self._provider_has_session(): + return False + try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from playwright.async_api import async_playwright + except Exception: + return False + + headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} + user_data_dir = self.browser_root / self.source_type + user_data_dir.mkdir(parents=True, exist_ok=True) + browser_user_agent = await self._stored_browser_user_agent() + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + str(user_data_dir), + headless=headless, + user_agent=browser_user_agent, + viewport={"width": 1280, "height": 720}, + args=self._browser_args(), + ) + try: + await self._restore_cookies(context) + page = context.pages[0] if context.pages else await context.new_page() + try: + await page.goto(self.canonical_url(username), wait_until="domcontentloaded", timeout=30000) + except PlaywrightTimeoutError: + pass + await self._dismiss_common_prompts(page) + if not await self._looks_logged_in(page, username=await self._provider_session_username()): + return False + state = await self._bongacams_page_follow_state(page) + return state is True + finally: + await context.close() + + async def _bongacams_browser_follow_action(self, username: str, follow: bool) -> dict[str, object]: + try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from playwright.async_api import async_playwright + except Exception as exc: + raise ProviderError("Playwright n'est pas installe") from exc + + headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} + user_data_dir = self.browser_root / self.source_type + user_data_dir.mkdir(parents=True, exist_ok=True) + session_username = await self._provider_session_username() + + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + str(user_data_dir), + headless=headless, + user_agent=DEFAULT_USER_AGENT, + viewport={"width": 1280, "height": 720}, + args=self._browser_args(), + ) + try: + await self._restore_cookies(context) + page = context.pages[0] if context.pages else await context.new_page() + try: + await page.goto(self.canonical_url(username), wait_until="domcontentloaded", timeout=30000) + except PlaywrightTimeoutError: + pass + await self._dismiss_common_prompts(page) + if not await self._looks_logged_in(page, username=session_username): + raise ProviderAuthError("Connexion BongaCams requise") + + current_state = await self._bongacams_page_follow_state(page) + if current_state is follow: + await self._save_browser_state(context, session_username, is_logged_in=True) + return {"success": True, "remote": True, "provider": "bongacams", "username": username} + + clicked = await self._bongacams_click_follow_button(page, follow=follow) + if not clicked: + raise ProviderError("Bouton follow BongaCams introuvable") + + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + await page.wait_for_timeout(500) + state = await self._bongacams_page_follow_state(page) + if state is follow: + await self._save_browser_state(context, session_username, is_logged_in=True) + return {"success": True, "remote": True, "provider": "bongacams", "username": username} + + await self._save_browser_state(context, session_username, is_logged_in=True) + return {"success": True, "remote": True, "provider": "bongacams", "username": username} + finally: + await context.close() + + async def _bongacams_page_follow_state(self, page) -> Optional[bool]: + try: + return await page.evaluate( + """ + () => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + }; + const controls = Array.from(document.querySelectorAll('button, a, [role="button"], input[type="button"], input[type="submit"]')) + .filter((el) => visible(el) && !el.disabled); + for (const el of controls) { + const text = (el.innerText || el.value || el.getAttribute('aria-label') || el.title || '').trim().toLowerCase(); + if (!text) continue; + if (/\\b(unfollow|following|favorited|remove from favorites?)\\b/.test(text)) return true; + if (/\\b(follow|favorite|add to favorites?)\\b/.test(text)) return false; + } + return null; + } + """ + ) + except Exception: + return None + + async def _bongacams_click_follow_button(self, page, follow: bool) -> bool: + pattern = ( + re.compile(r"^(follow|favorite|add to favorites?)$", re.IGNORECASE) + if follow + else re.compile(r"^(unfollow|following|favorited|remove from favorites?)$", re.IGNORECASE) + ) + for role in ("button", "link"): + try: + locator = page.get_by_role(role, name=pattern) + count = min(await locator.count(), 3) + for idx in range(count): + try: + await locator.nth(idx).click(timeout=1500) + return True + except Exception: + pass + except Exception: + pass + return False + + def _normalize_playwright_cookie(self, cookie: dict[str, Any], default_url: Optional[str] = None) -> Optional[dict[str, Any]]: + name = str(cookie.get("name") or "").strip() + if not name: + return None + normalized: dict[str, Any] = { + "name": name, + "value": str(cookie.get("value") or ""), + "path": str(cookie.get("path") or "/"), + } + domain = str(cookie.get("domain") or "").strip() + if domain: + normalized["domain"] = domain + else: + url = str(cookie.get("url") or default_url or "").strip() + if url: + normalized["url"] = url + else: + return None + expires = cookie.get("expires", cookie.get("expiry")) + if isinstance(expires, (int, float)) and expires > 0: + normalized["expires"] = int(expires) + for key in ("httpOnly", "secure"): + if key in cookie: + normalized[key] = bool(cookie.get(key)) + same_site = str(cookie.get("sameSite") or "").strip() + same_site_map = { + "strict": "Strict", + "lax": "Lax", + "none": "None", + "no_restriction": "None", + "unspecified": "Lax", + } + if same_site.lower() in same_site_map: + normalized["sameSite"] = same_site_map[same_site.lower()] + return normalized + + def _merge_cookies(self, existing: list[dict[str, Any]], incoming: list[dict[str, Any]]) -> list[dict[str, Any]]: + merged: dict[tuple[str, str, str], dict[str, Any]] = {} + for cookie in existing + incoming: + if not isinstance(cookie, dict): + continue + normalized = self._normalize_playwright_cookie(cookie) + if not normalized: + continue + key = ( + str(normalized.get("name") or ""), + str(normalized.get("domain") or normalized.get("url") or ""), + str(normalized.get("path") or "/"), + ) + merged[key] = normalized + return list(merged.values()) + + def _default_cookie_url(self) -> str: + if self.source_type == "bongacams": + return BONGACAMS_BASE_URL + if self.url_templates: + template = self.url_templates[0] + return template.format(username="") + return "https://" + (self.domains[0] if self.domains else "localhost") + + def _default_cookie_domain(self) -> str: + host = urlparse(self._default_cookie_url()).netloc.lower() + return f".{host.removeprefix('www.')}" if host else "" + + def _cookie_header_to_playwright_cookies(self, cookie_header: str) -> list[dict[str, Any]]: + cookie_header = self._header_value(cookie_header, "cookie") or (cookie_header or "") + domain = self._default_cookie_domain() + if not domain: + return [] + ignored_names = { + "domain", + "path", + "expires", + "max-age", + "samesite", + "secure", + "httponly", + } + cookies: list[dict[str, Any]] = [] + for part in (cookie_header or "").split(";"): + if "=" not in part: + continue + name, value = part.split("=", 1) + name = name.strip() + if not name or name.lower() in ignored_names: + continue + normalized = self._normalize_playwright_cookie({ + "name": name, + "value": value.strip(), + "domain": domain, + "path": "/", + "sameSite": "Lax", + "secure": True, + }) + if normalized: + cookies.append(normalized) + return cookies + + def _header_value(self, raw: str, name: str) -> str: + wanted = (name or "").strip().lower() + for line in (raw or "").splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + if key.strip().lower() == wanted: + return value.strip() + return "" + + def _cookie_value(self, cookies: list[dict[str, Any]], name: str) -> str: + wanted = (name or "").strip().lower() + for cookie in cookies or []: + if not isinstance(cookie, dict): + continue + if str(cookie.get("name") or "").strip().lower() == wanted: + return str(cookie.get("value") or "").strip() + return "" + + def _provider_metadata_from_storage(self, local_storage: list[dict[str, Any]]) -> dict[str, str]: + for entry in local_storage or []: + if not isinstance(entry, dict) or entry.get("origin") != "pstreamrec://provider": + continue + metadata: dict[str, str] = {} + for item in entry.get("localStorage") or []: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + value = str(item.get("value") or "").strip() + if name and value: + metadata[name] = value + return metadata + return {} + + def _merge_provider_metadata_storage( + self, + local_storage: list[dict[str, Any]], + metadata: dict[str, str], + ) -> list[dict[str, Any]]: + cleaned = [ + entry + for entry in (local_storage or []) + if not (isinstance(entry, dict) and entry.get("origin") == "pstreamrec://provider") + ] + values = [ + {"name": name, "value": value} + for name, value in metadata.items() + if value + ] + if values: + cleaned.append({"origin": "pstreamrec://provider", "localStorage": values}) + return cleaned + + async def _stored_browser_user_agent(self) -> str: + state = await self._provider_session_state() + metadata = self._provider_metadata_from_storage(list(state.get("localStorage") or [])) + return metadata.get("userAgent") or DEFAULT_USER_AGENT + + def _session_check_url(self, username: Optional[str] = None) -> str: + if username: + return self.canonical_url(username) + if self.source_type == "bongacams": + return BONGACAMS_BASE_URL + return self._default_cookie_url() + + async def import_session( + self, + username: Optional[str] = None, + cookie_header: Optional[str] = None, + cookies: Optional[list[dict[str, Any]]] = None, + local_storage: Optional[list[dict[str, Any]]] = None, + user_agent: Optional[str] = None, + x_bc: Optional[str] = None, + ) -> dict[str, object]: + username = (username or "").strip() or await self._provider_session_username() + raw_cookie_header = cookie_header or "" + check_url = self._session_check_url(username) + incoming = self._cookie_header_to_playwright_cookies(raw_cookie_header) + incoming.extend( + normalized + for cookie in (cookies or []) + if isinstance(cookie, dict) + for normalized in [self._normalize_playwright_cookie(cookie, default_url=check_url)] + if normalized + ) + state = await self._provider_session_state() + merged = self._merge_cookies(list(state.get("cookies") or []), incoming) + storage = local_storage if local_storage is not None else list(state.get("localStorage") or []) + if not incoming: + return {"success": False, "error": "No session cookies provided"} + logged_in = await self._verify_imported_session( + username=username, + cookies=merged, + local_storage=storage, + check_url=check_url, + ) + if logged_in: + return {"success": True, "username": username, "importedSession": True} + return { + "success": False, + "error": "Session cookies were saved but did not authenticate the provider", + "importedSession": True, + } + + async def _verify_imported_session( + self, + username: Optional[str], + cookies: list[dict[str, Any]], + local_storage: list[dict[str, Any]], + check_url: str, + ) -> bool: + try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from playwright.async_api import async_playwright + except Exception as exc: + raise ProviderError("Playwright n'est pas installe") from exc + + headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} + user_data_dir = self.browser_root / self.source_type + user_data_dir.mkdir(parents=True, exist_ok=True) + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + str(user_data_dir), + headless=headless, + user_agent=DEFAULT_USER_AGENT, + viewport={"width": 1280, "height": 720}, + args=self._browser_args(), + ) + try: + await self._apply_browser_stealth(context) + try: + await context.add_cookies(cookies) + except Exception as exc: + raise ProviderError("Session cookies invalides") from exc + if local_storage: + try: + await context.add_init_script( + """ + (() => { + const origins = __PSTREAMREC_ORIGINS__; + const originState = origins.find((entry) => entry && entry.origin === window.location.origin); + if (!originState || !Array.isArray(originState.localStorage)) return; + for (const item of originState.localStorage) { + if (!item || !item.name || typeof item.value !== 'string') continue; + try { window.localStorage.setItem(item.name, item.value); } catch {} + } + })(); + """.replace("__PSTREAMREC_ORIGINS__", json.dumps(local_storage)) + ) + except Exception: + pass + page = context.pages[0] if context.pages else await context.new_page() + try: + await page.goto(check_url, wait_until="domcontentloaded", timeout=30000) + except PlaywrightTimeoutError: + pass + await self._dismiss_common_prompts(page) + await page.wait_for_timeout(1500) + logged_in = await self._looks_logged_in(page, username=username) + await self._save_browser_state( + context, + username, + is_logged_in=logged_in, + last_error=None if logged_in else "auth_required", + ) + return logged_in + finally: + await context.close() + + async def _refresh_flaresolverr_cookies( + self, + page_url: str, + username: Optional[str] = None, + ) -> tuple[list[dict[str, Any]], Optional[str]]: + flaresolverr_url = (os.getenv("FLARESOLVERR_URL") or os.getenv("PSTREAMREC_FLARESOLVERR_URL") or "").strip() + if not flaresolverr_url: + return [], None + + payload = { + "cmd": "request.get", + "url": page_url, + "maxTimeout": int(os.getenv("PSTREAMREC_FLARESOLVERR_TIMEOUT_MS", "60000") or "60000"), + } + endpoint = flaresolverr_url.rstrip("/") + "/v1" + try: + timeout = aiohttp.ClientTimeout(total=max(10, payload["maxTimeout"] / 1000 + 5)) + async with aiohttp.ClientSession(timeout=timeout, trust_env=False) as session: + async with session.post(endpoint, json=payload) as resp: + data = await resp.json(content_type=None) + except Exception as exc: + logger.debug("FlareSolverr cookie refresh failed", source_type=self.source_type, url=page_url, error=str(exc)) + return [], None + + if data.get("status") != "ok": + logger.debug("FlareSolverr cookie refresh rejected", source_type=self.source_type, response=data.get("message")) + return [], None + + solution = data.get("solution") or {} + raw_cookies = solution.get("cookies") or [] + cookies = [ + normalized + for cookie in raw_cookies + if isinstance(cookie, dict) + for normalized in [self._normalize_playwright_cookie(cookie, default_url=page_url)] + if normalized + ] + user_agent = str(solution.get("userAgent") or "").strip() or None + if not cookies: + return [], user_agent + + if self.session_store: + state = await self._provider_session_state() + merged = self._merge_cookies(list(state.get("cookies") or []), cookies) + await self.session_store.save( + self.source_type, + username=username or state.get("username") or state.get("credential_username"), + is_logged_in=bool(state.get("is_logged_in")), + cookies=merged, + local_storage=state.get("localStorage") or [], + last_error=state.get("last_error"), + ) + return cookies, user_agent + + async def _stripchat_sync_following_http(self) -> list[dict[str, object]]: + if not await self._stripchat_has_session(): + raise ProviderAuthError("Connexion Stripchat requise pour synchroniser les favoris") + + models: list[dict[str, object]] = [] + page_limit = max(1, min(100, int(os.getenv("PSTREAMREC_STRIPCHAT_FAVORITES_LIMIT", "50") or "50"))) + max_pages = max(1, int(os.getenv("PSTREAMREC_STRIPCHAT_FAVORITES_MAX_PAGES", "20") or "20")) + for path in ("/models/favorites", "/models/favorites/offline"): + offset = 0 + for _ in range(max_pages): + payload = await self._stripchat_api_json( + "GET", + path, + params={"limit": page_limit, "offset": offset}, + auth_required=True, + referer=f"{STRIPCHAT_BASE_URL}/favorites", + ) + page_models = self._stripchat_models_from_payload(payload) + models.extend(page_models) + total = int(payload.get("totalCount") or len(page_models)) if isinstance(payload, dict) else len(page_models) + if len(page_models) < page_limit or offset + page_limit >= total: + break + offset += page_limit + + items = [ + item + for item in (self._stripchat_model_item(model) for model in self._dedupe_stripchat_models(models)) + if item + ] + items.sort(key=lambda item: (not bool(item.get("is_online")), -int(item.get("viewers") or 0), str(item.get("username") or "").lower())) + return items + + async def _stripchat_follow_http(self, username: str, follow: bool) -> dict[str, object]: + if not await self._stripchat_has_session(): + raise ProviderAuthError("Connexion Stripchat requise") + model = await self._stripchat_model_by_username(username) + model_id = self._stripchat_model_id(model) + if not model_id: + raise ProviderError(f"Modele Stripchat introuvable: {username}") + current_user_id = await self._stripchat_current_user_id() + if not current_user_id: + raise ProviderAuthError("Utilisateur Stripchat connecte introuvable") + + if follow: + await self._stripchat_api_json( + "PUT", + f"/users/{current_user_id}/favorites/{model_id}", + body={"uniq": int(time.time() * 1000)}, + auth_required=True, + referer=self.canonical_url(username), + ) + else: + await self._stripchat_api_json( + "DELETE", + f"/users/{current_user_id}/favorites", + body={"favoriteIds": [int(model_id)], "uniq": int(time.time() * 1000)}, + auth_required=True, + referer=self.canonical_url(username), + ) + return {"success": True, "remote": True, "provider": "stripchat", "username": username} + + async def _stripchat_model_by_username(self, username: str) -> dict[str, object]: + payload = await self._stripchat_api_json( + "GET", + f"/v2/models/username/{quote_plus(username)}/cam", + auth_required=False, + referer=self.canonical_url(username), + ) + model = self._stripchat_profile_model(payload) + if model: + return model + payload = await self._stripchat_api_json( + "GET", + f"/v2/users/username/{quote_plus(username)}", + auth_required=False, + referer=self.canonical_url(username), + ) + model = self._stripchat_profile_model(payload) + if model: + return model + raise ProviderError(f"Modele Stripchat introuvable: {username}") + + async def _resolve_stripchat_public_hls( + self, + target: str, + max_height: Optional[int] = None, + ) -> Optional[ResolvedStream]: + del max_height + username = self._stripchat_username_from_target(target) + if not username: + return None + + page_url = self.canonical_url(username) + payload = await self._stripchat_api_json( + "GET", + f"/v2/models/username/{quote_plus(username)}/cam", + auth_required=False, + referer=page_url, + ) + model = self._stripchat_profile_model(payload) if isinstance(payload, dict) else None + if not model: + raise ProviderOfflineError(f"Modele Stripchat introuvable ou hors ligne: {username}") + + self._validate_stripchat_public_stream(payload, model, username) + model_id = self._stripchat_model_id(model) + if not model_id: + raise ProviderOfflineError(f"Flux Stripchat introuvable: {username}") + + headers = self._stream_headers(page_url, await self._provider_cookie_header()) + hosts = self._stripchat_hls_hosts_from_payload(payload) or list(STRIPCHAT_HLS_HOSTS) + for host in hosts: + playlist_url = ( + f"https://edge-hls.{host}/hls/{model_id}/master/{model_id}_auto.m3u8" + f"{self._stripchat_master_playlist_query()}" + ) + if await self._stripchat_probe_hls_playlist(playlist_url, headers): + item = self._stripchat_model_item(model) or {} + return ResolvedStream( + url=playlist_url, + headers=headers, + source_type=self.source_type, + is_live=True, + room_status="public", + viewers=int(item.get("viewers") or 0), + tags=list(item.get("tags") or []), + thumbnail=item.get("thumbnail") or None, + title=str(item.get("display_name") or username), + ) + + raise ProviderOfflineError(f"Aucun HLS Stripchat public valide pour {username}") + + def _stripchat_master_playlist_query(self) -> str: + params = { + "minHeight": os.getenv("PSTREAMREC_STRIPCHAT_MIN_HEIGHT", "240"), + "playlistType": os.getenv("PSTREAMREC_STRIPCHAT_PLAYLIST_TYPE", "standard"), + } + playback_key = (os.getenv("PSTREAMREC_STRIPCHAT_PLAYBACK_KEY", STRIPCHAT_PLAYBACK_KEY) or "").strip() + if playback_key: + params["pkey"] = playback_key + return "?" + urlencode(params) + + def _stripchat_username_from_target(self, target: str) -> Optional[str]: + target = (target or "").strip().strip("@/ ") + if target.startswith(("http://", "https://")): + return self._username_from_url(target) + if not re.match(r"^[A-Za-z0-9_.-]{2,64}$", target): + return None + if target.lower() in _RESERVED_PROFILE_SEGMENTS: + return None + return target + + def _validate_stripchat_public_stream( + self, + payload: object, + model: dict[str, object], + username: str, + ) -> None: + if self._stripchat_login_requires_interaction(payload): + raise ProviderInteractionRequired("Stripchat demande une verification interactive") + + cam = payload.get("cam") if isinstance(payload, dict) and isinstance(payload.get("cam"), dict) else {} + private_indicators = ( + cam.get("show"), + cam.get("privateMode"), + cam.get("groupShowAnnouncement"), + cam.get("ticketShow"), + cam.get("ticketShowAnnouncement"), + cam.get("privateShow"), + ) + if any(self._stripchat_truthy_indicator(value) for value in private_indicators): + raise ProviderPrivateError(f"Stripchat/{username}: show prive, groupe ou ticket") + + for value in (model.get("status"), cam.get("streamStatus"), cam.get("status")): + status = str(value or "").strip().lower() + if not status: + continue + if any(marker in status for marker in ("private", "group", "ticket", "premium", "spy", "p2p")): + raise ProviderPrivateError(f"Stripchat/{username}: show prive, groupe ou ticket") + if status in {"offline", "away", "idle", "inactive", "not_live", "not live"}: + raise ProviderOfflineError(f"Stripchat/{username}: modele hors ligne") + + if cam.get("isCamAvailable") is False or cam.get("isCamActive") is False: + raise ProviderOfflineError(f"Stripchat/{username}: modele hors ligne") + if model.get("isOnline") is False or model.get("isLive") is False: + raise ProviderOfflineError(f"Stripchat/{username}: modele hors ligne") + + def _stripchat_truthy_indicator(self, value: object) -> bool: + if value in (None, False, "", [], {}): + return False + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "none", "null", "no", "off"} + return bool(value) + + def _stripchat_hls_hosts_from_payload(self, payload: object) -> list[str]: + values: list[str] = [] + + def walk(value: object) -> None: + if isinstance(value, dict): + for key, child in value.items(): + key_lower = str(key).lower() + if key_lower == "hlsstreamhost" and child: + values.append(str(child)) + elif key_lower == "fallbackdomains" and isinstance(child, list): + values.extend(str(item) for item in child if item) + else: + walk(child) + elif isinstance(value, list): + for child in value: + walk(child) + + walk(payload) + hosts: list[str] = [] + seen = set() + for value in values: + host = str(value or "").strip().strip("/") + if not host or host in seen: + continue + seen.add(host) + hosts.append(host) + return hosts + + async def _stripchat_probe_hls_playlist(self, playlist_url: str, headers: dict[str, str]) -> bool: + try: + timeout = aiohttp.ClientTimeout(total=int(os.getenv("PSTREAMREC_STRIPCHAT_HLS_PROBE_TIMEOUT", "12") or "12")) + async with aiohttp_client_session(timeout=timeout) as session: + async with session.get( + playlist_url, + headers=headers, + allow_redirects=True, + **aiohttp_request_kwargs(), + ) as resp: + if resp.status in (401, 403): + raise ProviderAuthError("Flux Stripchat refuse ou session requise") + if resp.status >= 400: + return False + head = await resp.content.read(512) + except ProviderError: + raise + except Exception as exc: + logger.debug("Stripchat HLS probe failed", url=playlist_url, error=str(exc)) + return False + return head.lstrip().startswith(b"#EXTM3U") + + async def _stripchat_user_by_username(self, username: str) -> dict[str, object]: + payload = await self._stripchat_api_json( + "GET", + f"/v2/users/username/{quote_plus(username)}", + auth_required=True, + referer=STRIPCHAT_BASE_URL, + ) + model = self._stripchat_profile_model(payload) + if model: + return model + raise ProviderAuthError("Utilisateur Stripchat connecte introuvable") + + async def _stripchat_current_user_id(self) -> Optional[int]: + state = await self._stripchat_session_state() + saved_username = self._stripchat_saved_username(state) + local_id = self._stripchat_find_user_id(state.get("localStorage"), saved_username) + if local_id: + return local_id + if saved_username: + user = await self._stripchat_user_by_username(saved_username) + model_id = self._stripchat_model_id(user) + if model_id: + try: + return int(model_id) + except (TypeError, ValueError): + return None + return None + + def _stripchat_model_id(self, model: dict[str, object]) -> str: + return str(model.get("id") or model.get("streamName") or "").strip() + + def _stripchat_front_version(self) -> str: + return (os.getenv("PSTREAMREC_STRIPCHAT_FRONT_VERSION") or STRIPCHAT_FRONT_VERSION).strip() or STRIPCHAT_FRONT_VERSION + + def _stripchat_login_paths(self) -> tuple[str, ...]: + raw = (os.getenv("PSTREAMREC_STRIPCHAT_LOGIN_PATHS") or "").strip() + if not raw: + return STRIPCHAT_LOGIN_PATHS + paths = tuple(part.strip() for part in raw.split(",") if part.strip()) + return paths or STRIPCHAT_LOGIN_PATHS + + def _stripchat_login_payloads( + self, + username: str, + password: str, + seed: dict[str, object], + ) -> list[dict[str, object]]: + uniq = int(time.time() * 1000) + base: dict[str, object] = { + "loginOrEmail": username, + "password": password, + "uniq": uniq, + } + csrf_token = str(seed.get("csrfToken") or seed.get("csrf_token") or "").strip() + if csrf_token: + base["csrfToken"] = csrf_token + + payloads = [base] + fingerprint = str(seed.get("fingerprint") or "").strip() + fingerprint_v2 = seed.get("fingerprintV2") + if fingerprint or fingerprint_v2: + payloads.insert(0, { + **base, + **({"fingerprint": fingerprint} if fingerprint else {}), + **({"fingerprintV2": fingerprint_v2} if fingerprint_v2 else {}), + }) + if "@" in username: + payloads.append({"email": username, "password": password, "uniq": uniq}) + else: + payloads.append({"username": username, "password": password, "uniq": uniq}) + return payloads + + async def _stripchat_seed_http_session(self, session, username: str) -> dict[str, object]: + seed: dict[str, object] = {"front_version": self._stripchat_front_version()} + login_url = f"{STRIPCHAT_BASE_URL}/login" + + flaresolverr_cookies, _ = await self._refresh_flaresolverr_cookies(login_url, username=username) + if flaresolverr_cookies: + cookie_values = { + str(cookie.get("name")): str(cookie.get("value") or "") + for cookie in flaresolverr_cookies + if cookie.get("name") + } + try: + session.cookie_jar.update_cookies(cookie_values, response_url=URL(STRIPCHAT_BASE_URL)) + except Exception: + pass + + headers = { + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Referer": STRIPCHAT_BASE_URL, + } + try: + async with session.get(login_url, headers=headers, allow_redirects=True, **aiohttp_request_kwargs()) as resp: + text = await resp.text(errors="ignore") + except Exception: + text = "" + + release_match = re.search(r'"releaseVersion"\s*:\s*"([^"]+)"', text) + if release_match: + seed["front_version"] = release_match.group(1) + csrf_match = re.search(r'"csrfToken"\s*:\s*"([^"]+)"', text) + if csrf_match: + seed["csrfToken"] = csrf_match.group(1) + + try: + payload = await self._stripchat_http_json( + session, + "GET", + "/v3/config/initial-dynamic", + params={"requestPath": "/login"}, + referer=login_url, + include_stored_auth=False, + front_version=str(seed.get("front_version") or ""), + ) + version = self._stripchat_find_value(payload, {"releaseversion", "frontversion"}) + if version: + seed["front_version"] = str(version) + csrf_token = self._stripchat_find_value(payload, {"csrftoken", "csrf"}) + if csrf_token: + seed["csrfToken"] = str(csrf_token) + except ProviderError: + pass + return seed + + async def _stripchat_http_json( + self, + session, + method: str, + path: str, + params: Optional[dict[str, object]] = None, + body: Optional[dict[str, object]] = None, + referer: Optional[str] = None, + include_stored_auth: bool = False, + front_version: Optional[str] = None, + ) -> object: + headers = await self._stripchat_api_headers( + referer=referer, + has_body=body is not None, + include_stored_auth=include_stored_auth, + front_version=front_version, + ) + url = f"{STRIPCHAT_API_BASE}{path if path.startswith('/') else '/' + path}" + try: + async with session.request( + method.upper(), + url, + params=params or None, + json=body if body is not None else None, + headers=headers, + allow_redirects=True, + **aiohttp_request_kwargs(), + ) as resp: + text = await resp.text(errors="ignore") + stripped = text.lstrip() + if stripped.startswith(("{", "[")): + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise ProviderError("Reponse Stripchat JSON invalide") from exc + if isinstance(payload, dict): + payload.setdefault("_http_status", resp.status) + return payload + if _INTERACTION_RE.search(text): + raise ProviderInteractionRequired("Stripchat demande une verification interactive") + return {"_http_status": resp.status, "_raw": text[:1000]} + except ProviderError: + raise + except Exception as exc: + raise ProviderError(f"Stripchat API indisponible: {exc}") from exc + + def _stripchat_login_requires_interaction(self, value: object) -> bool: + challenge_keys = { + "captcha", + "recaptcha", + "hcaptcha", + "turnstile", + "needcodeconfirmation", + "needemailconfirmation", + "needxhconfirmation", + "twofa", + "twofactor", + "twofadata", + "isrequired", + "isaptcharequired", + "iscaptcharequired", + } + if isinstance(value, dict): + for key, child in value.items(): + key_lower = str(key).lower() + if key_lower in challenge_keys and child not in (None, False, "", [], {}): + return True + if self._stripchat_login_requires_interaction(child): + return True + elif isinstance(value, list): + return any(self._stripchat_login_requires_interaction(child) for child in value) + elif isinstance(value, str): + return bool(_INTERACTION_RE.search(value)) + return False + + def _stripchat_text_values(self, value: object) -> list[str]: + values: list[str] = [] + if isinstance(value, dict): + for child in value.values(): + values.extend(self._stripchat_text_values(child)) + elif isinstance(value, list): + for child in value: + values.extend(self._stripchat_text_values(child)) + elif isinstance(value, str): + text = value.strip() + if text: + values.append(text) + return values + + def _stripchat_login_error(self, payload: object) -> Optional[str]: + status = int(payload.get("_http_status") or 0) if isinstance(payload, dict) else 0 + if status in (404, 405): + return None + text = " ".join(self._stripchat_text_values(payload)) + if _LOGIN_FAILED_RE.search(text) or status in (400, 401): + return "Login failed. Check username and password." + if status == 429: + return "Stripchat login rate limited. Retry later." + if status >= 400: + return f"Stripchat login refused (HTTP {status})" + return None + + def _stripchat_user_matches(self, user: dict[str, object], username: str) -> bool: + if not user.get("id"): + return False + needle = username.lower().strip() + if not needle: + return True + candidates = [ + str(user.get("username") or "").lower().strip(), + str(user.get("login") or "").lower().strip(), + str(user.get("email") or "").lower().strip(), + ] + return needle in {candidate for candidate in candidates if candidate} + + def _stripchat_current_user_from_payload(self, value: object, username: str = "") -> Optional[dict[str, object]]: + if isinstance(value, dict): + for key in ("currentUser", "current_user"): + child = value.get(key) + if isinstance(child, dict) and child.get("id"): + return child + for key in ("user", "account", "viewer"): + child = value.get(key) + if isinstance(child, dict) and self._stripchat_user_matches(child, username): + return child + if self._stripchat_user_matches(value, username): + return value + for child in value.values(): + found = self._stripchat_current_user_from_payload(child, username) + if found: + return found + elif isinstance(value, list): + for child in value: + found = self._stripchat_current_user_from_payload(child, username) + if found: + return found + elif isinstance(value, str) and value.strip().startswith(("{", "[")): + try: + return self._stripchat_current_user_from_payload(json.loads(value), username) + except Exception: + return None + return None + + def _stripchat_login_state_from_payload( + self, + payload: object, + username: str, + ) -> Optional[tuple[str, dict[str, object], str]]: + user = self._stripchat_current_user_from_payload(payload, username) + if not user: + return None + resolved_username = str(user.get("username") or user.get("login") or username or "").strip() + jwt_token = self._stripchat_find_value(payload, {"jwttoken", "jwt", "authjwt", "accesstoken"}) + return resolved_username or username, user, str(jwt_token or "").strip() + + def _stripchat_local_storage_state( + self, + user: dict[str, object], + jwt_token: str = "", + ) -> list[dict[str, object]]: + entries = [ + {"name": "currentUser", "value": json.dumps({"currentUser": user}, separators=(",", ":"))}, + ] + if jwt_token: + entries.append({"name": "jwtToken", "value": jwt_token}) + return [{"origin": STRIPCHAT_BASE_URL, "localStorage": entries}] + + def _stripchat_cookie_jar_to_playwright(self, session) -> list[dict[str, Any]]: + cookies: list[dict[str, Any]] = [] + try: + morsels = session.cookie_jar.filter_cookies(URL(STRIPCHAT_BASE_URL)).values() + except Exception: + morsels = [] + for morsel in morsels: + normalized = self._normalize_playwright_cookie({ + "name": morsel.key, + "value": morsel.value, + "domain": ".stripchat.com", + "path": morsel["path"] or "/", + "secure": True, + "httpOnly": bool(morsel["httponly"]), + "sameSite": "Lax", + }) + if normalized: + cookies.append(normalized) + return cookies + + async def _stripchat_save_login_failure(self, username: str, last_error: str) -> None: + if self.session_store: + await self.session_store.save( + self.source_type, + username=username, + is_logged_in=False, + last_error=last_error, + ) + + async def _stripchat_login_http(self, username: str, password: str) -> dict[str, object]: + username = (username or "").strip() + if not username or not password: + return {"success": False, "error": "Username and password are required"} + + timeout = aiohttp.ClientTimeout(total=int(os.getenv("PSTREAMREC_STRIPCHAT_LOGIN_TIMEOUT", "30") or "30")) + cookie_jar = aiohttp.CookieJar(unsafe=True) + async with aiohttp_client_session(timeout=timeout, cookie_jar=cookie_jar) as session: + seed = await self._stripchat_seed_http_session(session, username) + last_error: Optional[str] = None + for path in self._stripchat_login_paths(): + for body in self._stripchat_login_payloads(username, password, seed): + try: + payload = await self._stripchat_http_json( + session, + "POST", + path, + body=body, + referer=f"{STRIPCHAT_BASE_URL}/login", + include_stored_auth=False, + front_version=str(seed.get("front_version") or ""), + ) + except ProviderInteractionRequired: + await self._stripchat_save_login_failure(username, "interaction_required") + raise + except ProviderError as exc: + last_error = str(exc) + continue + + if self._stripchat_login_requires_interaction(payload): + await self._stripchat_save_login_failure(username, "interaction_required") + raise ProviderInteractionRequired( + "Stripchat demande un CAPTCHA/2FA; importez une session navigateur verifiee" + ) + + error = self._stripchat_login_error(payload) + if error: + status = int(payload.get("_http_status") or 0) if isinstance(payload, dict) else 0 + if status in (404, 405): + continue + if status == 403: + await self._stripchat_save_login_failure(username, "interaction_required") + raise ProviderInteractionRequired( + "Stripchat refuse le login automatique; importez une session navigateur verifiee" + ) + await self._stripchat_save_login_failure(username, "login_failed") + return {"success": False, "error": error} + + state = self._stripchat_login_state_from_payload(payload, username) + if state: + resolved_username, user, jwt_token = state + if self.session_store: + await self.session_store.save( + self.source_type, + username=resolved_username, + is_logged_in=True, + cookies=self._stripchat_cookie_jar_to_playwright(session), + local_storage=self._stripchat_local_storage_state(user, jwt_token), + last_error=None, + ) + return {"success": True, "username": resolved_username} + + for path in ("/v3/config/initial-dynamic", "/v3/config/dynamic"): + try: + payload = await self._stripchat_http_json( + session, + "GET", + path, + params={"requestPath": "/"}, + referer=STRIPCHAT_BASE_URL, + include_stored_auth=False, + front_version=str(seed.get("front_version") or ""), + ) + except ProviderInteractionRequired: + await self._stripchat_save_login_failure(username, "interaction_required") + raise + except ProviderError as exc: + last_error = str(exc) + continue + state = self._stripchat_login_state_from_payload(payload, username) + if state: + resolved_username, user, jwt_token = state + if self.session_store: + await self.session_store.save( + self.source_type, + username=resolved_username, + is_logged_in=True, + cookies=self._stripchat_cookie_jar_to_playwright(session), + local_storage=self._stripchat_local_storage_state(user, jwt_token), + last_error=None, + ) + return {"success": True, "username": resolved_username} + + await self._stripchat_save_login_failure(username, "interaction_required") + if last_error: + logger.debug("Stripchat HTTP login did not produce a verified session", error=last_error) + raise ProviderInteractionRequired( + "Stripchat demande un CAPTCHA/2FA ou refuse le login automatique; importez une session navigateur verifiee" + ) + + async def _stripchat_api_json( + self, + method: str, + path: str, + params: Optional[dict[str, object]] = None, + body: Optional[dict[str, object]] = None, + auth_required: bool = False, + referer: Optional[str] = None, + ) -> object: + if auth_required and not await self._stripchat_has_session(): + raise ProviderAuthError("Connexion Stripchat requise") + headers = await self._stripchat_api_headers(referer=referer, has_body=body is not None) + url = f"{STRIPCHAT_API_BASE}{path if path.startswith('/') else '/' + path}" + timeout = aiohttp.ClientTimeout(total=int(os.getenv("PSTREAMREC_STRIPCHAT_API_TIMEOUT", "20") or "20")) + try: + async with aiohttp_client_session(timeout=timeout) as session: + async with session.request( + method.upper(), + url, + params=params or None, + json=body if body is not None else None, + headers=headers, + allow_redirects=True, + **aiohttp_request_kwargs(), + ) as resp: + text = await resp.text(errors="ignore") + if resp.status in (401, 403): + raise ProviderAuthError("Session Stripchat expiree ou refusee") + if resp.status >= 400: + raise ProviderError(f"Stripchat API HTTP {resp.status}") + stripped = text.lstrip() + if not stripped: + return {} + if not stripped.startswith(("{", "[")): + if auth_required and _INTERACTION_RE.search(text): + raise ProviderInteractionRequired("Interaction Stripchat requise") + raise ProviderError("Reponse Stripchat non JSON") + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise ProviderError("Reponse Stripchat JSON invalide") from exc + except ProviderError: + raise + except Exception as exc: + raise ProviderError(f"Stripchat API indisponible: {exc}") from exc + + async def _stripchat_api_headers( + self, + referer: Optional[str], + has_body: bool, + include_stored_auth: bool = True, + front_version: Optional[str] = None, + ) -> dict[str, str]: + headers = { + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", + "Origin": STRIPCHAT_BASE_URL, + "Referer": referer or f"{STRIPCHAT_BASE_URL}/", + "Front-Version": (front_version or self._stripchat_front_version()).strip(), + } + if has_body: + headers["Content-Type"] = "application/json" + if include_stored_auth: + cookie_header = await self._provider_cookie_header() + if cookie_header: + headers["Cookie"] = cookie_header + jwt_token = await self._stripchat_jwt_token() + if jwt_token: + headers["Authorization"] = jwt_token + return headers + + async def _stripchat_session_state(self) -> dict[str, Any]: + if not self.session_store: + return {} + try: + return await self.session_store.get(self.source_type) + except Exception: + return {} + + async def _stripchat_has_session(self) -> bool: + state = await self._stripchat_session_state() + return bool(state.get("is_logged_in") and (state.get("cookies") or state.get("localStorage"))) + + def _stripchat_saved_username(self, state: dict[str, Any]) -> str: + return str(state.get("username") or state.get("credential_username") or "").strip() + + async def _stripchat_jwt_token(self) -> str: + state = await self._stripchat_session_state() + token = self._stripchat_find_value(state.get("localStorage"), {"jwttoken", "jwt"}) + return str(token or "").strip() + + def _stripchat_find_user_id(self, value: object, username: str = "") -> Optional[int]: + username = username.lower().strip() + def to_int(raw: object) -> Optional[int]: + try: + return int(raw) + except (TypeError, ValueError): + return None + + if isinstance(value, dict): + current = value.get("currentUser") + if isinstance(current, dict) and current.get("id"): + return to_int(current.get("id")) + user = value.get("user") + if isinstance(user, dict) and user.get("id") and (not username or str(user.get("username") or "").lower() == username): + return to_int(user.get("id")) + if value.get("id") and value.get("username") and (not username or str(value.get("username") or "").lower() == username): + return to_int(value.get("id")) + for child in value.values(): + found = self._stripchat_find_user_id(child, username) + if found: + return found + elif isinstance(value, list): + for child in value: + found = self._stripchat_find_user_id(child, username) + if found: + return found + elif isinstance(value, str) and value.strip().startswith(("{", "[")): + try: + return self._stripchat_find_user_id(json.loads(value), username) + except Exception: + return None + return None + + def _stripchat_find_value(self, value: object, keys: set[str]) -> object: + keys = {str(key).lower() for key in keys} + if isinstance(value, dict): + storage_name = str(value.get("name") or "").lower() + storage_value = value.get("value") + if storage_name in keys and storage_value not in (None, "", [], {}): + return storage_value + for key, child in value.items(): + if str(key).lower() in keys: + return child + found = self._stripchat_find_value(child, keys) + if found is not None: + return found + elif isinstance(value, list): + for child in value: + found = self._stripchat_find_value(child, keys) + if found is not None: + return found + elif isinstance(value, str): + if value.strip().startswith(("{", "[")): + try: + return self._stripchat_find_value(json.loads(value), keys) + except Exception: + return None + return None + + async def _stripchat_browser_follow_action(self, username: str, follow: bool) -> dict[str, object]: + try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from playwright.async_api import async_playwright + except Exception as exc: + raise ProviderError("Playwright n'est pas installe") from exc + + headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} + user_data_dir = self.browser_root / self.source_type + user_data_dir.mkdir(parents=True, exist_ok=True) + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + str(user_data_dir), + headless=headless, + user_agent=DEFAULT_USER_AGENT, + viewport={"width": 1280, "height": 720}, + args=self._browser_args(), + ) + try: + await self._restore_cookies(context) + page = context.pages[0] if context.pages else await context.new_page() + try: + await page.goto(self.canonical_url(username), wait_until="domcontentloaded", timeout=30000) + except PlaywrightTimeoutError: + pass + await self._dismiss_common_prompts(page) + result = await page.evaluate( + """ + async ({ username, follow }) => { + const frontVersion = window.DEPLOY_CONFIG && window.DEPLOY_CONFIG.releaseVersion; + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + ...(frontVersion ? { "Front-Version": frontVersion } : {}) + }; + const currentUser = (() => { + const candidates = []; + try { + const state = window.getState && window.getState(); + candidates.push(state && state.userSession && state.userSession.currentUser); + } catch {} + try { + candidates.push(window.__PRELOADED_STATE__ && + window.__PRELOADED_STATE__.userSession && + window.__PRELOADED_STATE__.userSession.currentUser); + } catch {} + try { + if (window.StripChat && typeof window.StripChat.getCurrentUser === "function") { + candidates.push(window.StripChat.getCurrentUser()); + } + } catch {} + return candidates.find((item) => item && item.id) || null; + })(); + if (!currentUser || !currentUser.id) return { authError: true, error: "not_logged_in" }; + const profile = await fetch(`/api/front/v2/models/username/${encodeURIComponent(username)}/cam`, { + credentials: "include", + headers: { Accept: "application/json", ...(frontVersion ? { "Front-Version": frontVersion } : {}) } + }); + if (profile.status === 401 || profile.status === 403) return { authError: true, error: "auth_required" }; + if (!profile.ok) return { success: false, error: `profile_http_${profile.status}` }; + const payload = await profile.json(); + const model = (payload.user && payload.user.user) || payload.item || {}; + const modelId = model.id || (payload.cam && payload.cam.streamName); + if (!modelId) return { success: false, error: "model_id_missing" }; + const response = follow + ? await fetch(`/api/front/users/${currentUser.id}/favorites/${modelId}`, { + method: "PUT", + credentials: "include", + headers, + body: JSON.stringify({ uniq: Date.now() }) + }) + : await fetch(`/api/front/users/${currentUser.id}/favorites`, { + method: "DELETE", + credentials: "include", + headers, + body: JSON.stringify({ favoriteIds: [Number(modelId)], uniq: Date.now() }) + }); + if (response.status === 401 || response.status === 403) return { authError: true, error: "auth_required" }; + return { success: response.ok, status: response.status }; + } + """, + {"username": username, "follow": follow}, + ) + if result.get("authError"): + raise ProviderAuthError("Connexion Stripchat requise") + if not result.get("success"): + raise ProviderError(f"Action favoris Stripchat refusee ({result.get('error') or result.get('status')})") + await self._save_browser_state(context, username, is_logged_in=True) + return {"success": True, "remote": True, "provider": "stripchat", "username": username} + finally: + await context.close() + + def _parse_xcams_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: + items: list[dict[str, object]] = [] + for match in re.finditer( + r'[^>]*data-testid\s*=\s*["\']model-card["\'][^>]*)>(?P.*?)(?=]*data-testid\s*=\s*["\']model-card["\']||\Z)', + html_text or "", + re.IGNORECASE | re.DOTALL, + ): + attrs = _parse_attrs(match.group("attrs")) + body = match.group("body") or "" + username = (attrs.get("data-cammer-nickname") or "").strip() + if not username: + profile_match = re.search(r'href\s*=\s*["\']/profile/([^/"\']+)/', body, re.IGNORECASE) + username = unquote(profile_match.group(1)).strip() if profile_match else "" + if not username: + continue + tags = ["female"] + if "models__features--toy" in body or "toy--icon" in body: + tags.append("toy") + if "models__labels__item--new" in body: + tags.append("new") + if "chat-mode models__labels__item--free" in body or re.search(r">\s*FREE\s*<", body, re.IGNORECASE): + tags.append("free") + thumbnail = _first_image(body, page_url) + items.append({ + "username": username, + "display_name": username, + "thumbnail": thumbnail, + "viewers": _viewer_count(body), + "subject": "", + "age": None, + "gender": "female", + "is_online": True, + "tags": _normalize_tags(tags), + "room_status": "public", + "source_type": self.source_type, + }) + return items + + def _parse_cams_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: + data = self._next_data_json(html_text) + compressed = self._find_first_dict(data, "compressedWonResponse") if data else None + if not compressed: + return [] + mapping = compressed.get("mapping") or [] + rows = compressed.get("models") or [] + items: list[dict[str, object]] = [] + for row in rows: + if not isinstance(row, list): continue model = {key: row[idx] if idx < len(row) else None for idx, key in enumerate(mapping)} username = str(model.get("screen_name") or model.get("stream_name") or "").strip() @@ -1515,53 +2863,6 @@ def _parse_cams_models(self, html_text: str, page_url: str) -> list[dict[str, ob }) return items - def _parse_streamate_models(self, html_text: str, page_url: str) -> list[dict[str, object]]: - payloads = self._embedded_json_payloads(html_text) - performers: list[dict[str, object]] = [] - for payload in payloads: - performers.extend(self._find_dicts_with_key(payload, "nickname")) - items: list[dict[str, object]] = [] - for performer in performers: - username = str(performer.get("nickname") or "").strip() - if not username: - continue - subject = str(performer.get("headlineMessage") or performer.get("headline") or "") - tags = [ - performer.get("categoryName"), - performer.get("gender"), - performer.get("country"), - ] - languages = performer.get("languages") or performer.get("languagesSpoken") or [] - if isinstance(languages, list): - tags.extend(languages) - if performer.get("new"): - tags.append("new") - if performer.get("hd") or performer.get("isHD"): - tags.append("hd") - tags = _normalize_tags(tags + _subject_keyword_tags(subject)) - thumbnail = ( - performer.get("thumbnail") - or performer.get("thumbnailUrl") - or performer.get("previewUrl") - or performer.get("image") - ) - items.append({ - "username": username, - "display_name": username, - "thumbnail": thumbnail, - "viewers": int(performer.get("viewerCount") or performer.get("viewers") or 0), - "subject": subject, - "age": performer.get("age"), - "gender": str(performer.get("gender") or ""), - "is_online": bool(performer.get("online", True)), - "tags": tags, - "room_status": "public", - "source_type": self.source_type, - }) - if items: - return self._dedupe_discover_models(items) - return [] - def _embedded_json_payloads(self, html_text: str) -> list[object]: payloads: list[object] = [] for match in re.finditer(r']*type\s*=\s*["\']application/json["\'][^>]*>(.*?)', html_text or "", re.IGNORECASE | re.DOTALL): @@ -1731,13 +3032,6 @@ def _username_from_url(self, value: str) -> Optional[str]: break if not candidate and path_parts: candidate = path_parts[-1] - elif self.source_type == "streamate": - if "cam" in path_parts: - idx = path_parts.index("cam") - if len(path_parts) > idx + 1: - candidate = path_parts[idx + 1] - elif path_parts: - candidate = path_parts[-1] elif self.source_type == "xcams": for marker in ("chat", "profile"): if marker in path_parts: @@ -1747,16 +3041,6 @@ def _username_from_url(self, value: str) -> Optional[str]: break if not candidate and path_parts: candidate = path_parts[-1] - elif self.source_type == "flirt4free": - if len(path_parts) >= 4 and path_parts[0] == "models" and path_parts[1] == "bios": - candidate = path_parts[2] - elif "models" in path_parts: - idx = path_parts.index("models") - if len(path_parts) > idx + 1: - candidate = path_parts[idx + 1] - else: - return None - candidate = re.sub(r"\.html?$", "", candidate) else: if "model" in path_parts: idx = path_parts.index("model") @@ -1789,13 +3073,16 @@ async def _resolve_from_browser(self, urls: list[str], target: str) -> ResolvedS user_data_dir = self.browser_root / self.source_type user_data_dir.mkdir(parents=True, exist_ok=True) captured: list[str] = [] + session_username = await self._provider_session_username() + browser_user_agent = await self._stored_browser_user_agent() async with async_playwright() as playwright: context = await playwright.chromium.launch_persistent_context( str(user_data_dir), headless=headless, - user_agent=DEFAULT_USER_AGENT, + user_agent=browser_user_agent, viewport={"width": 1280, "height": 720}, + args=self._browser_args(), ) try: await self._restore_cookies(context) @@ -1847,13 +3134,11 @@ def remember_ws_frame(event) -> None: continue await self._dismiss_common_prompts(page) - if self.source_type == "flirt4free" and not target.startswith(("http://", "https://")): - await self._try_search(page, target) deadline = time.monotonic() + max(5, timeout_seconds) while time.monotonic() < deadline: if captured: - await self._save_browser_state(context, target, is_logged_in=True) + await self._save_browser_state(context, session_username, is_logged_in=True) stream_url = captured[0] try: last_html = await page.content() @@ -1876,7 +3161,7 @@ def remember_ws_frame(event) -> None: remember_hls_field_urls(last_html) media_urls = extract_media_urls(last_html) if media_urls: - await self._save_browser_state(context, target, is_logged_in=True) + await self._save_browser_state(context, session_username, is_logged_in=True) meta = _page_metadata(last_html, page.url) return ResolvedStream( url=media_urls[0], @@ -1893,13 +3178,20 @@ def remember_ws_frame(event) -> None: pass await page.wait_for_timeout(500) - await self._save_browser_state(context, target, is_logged_in=False) + logged_in = await self._looks_logged_in(page, username=session_username) + await self._save_browser_state(context, session_username, is_logged_in=logged_in) self._raise_page_state(last_html) raise ProviderOfflineError(f"Aucun flux public trouve pour {self.display_name}/{target}") finally: await context.close() - async def login(self, username: str, password: str) -> dict[str, object]: + def _bongacams_interaction_required_message(self, page_url: str, visible_text: str) -> str: + lower = f"{page_url or ''}\n{visible_text or ''}".lower() + if "suspect-login" in lower or "unfamiliar device" in lower or "new ip address" in lower: + return "BongaCams demande un captcha pour ce nouvel appareil ou cette nouvelle IP" + return "Automatic login blocked by the provider challenge" + + async def _bongacams_login(self, username: str, password: str) -> dict[str, object]: try: from playwright.async_api import TimeoutError as PlaywrightTimeoutError from playwright.async_api import async_playwright @@ -1907,23 +3199,145 @@ async def login(self, username: str, password: str) -> dict[str, object]: raise ProviderError("Playwright n'est pas installe") from exc headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} + login_urls = [ + template.format(username=quote_plus(username)) + for template in self.login_templates + ] or [f"{BONGACAMS_BASE_URL}/login"] + + flaresolverr_cookies: list[dict[str, Any]] = [] + flaresolverr_user_agent: Optional[str] = None + for login_url in login_urls: + flaresolverr_cookies, flaresolverr_user_agent = await self._refresh_flaresolverr_cookies(login_url, username=username) + if flaresolverr_cookies: + break + user_data_dir = self.browser_root / self.source_type user_data_dir.mkdir(parents=True, exist_ok=True) + + async with async_playwright() as playwright: + context = await playwright.chromium.launch_persistent_context( + str(user_data_dir), + headless=headless, + args=self._browser_args(), + user_agent=flaresolverr_user_agent or DEFAULT_USER_AGENT, + viewport={"width": 1280, "height": 720}, + ) + try: + await self._restore_cookies(context) + if flaresolverr_cookies: + try: + await context.add_cookies(flaresolverr_cookies) + except Exception: + pass + page = await context.new_page() + for login_url in login_urls: + try: + await page.goto(login_url, wait_until="domcontentloaded", timeout=30000) + except PlaywrightTimeoutError: + pass + except Exception: + continue + await self._dismiss_common_prompts(page) + await page.wait_for_timeout(2000) + + visible_text = await self._visible_page_text(page) + if _INTERACTION_RE.search(visible_text) or await self._has_challenge_widget(page): + refreshed, _ = await self._refresh_flaresolverr_cookies(login_url, username=username) + if refreshed: + try: + await context.add_cookies(refreshed) + await page.goto(login_url, wait_until="domcontentloaded", timeout=30000) + await self._dismiss_common_prompts(page) + await page.wait_for_timeout(1500) + except Exception: + pass + + cookies = await context.cookies() + if cookies and await self._looks_logged_in(page, username=username): + await self._save_browser_state(context, username, is_logged_in=True) + return {"success": True, "username": username, "reusedSession": True} + + submitted = await self._fill_login_form(page, username, password) + if submitted: + try: + await page.wait_for_load_state("networkidle", timeout=7000) + except Exception: + pass + + verify_seconds = int(os.getenv("PSTREAMREC_PROVIDER_LOGIN_VERIFY_SECONDS", "25") or "25") + challenge_seen = False + for _ in range(max(5, verify_seconds)): + await page.wait_for_timeout(1000) + visible_text = await self._visible_page_text(page) + if _LOGIN_FAILED_RE.search(visible_text): + await self._save_browser_state(context, username, is_logged_in=False, last_error="login_failed") + return {"success": False, "error": "Login failed. Check username and password."} + cookies = await context.cookies() + if cookies and await self._looks_logged_in(page, username=username): + await self._save_browser_state(context, username, is_logged_in=True) + return {"success": True, "username": username} + if _INTERACTION_RE.search(visible_text) or await self._has_challenge_widget(page): + challenge_seen = True + break + + if challenge_seen: + visible_text = await self._visible_page_text(page) + await self._save_browser_state(context, username, is_logged_in=False, last_error="interaction_required") + raise ProviderInteractionRequired( + self._bongacams_interaction_required_message(page.url, visible_text) + ) + + await self._save_browser_state(context, username, is_logged_in=False, last_error="login_failed") + return {"success": False, "error": "Login form introuvable ou connexion refusee"} + finally: + await context.close() + + async def login(self, username: str, password: str) -> dict[str, object]: + if self.source_type == "stripchat": + result = await self._stripchat_login_http(username, password) + if result.get("success"): + return result + try: + return await self._browser_login(username, password) + except ProviderInteractionRequired: + raise + except ProviderError: + return result + if self.source_type == "bongacams": + return await self._bongacams_login(username, password) + return await self._browser_login(username, password) + + async def _browser_login(self, username: str, password: str) -> dict[str, object]: + username = (username or "").strip() + if not username or not password: + return {"success": False, "error": "Username and password are required"} + + try: + from playwright.async_api import TimeoutError as PlaywrightTimeoutError + from playwright.async_api import async_playwright + except Exception as exc: + raise ProviderError("Playwright n'est pas installe") from exc + + headless = os.getenv("PSTREAMREC_BROWSER_HEADLESS", "true").lower() not in {"0", "false", "no"} login_urls = [ template.format(username=quote_plus(username)) for template in self.login_templates ] + user_data_dir = self.browser_root / self.source_type + user_data_dir.mkdir(parents=True, exist_ok=True) + browser_user_agent = await self._stored_browser_user_agent() async with async_playwright() as playwright: context = await playwright.chromium.launch_persistent_context( str(user_data_dir), headless=headless, - user_agent=DEFAULT_USER_AGENT, + args=self._browser_args(), + user_agent=browser_user_agent, viewport={"width": 1280, "height": 720}, ) try: await self._restore_cookies(context) - page = context.pages[0] if context.pages else await context.new_page() + page = await context.new_page() for login_url in login_urls: try: await page.goto(login_url, wait_until="domcontentloaded", timeout=30000) @@ -1932,16 +3346,55 @@ async def login(self, username: str, password: str) -> dict[str, object]: except Exception: continue await self._dismiss_common_prompts(page) - await self._fill_login_form(page, username, password) await page.wait_for_timeout(2500) - content = await page.content() - if _INTERACTION_RE.search(content): - await self._save_browser_state(context, username, is_logged_in=False, last_error="interaction_required") - raise ProviderInteractionRequired("Interaction manuelle requise dans le navigateur integre") cookies = await context.cookies() - if cookies: + if cookies and await self._looks_logged_in(page, username=username): await self._save_browser_state(context, username, is_logged_in=True) - return {"success": True, "username": username} + return {"success": True, "username": username, "reusedSession": True} + submitted = await self._fill_login_form(page, username, password) + if submitted: + try: + await page.wait_for_load_state("networkidle", timeout=5000) + except Exception: + pass + verify_seconds = int(os.getenv("PSTREAMREC_PROVIDER_LOGIN_VERIFY_SECONDS", "25") or "25") + challenge_seen = False + for _ in range(max(5, verify_seconds)): + await page.wait_for_timeout(1000) + visible_text = await self._visible_page_text(page) + if _LOGIN_FAILED_RE.search(visible_text): + await self._save_browser_state(context, username, is_logged_in=False, last_error="login_failed") + return {"success": False, "error": "Login failed. Check username and password."} + cookies = await context.cookies() + if (submitted or cookies) and cookies and await self._looks_logged_in(page, username=username): + await self._save_browser_state(context, username, is_logged_in=True) + return {"success": True, "username": username} + if _INTERACTION_RE.search(visible_text): + challenge_seen = True + break + if submitted and await self._has_challenge_widget(page): + challenge_seen = True + if challenge_seen: + visible_text = await self._visible_page_text(page) + cookies = await context.cookies() + if not (submitted and cookies and await self._looks_logged_in(page, username=username)): + interactive_wait = int(os.getenv("PSTREAMREC_PROVIDER_LOGIN_INTERACTIVE_WAIT_SECONDS", "0") or "0") + if interactive_wait > 0 and not headless: + deadline = time.monotonic() + interactive_wait + while time.monotonic() < deadline: + await page.wait_for_timeout(1000) + visible_text = await self._visible_page_text(page) + if _LOGIN_FAILED_RE.search(visible_text): + await self._save_browser_state(context, username, is_logged_in=False, last_error="login_failed") + return {"success": False, "error": "Login failed. Check username and password."} + cookies = await context.cookies() + if cookies and await self._looks_logged_in(page, username=username): + await self._save_browser_state(context, username, is_logged_in=True) + return {"success": True, "username": username, "interactiveSession": True} + await self._save_browser_state(context, username, is_logged_in=False, last_error="interaction_required") + raise ProviderInteractionRequired( + "Automatic login blocked by the provider challenge" + ) await self._save_browser_state(context, username, is_logged_in=False, last_error="login_failed") return {"success": False, "error": "Login form introuvable ou connexion refusee"} finally: @@ -1966,19 +3419,54 @@ def _stream_headers(self, page_url: str, cookie_header: Optional[str] = None) -> return headers def _is_media_url(self, value: str) -> bool: - lower = (value or "").lower() - return ".m3u8" in lower or ".mpd" in lower + return _is_probable_media_url(value) def _raise_page_state(self, content: str) -> None: text = content or "" if _INTERACTION_RE.search(text): raise ProviderInteractionRequired("Interaction manuelle requise") + if _AUTH_REQUIRED_RE.search(text): + raise ProviderAuthError("Connexion requise") if _PRIVATE_RE.search(text): raise ProviderPrivateError("Le modele semble en show prive ou premium") if _OFFLINE_RE.search(text): raise ProviderOfflineError("Le modele semble hors ligne") + async def _apply_browser_stealth(self, context) -> None: + try: + await context.add_init_script( + """ + (() => { + try { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + } catch {} + try { + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); + } catch {} + try { + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + } catch {} + try { + window.chrome = window.chrome || { runtime: {} }; + } catch {} + try { + const originalQuery = window.navigator.permissions && window.navigator.permissions.query; + if (originalQuery) { + window.navigator.permissions.query = (parameters) => ( + parameters && parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + ); + } + } catch {} + })(); + """ + ) + except Exception: + return + async def _restore_cookies(self, context) -> None: + await self._apply_browser_stealth(context) if not self.session_store: return state = await self.session_store.get(self.source_type) @@ -1988,6 +3476,24 @@ async def _restore_cookies(self, context) -> None: await context.add_cookies(cookies) except Exception: pass + local_storage = state.get("localStorage") or [] + if local_storage: + try: + await context.add_init_script( + """ + (() => { + const origins = __PSTREAMREC_ORIGINS__; + const originState = origins.find((entry) => entry && entry.origin === window.location.origin); + if (!originState || !Array.isArray(originState.localStorage)) return; + for (const item of originState.localStorage) { + if (!item || !item.name || typeof item.value !== 'string') continue; + try { window.localStorage.setItem(item.name, item.value); } catch {} + } + })(); + """.replace("__PSTREAMREC_ORIGINS__", json.dumps(local_storage)) + ) + except Exception: + pass async def _save_browser_state( self, @@ -2019,7 +3525,7 @@ async def _cookie_header(self, context) -> str: async def _dismiss_common_prompts(self, page) -> None: labels = re.compile( - r"(^(accept|i agree|agree|continue|enter|yes|allow|j'accepte|accepter|ok)$|over 18|enter site)", + r"(^(accept|accept all|accept cookies|i agree|agree|continue|enter|yes|allow|j'accepte|accepter|tout accepter|ok)$|over 18|enter site)", re.IGNORECASE, ) clicked = False @@ -2059,40 +3565,241 @@ async def _try_search(self, page, target: str) -> None: except Exception: continue - async def _fill_login_form(self, page, username: str, password: str) -> None: - user_selectors = [ - "input[type=email]", - 'input[name*="user" i]', - 'input[name*="login" i]', - 'input[name*="email" i]', - "input[autocomplete=username]", - "input[type=text]", - ] - pass_selectors = [ - "input[type=password]", - 'input[name*="pass" i]', - "input[autocomplete=current-password]", - ] - for selector in user_selectors: - try: - field = page.locator(selector).first - if await field.count(): - await field.fill(username, timeout=1500) - break - except Exception: - pass - for selector in pass_selectors: - try: - field = page.locator(selector).first - if await field.count(): - await field.fill(password, timeout=1500) - break - except Exception: - pass + async def _visible_page_text(self, page) -> str: + try: + return await page.locator("body").inner_text(timeout=3000) + except Exception: + return "" + + async def _stripchat_page_current_user(self, page) -> bool: + try: + return bool(await page.evaluate( + """ + () => { + const candidates = []; + try { + const state = window.getState && window.getState(); + candidates.push(state && state.userSession && state.userSession.currentUser); + } catch {} + try { + candidates.push(window.__PRELOADED_STATE__ && + window.__PRELOADED_STATE__.userSession && + window.__PRELOADED_STATE__.userSession.currentUser); + } catch {} + try { + if (window.StripChat && typeof window.StripChat.getCurrentUser === 'function') { + candidates.push(window.StripChat.getCurrentUser()); + } + } catch {} + return candidates.some((user) => user && (user.id || user.username || user.login)); + } + """ + )) + except Exception: + return False + + async def _bongacams_page_has_account_shell(self, page) -> bool: try: - await page.locator("button[type=submit], input[type=submit]").first.click(timeout=1500) + return bool(await page.evaluate( + """ + () => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + }; + if (Array.from(document.querySelectorAll('input[type="password"]')).some(visible)) { + return false; + } + const accountText = /\\b(log\\s*out|logout|sign\\s*out|my\\s+account|account\\s+settings|my\\s+profile|tokens|credits)\\b/i; + const loginText = /^(log\\s*in|login|sign\\s*in|signin)$/i; + let sawAccount = false; + for (const el of document.querySelectorAll('a, button, [role="button"]')) { + if (!visible(el)) continue; + const text = (el.innerText || el.value || el.getAttribute('aria-label') || el.title || '').trim(); + const href = el.getAttribute('href') || ''; + if (loginText.test(text)) return false; + if (accountText.test(`${text} ${href}`)) sawAccount = true; + } + return sawAccount; + } + """ + )) except Exception: + return False + + async def _looks_logged_in(self, page, username: Optional[str] = None) -> bool: + for selector in ( + "input[type=password]:visible", + 'input[autocomplete="current-password"]:visible', + 'input[name*="pass" i]:visible', + ): try: - await page.keyboard.press("Enter") + if await page.locator(selector).count(): + return False except Exception: pass + if self.source_type == "stripchat" and await self._stripchat_page_current_user(page): + return True + if self.source_type == "bongacams" and await self._bongacams_page_has_account_shell(page): + return True + visible_text = await self._visible_page_text(page) + lower_text = visible_text.lower() + normalized_username = (username or "").strip().lower() + if normalized_username and normalized_username in lower_text: + return True + if re.search(r"\b(log\s*out|logout|sign\s*out|my\s+account|account\s+settings)\b", lower_text): + return True + if await self._has_visible_login_action(page): + return False + return self.source_type not in { + "bongacams", + "cams", + "camsoda", + "livejasmin", + "myfreecams", + "stripchat", + "xcams", + } + + async def _has_visible_login_action(self, page) -> bool: + try: + return bool(await page.evaluate( + """ + () => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + }; + const loginText = /^(log\\s*in|login|sign\\s*in|signin|join\\s+now|join\\s+for\\s+free)$/i; + return Array.from(document.querySelectorAll('a, button, input[type="submit"], input[type="button"]')) + .some((el) => { + if (!visible(el) || el.disabled) return false; + const text = (el.innerText || el.value || el.getAttribute('aria-label') || el.title || '').trim(); + return loginText.test(text); + }); + } + """ + )) + except Exception: + return False + + async def _has_challenge_widget(self, page) -> bool: + try: + return bool(await page.evaluate( + """ + () => { + const selectors = [ + 'iframe[src*="captcha" i]', + 'iframe[src*="turnstile" i]', + 'iframe[src*="challenge" i]', + 'input[name*="captcha" i]', + 'input[id*="captcha" i]', + 'input[name*="turnstile" i]', + 'input[id*="turnstile" i]', + '[class*="captcha" i]', + '[id*="captcha" i]', + '[class*="turnstile" i]', + '[id*="turnstile" i]' + ]; + return selectors.some((selector) => document.querySelector(selector)); + } + """ + )) + except Exception: + return False + + async def _fill_login_form(self, page, username: str, password: str) -> bool: + try: + submitted = await page.evaluate( + """ + ({ username, password }) => { + const visible = (el) => { + if (!el) return false; + const style = window.getComputedStyle(el); + if (style.visibility === 'hidden' || style.display === 'none') return false; + return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + }; + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; + const setValue = (el, value) => { + if (!el) return; + valueSetter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + const attrText = (el) => [ + el.name, + el.id, + el.placeholder, + el.autocomplete, + el.getAttribute('aria-label'), + ].filter(Boolean).join(' ').toLowerCase(); + const scoreUserField = (el, passwordField) => { + const type = (el.type || '').toLowerCase(); + if (el === passwordField || type === 'password' || type === 'hidden' || + type === 'submit' || type === 'button' || type === 'checkbox' || + type === 'radio' || type === 'search') { + return -1000; + } + if (!visible(el) || el.disabled || el.readOnly) return -1000; + const text = attrText(el); + let score = 0; + if (/user|login|email|account|member/.test(text)) score += 80; + if (/search|model|find/.test(text)) score -= 120; + if (type === 'email') score += 60; + if (type === 'text' || !type) score += 10; + const passRect = passwordField.getBoundingClientRect(); + const rect = el.getBoundingClientRect(); + if (rect.top <= passRect.top) score += 15; + score -= Math.min(50, Math.abs(passRect.top - rect.top) / 20); + return score; + }; + const submitText = /^(log\\s*in|login|sign\\s*in|signin|connect|continue|submit)$/i; + const passwordFields = Array.from(document.querySelectorAll('input[type="password"]')) + .filter((el) => visible(el) && !el.disabled && !el.readOnly); + for (const passwordField of passwordFields) { + const root = passwordField.closest('form') || passwordField.closest('[role="dialog"]') || document; + const fields = Array.from(root.querySelectorAll('input')) + .map((el) => [scoreUserField(el, passwordField), el]) + .filter(([score]) => score > -1000) + .sort((a, b) => b[0] - a[0]); + const usernameField = fields.length ? fields[0][1] : null; + if (!usernameField) continue; + setValue(usernameField, username); + setValue(passwordField, password); + + const submitCandidates = Array.from(root.querySelectorAll('button, input[type="submit"], a')) + .filter((el) => visible(el) && !el.disabled); + let submit = root.querySelector('button[type="submit"], input[type="submit"]'); + if (!submit || !visible(submit) || submit.disabled) { + submit = submitCandidates.find((el) => { + const text = (el.innerText || el.value || el.getAttribute('aria-label') || el.title || '').trim(); + return submitText.test(text); + }); + } + if (submit) { + submit.click(); + } else { + passwordField.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + passwordField.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true })); + } + return true; + } + return false; + } + """, + {"username": username, "password": password}, + ) + if submitted: + return True + except Exception: + pass + + try: + await page.keyboard.press("Enter") + return True + except Exception: + return False diff --git a/app/providers/builtin.py b/app/providers/builtin.py index 6672f52..9ea94c9 100644 --- a/app/providers/builtin.py +++ b/app/providers/builtin.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import time from typing import Any, Optional from .base import ( @@ -14,6 +16,33 @@ from .browser import DEFAULT_USER_AGENT +def _cookies_to_dict( + cookies: Optional[list[dict[str, Any]]] = None, + cookie_header: Optional[str] = None, +) -> dict[str, str]: + values: dict[str, str] = {} + if cookie_header: + for part in cookie_header.split(";"): + if "=" not in part: + continue + name, value = part.split("=", 1) + name = name.strip() + if name: + values[name] = value.strip() + for cookie in cookies or []: + if not isinstance(cookie, dict): + continue + name = cookie.get("name") + value = cookie.get("value") + if name and value is not None: + values[str(name)] = str(value) + return values + + +def _cookie_list(cookie_map: dict[str, str]) -> list[dict[str, str]]: + return [{"name": name, "value": value} for name, value in cookie_map.items()] + + class ChaturbateProvider(BaseProvider): source_type = "chaturbate" display_name = "Chaturbate" @@ -117,28 +146,95 @@ async def logout(self) -> dict[str, Any]: await self.auth.logout() return {"success": True} + async def import_session( + self, + username: Optional[str] = None, + cookie_header: Optional[str] = None, + cookies: Optional[list[dict[str, Any]]] = None, + local_storage: Optional[list[dict[str, Any]]] = None, + user_agent: Optional[str] = None, + x_bc: Optional[str] = None, + ) -> dict[str, Any]: + if not self.auth: + raise ProviderAuthError("Chaturbate auth service non initialise") + cookie_map = _cookies_to_dict(cookies, cookie_header) + if not cookie_map: + return {"success": False, "error": "Chaturbate cookies are required"} + if user_agent: + self.auth._user_agent = user_agent + self.auth._cookies = cookie_map + self.auth._username = (username or self.auth._username or "").strip() or None + self.auth._last_error = None + verified = await self.auth._validate_session() + self.auth._is_logged_in = bool(verified) + now = int(time.time()) + row = await self.auth.db.get_auth_state() + saved_username = self.auth._username or (row or {}).get("username") or "" + password_hash = (row or {}).get("password_hash") or "" + last_error = None if verified else "Imported Chaturbate session is not verified" + if not verified: + self.auth._last_error = last_error + await self.auth.db.save_auth_state( + username=saved_username, + password_hash=password_hash, + is_logged_in=bool(verified), + session_cookies=json.dumps(cookie_map), + cf_clearance=cookie_map.get("cf_clearance"), + csrf_token=cookie_map.get("csrftoken"), + last_login_at=now if verified else None, + last_error=last_error, + ) + if self.session_store: + await self.session_store.save( + self.source_type, + username=saved_username or None, + is_logged_in=bool(verified), + cookies=cookies or _cookie_list(cookie_map), + local_storage=local_storage or [], + last_error=last_error, + ) + if not verified: + return {"success": False, "error": last_error} + return {"success": True, "username": saved_username, "hasCookies": True} + async def sync_following(self) -> list[dict[str, Any]]: if not self.api: raise ProviderAuthError("Chaturbate API non initialisee") + self._require_verified_auth() return await self.api.get_followed_models() async def follow(self, username: str) -> dict[str, Any]: if not self.api: raise ProviderAuthError("Chaturbate API non initialisee") + self._require_verified_auth() ok = await self.api.follow_model(username) return {"success": bool(ok)} async def unfollow(self, username: str) -> dict[str, Any]: if not self.api: raise ProviderAuthError("Chaturbate API non initialisee") + self._require_verified_auth() ok = await self.api.unfollow_model(username) return {"success": bool(ok)} async def is_following(self, username: str) -> bool: if not self.api: return False + if not self._has_verified_auth(): + return False return bool(await self.api.is_following(username)) + def _has_verified_auth(self) -> bool: + if not self.auth: + return False + status = self.auth.get_status() + cookies = self.auth.get_cookies() + return bool(status.get("isLoggedIn") and cookies.get("sessionid")) + + def _require_verified_auth(self) -> None: + if not self._has_verified_auth(): + raise ProviderAuthError("Connexion Chaturbate requise") + def _headers(self, target: str) -> dict[str, str]: headers = { "User-Agent": DEFAULT_USER_AGENT, @@ -238,6 +334,50 @@ async def logout(self) -> dict[str, Any]: await self.auth.logout() return {"success": True} + async def import_session( + self, + username: Optional[str] = None, + cookie_header: Optional[str] = None, + cookies: Optional[list[dict[str, Any]]] = None, + local_storage: Optional[list[dict[str, Any]]] = None, + user_agent: Optional[str] = None, + x_bc: Optional[str] = None, + ) -> dict[str, Any]: + if not self.auth: + raise ProviderAuthError("CAM4 auth service non initialise") + cookie_map = _cookies_to_dict(cookies, cookie_header) + if not cookie_map: + return {"success": False, "error": "CAM4 cookies are required"} + session_username = (username or self.auth.get_status().get("username") or "").strip() + if not session_username: + return {"success": False, "error": "Username is required for CAM4 session import"} + if user_agent: + self.auth._user_agent = user_agent + self.auth._cookies = cookie_map + self.auth._username = session_username + self.auth._last_error = None + verified = await self.auth._validate_session() + self.auth._is_logged_in = bool(verified) + self.auth._last_login_at = int(time.time()) if verified else None + last_error = None if verified else "Imported CAM4 session is not verified" + if verified: + await self.auth._persist_success(session_username) + else: + self.auth._last_error = last_error + await self.auth._persist_error(session_username, last_error) + if self.session_store: + await self.session_store.save( + self.source_type, + username=session_username, + is_logged_in=bool(verified), + cookies=cookies or _cookie_list(cookie_map), + local_storage=local_storage or [], + last_error=last_error, + ) + if not verified: + return {"success": False, "error": last_error} + return {"success": True, "username": session_username, "hasCookies": True} + async def sync_following(self) -> list[dict[str, Any]]: from ..services import cam4_source @@ -246,7 +386,13 @@ async def sync_following(self) -> list[dict[str, Any]]: cookies = self.auth.get_cookies() if not cookies: raise ProviderAuthError("CAM4 session absente") - return await cam4_source.list_followed(cookies) + try: + return await cam4_source.list_followed(cookies) + except cam4_source.CAM4FollowingError as exc: + message = str(exc) + if "session" in message.lower() or "login" in message.lower(): + raise ProviderAuthError(message) from exc + raise ProviderError(message) from exc async def follow(self, username: str) -> dict[str, Any]: from ..services import cam4_source diff --git a/app/providers/registry.py b/app/providers/registry.py index a1ac7f5..ed03622 100644 --- a/app/providers/registry.py +++ b/app/providers/registry.py @@ -60,6 +60,8 @@ def browser( domains, login_templates=None, discover_templates=None, + can_login=False, + can_remote_follow=None, can_stream=True, can_record=True, ): @@ -72,6 +74,8 @@ def browser( browser_root=browser_root, login_templates=login_templates, discover_templates=discover_templates, + can_login=can_login, + can_remote_follow=can_remote_follow, can_stream=can_stream, can_record=can_record, ) @@ -83,6 +87,8 @@ def browser( ("stripchat.com",), ("https://stripchat.com/login", "https://stripchat.com/{username}"), ("https://stripchat.com/", "https://stripchat.com/girls", "https://stripchat.com/search/{query}"), + can_login=False, + can_remote_follow=True, ) registry.register( YtDlpProvider( @@ -146,29 +152,9 @@ def browser( "LiveJasmin", ("https://www.livejasmin.com/en/girls/{username}", "https://www.livejasmin.com/en/chat/{username}"), ("livejasmin.com",), - ("https://www.livejasmin.com/en/login", "https://www.livejasmin.com/en/girls/{username}"), + ("https://www.livejasmin.com/en/auth/login", "https://www.livejasmin.com/en/girls/{username}"), ("https://www.livejasmin.com/en/girls", "https://www.livejasmin.com/en/search?query={query}"), )) - registry.register(browser( - "streamate", - "Streamate", - ("https://streamate.com/cam/{username}",), - ("streamate.com",), - ("https://streamate.com/member/login", "https://streamate.com/cam/{username}"), - ("https://streamate.com/cam", "https://streamate.com/search?query={query}"), - )) - registry.register(browser( - "flirt4free", - "Flirt4Free", - ( - "https://www.flirt4free.com/search?q={username}", - "https://www.flirt4free.com/models/{username}.html", - "https://www.flirt4free.com/{username}", - ), - ("flirt4free.com",), - ("https://www.flirt4free.com/login", "https://www.flirt4free.com/search?q={username}"), - ("https://www.flirt4free.com/live/girls/", "https://www.flirt4free.com/search?q={query}"), - )) registry.register(browser( "cams", "Cams.com", diff --git a/app/providers/ytdlp.py b/app/providers/ytdlp.py index 1ff2b56..0d4291a 100644 --- a/app/providers/ytdlp.py +++ b/app/providers/ytdlp.py @@ -1,12 +1,8 @@ from __future__ import annotations import asyncio -import gzip -import re -from typing import Optional -from urllib.error import URLError +from typing import Any, Optional from urllib.parse import quote_plus, urlparse -from urllib.request import Request, urlopen from ..logger import logger from .base import ( @@ -37,9 +33,12 @@ def __init__( self.url_template = url_template self.domains = domains self.browser_fallback = browser_fallback + fallback_caps = getattr(browser_fallback, "capabilities", None) self.capabilities = ProviderCapabilities( - can_login=bool(browser_fallback), - can_discover=bool(browser_fallback and getattr(browser_fallback, "capabilities", None) and browser_fallback.capabilities.can_discover), + can_login=bool(fallback_caps and fallback_caps.can_login), + can_follow=bool(fallback_caps and fallback_caps.can_follow), + can_sync_following=bool(fallback_caps and fallback_caps.can_sync_following), + can_discover=bool(fallback_caps and fallback_caps.can_discover), uses_browser=bool(browser_fallback), uses_ytdlp=True, ) @@ -71,31 +70,19 @@ async def resolve_stream( title=info.get("title"), ) except ProviderPrivateError as exc: - if self.source_type == "stripchat": - try: - return await asyncio.to_thread(self._resolve_stripchat_hls, page_url, max_height) - except Exception as fallback_exc: - logger.debug( - "Stripchat direct HLS fallback failed", - target=target, - error=str(fallback_exc), - ) + if self._can_use_guarded_stripchat_fallback(): + return await self._resolve_stripchat_guarded_fallback(target, max_height, exc) raise - except ProviderOfflineError: + except ProviderOfflineError as exc: + if self._can_use_guarded_stripchat_fallback(): + return await self._resolve_stripchat_guarded_fallback(target, max_height, exc) raise except Exception as exc: + if self._can_use_guarded_stripchat_fallback(): + return await self._resolve_stripchat_guarded_fallback(target, max_height, exc) if self._looks_offline(exc): raise ProviderOfflineError(str(exc)) from exc if self._looks_private(exc): - if self.source_type == "stripchat": - try: - return await asyncio.to_thread(self._resolve_stripchat_hls, page_url, max_height) - except Exception as fallback_exc: - logger.debug( - "Stripchat direct HLS fallback failed", - target=target, - error=str(fallback_exc), - ) raise ProviderPrivateError(str(exc)) from exc logger.debug( "yt-dlp provider resolve failed", @@ -107,6 +94,23 @@ async def resolve_stream( return await self.browser_fallback.resolve_stream(target, max_height=max_height) raise ProviderError(f"yt-dlp n'a pas trouve de flux pour {self.display_name}/{target}") from exc + def _can_use_guarded_stripchat_fallback(self) -> bool: + return self.source_type == "stripchat" and bool(self.browser_fallback) + + async def _resolve_stripchat_guarded_fallback( + self, + target: str, + max_height: Optional[int], + original_exc: Exception, + ) -> ResolvedStream: + logger.debug( + "yt-dlp Stripchat resolve failed; trying guarded public API fallback", + target=target, + error=str(original_exc), + ) + assert self.browser_fallback is not None + return await self.browser_fallback.resolve_stream(target, max_height=max_height) + async def check_status(self, username: str) -> ProviderStatus: try: stream = await self.resolve_stream(username) @@ -125,10 +129,30 @@ async def check_status(self, username: str) -> ProviderStatus: return ProviderStatus(False, room_status="offline", source_type=self.source_type, detail=str(exc)) async def login(self, username: str, password: str) -> dict[str, object]: - if not self.browser_fallback: + if not self.browser_fallback or not self.capabilities.can_login: return {"success": False, "error": "Connexion non supportee"} return await self.browser_fallback.login(username, password) + async def import_session( + self, + username: Optional[str] = None, + cookie_header: Optional[str] = None, + cookies: Optional[list[dict[str, Any]]] = None, + local_storage: Optional[list[dict[str, Any]]] = None, + user_agent: Optional[str] = None, + x_bc: Optional[str] = None, + ) -> dict[str, object]: + if not self.browser_fallback or not self.capabilities.can_login: + return {"success": False, "error": "Import de session non supporte"} + return await self.browser_fallback.import_session( + username=username, + cookie_header=cookie_header, + cookies=cookies, + local_storage=local_storage, + user_agent=user_agent, + x_bc=x_bc, + ) + async def logout(self) -> dict[str, object]: if self.browser_fallback: return await self.browser_fallback.logout() @@ -139,6 +163,26 @@ async def list_live_models(self, **kwargs) -> dict[str, object]: return await self.browser_fallback.list_live_models(**kwargs) return await super().list_live_models(**kwargs) + async def sync_following(self) -> list[dict[str, object]]: + if self.browser_fallback and self.capabilities.can_sync_following: + return await self.browser_fallback.sync_following() + return await super().sync_following() + + async def follow(self, username: str) -> dict[str, object]: + if self.browser_fallback and self.capabilities.can_follow: + return await self.browser_fallback.follow(username) + return await super().follow(username) + + async def unfollow(self, username: str) -> dict[str, object]: + if self.browser_fallback and self.capabilities.can_follow: + return await self.browser_fallback.unfollow(username) + return await super().unfollow(username) + + async def is_following(self, username: str) -> bool: + if self.browser_fallback and self.capabilities.can_follow: + return await self.browser_fallback.is_following(username) + return await super().is_following(username) + def _extract_info(self, page_url: str) -> dict: try: from yt_dlp import YoutubeDL @@ -206,81 +250,6 @@ def _headers(self, page_url: str) -> dict[str, str]: headers["Origin"] = origin return headers - def _resolve_stripchat_hls(self, page_url: str, max_height: Optional[int]) -> ResolvedStream: - del max_height - req = Request( - page_url, - headers={ - "User-Agent": DEFAULT_USER_AGENT, - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "en-US,en;q=0.9", - }, - ) - with urlopen(req, timeout=20) as resp: - body = resp.read() - if resp.headers.get("Content-Encoding", "").lower() == "gzip": - body = gzip.decompress(body) - webpage = body.decode("utf-8", errors="replace") - - model_id = self._stripchat_model_id(webpage) - if not model_id: - raise ProviderOfflineError("Stripchat model id introuvable") - - hosts = self._stripchat_hls_hosts(webpage) - if not hosts: - hosts = ["doppiocdn.net", "doppiocdn.com", "doppiocdn.org", "doppiocdn.live", "doppiocdn.media"] - - headers = self._headers(page_url) - for host in hosts: - playlist_url = f"https://edge-hls.{host}/hls/{model_id}/master/{model_id}_auto.m3u8" - try: - playlist_req = Request(playlist_url, headers=headers) - with urlopen(playlist_req, timeout=12) as resp: - head = resp.read(256) - if resp.headers.get("Content-Encoding", "").lower() == "gzip": - head = gzip.decompress(head) - if head.lstrip().startswith(b"#EXTM3U"): - return ResolvedStream( - url=playlist_url, - headers=headers, - source_type=self.source_type, - is_live=True, - room_status="public", - ) - except (OSError, URLError, gzip.BadGzipFile): - continue - raise ProviderOfflineError("Aucun HLS Stripchat public valide") - - def _stripchat_model_id(self, webpage: str) -> str: - patterns = [ - r'https?://img\.doppiocdn\.net/(?:thumbs|snapshot)/\d+/(\d+)', - r'"streamName"\s*:\s*"(\d+)"', - r'"id"\s*:\s*(\d+)\s*,\s*"hasGroupShowAnnouncement"', - ] - for pattern in patterns: - match = re.search(pattern, webpage or "") - if match: - return match.group(1) - return "" - - def _stripchat_hls_hosts(self, webpage: str) -> list[str]: - values: list[str] = [] - for match in re.finditer(r'"hlsStreamHost"\s*:\s*"([^"]+)"', webpage or ""): - values.append(match.group(1)) - for match in re.finditer(r'"fallbackDomains"\s*:\s*\[([^\]]+)\]', webpage or ""): - values.extend(re.findall(r'"([^"]+)"', match.group(1))) - for match in re.finditer(r'"[A-Z]\d*"\s*:\s*"([^"]*doppiocdn[^"]*)"', webpage or ""): - values.append(match.group(1)) - seen = set() - hosts = [] - for value in values: - host = str(value or "").strip().strip("/") - if not host or host in seen: - continue - seen.add(host) - hosts.append(host) - return hosts - def _tags(self, info: dict) -> list[str]: values = [] for key in ("tags", "categories"): diff --git a/app/services/cam4_auth.py b/app/services/cam4_auth.py index de73323..1bd2fdd 100644 --- a/app/services/cam4_auth.py +++ b/app/services/cam4_auth.py @@ -26,6 +26,7 @@ _PREFIX = "cam4:" LOGIN_URL = "https://www.cam4.com/rest/v2.0/login" +LOGIN_USER_URL = "https://www.cam4.com/rest/v2.0/login/user" HOMEPAGE_URL = "https://www.cam4.com/" LOGIN_PAGE_URL = "https://www.cam4.com/login" @@ -37,6 +38,7 @@ def __init__(self, db): self._username: Optional[str] = None self._last_error: Optional[str] = None self._last_login_at: Optional[int] = None + self._is_logged_in: bool = False self._user_agent: str = ( "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" @@ -57,7 +59,12 @@ async def initialize(self) -> None: self._last_login_at = None self._last_error = await self.db.get_setting(f"{_PREFIX}last_error") if self._cookies: - logger.info("CAM4 session restaurée depuis la DB", username=self._username) + self._is_logged_in = await self._validate_session() + if self._is_logged_in: + logger.info("CAM4 session restaurée et vérifiée depuis la DB", username=self._username) + else: + self._last_error = "CAM4 session expirée, reconnectez le compte" + await self.db.set_setting(f"{_PREFIX}last_error", self._last_error) async def login(self, username: str, password: str) -> Dict[str, Any]: """Login programmatique via POST /rest/v2.0/login. Retourne @@ -123,6 +130,7 @@ async def login(self, username: str, password: str) -> Dict[str, Any]: self._username = username self._last_login_at = int(time.time()) self._last_error = None + self._is_logged_in = True await self._persist_success(username) logger.success("CAM4 login réussi", username=username) return {"success": True, "username": username} @@ -163,6 +171,7 @@ async def logout(self) -> None: self._username = None self._last_login_at = None self._last_error = None + self._is_logged_in = False await self.db.set_setting(f"{_PREFIX}session_cookies", "") await self.db.set_setting(f"{_PREFIX}username", "") await self.db.set_setting(f"{_PREFIX}last_login_at", "") @@ -177,13 +186,35 @@ def get_user_agent(self) -> str: def get_status(self) -> Dict[str, Any]: return { - "isLoggedIn": bool(self._cookies), + "isLoggedIn": bool(self._is_logged_in and self._cookies), "username": self._username, "lastError": self._last_error, "lastLoginAt": self._last_login_at, "hasCookies": bool(self._cookies), } + async def _validate_session(self) -> bool: + if not self._cookies: + return False + headers = { + "User-Agent": self._user_agent, + "Accept": "application/json", + "Cookie": "; ".join(f"{k}={v}" for k, v in self._cookies.items()), + } + try: + async with aiohttp_client_session() as session: + async with session.get( + LOGIN_USER_URL, + headers=headers, + allow_redirects=False, + timeout=aiohttp.ClientTimeout(total=15), + **aiohttp_request_kwargs(), + ) as resp: + return resp.status == 200 + except Exception as exc: + logger.debug("CAM4 session validation failed", error=str(exc)) + return False + async def _persist_success(self, username: str) -> None: await self.db.set_setting(f"{_PREFIX}session_cookies", json.dumps(self._cookies)) await self.db.set_setting(f"{_PREFIX}username", username) diff --git a/app/services/cam4_source.py b/app/services/cam4_source.py index 86c6710..d20c713 100644 --- a/app/services/cam4_source.py +++ b/app/services/cam4_source.py @@ -8,8 +8,10 @@ """ from __future__ import annotations +import asyncio import json import re +import time from typing import Any, Dict, List, Optional import aiohttp @@ -48,6 +50,10 @@ class CAM4StatusError(CAM4Error): pass +class CAM4FollowingError(CAM4Error): + pass + + # --------------------------------------------------------------------------- # Parsing helpers # --------------------------------------------------------------------------- @@ -323,6 +329,54 @@ async def _fetch_html( return None +def _is_followed_page_html(html: str, final_url: str) -> bool: + final_url = (final_url or "").lower() + html = html or "" + html_lower = html.lower() + if "error-no-profile" in final_url or "error-no-profile" in html_lower: + return False + if "/login" in final_url or "loginform" in html_lower: + return False + if "/friends_favorites" in final_url: + return True + return "friends_favorites" in html or "pages-friendsFavorites" in html or "BroadcastItem:" in html + + +async def _fetch_followed_html(cookies: Dict[str, str]) -> str: + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "en-US,en;q=0.9", + "Cookie": "; ".join(f"{k}={v}" for k, v in cookies.items()), + } + timeout = aiohttp.ClientTimeout(total=15) + url = "https://www.cam4.com/friends_favorites?showOfflineBroadcasters=true" + try: + async with aiohttp_client_session(timeout=timeout) as session: + async with session.get( + url, + headers=headers, + allow_redirects=True, + **aiohttp_request_kwargs(), + ) as resp: + final_url = str(resp.url) + if resp.status in {401, 403}: + raise CAM4FollowingError("CAM4 session expirée") + if resp.status != 200: + raise CAM4FollowingError(f"CAM4 favorites HTTP {resp.status}") + html = await resp.text() + except CAM4FollowingError: + raise + except asyncio.TimeoutError as exc: + raise CAM4FollowingError("Timeout CAM4 favorites") from exc + except Exception as exc: + raise CAM4FollowingError(f"Échec CAM4 favorites: {exc}") from exc + + if not _is_followed_page_html(html, final_url): + raise CAM4FollowingError("CAM4 favorites page invalide ou session expirée") + return html + + async def _fetch_stream_info( username: str, user_agent: str = _DEFAULT_UA ) -> Dict[str, Any]: @@ -478,13 +532,8 @@ async def list_followed(cookies: Dict[str, str]) -> List[Dict[str, Any]]: `showOfflineBroadcasters=true` inclut les favoris offline. """ if not cookies: - return [] - html = await _fetch_html( - "https://www.cam4.com/friends_favorites?showOfflineBroadcasters=true", - cookies=cookies, - ) - if not html: - return [] + raise CAM4FollowingError("CAM4 session absente") + html = await _fetch_followed_html(cookies) return _parse_broadcasts(html) @@ -494,6 +543,7 @@ async def list_followed(cookies: Dict[str, str]) -> List[Dict[str, Any]]: _FAVORITE_URL = "https://www.cam4.com/rest/v1.0/favorites/{username}/{performerName}" +_ADD_FAVORITE_URL = "https://www.cam4.com/profiles/addFriendFavorite" async def _favorite_request( @@ -509,9 +559,11 @@ async def _favorite_request( headers = { "User-Agent": _DEFAULT_UA, "Accept": "application/json", + "Accept-Language": "en-US,en;q=0.9", "Origin": "https://www.cam4.com", "Referer": f"https://www.cam4.com/{target}", "Cookie": "; ".join(f"{k}={v}" for k, v in cookies.items()), + "X-Requested-With": "XMLHttpRequest", } # POST/DELETE exigent un Content-Type et un body vide — sans ça CAM4 # renvoie 415 Unsupported Media Type. @@ -533,6 +585,37 @@ async def _favorite_request( return resp.status, body +async def _add_favorite_request( + target: str, + cookies: Dict[str, str], +) -> tuple[int, str]: + headers = { + "User-Agent": _DEFAULT_UA, + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": "en-US,en;q=0.9", + "Origin": "https://www.cam4.com", + "Referer": f"https://www.cam4.com/{target}", + "Cookie": "; ".join(f"{k}={v}" for k, v in cookies.items()), + "X-Requested-With": "XMLHttpRequest", + } + params = { + "action": "addFavorite", + "object": target.strip(), + "_": str(int(time.time() * 1000)), + } + timeout = aiohttp.ClientTimeout(total=15) + async with aiohttp_client_session(timeout=timeout) as session: + async with session.post( + _ADD_FAVORITE_URL, + params=params, + headers=headers, + allow_redirects=False, + **aiohttp_request_kwargs(), + ) as resp: + body = await resp.text() + return resp.status, body + + async def is_favorite( auth_username: str, target: str, cookies: Dict[str, str] ) -> bool: @@ -574,7 +657,7 @@ async def follow( if not cookies or not auth_username: return {"success": False, "error": "CAM4 session absente"} try: - status, body = await _favorite_request("POST", auth_username, target, cookies) + status, body = await _add_favorite_request(target, cookies) except Exception as e: return {"success": False, "error": f"Erreur réseau: {e}"} if status in (200, 201, 204): diff --git a/app/services/chaturbate_api.py b/app/services/chaturbate_api.py index 2e2db8f..6788c16 100644 --- a/app/services/chaturbate_api.py +++ b/app/services/chaturbate_api.py @@ -435,18 +435,22 @@ async def _toggle_follow(self, username: str, action: str) -> bool: return False try: - url = f"https://chaturbate.com/follow/{action}/{username}/" + room_url = f"https://chaturbate.com/{username}/" headers = self._get_headers() - headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["Accept"] = "*/*" headers["X-Requested-With"] = "XMLHttpRequest" - headers["Referer"] = f"https://chaturbate.com/{username}/" + headers["Referer"] = room_url + + # Prime room cookies before the AJAX follow endpoint, matching the + # browser flow used by CTBrec. + await self._request("GET", room_url, headers=headers) + url = f"https://chaturbate.com/follow/{action}/{username}/" csrf = self.auth.get_cookies().get("csrftoken", "") if csrf: headers["X-CSRFToken"] = csrf - data = f"room_slug={username}" - resp = await self._request("POST", url, headers=headers, data=data) + resp = await self._request("POST", url, headers=headers, data=b"") if not resp or resp.status != 200: logger.warning(f"Failed to {action} model", username=username, diff --git a/app/services/chaturbate_auth.py b/app/services/chaturbate_auth.py index a136e1d..138bcd0 100644 --- a/app/services/chaturbate_auth.py +++ b/app/services/chaturbate_auth.py @@ -39,9 +39,9 @@ def __init__(self, db, flaresolverr: Optional[FlareSolverrClient] = None): async def initialize(self): """Load saved auth state from DB""" auth_state = await self.db.get_auth_state() - if auth_state and auth_state.get("is_logged_in"): - self._is_logged_in = True + if auth_state: self._username = auth_state.get("username") + self._last_error = auth_state.get("last_error") if auth_state.get("session_cookies"): try: self._cookies = json.loads(auth_state["session_cookies"]) @@ -57,8 +57,24 @@ async def initialize(self): pass if self._cookies: - logger.info("Restored Chaturbate session from DB", - username=self._username) + self._is_logged_in = False + if bool(auth_state.get("is_logged_in")): + self._is_logged_in = await self._validate_session() + if self._is_logged_in: + logger.info("Restored verified Chaturbate session from DB", + username=self._username) + elif auth_state.get("is_logged_in"): + self._last_error = "Session expired, please re-login" + await self.db.save_auth_state( + username=self._username or "", + password_hash=auth_state.get("password_hash") or "", + is_logged_in=False, + session_cookies=json.dumps(self._cookies), + cf_clearance=self._cookies.get("cf_clearance"), + csrf_token=self._cookies.get("csrftoken"), + last_login_at=auth_state.get("last_login_at"), + last_error=self._last_error, + ) # Apply legacy cookie env vars as fallback if not self._cookies: @@ -68,6 +84,9 @@ async def initialize(self): self._cookies["sessionid"] = CHATURBATE_SESSIONID if self._cookies: logger.info("Using legacy cookie env vars as fallback") + self._is_logged_in = await self._validate_session() + if not self._is_logged_in: + self._last_error = "Legacy Chaturbate cookies are not verified" async def login(self, username: str, password: str) -> Dict[str, Any]: """ @@ -317,16 +336,27 @@ async def ensure_session(self) -> Optional[aiohttp.ClientSession]: return None async def _validate_session(self) -> bool: - """Test if session is still valid by checking /followed-cams/""" + """Test if session is still valid with Chaturbate's authenticated APIs.""" if not self._cookies.get("sessionid"): return False try: headers = { "User-Agent": self._user_agent, + "Accept": "application/json, text/html", "Cookie": "; ".join(f"{k}={v}" for k, v in self._cookies.items()), } async with aiohttp_client_session() as session: + async with session.get( + "https://chaturbate.com/api/ts/chatmessages/pm_users/?offset=0", + headers=headers, + allow_redirects=False, + ssl=False, + timeout=aiohttp.ClientTimeout(total=10), + **aiohttp_request_kwargs(), + ) as resp: + if resp.status == 200: + return True async with session.get( "https://chaturbate.com/followed-cams/", headers=headers, @@ -335,7 +365,6 @@ async def _validate_session(self) -> bool: timeout=aiohttp.ClientTimeout(total=10), **aiohttp_request_kwargs(), ) as resp: - # 200 = valid session, 302 to login = expired return resp.status == 200 except Exception as e: logger.debug("Session validation error", error=str(e)) diff --git a/app/tasks/monitor.py b/app/tasks/monitor.py index 4edbe58..c78f43e 100644 --- a/app/tasks/monitor.py +++ b/app/tasks/monitor.py @@ -678,6 +678,7 @@ async def monitor_models_task( is_recording=is_recording, thumbnail_path=thumbnail_path, room_status=status.get('room_status'), + source_type=source_type, ) # Mettre à jour le cache des enregistrements diff --git a/docker-compose.yml b/docker-compose.yml index 1583241..793eec9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: restart: unless-stopped p-streamrec: - image: ghcr.io/raccommode/p-streamrec:latest + image: ${PSTREAMREC_IMAGE:-ghcr.io/raccommode/p-streamrec:latest} container_name: p-streamrec # Startup race against FlareSolverr is handled by the Python-side retry # loop in app/main.py — the docker healthcheck approach was blocking diff --git a/p-streamrec-store-p-streamrec/umbrel-app.yml b/p-streamrec-store-p-streamrec/umbrel-app.yml index df63df0..22e9060 100644 --- a/p-streamrec-store-p-streamrec/umbrel-app.yml +++ b/p-streamrec-store-p-streamrec/umbrel-app.yml @@ -18,7 +18,7 @@ description: >- - Stripchat, BongaCams, CamSoda — discover, watch and record via yt-dlp, with browser fallback - - MyFreeCams, LiveJasmin, Streamate, Flirt4Free, Cams.com, Xcams — discover, watch and record via integrated browser capture + - MyFreeCams, LiveJasmin, Cams.com, Xcams — discover, watch and record via integrated browser capture - Any m3u8 / HLS URL — watch, record diff --git a/static/app.js b/static/app.js deleted file mode 100644 index eec8430..0000000 --- a/static/app.js +++ /dev/null @@ -1,823 +0,0 @@ -// ============================================ -// Cache for instant performance -// ============================================ - -// Chaturbate room statuses that mean the model is broadcasting but not -// publicly watchable. Treated as "Private" instead of "Offline". -const PRIVATE_STATUSES = [ - 'private', 'group', 'password_protected', 'password protected', - 'hidden', 'true_private', 'private_spy' -]; - -function isPrivateStatus(modelInfo) { - const rs = (modelInfo && (modelInfo.roomStatus || modelInfo.room_status) || '').toLowerCase(); - return PRIVATE_STATUSES.indexOf(rs) !== -1; -} - -function renderPlatformBadge(sourceType) { - const t = (sourceType || '').toLowerCase(); - const label = t.charAt(0).toUpperCase() + t.slice(1); - return `${label}`; -} - -// Store previous model statuses for change detection -let previousModelStatuses = {}; -let availableProviders = []; - -const PROVIDER_LABELS = { - chaturbate: 'Chaturbate', - cam4: 'CAM4', - stripchat: 'Stripchat', - bongacams: 'BongaCams', - myfreecams: 'MyFreeCams', - livejasmin: 'LiveJasmin', - camsoda: 'CamSoda', - streamate: 'Streamate', - flirt4free: 'Flirt4Free', - cams: 'Cams.com', - xcams: 'Xcams' -}; - -function labelForProvider(sourceType) { - return PROVIDER_LABELS[(sourceType || '').toLowerCase()] || sourceType || 'Provider'; -} - -// Save models cache -function saveModelsCache(models) { - localStorage.setItem('models_cache', JSON.stringify({ - models, - timestamp: Date.now() - })); -} - -// Get models cache -function getModelsCache() { - const cached = localStorage.getItem('models_cache'); - if (cached) { - const data = JSON.parse(cached); - // Cache valid for 5 minutes - if (Date.now() - data.timestamp < 300000) { - return data.models; - } - } - return null; -} - -// Save model info -function modelInfoCacheKey(username, sourceType = '') { - return `model_info_${sourceType || 'auto'}_${username}`; -} - -function saveModelInfoCache(username, info, sourceType = '') { - const key = modelInfoCacheKey(username, sourceType); - localStorage.setItem(key, JSON.stringify({ - ...info, - timestamp: Date.now() - })); -} - -// Get cached info -function getModelInfoCache(username, sourceType = '') { - const key = modelInfoCacheKey(username, sourceType); - const cached = localStorage.getItem(key); - if (cached) { - const data = JSON.parse(cached); - // Cache valid for 30 seconds - if (Date.now() - data.timestamp < 30000) { - return data; - } - } - return null; -} - -// Load models from server -async function getModels() { - try { - const res = await fetch('/api/models'); - if (res.ok) { - const data = await res.json(); - const models = data.models || []; - saveModelsCache(models); // Save to cache - return models; - } - } catch (e) { - console.error('Error loading models:', e); - } - return []; -} - -async function loadProviderSelect() { - const select = document.getElementById('modelSource'); - if (!select) return; - try { - const res = await fetch('/api/providers'); - if (!res.ok) return; - const data = await res.json(); - availableProviders = data.providers || []; - if (!availableProviders.length) return; - const current = select.value || 'chaturbate'; - select.innerHTML = availableProviders.map(provider => { - const value = provider.sourceType; - return ``; - }).join(''); - select.value = availableProviders.some(provider => provider.sourceType === current) ? current : 'chaturbate'; - } catch (e) { - console.debug('Provider list unavailable:', e); - } -} - -// Extract username from a supported provider URL or plain username. -function extractUsername(input, sourceType = 'chaturbate') { - if (!input) return null; - const raw = input.trim(); - - // If it's just a username - if (!raw.includes('/') && !raw.includes('.')) { - return raw.toLowerCase(); - } - - try { - const parsed = new URL(raw); - if (sourceType === 'myfreecams' && parsed.hash) { - return parsed.hash.replace(/^#\/?/, '').split(/[/?#]/)[0].toLowerCase(); - } - const parts = parsed.pathname.split('/').filter(Boolean); - const ignored = new Set(['en', 'girls', 'chat', 'cam', 'models', 'live', 'search']); - for (let i = parts.length - 1; i >= 0; i--) { - let part = decodeURIComponent(parts[i]).replace(/\.html?$/i, ''); - if (part && !ignored.has(part.toLowerCase())) { - return part.toLowerCase(); - } - } - } catch (e) { - const match = raw.match(/\/([a-zA-Z0-9_-]+)\/?$/); - if (match) return match[1].toLowerCase(); - } - - return null; -} - -// ============================================ -// Modal -// ============================================ - -function normalizeRetentionDays(value, fallback) { - const parsed = parseInt(value, 10); - if (Number.isNaN(parsed)) return fallback; - return Math.max(0, Math.min(365, parsed)); -} - -async function openAddModal() { - document.getElementById('addModal').classList.add('active'); - document.getElementById('modelUrl').value = ''; - await loadProviderSelect(); - document.getElementById('recordQuality').value = 'best'; - document.getElementById('retentionDays').value = '30'; - document.getElementById('autoRecord').checked = true; - document.getElementById('modelUrl').focus(); - - try { - const res = await fetch('/api/settings/recording'); - if (res.ok) { - const data = await res.json(); - document.getElementById('retentionDays').value = normalizeRetentionDays(data.default_retention_days, 30); - } - } catch (e) { - console.debug('Using built-in retention default:', e); - } -} - -function closeAddModal() { - document.getElementById('addModal').classList.remove('active'); -} - -// ============================================ -// Add a model -// ============================================ - -async function addModel(event) { - event.preventDefault(); - - const url = document.getElementById('modelUrl').value.trim(); - const sourceType = (document.getElementById('modelSource').value || 'chaturbate').toLowerCase(); - const username = extractUsername(url, sourceType); - const quality = document.getElementById('recordQuality').value; - const retentionDays = normalizeRetentionDays(document.getElementById('retentionDays').value, 30); - const autoRecord = document.getElementById('autoRecord').checked; - - if (!username) { - showNotification('URL invalide', 'error'); - return; - } - - try { - // Ajouter le modèle via l'API serveur - const res = await fetch('/api/models', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - username: username, - sourceType: sourceType, - addedAt: new Date().toISOString(), - recordQuality: quality, - retentionDays: retentionDays, - autoRecord: autoRecord - }) - }); - - if (res.status === 409) { - showNotification('This model is already in the list', 'error'); - return; - } - - if (!res.ok) { - showNotification('Error adding model', 'error'); - return; - } - - closeAddModal(); - showNotification(`${username} added to ${labelForProvider(sourceType)}!`, 'success'); - - // Clear cache to force reload from server - localStorage.removeItem('dashboard_cache'); - localStorage.removeItem('models_cache'); - - renderModels(); - } catch (e) { - console.error('Error adding model:', e); - showNotification('Connection error', 'error'); - } -} - -// ============================================ -// Get model information -// ============================================ - -function sourceQuery(sourceType) { - return sourceType ? `?source=${encodeURIComponent(sourceType)}` : ''; -} - -async function getModelInfo(username, useCache = true, sourceType = '') { - // Try cache first for instant display - if (useCache) { - const cached = getModelInfoCache(username, sourceType); - if (cached) { - // Return cache immediately - // and update in background - getModelInfo(username, false, sourceType).then(freshData => { - saveModelInfoCache(username, freshData, sourceType); - }); - return cached; - } - } - - try { - // Use our backend API to avoid CORS issues - const response = await fetch(`/api/model/${username}/status${sourceQuery(sourceType)}`); - if (response.ok) { - const data = await response.json(); - const info = { - username: data.username, - thumbnail: data.thumbnail, - isOnline: data.isOnline, - viewers: data.viewers || 0, - sourceType: data.sourceType || sourceType || 'chaturbate' - }; - saveModelInfoCache(username, info, sourceType); - return info; - } - } catch (e) { - console.error(`Error fetching info for ${username}:`, e); - } - - // Fallback: utiliser l'image par défaut - const fallback = { - username: username, - thumbnail: `https://roomimg.stream.highwebmedia.com/ri/${username}.jpg`, - isOnline: false, - viewers: 0 - }; - saveModelInfoCache(username, fallback, sourceType); - return fallback; -} - -// ============================================ -// Display models -// ============================================ - -// Load ALL data at once from optimized backend -async function getDashboardData() { - try { - const res = await fetch('/api/dashboard'); - if (res.ok) { - const data = await res.json(); - // Save to cache - localStorage.setItem('dashboard_cache', JSON.stringify({ - ...data, - cachedAt: Date.now() - })); - return data; - } - } catch (e) { - console.error('Error loading dashboard:', e); - } - - // Fallback to cache - const cached = localStorage.getItem('dashboard_cache'); - if (cached) { - const data = JSON.parse(cached); - // Cache valid for 1 minute - if (Date.now() - data.cachedAt < 60000) { - return data; - } - } - - return { models: [], sessions: [] }; -} - -// Dynamic status update without recreating cards -async function updateModelsStatus() { - try { - // ONE single request for everything! - const dashboardData = await getDashboardData(); - const models = dashboardData.models || []; - const sessions = dashboardData.sessions || []; - - const liveGrid = document.getElementById('liveGrid'); - const liveSection = document.getElementById('liveSection'); - let liveCount = 0; - - for (const modelInfo of models) { - const card = document.querySelector(`.model-card[data-username="${modelInfo.username}"]`); - if (!card) continue; // Card not yet created - - const isRecording = modelInfo.isRecording; - const isLive = isRecording || modelInfo.isOnline; - - // Check if model just went live (offline -> online) - const previousStatus = previousModelStatuses[modelInfo.username]; - if (previousStatus !== undefined && !previousStatus && modelInfo.isOnline) { - showLiveNotification( - modelInfo.username, - modelInfo.viewers || 0, - modelInfo.sourceType || modelInfo.source_type || '' - ); - } - - // Update stored status - previousModelStatuses[modelInfo.username] = modelInfo.isOnline; - - // Update card status - card.className = `model-card ${isRecording ? 'recording' : modelInfo.isOnline ? 'online' : 'offline'}`; - - // Update badges - const existingBadges = card.querySelectorAll('.badge'); - existingBadges.forEach(b => b.remove()); - - if (isRecording) { - const badge = document.createElement('div'); - badge.className = 'badge recording'; - badge.textContent = 'REC'; - card.insertBefore(badge, card.firstChild); - } - - // Add replays count badge - if (modelInfo.recordingsCount > 0) { - const recBadge = document.createElement('div'); - recBadge.className = 'badge recordings-count'; - recBadge.textContent = `📁 ${modelInfo.recordingsCount}`; - recBadge.style.top = isRecording || modelInfo.isOnline ? '3rem' : '0.75rem'; - card.insertBefore(recBadge, card.firstChild); - } - - // Update status text - const statusDiv = card.querySelector('.model-status'); - if (statusDiv) { - const priv = !modelInfo.isOnline && isPrivateStatus(modelInfo); - const dotClass = isRecording ? 'recording' : modelInfo.isOnline ? 'online' : priv ? 'private' : 'offline'; - const label = isRecording ? 'Recording' : modelInfo.isOnline ? 'Live' : priv ? 'Private' : 'Offline'; - statusDiv.innerHTML = ` - - ${label} - ${modelInfo.isOnline && modelInfo.viewers > 0 ? ` · ${modelInfo.viewers} viewers` : ''} - `; - } - - // Update thumbnail - const thumbnail = card.querySelector('.model-thumbnail'); - if (thumbnail) { - if (isLive) { - // Live/Recording: Color + refresh thumbnail from stream - thumbnail.style.filter = 'none'; - thumbnail.src = `/api/thumbnail/${modelInfo.username}?t=${Date.now()}`; - } else { - // Offline: Black and white (keep last replay thumbnail in cache) - thumbnail.style.filter = 'grayscale(100%) brightness(0.7)'; - // Don't change URL to keep cached generated thumbnail - if (!thumbnail.src.includes('/api/thumbnail/')) { - thumbnail.src = `/api/thumbnail/${modelInfo.username}`; - } - } - } - - // MOVE LIVE cards to LIVE section - if (isLive) { - liveCount++; - if (card.parentElement !== liveGrid) { - liveGrid.appendChild(card); - } - } else { - // Move back to All Models section if not live - const allGrid = document.getElementById('allGrid'); - if (allGrid && card.parentElement !== allGrid) { - allGrid.appendChild(card); - } - } - } - - // Show/hide LIVE section based on count - if (liveSection) { - liveSection.style.display = liveCount > 0 ? 'block' : 'none'; - } - } catch (e) { - console.error('Error updating status:', e); - } -} - -async function renderModels() { - const grid = document.getElementById('modelsGrid'); - const emptyState = document.getElementById('emptyState'); - - // Try dashboard cache for instant display - const cached = localStorage.getItem('dashboard_cache'); - let models = []; - - if (cached) { - try { - const data = JSON.parse(cached); - if (Date.now() - data.cachedAt < 60000) { - models = data.models || []; - } - } catch (e) {} - } - - // Display cache immediately if available - if (models.length > 0) { - emptyState.style.display = 'none'; - grid.innerHTML = ''; - - // Section LIVE MODELS - const liveSection = document.createElement('div'); - liveSection.id = 'liveSection'; - liveSection.style.cssText = 'grid-column: 1 / -1; display: none; margin-bottom: 2rem;'; - liveSection.innerHTML = '

🔴 Live Now

'; - grid.appendChild(liveSection); - - // Section ALL MODELS - const allSection = document.createElement('div'); - allSection.id = 'allSection'; - allSection.style.cssText = 'grid-column: 1 / -1;'; - allSection.innerHTML = '

📁 All Models

'; - grid.appendChild(allSection); - - // Create cards IMMEDIATELY with cached data - for (const modelInfo of models) { - const card = document.createElement('div'); - card.className = 'model-card offline'; - card.setAttribute('data-username', modelInfo.username); - card.onclick = () => openModelPage( - modelInfo.username, - modelInfo.sourceType || modelInfo.source_type || '' - ); - - const priv = !modelInfo.isOnline && isPrivateStatus(modelInfo); - const statusText = modelInfo.isOnline ? 'Live' : priv ? 'Private' : 'Offline'; - const statusClass = modelInfo.isOnline ? 'online' : priv ? 'private' : 'offline'; - - card.innerHTML = ` -
- ${modelInfo.username} - ${renderPlatformBadge(modelInfo.sourceType || modelInfo.source_type || 'chaturbate')} -
-
-
${modelInfo.username}
-
- - ${statusText} -
-
- `; - - // Add to All Models section by default - const allGrid = document.getElementById('allGrid'); - if (allGrid) { - allGrid.appendChild(card); - } else { - grid.appendChild(card); - } - } - } - - // Load real data in background - const dashboardData = await getDashboardData(); - const freshModels = dashboardData.models || []; - - if (freshModels.length === 0) { - grid.innerHTML = ''; - emptyState.style.display = 'block'; - return; - } - - // If no cache, display now - if (models.length === 0) { - emptyState.style.display = 'none'; - grid.innerHTML = ''; - - const liveSection = document.createElement('div'); - liveSection.id = 'liveSection'; - liveSection.style.cssText = 'grid-column: 1 / -1; display: none; margin-bottom: 2rem;'; - liveSection.innerHTML = '

🔴 Live Now

'; - grid.appendChild(liveSection); - - const allSection = document.createElement('div'); - allSection.id = 'allSection'; - allSection.style.cssText = 'grid-column: 1 / -1;'; - allSection.innerHTML = '

📁 All Models

'; - grid.appendChild(allSection); - - for (const modelInfo of freshModels) { - const card = document.createElement('div'); - card.className = 'model-card offline'; - card.setAttribute('data-username', modelInfo.username); - card.onclick = () => openModelPage( - modelInfo.username, - modelInfo.sourceType || modelInfo.source_type || '' - ); - - const priv = !modelInfo.isOnline && isPrivateStatus(modelInfo); - const statusText = modelInfo.isOnline ? 'Live' : priv ? 'Private' : 'Offline'; - const statusClass = modelInfo.isOnline ? 'online' : priv ? 'private' : 'offline'; - - card.innerHTML = ` -
- ${modelInfo.username} - ${renderPlatformBadge(modelInfo.sourceType || modelInfo.source_type || 'chaturbate')} -
-
-
${modelInfo.username}
-
- - ${statusText} -
-
- `; - - // Add to All Models section - const allGrid = document.getElementById('allGrid'); - if (allGrid) { - allGrid.appendChild(card); - } else { - grid.appendChild(card); - } - } - } - - // Update with real data (just badges and positions) - updateModelsStatus(); -} - -// ============================================ -// Get active sessions -// ============================================ - -async function getActiveSessions() { - try { - const res = await fetch('/api/status'); - if (res.ok) { - return await res.json(); - } - } catch (e) { - console.error('Error fetching sessions:', e); - } - return []; -} - -// ============================================ -// Open model page -// ============================================ - -function openModelPage(username, sourceType = '') { - // Create a new page or redirect - const qs = sourceType ? `&source=${encodeURIComponent(sourceType)}` : ''; - window.location.href = `/model.html?username=${encodeURIComponent(username)}${qs}`; -} - -// ============================================ -// Notifications -// ============================================ - -function showNotification(message, type = 'success') { - const notif = document.createElement('div'); - const bgColor = type === 'success' ? '#10b981' : '#ef4444'; - notif.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: ${bgColor}; - color: white; - padding: 1rem 1.5rem; - border-radius: 10px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); - z-index: 9999; - animation: slideIn 0.3s ease-out; - font-weight: 500; - `; - notif.textContent = message; - document.body.appendChild(notif); - - setTimeout(() => { - notif.style.animation = 'slideOut 0.3s ease-out'; - setTimeout(() => notif.remove(), 300); - }, 3000); -} - -// Show live notification with enhanced styling -function showLiveNotification(username, viewers = 0, sourceType = '') { - // Check if notifications are enabled - const notifEnabled = localStorage.getItem('notifications_enabled') !== 'false'; - if (!notifEnabled) { - console.log(`🔕 Notifications disabled, skipping for ${username}`); - return; - } - - const notif = document.createElement('div'); - notif.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - color: white; - padding: 1.25rem 1.75rem; - border-radius: 12px; - box-shadow: 0 10px 40px rgba(239, 68, 68, 0.5); - z-index: 9999; - animation: slideIn 0.3s ease-out, pulse 2s ease-in-out; - font-weight: 600; - border: 2px solid rgba(255, 255, 255, 0.2); - cursor: pointer; - min-width: 280px; - `; - - notif.innerHTML = ` -
- 🔴 -
-
${username} est en live !
-
${viewers > 0 ? `${viewers} spectateurs` : 'Cliquez pour voir'}
-
-
- `; - - // Click to open model page - notif.onclick = () => { - openModelPage(username, sourceType); - }; - - document.body.appendChild(notif); - - setTimeout(() => { - notif.style.animation = 'slideOut 0.3s ease-out'; - setTimeout(() => notif.remove(), 300); - }, 5000); -} - -// Play notification sound (DISABLED - no sound on notifications) -function playNotificationSound() { - // Notifications without sound - return; -} - -// Request notification permission on page load (DISABLED - using internal notifications only) -function requestNotificationPermission() { - // Internal notifications only - browser notifications disabled - return; -} - -// ============================================ -// Automatic recording start -// ============================================ - -async function checkAndStartRecordings() { - const models = await getModels(); - const sessions = await getActiveSessions(); - - for (const model of models) { - const username = model.username; - const sourceType = model.sourceType || model.source_type || 'chaturbate'; - const autoRecord = model.autoRecord !== false; // Par défaut true si non défini - const session = sessions.find(s => s.person === username); - const isRecording = session && session.running; - - // Vérifier si l'auto-record est activé pour ce modèle - if (!autoRecord) { - console.log(`⏭️ Auto-record désactivé pour ${username}, skip`); - continue; - } - - if (!isRecording) { - // Check if model is online - const info = await getModelInfo(username, true, sourceType); - if (info.isOnline) { - // Start recording automatically - console.log(`🔴 ${username} is online, starting automatically...`); - try { - const res = await fetch('/api/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - target: username, - source_type: sourceType, - person: username, - name: username, - auto_start: true // Indiquer que c'est un démarrage automatique - }) - }); - - if (res.ok) { - console.log(`✅ Recording started automatically for ${username}`); - // Update just statuses without reloading everything - await updateModelsStatus(); - } else if (res.status === 409) { - // Session already running, this is normal, skip - console.log(`⏭️ Session already running for ${username}, skip`); - } else if (res.status === 403) { - // Auto-record désactivé pour ce modèle - console.log(`🚫 Auto-record désactivé pour ${username}`); - } else if (res.status === 404) { - // Modèle non trouvé en DB - console.log(`❓ Modèle ${username} non trouvé en DB`); - } else { - const error = await res.json(); - console.error(`❌ Error for ${username}:`, error.detail); - } - } catch (e) { - console.error(`❌ Error starting ${username}:`, e); - } - } - } - } -} - -// ============================================ -// Initialization -// ============================================ - -window.addEventListener('DOMContentLoaded', () => { - // Add animation styles - const style = document.createElement('style'); - style.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } - `; - document.head.appendChild(style); - - // Internal notifications only (browser notifications disabled) - - // Display models - loadProviderSelect(); - renderModels(); - - // Update statuses every 30 seconds (backend monitor polls every 60s anyway) - setInterval(updateModelsStatus, 30000); - - // Check and start recordings every 3 minutes (backend auto-record covers this) - setInterval(checkAndStartRecordings, 180000); - checkAndStartRecordings(); // First immediate check - - // Close modal when clicking outside - document.getElementById('addModal').addEventListener('click', (e) => { - if (e.target.id === 'addModal') { - closeAddModal(); - } - }); -}); diff --git a/static/discover.html b/static/discover.html index 3369b22..bd7b79b 100644 --- a/static/discover.html +++ b/static/discover.html @@ -9,7 +9,7 @@ - +
diff --git a/static/discover.js b/static/discover.js index eae4b3e..d5bdd9e 100644 --- a/static/discover.js +++ b/static/discover.js @@ -41,6 +41,27 @@ function providerLabel(sourceType) { return t.charAt(0).toUpperCase() + t.slice(1); } +function fallbackThumbnailUrl(username, sourceType) { + var source = (sourceType || 'chaturbate').toLowerCase(); + if (source === 'chaturbate') { + return 'https://roomimg.stream.highwebmedia.com/ri/' + encodeURIComponent(username || '') + '.jpg'; + } + var label = providerLabel(source) || 'Live'; + var title = username || label; + var svg = '' + + '' + + '' + + '' + escapeHtml(label) + '' + + '' + escapeHtml(title) + '' + + ''; + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); +} + +function thumbnailUrlForModel(model, sourceType) { + var thumbnail = String(model.thumbnail || '').trim(); + return thumbnail || fallbackThumbnailUrl(model.username, sourceType); +} + async function loadFollowedSet() { try { var res = await fetch('/api/following'); @@ -197,7 +218,9 @@ function renderGrid(models, providerStatuses) { } grid.innerHTML = models.map(function(model) { - var thumbUrl = model.thumbnail || ('https://roomimg.stream.highwebmedia.com/ri/' + model.username + '.jpg'); + var cardSource = (model.source_type || model.platform || 'chaturbate'); + var thumbUrl = thumbnailUrlForModel(model, cardSource); + var fallbackThumbUrl = fallbackThumbnailUrl(model.username, cardSource); var viewerText = '👁 ' + Number(model.viewers || 0).toLocaleString() + ''; var ageText = model.age ? ('' + model.age + '') : ''; var tagsHtml = ''; @@ -208,9 +231,10 @@ function renderGrid(models, providerStatuses) { }).join('') + '

'; } - var cardSource = (model.source_type || model.platform || 'chaturbate'); var streamAvailable = model.stream_available !== false; - var canFollow = model.can_follow !== false && (!providerCapsBySource[cardSource] || providerCapsBySource[cardSource].can_follow !== false); + var cardCaps = providerCapsBySource[cardSource] || {}; + var localFollowAvailable = cardCaps.can_stream !== false || cardCaps.can_record === true; + var canFollow = model.can_follow !== false && (cardCaps.can_follow !== false || localFollowAvailable); var isFollowed = followedSet.has(sourceKey(model.username, cardSource)); var heartBtn = canFollow ? ' - - - - - - - - + diff --git a/static/following.js b/static/following.js index 288e276..da7ebc2 100644 --- a/static/following.js +++ b/static/following.js @@ -23,6 +23,10 @@ function isPrivateModel(model) { return isPrivateRoomStatus(model.room_status || model.roomStatus); } +function isLiveFollowingModel(model) { + return isPubliclyOnline(model) || isPrivateModel(model); +} + // Render a small platform badge overlaid on the thumbnail. function renderPlatformBadge(sourceType) { var t = (sourceType || '').toLowerCase(); @@ -31,49 +35,70 @@ function renderPlatformBadge(sourceType) { return '' + label + ''; } +function renderProviderBadge(sourceType) { + var t = (sourceType || '').toLowerCase(); + var label = t.charAt(0).toUpperCase() + t.slice(1); + var cls = 'following-provider-badge platform-' + (t || 'unknown'); + return '' + escapeHtml(label) + ''; +} + +function sourceKey(username, sourceType) { + return normalizeSourceType(sourceType || 'chaturbate') + ':' + (username || ''); +} + // State let trackedModels = new Set(); -let isLoggedIn = false; let followingProviders = []; - -async function fetchJsonWithTimeout(url, options, timeoutMs) { - timeoutMs = timeoutMs || 45000; - var controller = window.AbortController ? new AbortController() : null; - var timer = controller ? setTimeout(function() { controller.abort(); }, timeoutMs) : null; - try { - var requestOptions = options || {}; - if (controller) requestOptions.signal = controller.signal; - var res = await fetch(url, requestOptions); - var data = await res.json().catch(function() { return {}; }); - return { ok: res.ok, status: res.status, data: data }; - } finally { - if (timer) clearTimeout(timer); - } -} +let followingProviderMap = {}; // ============================================ -// Check provider login status +// Load provider capabilities // ============================================ async function loadFollowingProviders() { try { var res = await fetch('/api/providers', { cache: 'no-store' }); if (res.ok) { var data = await res.json(); - followingProviders = (data.providers || []).filter(function(provider) { - var caps = provider.capabilities || {}; - return caps.can_sync_following === true; - }); - return followingProviders.some(function(provider) { - return provider.status && provider.status.isLoggedIn === true; - }); + setFollowingProviders(data.providers || []); + return true; } } catch (e) { console.error('Error loading provider status:', e); } followingProviders = []; + followingProviderMap = {}; return false; } +function setFollowingProviders(providers) { + followingProviders = providers || []; + followingProviderMap = {}; + followingProviders.forEach(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + if (sourceType) followingProviderMap[sourceType] = provider; + }); +} + +function mergeFollowingProviderSummaries(providers) { + if (!providers || !providers.length) return; + var merged = followingProviders.slice(); + var indexBySource = {}; + merged.forEach(function(provider, index) { + indexBySource[normalizeSourceType(provider.sourceType || provider.source_type)] = index; + }); + providers.forEach(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + if (!sourceType) return; + if (indexBySource[sourceType] == null) { + indexBySource[sourceType] = merged.length; + merged.push(provider); + } else { + merged[indexBySource[sourceType]] = Object.assign({}, merged[indexBySource[sourceType]], provider); + } + }); + setFollowingProviders(merged); +} + // ============================================ // Load tracked models // ============================================ @@ -82,7 +107,9 @@ async function loadTrackedModels() { var res = await fetch('/api/models'); if (res.ok) { var data = await res.json(); - trackedModels = new Set((data.models || []).map(function(m) { return m.username; })); + trackedModels = new Set((data.models || []).map(function(m) { + return sourceKey(m.username, m.sourceType || m.source_type || 'chaturbate'); + })); } } catch (e) { console.error('Error loading tracked models:', e); @@ -97,6 +124,7 @@ async function loadFollowing() { var res = await fetch('/api/following'); if (res.ok) { var data = await res.json(); + mergeFollowingProviderSummaries(data.providers || []); return data.models || data.following || []; } } catch (e) { @@ -109,53 +137,278 @@ async function loadFollowing() { // Render following models // ============================================ function renderFollowing(models) { - var onlineGrid = document.getElementById('onlineGrid'); - var offlineGrid = document.getElementById('offlineGrid'); - var onlineSection = document.getElementById('onlineSection'); - var offlineSection = document.getElementById('offlineSection'); - var onlineCount = document.getElementById('onlineCount'); - var offlineCount = document.getElementById('offlineCount'); + var providerSections = document.getElementById('providerSections'); var emptyFollowing = document.getElementById('emptyFollowing'); - if (!models || models.length === 0) { - onlineSection.style.display = 'none'; - offlineSection.style.display = 'none'; + models = sortFollowingModels(models || []); + + if (models.length === 0) { + var emptySyncControls = renderFollowingSyncControls(); + if (emptySyncControls) { + providerSections.style.display = 'block'; + providerSections.innerHTML = + '
' + + '
' + + '
' + + '

Provider sync

' + + '
Sync connected providers or keep local follows for providers without sync.
' + + '
' + + emptySyncControls + + '
' + + '
'; + } else { + providerSections.style.display = 'none'; + } emptyFollowing.style.display = 'flex'; return; } emptyFollowing.style.display = 'none'; + providerSections.style.display = 'block'; + providerSections.innerHTML = renderGlobalFollowingSection(models); +} + +function normalizeSourceType(sourceType) { + return (sourceType || '').toString().trim().toLowerCase(); +} + +function modelSourceType(model) { + return normalizeSourceType(model.source_type || model.platform || 'chaturbate'); +} + +function providerLabel(sourceType) { + var provider = followingProviderMap[normalizeSourceType(sourceType)] || {}; + return provider.displayName || provider.display_name || sourceType || 'Unknown'; +} + +function providersForFollowing(models) { + var providerBySource = {}; + + followingProviders.forEach(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + if (!sourceType) return; + providerBySource[sourceType] = Object.assign({}, provider, { sourceType: sourceType }); + }); + + models.forEach(function(model) { + var sourceType = modelSourceType(model); + if (!providerBySource[sourceType]) { + var known = followingProviderMap[sourceType] || {}; + providerBySource[sourceType] = Object.assign({ + sourceType: sourceType, + displayName: providerLabel(sourceType), + capabilities: known.capabilities || {}, + status: known.status || {} + }, known); + } + }); + + return Object.keys(providerBySource).map(function(sourceType) { + return providerBySource[sourceType]; + }).sort(function(a, b) { + var aModels = modelsForProvider(models, a.sourceType || a.source_type).length; + var bModels = modelsForProvider(models, b.sourceType || b.source_type).length; + if (aModels !== bModels) return bModels - aModels; + return providerLabel(a.sourceType || a.source_type).localeCompare(providerLabel(b.sourceType || b.source_type)); + }); +} + +function modelsForProvider(models, sourceType) { + var normalized = normalizeSourceType(sourceType); + return (models || []).filter(function(model) { + return modelSourceType(model) === normalized; + }).sort(compareFollowingModels); +} + +function sortFollowingModels(models) { + return (models || []).slice().sort(compareFollowingModels); +} + +function compareFollowingModels(a, b) { + var viewersA = modelViewers(a); + var viewersB = modelViewers(b); + if (viewersA !== viewersB) return viewersB - viewersA; + var rankA = modelStatusRank(a); + var rankB = modelStatusRank(b); + if (rankA !== rankB) return rankA - rankB; + var sourceCompare = providerLabel(modelSourceType(a)).localeCompare(providerLabel(modelSourceType(b))); + if (sourceCompare !== 0) return sourceCompare; + return (a.username || a.name || '').localeCompare(b.username || b.name || ''); +} + +function modelViewers(model) { + if (!isPubliclyOnline(model)) return 0; + var value = Number(model.viewers || model.num_viewers || 0); + return Number.isFinite(value) ? value : 0; +} + +function modelStatusRank(model) { + if (isPubliclyOnline(model)) return 0; + if (isPrivateModel(model)) return 1; + return 2; +} + +function providerCountForModels(models) { + var seen = {}; + (models || []).forEach(function(model) { + seen[modelSourceType(model)] = true; + }); + return Object.keys(seen).length; +} + +function connectedFollowingProviders(models) { + var seen = {}; + var sourceTypes = {}; + (models || []).forEach(function(model) { + sourceTypes[modelSourceType(model)] = true; + }); + return Object.keys(sourceTypes).map(function(sourceType) { + var provider = followingProviderMap[sourceType] || {}; + return Object.assign({ + sourceType: sourceType, + displayName: providerLabel(sourceType), + status: {}, + capabilities: {} + }, provider); + }).filter(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + if (!sourceType || seen[sourceType]) return false; + seen[sourceType] = true; + return true; + }); +} - var online = models.filter(function(m) { - return isPubliclyOnline(m) || isPrivateModel(m); +function modelsMatchingSource(models, sourceType) { + var normalized = normalizeSourceType(sourceType); + return (models || []).filter(function(model) { + return modelSourceType(model) === normalized; }); +} + +function renderConnectedProviderMeta(models) { + return connectedFollowingProviders(models).map(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + var providerModels = modelsMatchingSource(models, sourceType); + var online = providerModels.filter(function(model) { return isPubliclyOnline(model); }).length; + var name = providerLabel(sourceType).toUpperCase(); + return '' + + '' + escapeHtml(name) + '' + + ' ' + online + '/' + providerModels.length + '' + + ''; + }).join(' '); +} + +function renderGlobalFollowingSection(models) { + var liveModels = models.filter(isLiveFollowingModel); + var offlineModels = models.filter(function(model) { return !isLiveFollowingModel(model); }); + var meta = renderConnectedProviderMeta(models); + var syncControls = renderFollowingSyncControls(); + var body = renderProviderStatusGroup('Live online', liveModels, 'online') + + renderProviderStatusGroup('Offline', offlineModels, 'offline'); + + return '
' + + '
' + + '
' + + '
' + + '

All providers

' + + '' + models.length + '' + + '
' + + (meta ? '
' + meta + '
' : '') + + '
' + + syncControls + + '
' + + body + + '
'; +} + +function syncCapableFollowingProviders() { + return (followingProviders || []).filter(function(provider) { + var caps = provider.capabilities || {}; + return caps.can_sync_following === true; + }); +} + +function providerCanStartSync(provider) { + var status = provider.status || {}; + return status.isLoggedIn === true || status.hasSavedCredentials === true || status.hasSavedSessionData === true; +} + +function renderFollowingSyncControls() { + var providers = syncCapableFollowingProviders(); + if (!providers.length) return ''; + return '
' + providers.map(function(provider) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + var disabled = providerCanStartSync(provider) ? '' : ' disabled'; + var title = disabled ? ' title="Connect this provider in Settings first"' : ''; + return ''; + }).join('') + '
'; +} + +function renderProviderSection(provider, models) { + var sourceType = normalizeSourceType(provider.sourceType || provider.source_type); + var displayName = provider.displayName || provider.display_name || providerLabel(sourceType); + var caps = provider.capabilities || {}; + var status = provider.status || {}; + var online = models.filter(isLiveFollowingModel); var offline = models.filter(function(m) { - return !isPubliclyOnline(m) && !isPrivateModel(m); + return !isLiveFollowingModel(m); }); + var meta = providerMetaText(provider, models.length); + var body = ''; - // Online section - if (online.length > 0) { - onlineSection.style.display = 'block'; - onlineCount.textContent = online.length; - onlineGrid.innerHTML = online.map(function(model) { return renderFollowingCard(model); }).join(''); + if (models.length) { + body += renderProviderStatusGroup('Online', online, 'online'); + body += renderProviderStatusGroup('Offline', offline, 'offline'); } else { - onlineSection.style.display = 'none'; + body = '
' + escapeHtml(emptyProviderText(provider)) + '
'; } - // Offline section - if (offline.length > 0) { - offlineSection.style.display = 'block'; - offlineCount.textContent = offline.length; - offlineGrid.innerHTML = offline.map(function(model) { return renderFollowingCard(model); }).join(''); - } else { - offlineSection.style.display = 'none'; - } + return '
' + + '
' + + '
' + + '
' + + renderProviderBadge(sourceType) + + '

' + escapeHtml(displayName) + '

' + + '' + models.length + '' + + '
' + + '
' + escapeHtml(meta) + '
' + + '
' + + '
' + + body + + '
'; +} + +function renderProviderStatusGroup(label, models, kind) { + if (!models.length) return ''; + var dot = kind === 'online' ? 'var(--success)' : 'var(--text-muted)'; + return '
' + + '
' + + '●' + + '' + label + '' + + '' + models.length + '' + + '
' + + '
' + models.map(function(model) { return renderFollowingCard(model); }).join('') + '
' + + '
'; +} + +function providerMetaText(provider, modelCount) { + var pieces = []; + pieces.push('Local follows'); + if (modelCount === 0) pieces.push('none saved'); + return pieces.join(' / '); +} + +function emptyProviderText(provider) { + return 'No local follows saved for this provider.'; } function renderFollowingCard(model) { var username = model.username || model.name || ''; var thumbUrl = model.thumbnail_url || model.thumbnail || ('https://roomimg.stream.highwebmedia.com/ri/' + username + '.jpg'); - var isTracked = trackedModels.has(username); + var sourceType = model.source_type || model.platform || 'chaturbate'; + var isTracked = trackedModels.has(sourceKey(username, sourceType)); var isRecording = model.isRecording || model.is_recording || false; var isPrivate = isPrivateModel(model); var isOnline = isPubliclyOnline(model); @@ -172,7 +425,6 @@ function renderFollowingCard(model) { } var platformBadge = renderPlatformBadge(model.source_type || model.platform || 'chaturbate'); - var sourceType = model.source_type || model.platform || 'chaturbate'; var watchHref = '/watch/' + encodeURIComponent(username) + '?source=' + encodeURIComponent(sourceType); var privateRibbon = isPrivate ? '
Private show
' : ''; @@ -221,15 +473,20 @@ function formatLastSeen(timestamp) { // ============================================ // Track a followed model // ============================================ -async function trackFollowedModel(username, btn) { - if (trackedModels.has(username)) return; +async function trackFollowedModel(username, sourceType, btn) { + if (typeof sourceType !== 'string') { + btn = sourceType; + sourceType = 'chaturbate'; + } + sourceType = normalizeSourceType(sourceType || 'chaturbate'); + if (trackedModels.has(sourceKey(username, sourceType))) return; btn.textContent = '...'; btn.disabled = true; try { // Try the dedicated following track endpoint first - var res = await fetch('/api/following/' + username + '/track', { + var res = await fetch('/api/following/' + username + '/track?source_type=' + encodeURIComponent(sourceType), { method: 'POST', headers: { 'Content-Type': 'application/json' } }); @@ -242,13 +499,14 @@ async function trackFollowedModel(username, btn) { body: JSON.stringify({ username: username, autoRecord: true, - recordQuality: 'best' + recordQuality: 'best', + sourceType: sourceType }) }); } if (res.ok || res.status === 409) { - trackedModels.add(username); + trackedModels.add(sourceKey(username, sourceType)); btn.textContent = 'Tracked'; btn.classList.add('tracked'); btn.disabled = false; @@ -267,77 +525,63 @@ async function trackFollowedModel(username, btn) { } // ============================================ -// Sync following list +// Refresh local following list // ============================================ async function syncFollowing() { var options = arguments[0] || {}; var silent = options.silent === true; - var syncBtn = document.getElementById('syncBtn'); - var syncIcon = document.getElementById('syncIcon'); - - if (syncBtn) syncBtn.classList.add('syncing'); - if (syncIcon) syncIcon.style.animation = 'spin 1s linear infinite'; try { if (!followingProviders.length) { await loadFollowingProviders(); } - var targets = followingProviders.filter(function(provider) { - var caps = provider.capabilities || {}; - return caps.can_sync_following === true && provider.status && provider.status.isLoggedIn === true; - }); - - if (!targets.length) { - if (!silent) showNotification('No connected provider to sync', 'error'); - return []; - } - - var synced = 0; - var failed = []; - for (var i = 0; i < targets.length; i += 1) { - var provider = targets[i]; - try { - var result = await fetchJsonWithTimeout( - '/api/providers/' + encodeURIComponent(provider.sourceType) + '/following/sync', - { method: 'POST' }, - 45000 - ); - if (result.ok) { - synced += Number(result.data.synced || 0); - } else { - failed.push(provider.displayName || provider.sourceType); - } - } catch (e) { - failed.push(provider.displayName || provider.sourceType); - } + var providers = syncCapableFollowingProviders().filter(providerCanStartSync); + for (var i = 0; i < providers.length; i += 1) { + var sourceType = normalizeSourceType(providers[i].sourceType || providers[i].source_type); + await syncSingleProvider(sourceType, null, true); } var models = await loadFollowing(); renderFollowing(models); - if (failed.length) { - if (!silent) showNotification('Sync incomplete: ' + failed.join(', '), 'error'); - } else { - if (!silent) showNotification('Following list synced!', 'success'); - updateLastSynced(); - } + if (!silent) showNotification(providers.length ? 'Following synced' : 'Following list refreshed', 'success'); return models; } catch (e) { - console.error('Error syncing following:', e); + console.error('Error refreshing following:', e); if (!silent) showNotification('Connection error', 'error'); return []; - } finally { - if (syncBtn) syncBtn.classList.remove('syncing'); - if (syncIcon) syncIcon.style.animation = ''; } } -function updateLastSynced() { - var el = document.getElementById('lastSynced'); - if (el) { - var now = new Date(); - el.textContent = 'Last synced: ' + now.toLocaleTimeString(); - localStorage.setItem('following_last_synced', now.toISOString()); +async function syncSingleProvider(sourceType, button, silent) { + sourceType = normalizeSourceType(sourceType); + if (!sourceType) return false; + var originalText = button ? button.textContent : ''; + if (button) { + button.disabled = true; + button.textContent = 'Syncing...'; + } + try { + var res = await fetch('/api/providers/' + encodeURIComponent(sourceType) + '/following/sync', { method: 'POST' }); + var data = await res.json().catch(function() { return {}; }); + if (!res.ok) { + if (!silent) showNotification(data.detail || 'Sync failed', 'error'); + return false; + } + await loadFollowingProviders(); + var models = await loadFollowing(); + renderFollowing(models); + if (!silent) showNotification(data.message || 'Following synced', 'success'); + return true; + } catch (e) { + console.error('Error syncing provider:', e); + if (!silent) showNotification('Connection error', 'error'); + return false; + } finally { + if (button) { + button.disabled = false; + button.textContent = originalText; + } } } @@ -395,9 +639,9 @@ function _bustChaturbateUrl(src) { async function refreshLiveThumbnails() { if (document.hidden) return; - var onlineGrid = document.getElementById('onlineGrid'); - if (!onlineGrid) return; - var cards = onlineGrid.querySelectorAll('.following-card'); + var providerSections = document.getElementById('providerSections'); + if (!providerSections) return; + var cards = providerSections.querySelectorAll('.following-card.is-online, .following-card.is-private'); if (!cards.length) return; cards.forEach(function(card) { @@ -414,56 +658,26 @@ async function refreshLiveThumbnails() { window.addEventListener('DOMContentLoaded', function() { // Refresh live thumbnails toutes les 30s setInterval(refreshLiveThumbnails, 30000); + document.addEventListener('click', function(event) { + var button = event.target.closest('[data-sync-provider]'); + if (!button) return; + syncSingleProvider(button.getAttribute('data-sync-provider'), button, false); + }); // Add animation keyframes var style = document.createElement('style'); style.textContent = '@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }'; document.head.appendChild(style); - // Show last synced time from localStorage - var lastSynced = localStorage.getItem('following_last_synced'); - if (lastSynced) { - var el = document.getElementById('lastSynced'); - if (el) { - var date = new Date(lastSynced); - el.textContent = 'Last synced: ' + date.toLocaleString(); - } - } - var loadingState = document.getElementById('loadingState'); - var loginBanner = document.getElementById('loginBanner'); - var syncControls = document.getElementById('syncControls'); // Load data - Promise.all([loadTrackedModels(), loadFollowingProviders()]).then(function(results) { - isLoggedIn = results[1]; - + Promise.all([loadTrackedModels(), loadFollowingProviders()]).then(function() { loadingState.style.display = 'none'; - if (!isLoggedIn) { - // Not logged in - show banner but still try to load following - loginBanner.style.display = 'block'; - syncControls.style.display = 'none'; - - // Try loading following anyway (might have cached data or public API) - loadFollowing().then(function(models) { - if (models.length > 0) { - syncControls.style.display = 'block'; - renderFollowing(models); - } - }); - } else { - // Logged in - show sync controls and load data - loginBanner.style.display = 'none'; - syncControls.style.display = 'block'; - - loadFollowing().then(function(models) { - renderFollowing(models); - if (!models.length) { - syncFollowing({ silent: true }); - } - }); - } + loadFollowing().then(function(models) { + renderFollowing(models); + }); }).catch(function(e) { console.error('Error initializing following page:', e); loadingState.innerHTML = '
⚠

Failed to load. Please try refreshing.

'; diff --git a/static/header.html b/static/header.html index 7659531..d16aacf 100644 --- a/static/header.html +++ b/static/header.html @@ -12,7 +12,7 @@
⚙ diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 29c0997..0000000 --- a/static/index.html +++ /dev/null @@ -1,732 +0,0 @@ - - - - - - P-StreamRec - - - - - - - - - - -
- - - -
-
- -
- -
-
🎬
-

No Models Added

-

Click the + button to add a Chaturbate model

-
-
- - - - - - - - - - - - - diff --git a/static/media.html b/static/media.html new file mode 100644 index 0000000..5408dee --- /dev/null +++ b/static/media.html @@ -0,0 +1,240 @@ + + + + + + Média - P-StreamRec + + + + + + + + +
+ +
+
+
+
-
+
Fichiers
+
+
+
-
+
Videos
+
+
+
-
+
Photos
+
+
+
-
+
Stockage
+
+
+ +
+
+
+

Profils

+
Loading...
+
+ +
+ +
+ +
+ + +
+
+

Videos recentes

+
Loading...
+
+ +
+ +
+
+ + +
+
+ + + +
+ +
+ +
+
+
+
⏳
+

Loading media...

+
+
+
+
+
+ + + + + + + + + + + + diff --git a/static/media.js b/static/media.js new file mode 100644 index 0000000..76799cd --- /dev/null +++ b/static/media.js @@ -0,0 +1,859 @@ +// ============================================ +// Media Page - profile-first media catalogue +// ============================================ + +(function() { + 'use strict'; + + var state = { + items: [], + profiles: [], + kind: 'video', + profile: '', + search: '', + sort: 'newest', + loading: false, + pendingDelete: null, + pendingProfileDelete: null, + currentViewerItem: null, + profileSettings: null + }; + + var searchTimer = null; + + function $(id) { + return document.getElementById(id); + } + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatDate(timestamp) { + if (!timestamp) return '-'; + try { + return new Date(timestamp * 1000).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } catch (e) { + return '-'; + } + } + + function formatType(item) { + if (item.type === 'image') return 'Photo'; + if (item.type === 'audio') return 'Audio'; + return 'Video'; + } + + function itemById(id) { + for (var i = 0; i < state.items.length; i++) { + if (state.items[i].id === id) return state.items[i]; + } + return null; + } + + function profileByUsername(username) { + for (var i = 0; i < state.profiles.length; i++) { + if (state.profiles[i].username === username) return state.profiles[i]; + } + return null; + } + + function profileLabel(profile) { + if (!profile) return state.profile || ''; + return profile.displayName || profile.username || ''; + } + + function firstLetter(value) { + value = String(value || '?').trim(); + return (value.charAt(0) || '?').toUpperCase(); + } + + function splitLines(value) { + return String(value || '') + .split(/\r?\n/) + .map(function(line) { return line.trim(); }) + .filter(Boolean); + } + + function joinLines(value) { + if (Array.isArray(value)) return value.join('\n'); + return String(value || ''); + } + + function buildQuery() { + var params = new URLSearchParams(); + params.set('kind', state.kind); + params.set('sort', state.sort); + params.set('limit', '1000'); + if (state.profile) params.set('username', state.profile); + if (state.search) params.set('search', state.search); + return params.toString(); + } + + async function loadMediaLibrary() { + if (state.loading) return; + state.loading = true; + renderLoading(); + + try { + var res = await fetch('/api/media-library?' + buildQuery(), { cache: 'no-store' }); + if (!res.ok) throw new Error('Failed to load media library'); + var data = await res.json(); + state.items = data.items || []; + state.profiles = data.profiles || []; + renderStats(data.libraryStats || data.stats || {}); + renderProfileCarousel(); + renderProfileDetail(); + renderRecentSection(data.total || state.items.length); + } catch (e) { + console.error('Error loading media library:', e); + renderError(); + } finally { + state.loading = false; + } + } + + function renderLoading() { + var grid = $('mediaGrid'); + var meta = $('mediaResultMeta'); + var rail = $('mediaProfileRail'); + if (meta) meta.textContent = 'Loading...'; + if (!state.profiles.length && rail) { + rail.innerHTML = '
⏳

Loading profiles...

'; + } + if (grid) { + grid.innerHTML = '
⏳

Loading media...

'; + } + } + + function renderError() { + var grid = $('mediaGrid'); + var rail = $('mediaProfileRail'); + var meta = $('mediaResultMeta'); + if (meta) meta.textContent = 'Unable to load media'; + if (rail && !state.profiles.length) { + rail.innerHTML = '
⚠

Profiles unavailable

'; + } + if (grid) { + grid.innerHTML = '
⚠

Media unavailable

'; + } + } + + function renderStats(stats) { + $('mediaTotalCount').textContent = stats.total || 0; + $('mediaVideoCount').textContent = stats.videos || 0; + $('mediaImageCount').textContent = stats.images || 0; + $('mediaTotalSize').textContent = stats.totalSizeFormatted || '0 B'; + } + + function renderProfileCarousel() { + var rail = $('mediaProfileRail'); + var meta = $('mediaProfileMeta'); + if (!rail) return; + + if (meta) { + var count = state.profiles.length; + meta.textContent = count === 1 ? '1 profil' : count + ' profils'; + } + + if (!state.profiles.length) { + rail.innerHTML = '
🖼

No profiles found

'; + return; + } + + rail.innerHTML = state.profiles.map(renderProfileCard).join(''); + } + + function renderProfileCard(profile) { + var active = state.profile === profile.username; + var name = profileLabel(profile); + var image = ''; + if (profile.thumbnail) { + image = '' + escapeHtml(name) + ''; + } + + var latest = profile.latestTitle || 'No recent media'; + var typeLabel = profile.latestType === 'image' ? 'Photo' : profile.latestType === 'audio' ? 'Audio' : 'Video'; + var countLabel = (profile.videos || 0) + ' videos'; + if (profile.images) countLabel += ' / ' + profile.images + ' photos'; + if (!profile.total) { + latest = profile.folderExists ? 'Dossier vide' : 'Aucun fichier'; + typeLabel = 'Profil'; + } + + return '' + + ''; + } + + function renderProfileDetail() { + var detail = $('mediaProfileDetail'); + if (!detail) return; + if (!state.profile) { + detail.hidden = true; + return; + } + + var profile = profileByUsername(state.profile); + var name = profileLabel(profile); + var avatar = $('mediaProfileAvatar'); + var title = $('mediaProfileDetailName'); + var meta = $('mediaProfileDetailMeta'); + if (avatar) avatar.textContent = firstLetter(name); + if (title) title.textContent = name || state.profile; + if (meta) { + var parts = []; + if (profile && profile.username && profile.displayName) parts.push(profile.username); + parts.push(((profile && profile.videos) || 0) + ' videos'); + parts.push(((profile && profile.images) || 0) + ' photos'); + if (profile && profile.country) parts.push(profile.country); + if (profile && profile.recordQuality) parts.push('Qualite ' + profile.recordQuality); + if (profile && profile.retentionDays != null) parts.push(profile.retentionDays + ' jours'); + meta.textContent = parts.join(' / '); + } + detail.hidden = false; + } + + function renderRecentSection(total) { + renderRecentTitle(); + renderGrid(total); + syncFilterControls(); + } + + function renderRecentTitle() { + var title = $('mediaRecentTitle'); + var meta = $('mediaResultMeta'); + var clearBtn = $('mediaClearProfileBtn'); + if (title) { + var base = state.kind === 'image' ? 'Photos recentes' : state.kind === 'all' ? 'Medias recents' : 'Videos recentes'; + title.textContent = state.profile ? base + ' / ' + profileLabel(profileByUsername(state.profile)) : base; + } + if (clearBtn) { + clearBtn.hidden = !state.profile; + clearBtn.textContent = state.profile ? 'All profiles' : 'All profiles'; + } + if (meta) { + var label = state.items.length === 1 ? '1 media file' : state.items.length + ' media files'; + if (state.search) label += ' matching search'; + meta.textContent = label; + } + } + + function renderGrid(total) { + var grid = $('mediaGrid'); + var meta = $('mediaResultMeta'); + if (!grid) return; + + if (meta) { + var label = total === 1 ? '1 media file' : total + ' media files'; + if (state.profile) label += ' in ' + state.profile; + if (state.search) label += ' matching search'; + meta.textContent = label; + } + + if (!state.items.length) { + grid.innerHTML = '
🖼

No media found

'; + return; + } + + grid.innerHTML = state.items.map(renderCard).join(''); + } + + function renderCard(item) { + var thumb = ''; + if (item.thumbnail) { + thumb = '' + escapeHtml(item.title || item.filename) + ''; + } + + var marker = item.type === 'image' ? '📷' : item.type === 'audio' ? '♫' : '▶'; + var badges = [ + '' + formatType(item) + '', + '' + escapeHtml((item.extension || '').toUpperCase()) + '' + ]; + if (item.isImported) badges.push('Imported'); + if (item.isRecording) badges.push('Recording'); + if (item.type === 'video' && !item.browserPlayable) badges.push('Original'); + + return '' + + '
' + + '
' + + thumb + + '
' + + (item.durationStr ? '' + escapeHtml(item.durationStr) + '' : '') + + '
' + + '
' + + '
' + escapeHtml(item.title || item.filename) + '
' + + '
' + escapeHtml(item.username) + '
' + + '
' + + '' + escapeHtml(item.sizeFormatted || '') + '' + + '' + escapeHtml(formatDate(item.createdAt)) + '' + + '
' + + '
' + badges.join('') + '
' + + '
' + + '' + + '
' + + '
' + + '
'; + } + + function openViewer(item) { + if (!item) return; + + var viewer = $('mediaViewer'); + var stage = $('mediaViewerStage'); + var title = $('mediaViewerTitle'); + var meta = $('mediaViewerMeta'); + var deleteBtn = $('mediaViewerDelete'); + if (!viewer || !stage) return; + + state.currentViewerItem = item; + title.textContent = item.title || item.filename; + meta.textContent = item.username + ' / ' + formatType(item) + ' / ' + (item.sizeFormatted || '') + ' / ' + formatDate(item.createdAt); + if (deleteBtn) deleteBtn.dataset.mediaId = item.id; + + stage.innerHTML = ''; + var mediaNode; + if (item.type === 'image') { + mediaNode = document.createElement('img'); + mediaNode.src = item.url; + mediaNode.alt = item.title || item.filename; + stage.appendChild(mediaNode); + } else if (item.type === 'audio') { + mediaNode = document.createElement('audio'); + mediaNode.src = item.url; + mediaNode.controls = true; + mediaNode.autoplay = true; + stage.appendChild(mediaNode); + } else { + mediaNode = document.createElement('video'); + mediaNode.src = item.url; + mediaNode.controls = true; + mediaNode.autoplay = true; + mediaNode.playsInline = true; + stage.appendChild(mediaNode); + if (!item.browserPlayable) { + var note = document.createElement('div'); + note.className = 'media-viewer-note'; + note.textContent = 'Format original. Le navigateur peut ne pas le lire directement.'; + stage.appendChild(note); + } + } + + viewer.style.display = 'flex'; + viewer.setAttribute('aria-hidden', 'false'); + document.body.classList.add('media-viewer-open'); + } + + function closeViewer() { + var viewer = $('mediaViewer'); + var stage = $('mediaViewerStage'); + if (!viewer || !stage) return; + + var active = stage.querySelector('video, audio'); + if (active) active.pause(); + stage.innerHTML = ''; + state.currentViewerItem = null; + viewer.style.display = 'none'; + viewer.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('media-viewer-open'); + } + + function showToast(message, type) { + if (typeof window.showNotification === 'function') { + window.showNotification(message, type || 'success'); + return; + } + var existing = document.querySelector('.media-toast'); + if (existing) existing.remove(); + var toast = document.createElement('div'); + toast.className = 'media-toast ' + (type || 'success'); + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(function() { + toast.remove(); + }, 2600); + } + + function openDeleteConfirm(item) { + if (!item) return; + state.pendingDelete = item; + var modal = $('mediaDeleteModal'); + var target = $('mediaDeleteTarget'); + var confirm = $('mediaDeleteConfirm'); + if (target) { + target.textContent = (item.title || item.filename) + ' / ' + item.username; + } + if (confirm) { + confirm.disabled = false; + confirm.textContent = 'Supprimer'; + } + if (modal) { + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('media-delete-open'); + } + } + + function closeDeleteConfirm() { + var modal = $('mediaDeleteModal'); + if (modal) { + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('media-delete-open'); + } + state.pendingDelete = null; + } + + async function confirmDeleteMedia() { + var item = state.pendingDelete; + if (!item) return; + var confirm = $('mediaDeleteConfirm'); + if (confirm) { + confirm.disabled = true; + confirm.textContent = 'Suppression...'; + } + + try { + var res = await fetch(item.deleteUrl, { method: 'DELETE' }); + var data = await res.json().catch(function() { return {}; }); + if (!res.ok || data.success === false) { + throw new Error(data.detail || data.message || 'Delete failed'); + } + + if (state.currentViewerItem && state.currentViewerItem.id === item.id) { + closeViewer(); + } + closeDeleteConfirm(); + showToast('Media supprime', 'success'); + await loadMediaLibrary(); + } catch (e) { + console.error('Error deleting media:', e); + showToast(e.message || 'Delete failed', 'error'); + if (confirm) { + confirm.disabled = false; + confirm.textContent = 'Supprimer'; + } + } + } + + function ensureSelectOption(select, value) { + if (!select || !value) return; + for (var i = 0; i < select.options.length; i++) { + if (select.options[i].value === value) return; + } + var option = document.createElement('option'); + option.value = value; + option.textContent = value; + select.appendChild(option); + } + + function setField(id, value) { + var field = $(id); + if (!field) return; + field.value = value == null ? '' : value; + } + + function fieldValue(id) { + var field = $(id); + return field ? field.value.trim() : ''; + } + + function fillProfileSettings(profile) { + state.profileSettings = profile; + var subtitle = $('mediaProfileSettingsSubtitle'); + if (subtitle) subtitle.textContent = profile.username; + + setField('profileDisplayName', profile.displayName || ''); + setField('profileFirstName', profile.firstName || ''); + setField('profileLastName', profile.lastName || ''); + setField('profileAge', profile.age == null ? '' : profile.age); + setField('profileAliases', profile.aliases || ''); + setField('profileTags', profile.tags || ''); + setField('profileAddress', profile.address || ''); + setField('profileCity', profile.city || ''); + setField('profileRegion', profile.region || ''); + setField('profilePostalCode', profile.postalCode || ''); + setField('profileCountry', profile.country || ''); + setField('profileSocialUrls', joinLines(profile.socialUrls)); + setField('profileStreamUrls', joinLines(profile.streamUrls)); + setField('profileProfileUrls', joinLines(profile.profileUrls)); + setField('profileNotes', profile.notes || ''); + var quality = $('profileRecordQuality'); + ensureSelectOption(quality, profile.recordQuality || 'best'); + if (quality) quality.value = profile.recordQuality || 'best'; + setField('profileRetentionDays', profile.retentionDays == null ? 30 : profile.retentionDays); + var source = $('profileSourceType'); + ensureSelectOption(source, profile.sourceType || profile.source_type || 'chaturbate'); + if (source) source.value = profile.sourceType || profile.source_type || 'chaturbate'; + var auto = $('profileAutoRecord'); + if (auto) auto.checked = !!profile.autoRecord; + } + + async function openProfileSettings() { + if (!state.profile) return; + var modal = $('mediaProfileSettingsModal'); + var save = $('mediaProfileSettingsSave'); + if (save) save.disabled = true; + + try { + var res = await fetch('/api/media-profiles/' + encodeURIComponent(state.profile), { cache: 'no-store' }); + var data = await res.json().catch(function() { return {}; }); + if (!res.ok) throw new Error(data.detail || 'Profile unavailable'); + fillProfileSettings(data); + if (modal) { + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('media-profile-settings-open'); + } + } catch (e) { + console.error('Error loading profile settings:', e); + showToast(e.message || 'Profile unavailable', 'error'); + } finally { + if (save) save.disabled = false; + } + } + + function closeProfileSettings() { + var modal = $('mediaProfileSettingsModal'); + if (modal) { + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('media-profile-settings-open'); + } + } + + async function saveProfileSettings(ev) { + if (ev) ev.preventDefault(); + if (!state.profile) return; + var save = $('mediaProfileSettingsSave'); + if (save) { + save.disabled = true; + save.textContent = 'Enregistrement...'; + } + + var retention = parseInt(fieldValue('profileRetentionDays'), 10); + if (Number.isNaN(retention)) retention = 30; + retention = Math.max(0, Math.min(365, retention)); + + var ageValue = fieldValue('profileAge'); + var age = ageValue ? parseInt(ageValue, 10) : null; + if (Number.isNaN(age)) age = null; + + var auto = $('profileAutoRecord'); + var source = $('profileSourceType'); + var payload = { + displayName: fieldValue('profileDisplayName'), + firstName: fieldValue('profileFirstName'), + lastName: fieldValue('profileLastName'), + age: age, + aliases: fieldValue('profileAliases'), + tags: fieldValue('profileTags'), + address: fieldValue('profileAddress'), + city: fieldValue('profileCity'), + region: fieldValue('profileRegion'), + postalCode: fieldValue('profilePostalCode'), + country: fieldValue('profileCountry'), + socialUrls: splitLines(fieldValue('profileSocialUrls')), + streamUrls: splitLines(fieldValue('profileStreamUrls')), + profileUrls: splitLines(fieldValue('profileProfileUrls')), + notes: fieldValue('profileNotes'), + recordQuality: fieldValue('profileRecordQuality') || 'best', + retentionDays: retention, + sourceType: source ? source.value : 'chaturbate', + autoRecord: auto ? auto.checked : false + }; + + try { + var res = await fetch('/api/media-profiles/' + encodeURIComponent(state.profile), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + var data = await res.json().catch(function() { return {}; }); + if (!res.ok || data.success === false) { + throw new Error(data.detail || data.message || 'Save failed'); + } + showToast('Parametres enregistres', 'success'); + closeProfileSettings(); + await loadMediaLibrary(); + } catch (e) { + console.error('Error saving profile settings:', e); + showToast(e.message || 'Save failed', 'error'); + } finally { + if (save) { + save.disabled = false; + save.textContent = 'Enregistrer'; + } + } + } + + function openProfileDeleteConfirm() { + if (!state.profile) return; + var profile = state.profileSettings || profileByUsername(state.profile) || { username: state.profile }; + state.pendingProfileDelete = profile; + var target = $('mediaProfileDeleteTarget'); + var confirm = $('mediaProfileDeleteConfirm'); + var modal = $('mediaProfileDeleteModal'); + if (target) target.textContent = profileLabel(profile) + ' / ' + profile.username; + if (confirm) { + confirm.disabled = false; + confirm.textContent = 'Supprimer le profil'; + } + if (modal) { + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + document.body.classList.add('media-delete-open'); + } + } + + function closeProfileDeleteConfirm() { + var modal = $('mediaProfileDeleteModal'); + if (modal) { + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('media-delete-open'); + } + state.pendingProfileDelete = null; + } + + async function confirmDeleteProfile() { + var profile = state.pendingProfileDelete; + if (!profile || !profile.username) return; + var confirm = $('mediaProfileDeleteConfirm'); + if (confirm) { + confirm.disabled = true; + confirm.textContent = 'Suppression...'; + } + + try { + var res = await fetch('/api/media-profiles/' + encodeURIComponent(profile.username), { method: 'DELETE' }); + var data = await res.json().catch(function() { return {}; }); + if (!res.ok || data.success === false) { + throw new Error(data.detail || data.message || 'Delete failed'); + } + closeProfileDeleteConfirm(); + closeProfileSettings(); + state.profile = ''; + state.profileSettings = null; + showToast('Profil supprime', 'success'); + await loadMediaLibrary(); + } catch (e) { + console.error('Error deleting profile:', e); + showToast(e.message || 'Delete failed', 'error'); + if (confirm) { + confirm.disabled = false; + confirm.textContent = 'Supprimer le profil'; + } + } + } + + function syncFilterControls() { + var buttons = document.querySelectorAll('.media-kind-btn'); + buttons.forEach(function(btn) { + btn.classList.toggle('active', btn.dataset.kind === state.kind); + }); + + var sort = $('mediaSortSelect'); + if (sort) sort.value = state.sort; + } + + function setKind(kind) { + state.kind = kind || 'all'; + loadMediaLibrary(); + } + + function setProfile(profile, shouldScroll) { + state.profile = profile || ''; + if (state.profile) state.kind = 'video'; + loadMediaLibrary(); + if (shouldScroll) { + var recent = document.querySelector('.media-recent-section'); + if (recent && recent.scrollIntoView) { + recent.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + } + } + + function scrollProfiles(direction) { + var rail = $('mediaProfileRail'); + if (!rail) return; + var amount = Math.max(280, Math.floor(rail.clientWidth * 0.8)); + rail.scrollBy({ left: direction * amount, behavior: 'smooth' }); + } + + function bindEvents() { + var search = $('mediaSearchInput'); + if (search) { + search.addEventListener('input', function() { + clearTimeout(searchTimer); + searchTimer = setTimeout(function() { + state.search = search.value.trim(); + loadMediaLibrary(); + }, 180); + }); + } + + var sortSelect = $('mediaSortSelect'); + if (sortSelect) { + sortSelect.addEventListener('change', function() { + state.sort = sortSelect.value; + loadMediaLibrary(); + }); + } + + document.querySelectorAll('.media-kind-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + setKind(btn.dataset.kind || 'all'); + }); + }); + + var prev = $('mediaProfilePrev'); + if (prev) prev.addEventListener('click', function() { scrollProfiles(-1); }); + + var next = $('mediaProfileNext'); + if (next) next.addEventListener('click', function() { scrollProfiles(1); }); + + var clearProfile = $('mediaClearProfileBtn'); + if (clearProfile) clearProfile.addEventListener('click', function() { setProfile('', false); }); + + var profileSettings = $('mediaProfileSettingsBtn'); + if (profileSettings) profileSettings.addEventListener('click', openProfileSettings); + + var rail = $('mediaProfileRail'); + if (rail) { + rail.addEventListener('click', function(ev) { + var card = ev.target.closest('.media-profile-card'); + if (!card) return; + setProfile(card.dataset.profile || '', true); + }); + } + + var grid = $('mediaGrid'); + if (grid) { + grid.addEventListener('click', function(ev) { + var action = ev.target.closest('[data-media-action]'); + if (action) { + if (action.dataset.mediaAction === 'delete') { + ev.preventDefault(); + ev.stopPropagation(); + openDeleteConfirm(itemById(action.dataset.mediaId)); + } + return; + } + var card = ev.target.closest('.media-card'); + if (card) openViewer(itemById(card.dataset.mediaId)); + }); + grid.addEventListener('keydown', function(ev) { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + var card = ev.target.closest('.media-card'); + if (!card) return; + ev.preventDefault(); + openViewer(itemById(card.dataset.mediaId)); + }); + } + + var close = $('mediaViewerClose'); + if (close) close.addEventListener('click', closeViewer); + + var viewerDelete = $('mediaViewerDelete'); + if (viewerDelete) { + viewerDelete.addEventListener('click', function() { + openDeleteConfirm(state.currentViewerItem); + }); + } + + var deleteCancel = $('mediaDeleteCancel'); + if (deleteCancel) deleteCancel.addEventListener('click', closeDeleteConfirm); + + var deleteConfirm = $('mediaDeleteConfirm'); + if (deleteConfirm) deleteConfirm.addEventListener('click', confirmDeleteMedia); + + var profileSettingsForm = $('mediaProfileSettingsForm'); + if (profileSettingsForm) profileSettingsForm.addEventListener('submit', saveProfileSettings); + + var profileSettingsClose = $('mediaProfileSettingsClose'); + if (profileSettingsClose) profileSettingsClose.addEventListener('click', closeProfileSettings); + + var profileSettingsCancel = $('mediaProfileSettingsCancel'); + if (profileSettingsCancel) profileSettingsCancel.addEventListener('click', closeProfileSettings); + + var profileDelete = $('mediaProfileDeleteBtn'); + if (profileDelete) profileDelete.addEventListener('click', openProfileDeleteConfirm); + + var profileDeleteCancel = $('mediaProfileDeleteCancel'); + if (profileDeleteCancel) profileDeleteCancel.addEventListener('click', closeProfileDeleteConfirm); + + var profileDeleteConfirm = $('mediaProfileDeleteConfirm'); + if (profileDeleteConfirm) profileDeleteConfirm.addEventListener('click', confirmDeleteProfile); + + var viewer = $('mediaViewer'); + if (viewer) { + viewer.addEventListener('click', function(ev) { + if (ev.target === viewer) closeViewer(); + }); + } + + var deleteModal = $('mediaDeleteModal'); + if (deleteModal) { + deleteModal.addEventListener('click', function(ev) { + if (ev.target === deleteModal) closeDeleteConfirm(); + }); + } + + var profileSettingsModal = $('mediaProfileSettingsModal'); + if (profileSettingsModal) { + profileSettingsModal.addEventListener('click', function(ev) { + if (ev.target === profileSettingsModal) closeProfileSettings(); + }); + } + + var profileDeleteModal = $('mediaProfileDeleteModal'); + if (profileDeleteModal) { + profileDeleteModal.addEventListener('click', function(ev) { + if (ev.target === profileDeleteModal) closeProfileDeleteConfirm(); + }); + } + + document.addEventListener('keydown', function(ev) { + if (ev.key === 'Escape') { + if (state.pendingProfileDelete) { + closeProfileDeleteConfirm(); + } else if (state.pendingDelete) { + closeDeleteConfirm(); + } else if ($('mediaProfileSettingsModal') && $('mediaProfileSettingsModal').style.display !== 'none') { + closeProfileSettings(); + } else { + closeViewer(); + } + } + }); + } + + document.addEventListener('DOMContentLoaded', function() { + bindEvents(); + syncFilterControls(); + loadMediaLibrary(); + }); +})(); diff --git a/static/settings.html b/static/settings.html index 9206bef..d73668a 100644 --- a/static/settings.html +++ b/static/settings.html @@ -9,7 +9,7 @@ - +