diff --git a/backend/app/api/channels.py b/backend/app/api/channels.py index 3a9112f..ea6cf6e 100644 --- a/backend/app/api/channels.py +++ b/backend/app/api/channels.py @@ -1,3 +1,5 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import select @@ -8,6 +10,11 @@ from app.db import get_db from app.models.category import Category from app.models.channel import Channel from app.models.channel_category import channel_categories +from app.services import sync +from app.services.google_oauth import OAuthNotConnected +from app.services.youtube_client import YouTubeAPIError, YouTubeInsufficientScope, YouTubeQuotaExceeded + +logger = logging.getLogger(__name__) router = APIRouter(dependencies=[Depends(require_session)]) @@ -104,3 +111,32 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db db.commit() return _serialize(channel, sorted(unique_ids)) + + +@router.post("/channels/{channel_id}/unsubscribe") +def unsubscribe_channel(channel_id: int, db: Session = Depends(get_db)) -> dict: + channel = db.get(Channel, channel_id) + if channel is None: + raise HTTPException(status_code=404, detail="Channel not found") + + try: + sync.unsubscribe_channel(db, channel) + except sync.ChannelHasNoSubscriptionId as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except OAuthNotConnected: + raise HTTPException(status_code=400, detail="Google account is not connected") + except YouTubeInsufficientScope: + raise HTTPException( + status_code=403, + detail="Недостаточно прав для отписки — переподключи YouTube, чтобы выдать разрешение на управление подписками", + ) + except YouTubeQuotaExceeded: + raise HTTPException(status_code=503, detail="YouTube API quota exhausted") + except YouTubeAPIError as exc: + raise HTTPException(status_code=502, detail=f"YouTube API error: {exc}") + except Exception: + logger.exception("Failed to unsubscribe from channel %s", channel_id) + raise HTTPException(status_code=502, detail="YouTube is unavailable") + + category_map = _category_ids_by_channel(db, [channel_id]) + return _serialize(channel, category_map.get(channel_id, [])) diff --git a/backend/app/api/videos.py b/backend/app/api/videos.py index 742d1e0..3613856 100644 --- a/backend/app/api/videos.py +++ b/backend/app/api/videos.py @@ -8,7 +8,13 @@ from app.db import get_db from app.models.channel import Channel from app.models.download_job import DownloadJob from app.models.video import Video -from app.services.download_jobs import MeTubeRejected, get_latest_job, request_download +from app.services.download_jobs import ( + DeleteNotAllowed, + MeTubeRejected, + delete_local_copy, + get_latest_job, + request_download, +) from app.services.metube_client import MeTubeClient from app.services.video_presentation import channel_categories_map, serialize_video @@ -64,6 +70,21 @@ def download_video(youtube_video_id: str, db: Session = Depends(get_db)) -> dict return _serialize_job(job) +@router.delete("/videos/{youtube_video_id}/download") +def delete_download(youtube_video_id: str, db: Session = Depends(get_db)) -> dict: + video = _get_video_or_404(db, youtube_video_id) + + try: + job = delete_local_copy(db, video) + except DeleteNotAllowed as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception: + logger.exception("Failed to delete local copy for %s", youtube_video_id) + raise HTTPException(status_code=502, detail="MeTube is unavailable") + + return _serialize_job(job) + + @router.get("/videos/{youtube_video_id}/download-status") def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dict: video = _get_video_or_404(db, youtube_video_id) diff --git a/backend/app/models/channel.py b/backend/app/models/channel.py index 2a66098..7384bdf 100644 --- a/backend/app/models/channel.py +++ b/backend/app/models/channel.py @@ -11,6 +11,7 @@ class Channel(Base): id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) youtube_channel_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + youtube_subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True) diff --git a/backend/app/models/download_job.py b/backend/app/models/download_job.py index 2728437..a3846fc 100644 --- a/backend/app/models/download_job.py +++ b/backend/app/models/download_job.py @@ -5,11 +5,11 @@ from sqlalchemy.orm import Mapped, mapped_column from app.db import Base -# queued -> downloading -> postprocessing -> completed +# queued -> downloading -> postprocessing -> completed -> deleted (by the user) # -> failed # unknown: state could not be reconciled after a restart ACTIVE_STATUSES = ("queued", "downloading", "postprocessing") -TERMINAL_STATUSES = ("completed", "failed") +TERMINAL_STATUSES = ("completed", "failed", "deleted") class DownloadJob(Base): diff --git a/backend/app/services/download_jobs.py b/backend/app/services/download_jobs.py index 5342a68..eb92edf 100644 --- a/backend/app/services/download_jobs.py +++ b/backend/app/services/download_jobs.py @@ -15,6 +15,10 @@ class MeTubeRejected(Exception): pass +class DeleteNotAllowed(Exception): + pass + + def get_latest_job(db: Session, video_id: int) -> DownloadJob | None: return ( db.query(DownloadJob) @@ -55,6 +59,24 @@ def request_download(db: Session, video: Video) -> DownloadJob: return job +def delete_local_copy(db: Session, video: Video) -> DownloadJob: + """Deletes our own previously-downloaded copy from MeTube. Only ever acts + on a job this app itself created and tracked (never a file MeTube already + had before we existed) -- see AGENTS.md constraint 10.""" + job = get_latest_job(db, video.id) + if job is None or job.status != "completed": + raise DeleteNotAllowed("No completed local copy to delete") + if not job.metube_job_id: + raise DeleteNotAllowed("Missing MeTube job id, cannot request deletion") + + MeTubeClient().delete_download(job.metube_job_id) + + job.status = "deleted" + job.media_url = None + db.commit() + return job + + def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None: metube_id = payload.get("id") if metube_id: diff --git a/backend/app/services/google_oauth.py b/backend/app/services/google_oauth.py index 7cce976..ab44336 100644 --- a/backend/app/services/google_oauth.py +++ b/backend/app/services/google_oauth.py @@ -14,7 +14,13 @@ from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials logger = logging.getLogger(__name__) SCOPES = [ - "https://www.googleapis.com/auth/youtube.readonly", + # Full read/write scope, not just youtube.readonly: unsubscribing from a + # channel (subscriptions.delete) requires write access. Deliberate + # deviation from the original "minimal scope" TZ recommendation, per + # explicit user request. Existing stored refresh tokens were granted + # under the old readonly-only scope and won't cover this -- users must + # reconnect once for this to take effect. + "https://www.googleapis.com/auth/youtube", "openid", "https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", diff --git a/backend/app/services/metube_client.py b/backend/app/services/metube_client.py index e430897..ffaf38b 100644 --- a/backend/app/services/metube_client.py +++ b/backend/app/services/metube_client.py @@ -77,6 +77,21 @@ class MeTubeClient: response.raise_for_status() return response.json() + def delete_download(self, metube_job_id: str) -> dict: + """Asks MeTube to remove a finished download from its 'done' list and + delete the underlying file (actual file deletion additionally depends + on MeTube's own DELETE_FILE_ON_TRASHCAN config, which we don't + control). Only ever called with a metube_job_id our own app tracked + from a download it started -- never touches files MeTube already had + before we existed.""" + response = httpx.post( + f"{self.api_base_url}/delete", + json={"ids": [metube_job_id], "where": "done"}, + timeout=self.timeout, + ) + response.raise_for_status() + return response.json() + def build_media_url(self, filename: str) -> str | None: """Safely turn a MeTube-reported filename into a public /download/... URL. diff --git a/backend/app/services/sync.py b/backend/app/services/sync.py index a19c9bd..6405f89 100644 --- a/backend/app/services/sync.py +++ b/backend/app/services/sync.py @@ -78,6 +78,7 @@ def sync_subscriptions(db: Session) -> dict: if channel is None: channel = Channel( youtube_channel_id=sub["youtube_channel_id"], + youtube_subscription_id=sub["youtube_subscription_id"], title=sub["title"], description=sub["description"], thumbnail_url=sub["thumbnail_url"], @@ -88,6 +89,7 @@ def sync_subscriptions(db: Session) -> dict: existing[sub["youtube_channel_id"]] = channel added += 1 else: + channel.youtube_subscription_id = sub["youtube_subscription_id"] channel.title = sub["title"] channel.description = sub["description"] channel.thumbnail_url = sub["thumbnail_url"] @@ -249,3 +251,21 @@ def sync_videos(db: Session) -> dict: raise finally: _videos_lock.release() + + +class ChannelHasNoSubscriptionId(Exception): + pass + + +def unsubscribe_channel(db: Session, channel: Channel) -> None: + """Actually unsubscribes on YouTube (subscriptions.delete) -- deliberately + out of the original MVP scope, added on explicit user request. Requires + the write-capable OAuth scope; see google_oauth.SCOPES.""" + if not channel.youtube_subscription_id: + raise ChannelHasNoSubscriptionId( + "This channel was synced before subscription ids were tracked; run a subscriptions sync first" + ) + credentials = google_oauth.get_credentials(db) + youtube_client.unsubscribe(credentials, channel.youtube_subscription_id) + channel.subscribed = False + db.commit() diff --git a/backend/app/services/youtube_client.py b/backend/app/services/youtube_client.py index b8cd4e3..3223615 100644 --- a/backend/app/services/youtube_client.py +++ b/backend/app/services/youtube_client.py @@ -19,12 +19,20 @@ class YouTubeAPIError(Exception): pass +class YouTubeInsufficientScope(Exception): + """Raised when Google rejects a call because the stored token was granted + under an older, narrower scope (e.g. readonly tokens issued before the + unsubscribe feature needed write access) -- the fix is reconnecting.""" + + pass + + def _headers(credentials: Credentials) -> dict: return {"Authorization": f"Bearer {credentials.token}"} def _raise_for_status(response: httpx.Response) -> None: - if response.status_code == 200: + if response.status_code in (200, 204): return try: payload = response.json() @@ -37,6 +45,11 @@ def _raise_for_status(response: httpx.Response) -> None: if response.status_code == 403 and reason in ("quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded"): raise YouTubeQuotaExceeded(message) + if response.status_code in (401, 403) and ( + reason == "insufficientPermissions" or "insufficient authentication scopes" in message.lower() + ): + raise YouTubeInsufficientScope(message) + logger.error("YouTube API error %s: %s", response.status_code, message) raise YouTubeAPIError(f"{response.status_code}: {message}") @@ -72,6 +85,10 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]: subscriptions.append( { "youtube_channel_id": channel_id, + # The subscription resource's own id -- distinct from + # the channel id, required to later call + # subscriptions.delete (unsubscribe). + "youtube_subscription_id": item.get("id"), "title": snippet.get("title", ""), "description": snippet.get("description", ""), "thumbnail_url": thumbnail, @@ -142,6 +159,19 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list return results +def unsubscribe(credentials: Credentials, youtube_subscription_id: str) -> None: + with httpx.Client(timeout=settings.metube_request_timeout_seconds) as client: + response = client.delete( + f"{API_BASE}/subscriptions", + params={"id": youtube_subscription_id}, + headers=_headers(credentials), + ) + if response.status_code == 404: + # Already gone (unsubscribed elsewhere, or stale id) -- treat as success. + return + _raise_for_status(response) + + def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]: result: dict[str, str] = {} diff --git a/frontend/src/App.css b/frontend/src/App.css index 8bc322a..6448b72 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -127,6 +127,21 @@ color: #999; } +.unsubscribe-button { + font-size: 12px; + padding: 2px 8px; + margin-left: 8px; + border: 1px solid #ccc; + border-radius: 10px; + background: transparent; + cursor: pointer; +} + +.unsubscribe-error { + font-size: 12px; + margin-top: 4px; +} + .channel-categories { display: flex; flex-wrap: wrap; @@ -388,7 +403,8 @@ color: #a3221f; } -.download-error { +.download-error, +.download-inline-actions { display: inline-flex; align-items: center; gap: 6px; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0ea0c84..5e04728 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,6 +81,10 @@ export function setChannelCategories(channelId: number, categoryIds: number[]) { }) } +export function unsubscribeChannel(channelId: number) { + return request(`/api/channels/${channelId}/unsubscribe`, { method: 'POST' }) +} + export interface CategoryDto { id: number name: string @@ -182,6 +186,10 @@ export function downloadVideo(youtubeVideoId: string) { return request(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' }) } +export function deleteDownload(youtubeVideoId: string) { + return request(`/api/videos/${youtubeVideoId}/download`, { method: 'DELETE' }) +} + export function getDownloadStatus(youtubeVideoId: string) { return request(`/api/videos/${youtubeVideoId}/download-status`) } diff --git a/frontend/src/components/ChannelCard.tsx b/frontend/src/components/ChannelCard.tsx index e9fa067..fab6396 100644 --- a/frontend/src/components/ChannelCard.tsx +++ b/frontend/src/components/ChannelCard.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' import type { CategoryDto, ChannelDto } from '../api/client' -import { setChannelCategories } from '../api/client' +import { setChannelCategories, unsubscribeChannel } from '../api/client' interface Props { channel: ChannelDto @@ -44,6 +44,13 @@ function ChannelCard({ channel, categories }: Props) { }, }) + const unsubscribeMutation = useMutation({ + mutationFn: () => unsubscribeChannel(channel.id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['channels'] }) + }, + }) + function toggleCategory(categoryId: number) { const current = new Set(channel.category_ids) if (current.has(categoryId)) { @@ -64,6 +71,22 @@ function ChannelCard({ channel, categories }: Props) { {channel.title} {!channel.subscribed && отписан} + {channel.subscribed && ( + + )} + {unsubscribeMutation.isError && ( +

{(unsubscribeMutation.error as Error).message}

+ )}
{assignedNames.map((name) => ( diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 964ca1a..85ff346 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { FeedVideoDto } from '../api/client' -import { downloadVideo, getDownloadStatus } from '../api/client' +import { deleteDownload, downloadVideo, getDownloadStatus } from '../api/client' const ACTIVE_STATUSES = ['queued', 'downloading', 'postprocessing'] @@ -40,6 +40,13 @@ function DownloadButton({ video }: Props) { }, }) + const deleteMutation = useMutation({ + mutationFn: () => deleteDownload(video.youtube_video_id), + onSuccess: (data) => { + queryClient.setQueryData(['download-status', video.youtube_video_id], data) + }, + }) + const status = statusQuery.data?.status ?? video.local.status const percent = statusQuery.data?.progress_percent ?? video.local.progress_percent @@ -47,7 +54,7 @@ function DownloadButton({ video }: Props) { useEffect(() => { if (prevStatus.current !== status) { prevStatus.current = status - if (status === 'completed' || status === 'failed') { + if (status === 'completed' || status === 'failed' || status === 'deleted') { queryClient.invalidateQueries({ queryKey: ['feed'] }) queryClient.invalidateQueries({ queryKey: ['video', video.youtube_video_id] }) } @@ -55,7 +62,21 @@ function DownloadButton({ video }: Props) { }, [status, queryClient, video.youtube_video_id]) if (status === 'completed') { - return ✓ На сервере + return ( + + ✓ На сервере + + + ) } if (status === 'downloading') { diff --git a/migrations/versions/0006_channel_subscription_id.py b/migrations/versions/0006_channel_subscription_id.py new file mode 100644 index 0000000..d9ad7f2 --- /dev/null +++ b/migrations/versions/0006_channel_subscription_id.py @@ -0,0 +1,24 @@ +"""channels.youtube_subscription_id + +Revision ID: 0006_channel_sub_id +Revises: 0005_download_jobs +Create Date: 2026-09-16 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0006_channel_sub_id" +down_revision: Union[str, None] = "0005_download_jobs" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("channels", sa.Column("youtube_subscription_id", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + op.drop_column("channels", "youtube_subscription_id") diff --git a/tests/test_channels.py b/tests/test_channels.py new file mode 100644 index 0000000..bd06dff --- /dev/null +++ b/tests/test_channels.py @@ -0,0 +1,88 @@ +import pytest +from fastapi.testclient import TestClient + +from app.core.auth_dependency import require_session +from app.db import get_db +from app.main import app +from app.models.channel import Channel +from app.services import sync +from app.services.youtube_client import YouTubeInsufficientScope + + +@pytest.fixture +def client(db_session): + def _get_db_override(): + yield db_session + + app.dependency_overrides[get_db] = _get_db_override + app.dependency_overrides[require_session] = lambda: None + # Deliberately not using `with TestClient(app)`: that runs the app's + # lifespan, which would try to reach the real MeTube instance and DB. + yield TestClient(app) + del app.dependency_overrides[get_db] + del app.dependency_overrides[require_session] + + +def _seed_channel(db_session, youtube_subscription_id="sub1"): + channel = Channel( + youtube_channel_id="chanA", + youtube_subscription_id=youtube_subscription_id, + title="Channel A", + subscribed=True, + ) + db_session.add(channel) + db_session.commit() + db_session.refresh(channel) + return channel + + +def test_unsubscribe_success(client, db_session, monkeypatch): + channel = _seed_channel(db_session) + + monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object()) + calls = {} + monkeypatch.setattr( + sync.youtube_client, + "unsubscribe", + lambda creds, sub_id: calls.setdefault("sub_id", sub_id), + ) + + resp = client.post(f"/api/channels/{channel.id}/unsubscribe") + + assert resp.status_code == 200 + assert resp.json()["subscribed"] is False + assert calls["sub_id"] == "sub1" + + db_session.refresh(channel) + assert channel.subscribed is False + + +def test_unsubscribe_without_subscription_id_returns_400(client, db_session): + channel = _seed_channel(db_session, youtube_subscription_id=None) + + resp = client.post(f"/api/channels/{channel.id}/unsubscribe") + + assert resp.status_code == 400 + + +def test_unsubscribe_insufficient_scope_returns_403(client, db_session, monkeypatch): + channel = _seed_channel(db_session) + + monkeypatch.setattr(sync.google_oauth, "get_credentials", lambda db: object()) + + def raise_insufficient(creds, sub_id): + raise YouTubeInsufficientScope("Request had insufficient authentication scopes.") + + monkeypatch.setattr(sync.youtube_client, "unsubscribe", raise_insufficient) + + resp = client.post(f"/api/channels/{channel.id}/unsubscribe") + + assert resp.status_code == 403 + + db_session.refresh(channel) + assert channel.subscribed is True + + +def test_unsubscribe_channel_not_found(client): + resp = client.post("/api/channels/9999/unsubscribe") + assert resp.status_code == 404 diff --git a/tests/test_sync.py b/tests/test_sync.py index a3734b5..264b506 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -13,8 +13,20 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session) sync.youtube_client, "fetch_subscriptions", lambda creds: [ - {"youtube_channel_id": "chanA", "title": "Channel A", "description": "d", "thumbnail_url": "t"}, - {"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"}, + { + "youtube_channel_id": "chanA", + "youtube_subscription_id": "subA", + "title": "Channel A", + "description": "d", + "thumbnail_url": "t", + }, + { + "youtube_channel_id": "chanB", + "youtube_subscription_id": "subB", + "title": "Channel B", + "description": "d", + "thumbnail_url": "t", + }, ], ) monkeypatch.setattr( @@ -39,8 +51,20 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session) sync.youtube_client, "fetch_subscriptions", lambda creds: [ - {"youtube_channel_id": "chanB", "title": "Channel B", "description": "d", "thumbnail_url": "t"}, - {"youtube_channel_id": "chanC", "title": "Channel C", "description": "d", "thumbnail_url": "t"}, + { + "youtube_channel_id": "chanB", + "youtube_subscription_id": "subB", + "title": "Channel B", + "description": "d", + "thumbnail_url": "t", + }, + { + "youtube_channel_id": "chanC", + "youtube_subscription_id": "subC", + "title": "Channel C", + "description": "d", + "thumbnail_url": "t", + }, ], ) diff --git a/tests/test_videos.py b/tests/test_videos.py index 1d60411..4874711 100644 --- a/tests/test_videos.py +++ b/tests/test_videos.py @@ -158,3 +158,40 @@ def test_recheck_local_keeps_completed_when_media_reachable(client, db_session, assert resp.status_code == 200 assert resp.json()["status"] == "completed" + + +def test_delete_download_requires_completed_job(client, db_session): + video = _seed_single_video(db_session) + resp = client.delete(f"/api/videos/{video.youtube_video_id}/download") + assert resp.status_code == 400 + + +def test_delete_download_success(client, db_session, monkeypatch): + from app.models.download_job import DownloadJob + + video = _seed_single_video(db_session) + job = DownloadJob( + video_id=video.id, + status="completed", + metube_job_id="vid1.vid1", + media_url="http://metube.local/download/f.mp4", + ) + db_session.add(job) + db_session.commit() + + calls = {} + monkeypatch.setattr( + "app.services.metube_client.MeTubeClient.delete_download", + lambda self, metube_job_id: calls.setdefault("id", metube_job_id) or {"status": "ok"}, + ) + + resp = client.delete(f"/api/videos/{video.youtube_video_id}/download") + + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + assert resp.json()["media_url"] is None + assert calls["id"] == "vid1.vid1" + + db_session.refresh(job) + assert job.status == "deleted" + assert job.media_url is None diff --git a/tests/test_youtube_client.py b/tests/test_youtube_client.py index e0d6779..a3a8d52 100644 --- a/tests/test_youtube_client.py +++ b/tests/test_youtube_client.py @@ -40,12 +40,13 @@ def test_fetch_subscriptions_paginates(monkeypatch): { "items": [ { + "id": "subA", "snippet": { "title": "Channel A", "description": "desc a", "resourceId": {"channelId": "chanA"}, "thumbnails": {"high": {"url": "http://thumb/a.jpg"}}, - } + }, } ], "nextPageToken": "page2", @@ -72,6 +73,7 @@ def test_fetch_subscriptions_paginates(monkeypatch): result = youtube_client.fetch_subscriptions(_credentials()) assert [r["youtube_channel_id"] for r in result] == ["chanA", "chanB"] + assert result[0]["youtube_subscription_id"] == "subA" assert result[0]["thumbnail_url"] == "http://thumb/a.jpg" assert result[1]["thumbnail_url"] == "http://thumb/b.jpg" @@ -143,6 +145,50 @@ def test_fetch_videos_details(monkeypatch): assert result[0]["thumbnail_url"] == "http://thumb/1.jpg" +def test_unsubscribe_success(monkeypatch): + response = FakeResponse(204, {}) + seen = {} + + class FakeDeleteClient(FakeClient): + def delete(self, url, params=None, headers=None): + seen["params"] = params + return self._responses.pop(0) + + monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response])) + + youtube_client.unsubscribe(_credentials(), "sub123") + + assert seen["params"] == {"id": "sub123"} + + +def test_unsubscribe_already_gone_is_not_an_error(monkeypatch): + response = FakeResponse(404, {"error": {"message": "not found"}}) + + class FakeDeleteClient(FakeClient): + def delete(self, url, params=None, headers=None): + return self._responses.pop(0) + + monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response])) + + youtube_client.unsubscribe(_credentials(), "sub123") # must not raise + + +def test_unsubscribe_insufficient_scope(monkeypatch): + response = FakeResponse( + 403, + {"error": {"message": "Request had insufficient authentication scopes.", "errors": [{"reason": "forbidden"}]}}, + ) + + class FakeDeleteClient(FakeClient): + def delete(self, url, params=None, headers=None): + return self._responses.pop(0) + + monkeypatch.setattr(youtube_client.httpx, "Client", lambda timeout: FakeDeleteClient([response])) + + with pytest.raises(youtube_client.YouTubeInsufficientScope): + youtube_client.unsubscribe(_credentials(), "sub123") + + def test_fetch_uploads_playlists_batches(monkeypatch): response = FakeResponse( 200,