Skip to content
This repository was archived by the owner on Aug 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,42 @@
name: Build, Push & Release
name: Fast checks / beta 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' || github.ref_name == 'beta'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: write
packages: write
Expand All @@ -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
Expand Down Expand Up @@ -72,7 +100,7 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
platforms: ${{ github.event_name == 'workflow_dispatch' && inputs.platforms || 'linux/amd64,linux/arm64' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@
| MyFreeCams | ✅ | ✅ | ✅ | Local |
| LiveJasmin | ✅ | ✅ | ✅ | Local |
| CamSoda | ✅ | ✅ | ✅ | Local |
| Streamate | ✅ | ✅ | ✅ | Local |
| Flirt4Free | ✅ | ✅ | ✅ | Local |
| Cams.com | ✅ | ✅ | ✅ | Local |
| Xcams | ✅ | ✅ | ✅ | Local |

Expand Down Expand Up @@ -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:
Expand All @@ -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 |
Expand All @@ -112,6 +119,7 @@ docker run -d --name p-streamrec \
| `CB_REQUEST_DELAY` | `1.0` | Delay between Chaturbate requests (seconds) |
| `PASSWORD` | — | Password to protect the interface (optional) |
| `AUTO_RECORD_USERS` | — | Comma-separated usernames to auto-record |
| `AUTO_RECORD_INTERVAL` | `120` | Default seconds between background live-status checks; can be overridden in Settings |
| `RECORD_SEGMENT_DURATION_MINUTES` | `0` | Optional recording split interval: `0`, `30`, `60`, or `90` minutes |
| `RECORD_SEGMENT_SIZE_MB` | `0` | Optional maximum TS segment size in MB; `0` disables size-based splitting |
| `CHATURBATE_USERNAME` | — | Chaturbate login (optional, enables Following + better quality) |
Expand Down
8 changes: 7 additions & 1 deletion app/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand All @@ -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:
Expand Down
112 changes: 55 additions & 57 deletions app/api/cam4.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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",
}
10 changes: 7 additions & 3 deletions app/api/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading