# Noxely — collecte côté serveur, écoute hors connexion côté mobile. # Copyright © 2026 Johnny (tuxgyver) # # Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier # selon les termes de la GNU General Public License, version 3 ou ultérieure, # telle que publiée par la Free Software Foundation. # # Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE. # Voir le fichier LICENSE à la racine du dépôt pour le texte complet. """Recherche dans les catalogues libres de droits.""" from __future__ import annotations import asyncio import re import urllib.parse import httpx # Wikimedia refuse (403) les agents génériques : sa politique demande un nom identifiable # et un moyen de contact. Les autres services s'en accommodent aussi. UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"} TIMEOUT = httpx.Timeout(30.0, connect=15.0) AUDIO_EXT = (".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav") VIDEO_EXT = (".mp4", ".m4v", ".webm", ".ogv") CATALOGS = { "archive": "Internet Archive", "underground": "Underground (netlabels)", "librivox": "LibriVox", "wikimedia": "Wikimedia Commons", } async def search_all(query: str, language: str | None = None) -> list[dict]: """Interroge les quatre catalogues en parallèle et fond les résultats. Chaque ligne porte son catalogue d'origine : c'est lui qu'il faudra pour en lister les pistes. Un catalogue en panne n'empêche pas les autres de répondre — on préfère une liste incomplète à une erreur globale. """ names = list(CATALOGS) outcomes = await asyncio.gather( *(search(name, query, language) for name in names), return_exceptions=True ) merged: list[dict] = [] for name, outcome in zip(names, outcomes): if isinstance(outcome, Exception): continue for item in outcome: merged.append({**item, "catalog": name}) # Les résultats les plus légers d'abord : sur un téléphone, la taille prime. merged.sort(key=lambda item: item["size"] or 1 << 62) return merged async def search(catalog: str, query: str, language: str | None = None) -> list[dict]: if catalog == "librivox": return await _librivox(query, language) if catalog == "wikimedia": return await _wikimedia(query) collections = ["netlabels", "freemusicarchive"] if catalog == "underground" else [] return await _archive(query, language, collections) async def tracks(catalog: str, item_id: str, title: str = "", kind: str = "Audio") -> list[dict]: """Les fichiers d'un résultat : un album en a plusieurs, un morceau un seul.""" if catalog == "librivox": return await _librivox_tracks(item_id) if catalog == "wikimedia": return [{"title": title or item_id, "artist": "Wikimedia Commons", "url": item_id, "size": 0}] return await _archive_tracks(item_id, title, kind) # --- archive.org -------------------------------------------------------- async def _archive(query: str, language: str | None, collections: list[str]) -> list[dict]: clauses = [f"({query})", "mediatype:(audio OR movies)"] if language: clauses.append(f"language:({language})") if collections: clauses.append("collection:(" + " OR ".join(collections) + ")") # Les éléments restreints répondent 401 au téléchargement : écartés dès la recherche. clauses.append("NOT access-restricted-item:true") params = { "q": " AND ".join(clauses), "fl[]": ["identifier", "title", "creator", "mediatype", "item_size"], "rows": "30", "page": "1", "output": "json", } async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: response = await client.get("https://archive.org/advancedsearch.php", params=params) response.raise_for_status() docs = response.json().get("response", {}).get("docs", []) return [ { "id": doc["identifier"], "title": _first(doc.get("title")) or doc["identifier"], "artist": _first(doc.get("creator")) or "Internet Archive", "kind": "Vidéo" if doc.get("mediatype") == "movies" else "Audio", "catalog": "archive", "size": int(doc.get("item_size") or 0), } for doc in docs if doc.get("identifier") ] async def _archive_tracks(identifier: str, title: str, kind: str) -> list[dict]: async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client: response = await client.get(f"https://archive.org/metadata/{identifier}") response.raise_for_status() meta = response.json() if str(meta.get("metadata", {}).get("access-restricted-item", "")).lower() == "true": raise ValueError(f"« {title or identifier} » est en accès restreint") wants_video = kind == "Vidéo" extensions = VIDEO_EXT if wants_video else AUDIO_EXT files = [f for f in meta.get("files", []) if f.get("name", "").lower().endswith(extensions)] if not files: raise ValueError("Aucun fichier lisible dans cet élément") # Un élément expose l'original et ses dérivés : on garde le plus léger de chaque morceau, # autant de pistes que d'œuvres et non que de formats. groups: dict[str, dict] = {} for entry in files: stem = entry["name"].rsplit(".", 1)[0].lower() size = int(entry.get("size") or 1 << 62) if stem not in groups or size < int(groups[stem].get("size") or 1 << 62): groups[stem] = entry out = [] for entry in sorted(groups.values(), key=lambda f: f["name"]): name = entry["name"] out.append({ "title": entry.get("title") or name.rsplit(".", 1)[0], "artist": title or identifier, "url": f"https://archive.org/download/{identifier}/" + urllib.parse.quote(name), "size": int(entry.get("size") or 0), "duration_ms": int(float(entry.get("length") or 0) * 1000), "artwork_url": f"https://archive.org/services/img/{identifier}", }) return out # --- LibriVox ----------------------------------------------------------- async def _librivox(query: str, language: str | None) -> list[dict]: term = urllib.parse.quote(query.strip()) words = query.strip().split() # `title` est une correspondance exacte sans « ^ », et `author` ne matche que le nom de # famille : trois interrogations valent mieux qu'une qui échouerait. criteria = [f"title=^{term}", f"author={term}"] if len(words) > 1: criteria.append("author=" + urllib.parse.quote(words[-1])) seen: dict[str, dict] = {} limit = 25 if not language else 100 async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: for criterion in criteria: url = f"https://librivox.org/api/feed/audiobooks/?format=json&limit={limit}&{criterion}" response = await client.get(url) if response.status_code == 404: # « aucun résultat », pas une panne continue response.raise_for_status() for book in response.json().get("books", []): # Le paramètre `language` de l'API est accepté puis ignoré : filtrage ici. if language and (book.get("language") or "").lower() != language.lower(): continue feed = book.get("url_rss") if feed and feed not in seen: authors = ", ".join( " ".join(filter(None, [a.get("first_name"), a.get("last_name")])) for a in book.get("authors", []) ) seen[feed] = { "id": feed, "title": book.get("title") or "Livre audio", "artist": authors or "LibriVox", "kind": "Audio", "size": 0, "catalog": "librivox", } return list(seen.values()) async def _librivox_tracks(feed_url: str) -> list[dict]: async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client: response = await client.get(feed_url) response.raise_for_status() xml = response.text title = re.search(r"(.*?)", xml, re.S) book = (title.group(1).strip() if title else "Livre audio") out = [] for item in re.findall(r"(.*?)", xml, re.S): url = re.search(r']*url="([^"]+)"', item) name = re.search(r"(.*?)", item, re.S) if url: out.append({ "title": (name.group(1).strip() if name else "Épisode"), "artist": book, "url": url.group(1), "size": 0, }) return out # --- Wikimedia Commons -------------------------------------------------- async def _wikimedia(query: str) -> list[dict]: params = { "action": "query", "format": "json", "generator": "search", "gsrsearch": f"filetype:audio {query.strip()}", "gsrnamespace": "6", "gsrlimit": "30", "prop": "imageinfo", "iiprop": "url|size|mime|extmetadata", } async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: response = await client.get("https://commons.wikimedia.org/w/api.php", params=params) response.raise_for_status() pages = response.json().get("query", {}).get("pages", {}) out = [] for page in pages.values(): info = (page.get("imageinfo") or [{}])[0] url = info.get("url") if not url or not url.split("?")[0].lower().endswith(AUDIO_EXT): continue licence = (info.get("extmetadata", {}).get("LicenseShortName", {}) or {}).get("value") out.append({ "id": url, "title": page.get("title", "").removeprefix("File:").rsplit(".", 1)[0], "artist": licence or "Wikimedia Commons", "kind": "Audio", "size": int(info.get("size") or 0), "catalog": "wikimedia", }) return out def _first(value): if isinstance(value, list): return value[0] if value else None return value