Compare commits

...

3 Commits

Author SHA1 Message Date
√(noham)²
8f115881e2 Cache tokens and improve Oqee login flow
Add local token caching and validation for Oqee client, including _is_token_valid, load_cache and save_cache using .oqee_token.json to reuse valid JWTs and avoid unnecessary re-authentication. Update login() to try loading cache first, keep existing credential/IP login flows with clearer logging and improved fallback/error handling, and save tokens after successful auth. Also add required imports (json, os, time) and minor logging tweak in utils/downloader.py to include the Widevine CDM device filename in the fetch message.
2026-05-26 17:21:54 +02:00
√(noham)²
c49254a6e4 Document Free Mobile auth in READMEs 2026-05-26 17:18:35 +02:00
√(noham)²
a1dfa082d7 Add Free Mobile SSO login
Add support for Free Mobile SSO authentication by implementing login_cred_mobile which parses the SSO page with BeautifulSoup and exchanges the resulting token for an OQEE access token. Rename login_cred_abo to login_cred_fbx, introduce a self.fbx flag (and mark 8-digit numeric usernames as abo/non-FBX), and update the login selection logic to call the appropriate handler (FBX vs mobile). Also import BeautifulSoup, simplify some request headers, add bs4 to pyproject.toml, and update the lockfile accordingly.
2026-05-26 17:07:48 +02:00
6 changed files with 207 additions and 60 deletions

View File

@@ -41,10 +41,11 @@ uv sync
```
### Configuration
Le script supporte trois méthodes d'authentification, toutes détectées automatiquement :
Le script supporte quatre méthodes d'authentification, toutes détectées automatiquement :
1. Connexion par IP : Si vous êtes sur un réseau Free, l'authentification se fait automatiquement via votre adresse IP (aucun identifiant requis)
2. Compte Freebox : Utilisez vos identifiants de compte Free (nom d'utilisateur contenant "fbx")
3. Compte OQEE : Utilisez vos identifiants de compte OQEE standard
3. Compte Free Mobile : Utilisez vos identifiants Free Mobile (nom d'utilisateur à 8 chiffres)
4. Compte OQEE : Utilisez vos identifiants de compte OQEE standard
Créez un fichier `.env` dans le répertoire racine et ajoutez vos identifiants Oqee (la connexion par IP est utilisée en dernier recours si les identifiants échouent) :
```bash

View File

@@ -41,10 +41,11 @@ uv sync
```
### Configuration
The script supports three authentication methods, all automatically detected:
The script supports four authentication methods, all automatically detected:
1. IP Login: If you're on a Free network, authentication happens automatically via your IP address (no credentials required)
2. Freebox Account: Use your Free account credentials (username containing "fbx")
3. OQEE Account: Use your standard OQEE account credentials
3. Free Mobile Account: Use your Free Mobile credentials (8-digit username)
4. OQEE Account: Use your standard OQEE account credentials
Create a `.env` file in the root directory and add your Oqee credentials (IP login is used as fallback if credentials fail):
```bash

View File

@@ -11,6 +11,7 @@ authors = [
dependencies = [
"aiohttp>=3.13.2",
"bs4>=0.0.2",
"InquirerPy>=0.3.4",
"python-dotenv>=1.2.1",
"pywidevine>=1.9.0",

View File

@@ -51,7 +51,8 @@ def get_keys(kids: list[str], method: dict) -> list[str]:
keys.append(f"{kid}:{key}")
return keys
logger.info("Fetching DRM keys via Widevine CDM...")
cdm_name = (method["device_file"]).split("/")[-1]
logger.info(f"Fetching DRM keys via Widevine CDM {cdm_name}...")
client = method["client_class"]
device = Device.load(method["device_file"])

View File

@@ -1,7 +1,11 @@
"""OQEE streaming service client for authentication and content access."""
import base64
import json
import os
import time
from urllib.parse import urlparse, parse_qs
from bs4 import BeautifulSoup
import requests
from dotenv import load_dotenv
@@ -31,15 +35,6 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
),
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
"cache-control": "no-cache",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": '"Not(A:Brand";v="8", "Chromium";v="144", "Google Chrome";v="144"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
"user-agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
@@ -61,6 +56,10 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
self.lic_url = "https://license.oqee.net/api/v1/live/license/widevine"
if "fbx" in username.lower():
self.abo = True
self.fbx = True
elif len(username) == 8 and username.isdigit():
self.abo = True
self.fbx = False
else:
self.abo = False
@@ -96,6 +95,50 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
logger.info("Logging in")
self.login(username, password)
def _is_token_valid(self, token):
"""Check if JWT token is still valid (checking expiry)."""
if not token:
return False
try:
payload_b64 = token.split(".")[1]
payload_b64 += "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.b64decode(payload_b64))
return payload.get("exp", 0) > time.time()
except Exception:
return False
def load_cache(self):
"""Load tokens from cache file."""
if os.path.exists(".oqee_token.json"):
try:
with open(".oqee_token.json", "r", encoding="utf-8") as f:
cache = json.load(f)
if self._is_token_valid(
cache.get("access_token")
) and self._is_token_valid(cache.get("right_token")):
self.access_token = cache.get("access_token")
self.right_token = cache.get("right_token")
self.profil_id = cache.get("profil_id")
return True
except Exception as e:
logger.debug("Failed to load token cache: %s", e)
return False
def save_cache(self):
"""Save tokens to cache file."""
try:
with open(".oqee_token.json", "w", encoding="utf-8") as f:
json.dump(
{
"access_token": self.access_token,
"right_token": self.right_token,
"profil_id": self.profil_id,
},
f,
)
except Exception as e:
logger.debug("Failed to save token cache: %s", e)
def _build_headers(self, overrides=None, remove=None):
"""Clone default headers and apply optional overrides/removals."""
headers = self._headers_template.copy()
@@ -131,7 +174,7 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
# logger.info("Selecting first profile by default.")
return data["result"][0]["id"]
def login_cred_abo(self, username, password):
def login_cred_fbx(self, username, password):
"""Authenticate with OQEE service using Free account credentials."""
headers = self._build_headers(
overrides={
@@ -169,20 +212,12 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://subscribe.free.fr",
"Referer": "https://subscribe.free.fr/auth/auth.pl?",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-User": "?1",
"Sec-GPC": "1",
"Upgrade-Insecure-Requests": "1",
"user-agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36"
),
"sec-ch-ua": '"Brave";v="123", "Not:A-Brand";v="8", "Chromium";v="123"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
}
data = {
"login": username,
@@ -214,6 +249,75 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
).json()
return data["result"]["token"]
def login_cred_mobile(self, username, password):
"""Authenticate with OQEE service using Free mobile account credentials."""
headers = {
"accept": "*/*",
"accept-language": "en-GB,en-US;q=0.9,en;q=0.8",
"cache-control": "no-cache",
"content-type": "application/json",
"origin": "https://tv.free.fr",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
"x-oqee-customization": "0",
"x-oqee-platform": "web",
}
data = {"provider": "freemobile", "platform": "web"}
response = self.session.post(
"https://api.oqee.net/api/v2/user/oauth/init", headers=headers, json=data
).json()
redirect_url = response["result"]["redirect_url"]
token = parse_qs(urlparse(redirect_url).query)["token"][0]
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Origin": "https://mobile.free.fr",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
}
page = self.session.get(redirect_url, headers=headers)
soup = BeautifulSoup(page.text, "html.parser")
token_csrf = soup.find("input", {"name": "token-csrf"})
token_csrf_value = token_csrf["value"] if token_csrf else None
callback_url = soup.find("input", {"name": "callback_url"})
callback_url_value = callback_url["value"] if callback_url else None
params = (
("service", "oqee"),
("token", token),
)
data = {
"token-csrf": token_csrf_value,
"callback_url": callback_url_value,
"identifiant": username,
"password": password,
"action": "sso-submit",
}
response = self.session.post(
"https://mobile.free.fr/sso/", headers=headers, params=params, data=data
)
parsed_url = parse_qs(urlparse(response.url).query)
if "result" not in parsed_url:
raise ValueError(
"Login failed: invalid credentials or error in authentication"
)
freemobile_token = parsed_url["result"][0]
headers = self._build_headers(
overrides={"x-oqee-customization": "0"}, remove=("x-oqee-account-provider",)
)
data = self.session.post(
"https://api.oqee.net/api/v5/user/login",
headers=headers,
json={"type": "freemobileoa", "token": freemobile_token},
).json()
return data["result"]["token"]
def login_cred_free(self, username, password):
"""Authenticate with OQEE service using Free account credentials."""
headers = {
@@ -222,15 +326,6 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
"cache-control": "no-cache",
"content-type": "application/json",
"origin": "https://tv.free.fr",
"pragma": "no-cache",
"priority": "u=1, i",
"referer": "https://tv.free.fr/",
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "cross-site",
"user-agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
@@ -240,7 +335,6 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
}
data = {"provider": "oqee", "platform": "web"}
response = self.session.post(
"https://api.oqee.net/api/v2/user/oauth/init", headers=headers, json=data
).json()
@@ -291,36 +385,49 @@ class OqeeClient: # pylint: disable=too-many-instance-attributes
"""
Log in to the Oqee service and set up necessary tokens and headers.
"""
if not username or not password:
logger.info("No credentials provided, using IP login.")
self.access_token = self.login_ip()
if self.load_cache():
logger.info("Loaded valid tokens from cache.")
else:
try:
if self.abo:
logger.info(
"Logging in with provided credentials (abo account detected)."
)
self.access_token = self.login_cred_abo(username, password)
else:
logger.info(
"Logging in with provided credentials (free account detected)."
)
self.access_token = self.login_cred_free(username, password)
except ValueError as e:
logger.warning(
"Credential login failed: %s. Falling back to IP login.", e
)
if not username or not password:
logger.info("No credentials provided, using IP login.")
self.access_token = self.login_ip()
else:
try:
if self.abo:
if self.fbx:
logger.info(
"Logging in with provided credentials (abo account detected)."
)
self.access_token = self.login_cred_fbx(username, password)
else:
logger.info(
"Logging in with provided credentials (abo account detected, non-FBX)."
)
self.access_token = self.login_cred_mobile(
username, password
)
else:
logger.info(
"Logging in with provided credentials (free account detected)."
)
self.access_token = self.login_cred_free(username, password)
except ValueError as e:
logger.warning(
"Credential login failed: %s. Falling back to IP login.", e
)
self.access_token = self.login_ip()
logger.info("Fetching rights token")
self.right_token = self.right()
logger.debug(
"Rights token obtained: %s",
self.right_token[:10] + "..." if self.right_token else "None",
)
logger.info("Fetching profile ID")
self.profil_id = self.profil()
logger.debug("Profile ID obtained: %s", self.profil_id)
logger.info("Fetching rights token")
self.right_token = self.right()
logger.debug(
"Rights token obtained: %s",
self.right_token[:10] + "..." if self.right_token else "None",
)
logger.info("Fetching profile ID")
self.profil_id = self.profil()
logger.debug("Profile ID obtained: %s", self.profil_id)
self.save_cache()
self.headers = self._build_headers(
overrides={

36
uv.lock generated
View File

@@ -217,6 +217,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "bs4"
version = "0.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698, upload-time = "2024-01-17T18:15:47.371Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189, upload-time = "2024-01-17T18:15:48.613Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
@@ -758,6 +783,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "bs4" },
{ name = "inquirerpy" },
{ name = "python-dotenv" },
{ name = "pywidevine" },
@@ -775,6 +801,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.13.2" },
{ name = "bs4", specifier = ">=0.0.2" },
{ name = "inquirerpy", specifier = ">=0.3.4" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "pywidevine", specifier = ">=1.9.0" },
@@ -1194,6 +1221,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
name = "tabulate"
version = "0.9.0"