Add delete-downloaded-video and real YouTube unsubscribe
Delete local copy:
- MeTubeClient.delete_download() -> POST /delete {ids, where: "done"}
- Only ever acts on a job our own app tracked (metube_job_id we stored from
its own 'completed' event), never a pre-existing MeTube file
- New "deleted" terminal status; DELETE /api/videos/{id}/download
- Frontend: delete button next to "На сервере" badge, confirm dialog
Unsubscribe (deliberate deviation from the original TZ's MVP exclusion of
subscription management, per explicit user request after being shown the
tradeoff):
- OAuth scope widened from youtube.readonly to full youtube (read/write) --
existing stored tokens only cover the old scope, so unsubscribing needs a
fresh reconnect; reads keep working unchanged on the old token meanwhile
- channels.youtube_subscription_id (distinct from the channel id; that's
what subscriptions.delete actually keys on) captured during subscriptions
sync
- YouTubeInsufficientScope raised on 401/403 "insufficient authentication
scopes" and surfaced as a clear 403 asking the user to reconnect, rather
than a generic API error
- POST /api/channels/{id}/unsubscribe calls subscriptions.delete and marks
the channel unsubscribed locally on success
- Frontend: "Отписаться" button on ChannelCard with confirm dialog
10 new backend tests (73 total).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
10c16ba2cb
commit
3089202316
18 changed files with 453 additions and 15 deletions
|
|
@ -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, []))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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] = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export function setChannelCategories(channelId: number, categoryIds: number[]) {
|
|||
})
|
||||
}
|
||||
|
||||
export function unsubscribeChannel(channelId: number) {
|
||||
return request<ChannelDto>(`/api/channels/${channelId}/unsubscribe`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export interface CategoryDto {
|
||||
id: number
|
||||
name: string
|
||||
|
|
@ -182,6 +186,10 @@ export function downloadVideo(youtubeVideoId: string) {
|
|||
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function deleteDownload(youtubeVideoId: string) {
|
||||
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function getDownloadStatus(youtubeVideoId: string) {
|
||||
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download-status`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</Link>
|
||||
{!channel.subscribed && <span className="badge">отписан</span>}
|
||||
{channel.subscribed && (
|
||||
<button
|
||||
className="unsubscribe-button"
|
||||
onClick={() => {
|
||||
if (confirm(`Отписаться от канала "${channel.title}" на YouTube?`)) {
|
||||
unsubscribeMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={unsubscribeMutation.isPending}
|
||||
>
|
||||
Отписаться
|
||||
</button>
|
||||
)}
|
||||
{unsubscribeMutation.isError && (
|
||||
<p className="error unsubscribe-error">{(unsubscribeMutation.error as Error).message}</p>
|
||||
)}
|
||||
|
||||
<div className="channel-categories">
|
||||
{assignedNames.map((name) => (
|
||||
|
|
|
|||
|
|
@ -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 <span className="download-badge completed">✓ На сервере</span>
|
||||
return (
|
||||
<span className="download-inline-actions">
|
||||
<span className="download-badge completed">✓ На сервере</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Удалить скачанную копию с сервера?')) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'downloading') {
|
||||
|
|
|
|||
24
migrations/versions/0006_channel_subscription_id.py
Normal file
24
migrations/versions/0006_channel_subscription_id.py
Normal file
|
|
@ -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")
|
||||
88
tests/test_channels.py
Normal file
88
tests/test_channels.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue