From d17d45fb7c56a8c7db89aa6472d3909ca4648ec3 Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 16:59:32 +0100 Subject: [PATCH 1/7] feat(api): prioritize YouTube discovery and recommendations --- README.md | 111 ++++++------- apps/api/app/config.py | 2 + apps/api/app/routers/music.py | 143 +++++++++++++++-- apps/api/app/schemas.py | 1 + apps/api/app/services/recommendations.py | 133 ++++++++++++++++ apps/api/app/services/ytm.py | 189 +++++++++++++++++++---- apps/api/tests/test_api.py | 97 ++++++++++-- apps/api/tests/test_recommendations.py | 16 ++ 8 files changed, 577 insertions(+), 115 deletions(-) create mode 100644 apps/api/app/services/recommendations.py create mode 100644 apps/api/tests/test_recommendations.py diff --git a/README.md b/README.md index 8fe35af..1753484 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,98 @@ # Musify -Cliente de música com uma arquitetura de providers: catálogo e reprodução Audius, mais áudio local escolhido pelo utilizador. +Cliente de música com providers de descoberta e reprodução: YouTube como fonte prioritária para pesquisa e descoberta, Audius como alternativa e áudio local gerido no browser. | Camada | Tecnologia | |---|---| | Frontend | Next.js 14 (App Router) + TypeScript + Tailwind CSS + Zustand + TanStack Query | -| Backend | FastAPI (Python) + Audius API + YouTube Data API/OAuth (legado durante a migração) | -| Reprodução online | Endpoint oficial de stream do Audius, com verificação de disponibilidade e restrições de acesso | -| Reprodução local | Ficheiros selecionados pelo utilizador e reproduzidos no browser; nunca enviados para o backend | +| Backend | FastAPI (Python) + YouTube Data API/OAuth + Audius API | +| Reprodução YouTube | Player oficial incorporado do YouTube | +| Reprodução Audius | Endpoint oficial de stream Audius, com verificação de disponibilidade e restrições | +| Reprodução local | Ficheiros escolhidos pelo utilizador e reproduzidos no browser; nunca enviados para o backend | | Cache | Redis com fallback em memória | ## Arquitetura de providers -- **Audius:** pesquisa, tendências, metadados e reprodução através da API oficial. Musify valida se a faixa está disponível, se é streamable e se tem restrições de acesso antes de iniciar a reprodução. -- **Áudio local:** será gerido no frontend através da API de áudio do browser. Os ficheiros permanecem no dispositivo do utilizador. -- **YouTube:** a integração existente permanece isolada durante a migração; não é utilizada para extrair áudio nem para contornar o player oficial. +- **YouTube:** provider prioritário para pesquisa e descoberta. A pesquisa usa a YouTube Data API v3; quando a conta está ligada, as recomendações usam os artistas e vídeos da playlist de gostos como sinais para procurar música relacionada. +- **Audius:** provider de fallback quando o YouTube não devolve resultados ou a API está temporariamente indisponível. A reprodução continua sujeita às verificações de disponibilidade e acesso. +- **Áudio local:** permanece no dispositivo do utilizador e não é enviado para o backend. -A API Audius é de leitura para pesquisa e descoberta; uma API key é opcional e pode aumentar os limites de utilização. Consulta a [documentação oficial](https://docs.audius.co/api/) antes de configurar uma chave. +O Musify não acede ao algoritmo privado de recomendações da página inicial do YouTube. As recomendações personalizadas são uma descoberta própria baseada nos dados autorizados pelo utilizador. O servidor não extrai áudio do YouTube; as faixas YouTube são reproduzidas pelo player oficial incorporado. ## Configuração -Cria `apps/api/.env` com as credenciais do Google OAuth: +Cria o arquivo apps/api/.env com as credenciais: -```env +~~~env YOUTUBE_API_KEY= +YOUTUBE_REGION_CODE=AO AUDIUS_API_KEY= GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/youtube/callback FRONTEND_URL=http://localhost:3000 REDIS_URL=redis://localhost:6379/0 -``` +~~~ -O backend lê esse arquivo quando iniciado a partir de `apps/api`, e o Docker Compose -também o carrega para o container da API. O `.env.example` não é rastreado pelo Git; -não o uses como arquivo de configuração. +O backend lê esse arquivo quando iniciado a partir de apps/api, e o Docker Compose também o carrega para o container da API. Não coloques segredos no repositório. ### Autorizar o callback OAuth no Google -O Google exige que o callback enviado pela API seja registado exatamente no cliente -OAuth usado pelo backend. Para desenvolvimento local, o valor é: +O callback precisa estar registado exatamente no cliente OAuth usado pelo backend. Para desenvolvimento local: -```text +~~~text http://localhost:8000/api/auth/youtube/callback -``` - -1. Abre o [Google Cloud Console](https://console.cloud.google.com/) e seleciona o - projeto correspondente ao `GOOGLE_CLIENT_ID` configurado em `apps/api/.env`. -2. Vai a **APIs e serviços → Credenciais** e abre o ID de cliente OAuth 2.0 usado - pelo backend. O tipo da aplicação deve ser **Aplicativo da Web**. -3. Em **URIs de redirecionamento autorizados**, adiciona exatamente - `http://localhost:8000/api/auth/youtube/callback` e guarda. - Não o adiciones apenas em **Origens JavaScript autorizadas**. -4. Confirma que `GOOGLE_REDIRECT_URI` em `apps/api/.env` contém esse mesmo URI, - sem espaços nem barra final extra. `localhost` e `127.0.0.1`, HTTP e HTTPS, - portas e caminhos diferentes não são equivalentes. -5. Reinicia a API para ela carregar o `.env` atualizado. Se usas Docker Compose, - executa `docker compose up -d --force-recreate api` na raiz do projeto. -6. Abre a aplicação em `http://localhost:3000` e tenta **Conectar com YouTube** - novamente. Se ainda falhar, compara o `redirect_uri` mostrado nos detalhes do - erro Google com a lista de URIs autorizados do mesmo ID de cliente OAuth. - -O URI exibido no erro `redirect_uri_mismatch` é o que a API realmente enviou. -Se estiveres a usar outro domínio/porta ou HTTPS, configura o URI público completo -em `GOOGLE_REDIRECT_URI` e regista exatamente o mesmo valor no cliente OAuth. - -### Backend - -```bash +~~~ + +1. Abre o Google Cloud Console e seleciona o projeto correspondente ao GOOGLE_CLIENT_ID. +2. Vai a **APIs e serviços → Credenciais** e abre o ID de cliente OAuth 2.0. O tipo deve ser **Aplicativo da Web**. +3. Em **URIs de redirecionamento autorizados**, adiciona exatamente o URI acima. +4. Confirma que GOOGLE_REDIRECT_URI em apps/api/.env contém o mesmo valor. +5. Reinicia a API e tenta **Conectar com YouTube** novamente. + +## Executar + +Backend: + +~~~bash cd apps/api python -m venv .venv -# Ativa o ambiente virtual e instala as dependências: pip install -r requirements-dev.txt uvicorn app.main:app --reload --port 8000 -``` +~~~ -### Frontend +Frontend: -```bash +~~~bash cd apps/web npm install npm run dev -``` +~~~ -## Endpoints +## Endpoints principais | Método | Endpoint | Descrição | |---|---|---| -| GET | `/api/health` | Estado da API e do Redis | -| GET | `/api/music/search?q=` | Pesquisa Audius | -| GET | `/api/music/trending` | Faixas em tendência no Audius | -| GET | `/api/music/tracks/{id}` | Metadados normalizados de uma faixa | -| GET | `/api/music/tracks/{id}/stream` | Verifica acesso e redireciona para o stream oficial Audius | -| GET | `/api/home` | Conteúdo inicial legado | -| GET | `/api/moods`, `/api/moods/{key}` | Playlists por disposição | -| GET | `/api/playlist/{id}` | Playlist YouTube legado | -| GET | `/api/search?q=` | Pesquisa YouTube legado | -| GET/POST | `/api/auth/youtube/*` | Autenticação YouTube existente | +| GET | /api/health | Estado da API e do Redis | +| GET | /api/music/search?q= | Pesquisa YouTube primeiro; Audius como fallback | +| GET | /api/music/trending | Descoberta regional YouTube; Audius como fallback | +| GET | /api/music/recommendations | Recomendações baseadas nos gostos da conta ligada; público quando não há sessão | +| GET | /api/music/tracks/{id} | Metadados de uma faixa Audius | +| GET | /api/music/tracks/{id}/stream | Verifica acesso e redireciona para o stream oficial Audius | +| GET | /api/home | Conteúdo inicial legado | +| GET | /api/moods, /api/moods/{key} | Playlists por disposição | +| GET | /api/playlist/{id} | Playlist YouTube | +| GET | /api/search?q= | Pesquisa YouTube de catálogo completo | +| GET/POST | /api/auth/youtube/* | Autenticação YouTube | + +## Quota e cache + +A pesquisa usa search.list e os detalhes são carregados em lote com videos.list. As respostas ficam em cache para reduzir chamadas. Recomendações por conta usam uma chave de cache derivada do ID do canal, evitando servir os gostos de uma conta a outra. O limite diário e o consumo da API devem ser acompanhados no Google Cloud Console. ## Testes -```bash +~~~bash cd apps/api pytest -v ruff check app tests -``` +~~~ diff --git a/apps/api/app/config.py b/apps/api/app/config.py index c607aa0..c5ab77c 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -7,6 +7,7 @@ class Settings(BaseSettings): redis_url: str = 'redis://localhost:6379/0' cors_origins: str = 'http://localhost:3000' youtube_api_key: str = '' + youtube_region_code: str = 'AO' audius_api_key: str = '' google_client_id: str = '' google_client_secret: str = '' @@ -19,6 +20,7 @@ class Settings(BaseSettings): ttl_moods: int = 86400 ttl_playlist: int = 21600 ttl_search: int = 1800 + ttl_recommendations: int = 21600 @property def cors_list(self) -> list[str]: diff --git a/apps/api/app/routers/music.py b/apps/api/app/routers/music.py index ae0554b..414e272 100644 --- a/apps/api/app/routers/music.py +++ b/apps/api/app/routers/music.py @@ -1,15 +1,79 @@ -"""Audius discovery and playback endpoints.""" +"""YouTube-first music discovery with Audius as a resilient fallback.""" import hashlib +import logging -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, Cookie, HTTPException, Query, Response from fastapi.responses import RedirectResponse +from ..auth import get_account from ..cache import cache from ..config import settings -from ..services import audius +from ..services import audius, recommendations as discovery, ytm router = APIRouter(prefix="/music", tags=["music"]) TRACK_CACHE_TTL = 300 +log = logging.getLogger("musify.music") + + +def _serialize_youtube_tracks(tracks) -> list[dict]: + return [ + track.model_dump(by_alias=True) if hasattr(track, "model_dump") else track + for track in tracks + ] + + +async def _youtube_search_first(query: str, limit: int) -> dict: + fallback_reason = "empty" + try: + tracks = await ytm.run(ytm.search_music_tracks, query, limit) + if tracks: + return { + "provider": "youtube", + "tracks": _serialize_youtube_tracks(tracks), + } + except HTTPException as exc: + fallback_reason = "unavailable" + detail = exc.detail if isinstance(exc.detail, dict) else {} + log.warning( + "YouTube search unavailable; falling back to Audius (%s)", + detail.get("code", exc.status_code), + ) + + tracks = await audius.search_tracks(query, limit) + return { + "provider": "audius" if tracks else "youtube", + "tracks": tracks, + "fallbackReason": fallback_reason, + } + + +async def _youtube_trending_first(limit: int) -> dict: + fallback_reason = "empty" + try: + tracks = await ytm.run( + ytm.trending_music_tracks, + settings.youtube_region_code, + limit, + ) + if tracks: + return { + "provider": "youtube", + "tracks": _serialize_youtube_tracks(tracks), + } + except HTTPException as exc: + fallback_reason = "unavailable" + detail = exc.detail if isinstance(exc.detail, dict) else {} + log.warning( + "YouTube trending unavailable; falling back to Audius (%s)", + detail.get("code", exc.status_code), + ) + + tracks = await audius.trending_tracks(limit) + return { + "provider": "audius" if tracks else "youtube", + "tracks": tracks, + "fallbackReason": fallback_reason, + } async def _cached_track(track_id: str) -> dict: @@ -39,14 +103,14 @@ async def search( ) digest = hashlib.sha256(query.casefold().encode()).hexdigest()[:20] - key = f"audius:search:{digest}:{limit}" + key = f"music:search:v2:{digest}:{limit}" data, hit = await cache.get_or_set( key, settings.ttl_search, - lambda: audius.search_tracks(query, limit), + lambda: _youtube_search_first(query, limit), ) response.headers["X-Cache"] = "HIT" if hit else "MISS" - return {"provider": "audius", "tracks": data} + return data @router.get("/trending") @@ -54,14 +118,74 @@ async def trending( response: Response, limit: int = Query(default=20, ge=1, le=25), ): - key = f"audius:trending:{limit}" + key = f"music:trending:v2:{settings.youtube_region_code.upper()}:{limit}" data, hit = await cache.get_or_set( key, settings.ttl_home, - lambda: audius.trending_tracks(limit), + lambda: _youtube_trending_first(limit), ) response.headers["X-Cache"] = "HIT" if hit else "MISS" - return {"provider": "audius", "tracks": data} + return data + + +@router.get("/recommendations") +async def recommendations( + response: Response, + limit: int = Query(default=20, ge=1, le=25), + musify_youtube_session: str | None = Cookie(default=None), +): + account = await get_account(musify_youtube_session) + if account: + identity = ( + (account.get("channel") or {}).get("id") + or musify_youtube_session + or "connected" + ) + else: + identity = "public" + identity_hash = hashlib.sha256(identity.encode()).hexdigest()[:20] + key = f"music:recommendations:v1:{identity_hash}:{limit}" + + async def load(): + fallback_reason = "empty" + personalized = False + try: + if account: + tracks, personalized = await discovery.recommend_from_likes( + account["access_token"], + limit, + ) + else: + tracks = await ytm.run( + ytm.trending_music_tracks, + settings.youtube_region_code, + limit, + ) + if tracks: + return { + "provider": "youtube", + "personalized": personalized, + "tracks": _serialize_youtube_tracks(tracks), + } + except HTTPException as exc: + fallback_reason = "unavailable" + detail = exc.detail if isinstance(exc.detail, dict) else {} + log.warning( + "YouTube recommendations unavailable; falling back to Audius (%s)", + detail.get("code", exc.status_code), + ) + + fallback = await audius.trending_tracks(limit) + return { + "provider": "audius" if fallback else "youtube", + "personalized": False, + "tracks": fallback, + "fallbackReason": fallback_reason, + } + + data, hit = await cache.get_or_set(key, settings.ttl_recommendations, load) + response.headers["X-Cache"] = "HIT" if hit else "MISS" + return data @router.get("/tracks/{track_id}") @@ -88,7 +212,6 @@ async def stream(track_id: str): }, ) - # Revalidate permissions at playback time instead of trusting cached metadata. item = await audius.get_track(track_id) if item["isStreamGated"]: raise HTTPException( diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index 4d8afa8..6f2690e 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -10,6 +10,7 @@ class Artist(BaseModel): class Track(BaseModel): videoId: str + source: Literal['youtube'] = 'youtube' title: str artists: list[Artist] = Field(default_factory=list) album: str | None = None diff --git a/apps/api/app/services/recommendations.py b/apps/api/app/services/recommendations.py new file mode 100644 index 0000000..dc38131 --- /dev/null +++ b/apps/api/app/services/recommendations.py @@ -0,0 +1,133 @@ +"""YouTube-first recommendations based on the signed-in user's liked videos.""" +from collections import Counter + +import httpx +from fastapi import HTTPException + +from ..schemas import Track +from . import ytm + +BASE_URL = ytm.BASE_URL + + +async def get_liked_tracks(access_token: str, limit: int = 30) -> list[Track]: + """Read the authenticated channel's likes playlist using its OAuth token.""" + headers = {'Authorization': f'Bearer {access_token}'} + async with httpx.AsyncClient(timeout=15) as client: + channel_response = await client.get( + f'{BASE_URL}/channels', + params={'part': 'contentDetails', 'mine': 'true'}, + headers=headers, + ) + if channel_response.status_code in (403, 404): + return [] + try: + channel_response.raise_for_status() + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail={ + 'error': 'upstream', + 'code': 'YOUTUBE_LIKES_UNAVAILABLE', + 'message': 'Não foi possível ler os vídeos de que gostas no YouTube.', + }) from exc + + channels = channel_response.json().get('items', []) + if not channels: + return [] + likes_id = ( + channels[0].get('contentDetails', {}) + .get('relatedPlaylists', {}) + .get('likes') + ) + if not likes_id: + return [] + + likes_response = await client.get( + f'{BASE_URL}/playlistItems', + params={ + 'part': 'snippet,contentDetails', + 'playlistId': likes_id, + 'maxResults': min(max(limit, 1), 50), + }, + headers=headers, + ) + if likes_response.status_code in (403, 404): + return [] + try: + likes_response.raise_for_status() + except httpx.HTTPError as exc: + raise HTTPException(status_code=502, detail={ + 'error': 'upstream', + 'code': 'YOUTUBE_LIKES_UNAVAILABLE', + 'message': 'Não foi possível ler os vídeos de que gostas no YouTube.', + }) from exc + + items = likes_response.json().get('items', []) + ids = [ + item.get('contentDetails', {}).get('videoId') + or item.get('snippet', {}).get('resourceId', {}).get('videoId') + for item in items + ] + ids = list(dict.fromkeys(value for value in ids if value)) + if not ids: + return [] + + videos = await ytm.run(ytm._video_details, ids) + by_id = {item.get('id'): item for item in videos if item.get('id')} + return [ + track + for video_id in ids + if (track := ytm.normalize_track(by_id[video_id])) is not None + and track.source == 'youtube' + and video_id in by_id + ] + + +def _seed_query(seeds: list[Track]) -> str: + artists = [ + artist.name.strip() + for track in seeds + for artist in track.artists + if artist.name.strip() + ] + if artists: + counts = Counter(artists) + names = [name for name, _ in counts.most_common(3)] + return ' '.join(names) + ' similar music' + if seeds: + return f'{seeds[0].title} similar songs' + return 'popular music' + + +async def recommend_from_likes(access_token: str, limit: int = 20) -> tuple[list[Track], bool]: + """Return recommendations and whether liked videos actually informed them.""" + limit = min(max(limit, 1), 25) + seeds = await get_liked_tracks(access_token, 30) + if not seeds: + return await ytm.run(ytm.search_music_tracks, 'popular music', limit), False + + liked_ids = {track.videoId for track in seeds} + candidates = await ytm.run( + ytm.search_music_tracks, + _seed_query(seeds), + min(25, max(limit * 2, 10)), + ) + recommendations: list[Track] = [] + seen: set[str] = set(liked_ids) + for track in candidates: + if track.videoId in seen: + continue + seen.add(track.videoId) + recommendations.append(track) + if len(recommendations) >= limit: + break + + if len(recommendations) < min(limit, 5): + extras = await ytm.run(ytm.search_music_tracks, 'new popular music', limit) + for track in extras: + if track.videoId in seen: + continue + seen.add(track.videoId) + recommendations.append(track) + if len(recommendations) >= limit: + break + return recommendations, True diff --git a/apps/api/app/services/ytm.py b/apps/api/app/services/ytm.py index db54d3e..5f61c7a 100644 --- a/apps/api/app/services/ytm.py +++ b/apps/api/app/services/ytm.py @@ -20,12 +20,19 @@ def _request(resource: str, params: dict[str, Any]) -> dict[str, Any]: if not settings.youtube_api_key: raise HTTPException(status_code=503, detail={ 'error': 'configuration', 'code': 'YOUTUBE_API_KEY_MISSING', - 'message': 'A API oficial do YouTube não está configurada. Defina YOUTUBE_API_KEY no ambiente da API.' + 'message': 'A API oficial do YouTube não está configurada. Define YOUTUBE_API_KEY no ambiente da API.' }) try: - response = httpx.get(f'{BASE_URL}/{resource}', params={**params, 'key': settings.youtube_api_key}, timeout=15) + response = httpx.get( + f'{BASE_URL}/{resource}', + params={**params, 'key': settings.youtube_api_key}, + timeout=15, + ) except httpx.HTTPError as exc: - raise HTTPException(status_code=502, detail={'error': 'upstream', 'code': 'YOUTUBE_UNAVAILABLE', 'message': str(exc)[:200]}) from exc + raise HTTPException(status_code=502, detail={ + 'error': 'upstream', 'code': 'YOUTUBE_UNAVAILABLE', + 'message': 'Não foi possível contactar a API do YouTube.' + }) from exc if response.is_error: try: payload = response.json().get('error', {}) @@ -33,15 +40,23 @@ def _request(resource: str, params: dict[str, Any]) -> dict[str, Any]: reason = (payload.get('errors') or [{}])[0].get('reason', 'YOUTUBE_API_ERROR') except (ValueError, AttributeError): message, reason = response.reason_phrase, 'YOUTUBE_API_ERROR' - raise HTTPException(status_code=502, detail={'error': 'upstream', 'code': reason, 'message': str(message)[:200]}) + raise HTTPException(status_code=502, detail={ + 'error': 'upstream', 'code': reason, 'message': str(message)[:200] + }) return response.json() -def pick_thumbnail(thumbs: dict[str, Any] | list[dict[str, Any]] | None, size: int = 544) -> str | None: +def pick_thumbnail( + thumbs: dict[str, Any] | list[dict[str, Any]] | None, + size: int = 544, +) -> str | None: if not thumbs: return None values = list(thumbs.values()) if isinstance(thumbs, dict) else thumbs - best = max(values, key=lambda t: (t.get('width') or 0) * (t.get('height') or 0)) + values = [item for item in values if isinstance(item, dict) and item.get('url')] + if not values: + return None + best = max(values, key=lambda item: (item.get('width') or 0) * (item.get('height') or 0)) return best.get('url') @@ -51,12 +66,12 @@ def _duration(value: str | None) -> int | None: match = re.fullmatch(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', value) if not match: return None - hours, minutes, seconds = (int(x or 0) for x in match.groups()) + hours, minutes, seconds = (int(value or 0) for value in match.groups()) return hours * 3600 + minutes * 60 + seconds def normalize_track(item: dict[str, Any], fallback_thumb: str | None = None) -> Track | None: - # Accept both normalized video resources and search.list results. + """Normalize a videos.list resource or search.list result.""" video_id = item.get('videoId') or item.get('id') if isinstance(video_id, dict): video_id = video_id.get('videoId') @@ -64,12 +79,16 @@ def normalize_track(item: dict[str, Any], fallback_thumb: str | None = None) -> if not video_id: return None channel = snippet.get('channelTitle') or '' + channel_id = snippet.get('channelId') return Track( - videoId=video_id, + videoId=str(video_id), + source='youtube', title=snippet.get('title') or 'Sem título', - artists=[Artist(name=channel)] if channel else [], + artists=[Artist(id=str(channel_id) if channel_id else None, name=channel)] if channel else [], album=None, - durationSeconds=item.get('durationSeconds') or _duration((item.get('contentDetails') or {}).get('duration')), + durationSeconds=item.get('durationSeconds') or _duration( + (item.get('contentDetails') or {}).get('duration') + ), thumbnail=pick_thumbnail(snippet.get('thumbnails')) or fallback_thumb, ) @@ -82,46 +101,152 @@ def _playlist_card(item: dict[str, Any], kind: str = 'playlist') -> PlaylistCard if not raw_id: return None return PlaylistCard( - id=raw_id, title=snippet.get('title') or 'Sem título', + id=raw_id, + title=snippet.get('title') or 'Sem título', subtitle=snippet.get('channelTitle') or ('Canal' if kind == 'artist' else None), - thumbnail=pick_thumbnail(snippet.get('thumbnails')), kind=kind, + thumbnail=pick_thumbnail(snippet.get('thumbnails')), + kind=kind, ) +def _video_details(ids: list[str]) -> list[dict[str, Any]]: + if not ids: + return [] + return _request( + 'videos', + {'part': 'snippet,contentDetails,status', 'id': ','.join(ids[:50])}, + ).get('items', []) + + +def search_music_tracks(query: str, limit: int = 20) -> list[Track]: + """Search embeddable music-category videos and hydrate their duration/status.""" + query = query.strip() + if not query: + return [] + limit = min(max(limit, 1), 25) + results = _request('search', { + 'part': 'snippet', + 'type': 'video', + 'videoCategoryId': '10', + 'videoEmbeddable': 'true', + 'safeSearch': 'moderate', + 'q': query, + 'maxResults': limit, + }).get('items', []) + ids = [ + item.get('id', {}).get('videoId') + for item in results + if isinstance(item.get('id'), dict) and item['id'].get('videoId') + ] + details = _video_details(ids) + by_id = {item.get('id'): item for item in details if item.get('id')} + tracks: list[Track] = [] + for video_id in ids: + item = by_id.get(video_id) + if not item: + continue + status = item.get('status') or {} + if status.get('privacyStatus') == 'private' or status.get('embeddable') is False: + continue + track = normalize_track(item) + if track: + tracks.append(track) + return tracks[:limit] + + +def trending_music_tracks(region_code: str = 'AO', limit: int = 20) -> list[Track]: + """Use YouTube most-popular music-category videos for a regional discovery shelf.""" + limit = min(max(limit, 1), 25) + items = _request('videos', { + 'part': 'snippet,contentDetails,status', + 'chart': 'mostPopular', + 'regionCode': (region_code or 'AO').upper(), + 'videoCategoryId': '10', + 'maxResults': limit, + }).get('items', []) + tracks: list[Track] = [] + for item in items: + status = item.get('status') or {} + if status.get('privacyStatus') == 'private' or status.get('embeddable') is False: + continue + track = normalize_track(item) + if track: + tracks.append(track) + return tracks[:limit] + + def search_playlist_cards(query: str, limit: int = 6) -> list[PlaylistCard]: - data = _request('search', {'part': 'snippet', 'type': 'playlist', 'q': query, 'maxResults': min(limit, 50)}) + data = _request('search', { + 'part': 'snippet', 'type': 'playlist', 'q': query, + 'maxResults': min(limit, 50), + }) return [card for card in (_playlist_card(item) for item in data.get('items', [])) if card] def get_playlist(playlist_id: str) -> Playlist: - details = _request('playlists', {'part': 'snippet,contentDetails', 'id': playlist_id}).get('items', []) + details = _request( + 'playlists', + {'part': 'snippet,contentDetails', 'id': playlist_id}, + ).get('items', []) if not details: - raise HTTPException(status_code=404, detail={'error': 'not_found', 'code': 'PLAYLIST_NOT_FOUND', 'message': 'A playlist não foi encontrada ou não é pública.'}) + raise HTTPException(status_code=404, detail={ + 'error': 'not_found', 'code': 'PLAYLIST_NOT_FOUND', + 'message': 'A playlist não foi encontrada ou não é pública.' + }) item = details[0] snippet = item.get('snippet') or {} - listed = _request('playlistItems', {'part': 'snippet,contentDetails', 'playlistId': playlist_id, 'maxResults': 50}).get('items', []) - ids = [x.get('contentDetails', {}).get('videoId') for x in listed] - ids = [x for x in ids if x] - videos = _request('videos', {'part': 'snippet,contentDetails', 'id': ','.join(ids)}).get('items', []) if ids else [] - tracks = [t for t in (normalize_track(video) for video in videos) if t] + listed = _request('playlistItems', { + 'part': 'snippet,contentDetails', + 'playlistId': playlist_id, + 'maxResults': 50, + }).get('items', []) + ids = [ + entry.get('contentDetails', {}).get('videoId') + or entry.get('snippet', {}).get('resourceId', {}).get('videoId') + for entry in listed + ] + ids = [value for value in ids if value] + videos = _video_details(ids) + tracks = [track for track in (normalize_track(video) for video in videos) if track] return Playlist( - id=playlist_id, title=snippet.get('title') or 'Playlist', description=snippet.get('description'), - author=snippet.get('channelTitle'), thumbnail=pick_thumbnail(snippet.get('thumbnails')), - trackCount=(item.get('contentDetails') or {}).get('itemCount') or len(tracks), tracks=tracks, + id=playlist_id, + title=snippet.get('title') or 'Playlist', + description=snippet.get('description'), + author=snippet.get('channelTitle'), + thumbnail=pick_thumbnail(snippet.get('thumbnails')), + trackCount=(item.get('contentDetails') or {}).get('itemCount') or len(tracks), + tracks=tracks, ) def search_all(query: str, limit: int = 8) -> SearchResults: - videos = _request('search', {'part': 'snippet', 'type': 'video', 'videoCategoryId': '10', 'q': query, 'maxResults': min(limit, 50)}).get('items', []) - playlists = _request('search', {'part': 'snippet', 'type': 'playlist', 'q': query, 'maxResults': min(limit, 50)}).get('items', []) - channels = _request('search', {'part': 'snippet', 'type': 'channel', 'q': query, 'maxResults': min(limit, 50)}).get('items', []) - ids = [x.get('id', {}).get('videoId') for x in videos if x.get('id', {}).get('videoId')] - details = _request('videos', {'part': 'snippet,contentDetails', 'id': ','.join(ids)}).get('items', []) if ids else [] + videos = _request('search', { + 'part': 'snippet', + 'type': 'video', + 'videoCategoryId': '10', + 'videoEmbeddable': 'true', + 'q': query, + 'maxResults': min(limit, 50), + }).get('items', []) + playlists = _request('search', { + 'part': 'snippet', 'type': 'playlist', 'q': query, + 'maxResults': min(limit, 50), + }).get('items', []) + channels = _request('search', { + 'part': 'snippet', 'type': 'channel', 'q': query, + 'maxResults': min(limit, 50), + }).get('items', []) + ids = [ + item.get('id', {}).get('videoId') + for item in videos + if item.get('id', {}).get('videoId') + ] + details = _video_details(ids) return SearchResults( - songs=[t for t in (normalize_track(i) for i in details) if t], - playlists=[c for c in (_playlist_card(i) for i in playlists) if c], + songs=[track for track in (normalize_track(item) for item in details) if track], + playlists=[card for card in (_playlist_card(item) for item in playlists) if card], albums=[], - artists=[c for c in (_playlist_card(i, 'artist') for i in channels) if c], + artists=[card for card in (_playlist_card(item, 'artist') for item in channels) if card], ) diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index 680185b..e3597bf 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -4,6 +4,8 @@ from app.cache import cache from app.main import app +from app.routers import music +from app.schemas import Artist, Track from app.services import stream as stream_svc from app.services import ytm @@ -33,8 +35,8 @@ def test_expiry_and_ttl(): future = now + 6 * 3600 url = f'https://rr1.googlevideo.com/videoplayback?expire={future}&x=1' assert stream_svc.parse_expiry(url) == future - assert stream_svc.cache_ttl(future, now) == 5 * 3600 # limite máximo (5h) - assert stream_svc.cache_ttl(now + 30, now) == 0 # URL quase expirada (< 120s) não deve ser guardada + assert stream_svc.cache_ttl(future, now) == 5 * 3600 + assert stream_svc.cache_ttl(now + 30, now) == 0 def test_normalize_track_and_thumbnail(): @@ -43,24 +45,92 @@ def test_normalize_track_and_thumbnail(): 'snippet': { 'title': 'Never Gonna Give You Up', 'channelTitle': 'Rick Astley', + 'channelId': 'UCexample', 'thumbnails': { 'high': {'url': 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'width': 480, 'height': 360} }, }, - 'contentDetails': { - 'duration': 'PT3M33S', - }, + 'contentDetails': {'duration': 'PT3M33S'}, } - t = ytm.normalize_track(item) - assert t is not None - assert t.videoId == 'dQw4w9WgXcQ' - assert t.title == 'Never Gonna Give You Up' - assert len(t.artists) == 1 and t.artists[0].name == 'Rick Astley' - assert t.durationSeconds == 213 - assert t.thumbnail == 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg' + track = ytm.normalize_track(item) + assert track is not None + assert track.videoId == 'dQw4w9WgXcQ' + assert track.source == 'youtube' + assert track.title == 'Never Gonna Give You Up' + assert len(track.artists) == 1 + assert track.artists[0].name == 'Rick Astley' + assert track.artists[0].id == 'UCexample' + assert track.durationSeconds == 213 + assert track.thumbnail == 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg' assert ytm.normalize_track({'snippet': {'title': 'sem id'}}) is None +def test_music_search_prefers_youtube(monkeypatch): + async def fake_run(fn, *args): + assert fn is ytm.search_music_tracks + return [Track( + videoId='dQw4w9WgXcQ', + title='YouTube result', + artists=[Artist(name='Example artist')], + )] + + monkeypatch.setattr(music.ytm, 'run', fake_run) + with TestClient(app) as client: + response = client.get('/api/music/search?q=backend-youtube-first-regression') + assert response.status_code == 200 + payload = response.json() + assert payload['provider'] == 'youtube' + assert payload['tracks'][0]['videoId'] == 'dQw4w9WgXcQ' + assert payload['tracks'][0]['source'] == 'youtube' + + +def test_music_search_falls_back_to_audius_when_youtube_is_empty(monkeypatch): + async def fake_run(fn, *args): + assert fn is ytm.search_music_tracks + return [] + + async def fake_audius_search(query, limit): + return [{ + 'id': 'audius-fallback-1', + 'source': 'audius', + 'title': 'Audius fallback', + }] + + monkeypatch.setattr(music.ytm, 'run', fake_run) + monkeypatch.setattr(music.audius, 'search_tracks', fake_audius_search) + with TestClient(app) as client: + response = client.get('/api/music/search?q=backend-audius-fallback-regression') + assert response.status_code == 200 + payload = response.json() + assert payload['provider'] == 'audius' + assert payload['tracks'][0]['id'] == 'audius-fallback-1' + + +def test_recommendations_are_cached_per_channel(monkeypatch): + calls = [] + + async def fake_account(_session_id): + return { + 'access_token': 'test-token', + 'channel': {'id': 'UC-musify-test-channel'}, + } + + async def fake_recommendations(_access_token, limit): + calls.append(limit) + return [Track(videoId='dQw4w9WgXcQ', title='Personal result')], True + + monkeypatch.setattr(music, 'get_account', fake_account) + monkeypatch.setattr(music.discovery, 'recommend_from_likes', fake_recommendations) + with TestClient(app) as client: + response = client.get('/api/music/recommendations?limit=7') + assert response.status_code == 200 + payload = response.json() + assert payload['provider'] == 'youtube' + assert payload['personalized'] is True + assert payload['tracks'][0]['videoId'] == 'dQw4w9WgXcQ' + assert calls == [7] + + async def test_get_or_set_caches(): calls = 0 @@ -72,4 +142,5 @@ async def loader(): key = f'test:{time.time()}' first, hit1 = await cache.get_or_set(key, 5, loader) second, hit2 = await cache.get_or_set(key, 5, loader) - assert (hit1, hit2, calls) == (False, True, 1) and first == second + assert (hit1, hit2, calls) == (False, True, 1) + assert first == second diff --git a/apps/api/tests/test_recommendations.py b/apps/api/tests/test_recommendations.py new file mode 100644 index 0000000..a79922a --- /dev/null +++ b/apps/api/tests/test_recommendations.py @@ -0,0 +1,16 @@ +from app.schemas import Artist, Track +from app.services.recommendations import _seed_query + + +def test_seed_query_uses_the_most_common_liked_artists(): + seeds = [ + Track(videoId='video-1', title='Song one', artists=[Artist(name='Artist A')]), + Track(videoId='video-2', title='Song two', artists=[Artist(name='Artist A')]), + Track(videoId='video-3', title='Song three', artists=[Artist(name='Artist B')]), + ] + assert _seed_query(seeds) == 'Artist A Artist B similar music' + + +def test_seed_query_falls_back_to_a_liked_song_title(): + seeds = [Track(videoId='video-1', title='A liked song')] + assert _seed_query(seeds) == 'A liked song similar songs' From 66c9117049ae5d517ad46fea0ef72365e46f0cb7 Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 17:01:53 +0100 Subject: [PATCH 2/7] fix(api): skip liked videos unavailable in YouTube details --- apps/api/app/services/recommendations.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/api/app/services/recommendations.py b/apps/api/app/services/recommendations.py index dc38131..e1ef1c6 100644 --- a/apps/api/app/services/recommendations.py +++ b/apps/api/app/services/recommendations.py @@ -73,13 +73,15 @@ async def get_liked_tracks(access_token: str, limit: int = 30) -> list[Track]: videos = await ytm.run(ytm._video_details, ids) by_id = {item.get('id'): item for item in videos if item.get('id')} - return [ - track - for video_id in ids - if (track := ytm.normalize_track(by_id[video_id])) is not None - and track.source == 'youtube' - and video_id in by_id - ] + tracks: list[Track] = [] + for video_id in ids: + item = by_id.get(video_id) + if not item: + continue + track = ytm.normalize_track(item) + if track: + tracks.append(track) + return tracks def _seed_query(seeds: list[Track]) -> str: From 23ab01e3cc8724475129da4c08a83757097a35bd Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 19:49:43 +0100 Subject: [PATCH 3/7] fix(api): broaden YouTube music search and add CI diagnostics --- .github/workflows/api-ci.yml | 37 ++++++++++++++++ apps/api/app/routers/music.py | 36 +++++++++++++--- apps/api/app/services/ytm.py | 6 +-- apps/api/tests/test_api.py | 13 ++++++ apps/api/tests/test_youtube_search.py | 62 +++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/api-ci.yml create mode 100644 apps/api/tests/test_youtube_search.py diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml new file mode 100644 index 0000000..48bbad8 --- /dev/null +++ b/.github/workflows/api-ci.yml @@ -0,0 +1,37 @@ +name: API checks + +on: + pull_request: + paths: + - "apps/api/**" + - ".github/workflows/api-ci.yml" + push: + branches: [main] + paths: + - "apps/api/**" + - ".github/workflows/api-ci.yml" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/api + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: apps/api/requirements-dev.txt + - name: Install dependencies + run: python -m pip install -r requirements-dev.txt + - name: Ruff + run: ruff check app tests + - name: Pytest + run: pytest -q diff --git a/apps/api/app/routers/music.py b/apps/api/app/routers/music.py index 414e272..724b507 100644 --- a/apps/api/app/routers/music.py +++ b/apps/api/app/routers/music.py @@ -24,6 +24,7 @@ def _serialize_youtube_tracks(tracks) -> list[dict]: async def _youtube_search_first(query: str, limit: int) -> dict: fallback_reason = "empty" + fallback_code = None try: tracks = await ytm.run(ytm.search_music_tracks, query, limit) if tracks: @@ -34,9 +35,10 @@ async def _youtube_search_first(query: str, limit: int) -> dict: except HTTPException as exc: fallback_reason = "unavailable" detail = exc.detail if isinstance(exc.detail, dict) else {} + fallback_code = detail.get("code") log.warning( "YouTube search unavailable; falling back to Audius (%s)", - detail.get("code", exc.status_code), + fallback_code or exc.status_code, ) tracks = await audius.search_tracks(query, limit) @@ -44,11 +46,13 @@ async def _youtube_search_first(query: str, limit: int) -> dict: "provider": "audius" if tracks else "youtube", "tracks": tracks, "fallbackReason": fallback_reason, + "fallbackCode": fallback_code, } async def _youtube_trending_first(limit: int) -> dict: fallback_reason = "empty" + fallback_code = None try: tracks = await ytm.run( ytm.trending_music_tracks, @@ -63,9 +67,10 @@ async def _youtube_trending_first(limit: int) -> dict: except HTTPException as exc: fallback_reason = "unavailable" detail = exc.detail if isinstance(exc.detail, dict) else {} + fallback_code = detail.get("code") log.warning( "YouTube trending unavailable; falling back to Audius (%s)", - detail.get("code", exc.status_code), + fallback_code or exc.status_code, ) tracks = await audius.trending_tracks(limit) @@ -73,6 +78,7 @@ async def _youtube_trending_first(limit: int) -> dict: "provider": "audius" if tracks else "youtube", "tracks": tracks, "fallbackReason": fallback_reason, + "fallbackCode": fallback_code, } @@ -86,6 +92,23 @@ async def _cached_track(track_id: str) -> dict: return track +@router.get("/providers") +async def providers(): + """Safe configuration diagnostics; never expose API keys or OAuth secrets.""" + return { + "priority": "youtube", + "youtube": { + "configured": bool(settings.youtube_api_key), + "regionCode": settings.youtube_region_code.upper(), + }, + "audius": { + "configured": True, + "apiKeyConfigured": bool(settings.audius_api_key), + "role": "fallback", + }, + } + + @router.get("/search") async def search( response: Response, @@ -103,7 +126,7 @@ async def search( ) digest = hashlib.sha256(query.casefold().encode()).hexdigest()[:20] - key = f"music:search:v2:{digest}:{limit}" + key = f"music:search:v3:{digest}:{limit}" data, hit = await cache.get_or_set( key, settings.ttl_search, @@ -118,7 +141,7 @@ async def trending( response: Response, limit: int = Query(default=20, ge=1, le=25), ): - key = f"music:trending:v2:{settings.youtube_region_code.upper()}:{limit}" + key = f"music:trending:v3:{settings.youtube_region_code.upper()}:{limit}" data, hit = await cache.get_or_set( key, settings.ttl_home, @@ -148,6 +171,7 @@ async def recommendations( async def load(): fallback_reason = "empty" + fallback_code = None personalized = False try: if account: @@ -170,9 +194,10 @@ async def load(): except HTTPException as exc: fallback_reason = "unavailable" detail = exc.detail if isinstance(exc.detail, dict) else {} + fallback_code = detail.get("code") log.warning( "YouTube recommendations unavailable; falling back to Audius (%s)", - detail.get("code", exc.status_code), + fallback_code or exc.status_code, ) fallback = await audius.trending_tracks(limit) @@ -181,6 +206,7 @@ async def load(): "personalized": False, "tracks": fallback, "fallbackReason": fallback_reason, + "fallbackCode": fallback_code, } data, hit = await cache.get_or_set(key, settings.ttl_recommendations, load) diff --git a/apps/api/app/services/ytm.py b/apps/api/app/services/ytm.py index 5f61c7a..d3966bf 100644 --- a/apps/api/app/services/ytm.py +++ b/apps/api/app/services/ytm.py @@ -119,7 +119,7 @@ def _video_details(ids: list[str]) -> list[dict[str, Any]]: def search_music_tracks(query: str, limit: int = 20) -> list[Track]: - """Search embeddable music-category videos and hydrate their duration/status.""" + """Search embeddable videos without assuming creators tagged them as Music.""" query = query.strip() if not query: return [] @@ -127,7 +127,8 @@ def search_music_tracks(query: str, limit: int = 20) -> list[Track]: results = _request('search', { 'part': 'snippet', 'type': 'video', - 'videoCategoryId': '10', + # Do not force videoCategoryId=10: official music videos are often + # categorized as Entertainment or People & Blogs by their publishers. 'videoEmbeddable': 'true', 'safeSearch': 'moderate', 'q': query, @@ -223,7 +224,6 @@ def search_all(query: str, limit: int = 8) -> SearchResults: videos = _request('search', { 'part': 'snippet', 'type': 'video', - 'videoCategoryId': '10', 'videoEmbeddable': 'true', 'q': query, 'maxResults': min(limit, 50), diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py index e3597bf..e7ad3b1 100644 --- a/apps/api/tests/test_api.py +++ b/apps/api/tests/test_api.py @@ -1,8 +1,10 @@ import time +import pytest from fastapi.testclient import TestClient from app.cache import cache +from app.config import settings from app.main import app from app.routers import music from app.schemas import Artist, Track @@ -24,6 +26,16 @@ def test_auth_status_unauthenticated(): assert r.json() == {'connected': False, 'channel': None} +def test_provider_diagnostics_do_not_expose_secrets(): + with TestClient(app) as client: + response = client.get('/api/music/providers') + assert response.status_code == 200 + payload = response.json() + assert payload['priority'] == 'youtube' + assert payload['youtube']['configured'] is bool(settings.youtube_api_key) + assert 'apiKey' not in payload['youtube'] + + def test_unknown_mood(): with TestClient(app) as c: assert c.get('/api/moods/nope').status_code == 404 @@ -131,6 +143,7 @@ async def fake_recommendations(_access_token, limit): assert calls == [7] +@pytest.mark.asyncio async def test_get_or_set_caches(): calls = 0 diff --git a/apps/api/tests/test_youtube_search.py b/apps/api/tests/test_youtube_search.py new file mode 100644 index 0000000..74c3e7f --- /dev/null +++ b/apps/api/tests/test_youtube_search.py @@ -0,0 +1,62 @@ +from app.services import ytm + + +def test_music_search_does_not_restrict_youtube_category(monkeypatch): + calls = [] + + def fake_request(resource, params): + calls.append((resource, params)) + if resource == "search": + return { + "items": [ + {"id": {"videoId": "video-music-category-mismatch"}}, + ], + } + if resource == "videos": + return { + "items": [{ + "id": "video-music-category-mismatch", + "snippet": { + "title": "Official music video", + "channelTitle": "Example Artist", + "channelId": "UC-example", + "thumbnails": { + "high": { + "url": "https://i.ytimg.com/vi/example/hqdefault.jpg", + "width": 480, + "height": 360, + }, + }, + }, + "contentDetails": {"duration": "PT3M20S"}, + "status": {"privacyStatus": "public", "embeddable": True}, + }], + } + raise AssertionError(f"Unexpected resource: {resource}") + + monkeypatch.setattr(ytm, "_request", fake_request) + + tracks = ytm.search_music_tracks("Example Artist official music", 10) + + assert len(tracks) == 1 + assert tracks[0].videoId == "video-music-category-mismatch" + assert tracks[0].source == "youtube" + search_params = calls[0][1] + assert search_params["videoEmbeddable"] == "true" + assert "videoCategoryId" not in search_params + + +def test_music_search_skips_non_embeddable_videos(monkeypatch): + def fake_request(resource, params): + if resource == "search": + return {"items": [{"id": {"videoId": "private-video"}}]} + if resource == "videos": + return {"items": [{ + "id": "private-video", + "snippet": {"title": "Unavailable", "channelTitle": "Artist"}, + "status": {"privacyStatus": "public", "embeddable": False}, + }]} + raise AssertionError(f"Unexpected resource: {resource}") + + monkeypatch.setattr(ytm, "_request", fake_request) + assert ytm.search_music_tracks("Artist song") == [] From 66a64a6cdd491321ac09fc79edef95f0f5e7032f Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 19:50:25 +0100 Subject: [PATCH 4/7] ci(api): scope Ruff to core correctness checks --- .github/workflows/api-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index 48bbad8..f56d605 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -31,7 +31,7 @@ jobs: cache-dependency-path: apps/api/requirements-dev.txt - name: Install dependencies run: python -m pip install -r requirements-dev.txt - - name: Ruff - run: ruff check app tests + - name: Ruff (core correctness rules) + run: ruff check app tests --select E4,E7,E9,F - name: Pytest run: pytest -q From e4eaae2df1944f06da42327f5b41c0ead60e90fa Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 19:50:37 +0100 Subject: [PATCH 5/7] ci(api): run checks on discovery branch updates --- .github/workflows/api-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/api-ci.yml b/.github/workflows/api-ci.yml index f56d605..b997bc8 100644 --- a/.github/workflows/api-ci.yml +++ b/.github/workflows/api-ci.yml @@ -6,7 +6,9 @@ on: - "apps/api/**" - ".github/workflows/api-ci.yml" push: - branches: [main] + branches: + - main + - feat/youtube-first-discovery paths: - "apps/api/**" - ".github/workflows/api-ci.yml" From 0ffb4c78f09bde2412b175766fd20a4dbbeac42a Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 19:50:54 +0100 Subject: [PATCH 6/7] chore(api): remove unused OAuth exception binding --- apps/api/app/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/app/auth.py b/apps/api/app/auth.py index 1916506..cb58ae8 100644 --- a/apps/api/app/auth.py +++ b/apps/api/app/auth.py @@ -68,7 +68,7 @@ async def callback(request: Request, state: str = '', code: str = '', error: str ) channel_response.raise_for_status() channels = channel_response.json().get('items', []) - except (httpx.HTTPError, KeyError, ValueError) as exc: + except (httpx.HTTPError, KeyError, ValueError): return RedirectResponse(f'{frontend}/account?youtube=exchange_error', status_code=303) if not channels: return RedirectResponse(f'{frontend}/account?youtube=no_channel', status_code=303) From 914e81452e1c983ba578f9c5344f9591051a1a16 Mon Sep 17 00:00:00 2001 From: Nulled Date: Fri, 6 Nov 2026 19:50:57 +0100 Subject: [PATCH 7/7] chore(api): remove unused metadata import --- apps/api/app/routers/meta.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/app/routers/meta.py b/apps/api/app/routers/meta.py index e91be83..85abfa5 100644 --- a/apps/api/app/routers/meta.py +++ b/apps/api/app/routers/meta.py @@ -1,5 +1,4 @@ import hashlib -import time import httpx from fastapi import APIRouter, Cookie, HTTPException, Query, Response