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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
@ -8,6 +10,11 @@ from app.db import get_db
|
||||||
from app.models.category import Category
|
from app.models.category import Category
|
||||||
from app.models.channel import Channel
|
from app.models.channel import Channel
|
||||||
from app.models.channel_category import channel_categories
|
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)])
|
router = APIRouter(dependencies=[Depends(require_session)])
|
||||||
|
|
||||||
|
|
@ -104,3 +111,32 @@ def set_channel_categories(channel_id: int, payload: ChannelCategoriesUpdate, db
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return _serialize(channel, sorted(unique_ids))
|
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.channel import Channel
|
||||||
from app.models.download_job import DownloadJob
|
from app.models.download_job import DownloadJob
|
||||||
from app.models.video import Video
|
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.metube_client import MeTubeClient
|
||||||
from app.services.video_presentation import channel_categories_map, serialize_video
|
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)
|
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")
|
@router.get("/videos/{youtube_video_id}/download-status")
|
||||||
def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
|
def download_status(youtube_video_id: str, db: Session = Depends(get_db)) -> dict:
|
||||||
video = _get_video_or_404(db, youtube_video_id)
|
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)
|
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_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)
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
thumbnail_url: Mapped[str | None] = mapped_column(String, 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
|
from app.db import Base
|
||||||
|
|
||||||
# queued -> downloading -> postprocessing -> completed
|
# queued -> downloading -> postprocessing -> completed -> deleted (by the user)
|
||||||
# -> failed
|
# -> failed
|
||||||
# unknown: state could not be reconciled after a restart
|
# unknown: state could not be reconciled after a restart
|
||||||
ACTIVE_STATUSES = ("queued", "downloading", "postprocessing")
|
ACTIVE_STATUSES = ("queued", "downloading", "postprocessing")
|
||||||
TERMINAL_STATUSES = ("completed", "failed")
|
TERMINAL_STATUSES = ("completed", "failed", "deleted")
|
||||||
|
|
||||||
|
|
||||||
class DownloadJob(Base):
|
class DownloadJob(Base):
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ class MeTubeRejected(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteNotAllowed(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
|
def get_latest_job(db: Session, video_id: int) -> DownloadJob | None:
|
||||||
return (
|
return (
|
||||||
db.query(DownloadJob)
|
db.query(DownloadJob)
|
||||||
|
|
@ -55,6 +59,24 @@ def request_download(db: Session, video: Video) -> DownloadJob:
|
||||||
return job
|
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:
|
def _find_job_by_payload(db: Session, payload: dict) -> DownloadJob | None:
|
||||||
metube_id = payload.get("id")
|
metube_id = payload.get("id")
|
||||||
if metube_id:
|
if metube_id:
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,13 @@ from app.models.oauth_credentials import SINGLETON_ID, OAuthCredentials
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCOPES = [
|
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",
|
"openid",
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,21 @@ class MeTubeClient:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
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:
|
def build_media_url(self, filename: str) -> str | None:
|
||||||
"""Safely turn a MeTube-reported filename into a public /download/... URL.
|
"""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:
|
if channel is None:
|
||||||
channel = Channel(
|
channel = Channel(
|
||||||
youtube_channel_id=sub["youtube_channel_id"],
|
youtube_channel_id=sub["youtube_channel_id"],
|
||||||
|
youtube_subscription_id=sub["youtube_subscription_id"],
|
||||||
title=sub["title"],
|
title=sub["title"],
|
||||||
description=sub["description"],
|
description=sub["description"],
|
||||||
thumbnail_url=sub["thumbnail_url"],
|
thumbnail_url=sub["thumbnail_url"],
|
||||||
|
|
@ -88,6 +89,7 @@ def sync_subscriptions(db: Session) -> dict:
|
||||||
existing[sub["youtube_channel_id"]] = channel
|
existing[sub["youtube_channel_id"]] = channel
|
||||||
added += 1
|
added += 1
|
||||||
else:
|
else:
|
||||||
|
channel.youtube_subscription_id = sub["youtube_subscription_id"]
|
||||||
channel.title = sub["title"]
|
channel.title = sub["title"]
|
||||||
channel.description = sub["description"]
|
channel.description = sub["description"]
|
||||||
channel.thumbnail_url = sub["thumbnail_url"]
|
channel.thumbnail_url = sub["thumbnail_url"]
|
||||||
|
|
@ -249,3 +251,21 @@ def sync_videos(db: Session) -> dict:
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
_videos_lock.release()
|
_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
|
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:
|
def _headers(credentials: Credentials) -> dict:
|
||||||
return {"Authorization": f"Bearer {credentials.token}"}
|
return {"Authorization": f"Bearer {credentials.token}"}
|
||||||
|
|
||||||
|
|
||||||
def _raise_for_status(response: httpx.Response) -> None:
|
def _raise_for_status(response: httpx.Response) -> None:
|
||||||
if response.status_code == 200:
|
if response.status_code in (200, 204):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
payload = response.json()
|
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"):
|
if response.status_code == 403 and reason in ("quotaExceeded", "dailyLimitExceeded", "rateLimitExceeded"):
|
||||||
raise YouTubeQuotaExceeded(message)
|
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)
|
logger.error("YouTube API error %s: %s", response.status_code, message)
|
||||||
raise YouTubeAPIError(f"{response.status_code}: {message}")
|
raise YouTubeAPIError(f"{response.status_code}: {message}")
|
||||||
|
|
||||||
|
|
@ -72,6 +85,10 @@ def fetch_subscriptions(credentials: Credentials) -> list[dict]:
|
||||||
subscriptions.append(
|
subscriptions.append(
|
||||||
{
|
{
|
||||||
"youtube_channel_id": channel_id,
|
"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", ""),
|
"title": snippet.get("title", ""),
|
||||||
"description": snippet.get("description", ""),
|
"description": snippet.get("description", ""),
|
||||||
"thumbnail_url": thumbnail,
|
"thumbnail_url": thumbnail,
|
||||||
|
|
@ -142,6 +159,19 @@ def fetch_videos_details(credentials: Credentials, video_ids: list[str]) -> list
|
||||||
return results
|
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]:
|
def fetch_uploads_playlists(credentials: Credentials, channel_ids: list[str]) -> dict[str, str]:
|
||||||
result: dict[str, str] = {}
|
result: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,21 @@
|
||||||
color: #999;
|
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 {
|
.channel-categories {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -388,7 +403,8 @@
|
||||||
color: #a3221f;
|
color: #a3221f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-error {
|
.download-error,
|
||||||
|
.download-inline-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
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 {
|
export interface CategoryDto {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -182,6 +186,10 @@ export function downloadVideo(youtubeVideoId: string) {
|
||||||
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download`, { method: 'POST' })
|
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) {
|
export function getDownloadStatus(youtubeVideoId: string) {
|
||||||
return request<DownloadJobDto>(`/api/videos/${youtubeVideoId}/download-status`)
|
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 { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import type { CategoryDto, ChannelDto } from '../api/client'
|
import type { CategoryDto, ChannelDto } from '../api/client'
|
||||||
import { setChannelCategories } from '../api/client'
|
import { setChannelCategories, unsubscribeChannel } from '../api/client'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: ChannelDto
|
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) {
|
function toggleCategory(categoryId: number) {
|
||||||
const current = new Set(channel.category_ids)
|
const current = new Set(channel.category_ids)
|
||||||
if (current.has(categoryId)) {
|
if (current.has(categoryId)) {
|
||||||
|
|
@ -64,6 +71,22 @@ function ChannelCard({ channel, categories }: Props) {
|
||||||
{channel.title}
|
{channel.title}
|
||||||
</Link>
|
</Link>
|
||||||
{!channel.subscribed && <span className="badge">отписан</span>}
|
{!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">
|
<div className="channel-categories">
|
||||||
{assignedNames.map((name) => (
|
{assignedNames.map((name) => (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import type { FeedVideoDto } from '../api/client'
|
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']
|
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 status = statusQuery.data?.status ?? video.local.status
|
||||||
const percent = statusQuery.data?.progress_percent ?? video.local.progress_percent
|
const percent = statusQuery.data?.progress_percent ?? video.local.progress_percent
|
||||||
|
|
||||||
|
|
@ -47,7 +54,7 @@ function DownloadButton({ video }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevStatus.current !== status) {
|
if (prevStatus.current !== status) {
|
||||||
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: ['feed'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['video', video.youtube_video_id] })
|
queryClient.invalidateQueries({ queryKey: ['video', video.youtube_video_id] })
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +62,21 @@ function DownloadButton({ video }: Props) {
|
||||||
}, [status, queryClient, video.youtube_video_id])
|
}, [status, queryClient, video.youtube_video_id])
|
||||||
|
|
||||||
if (status === 'completed') {
|
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') {
|
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,
|
sync.youtube_client,
|
||||||
"fetch_subscriptions",
|
"fetch_subscriptions",
|
||||||
lambda creds: [
|
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(
|
monkeypatch.setattr(
|
||||||
|
|
@ -39,8 +51,20 @@ def test_sync_subscriptions_idempotent_and_unsubscribes(monkeypatch, db_session)
|
||||||
sync.youtube_client,
|
sync.youtube_client,
|
||||||
"fetch_subscriptions",
|
"fetch_subscriptions",
|
||||||
lambda creds: [
|
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.status_code == 200
|
||||||
assert resp.json()["status"] == "completed"
|
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": [
|
"items": [
|
||||||
{
|
{
|
||||||
|
"id": "subA",
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": "Channel A",
|
"title": "Channel A",
|
||||||
"description": "desc a",
|
"description": "desc a",
|
||||||
"resourceId": {"channelId": "chanA"},
|
"resourceId": {"channelId": "chanA"},
|
||||||
"thumbnails": {"high": {"url": "http://thumb/a.jpg"}},
|
"thumbnails": {"high": {"url": "http://thumb/a.jpg"}},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nextPageToken": "page2",
|
"nextPageToken": "page2",
|
||||||
|
|
@ -72,6 +73,7 @@ def test_fetch_subscriptions_paginates(monkeypatch):
|
||||||
result = youtube_client.fetch_subscriptions(_credentials())
|
result = youtube_client.fetch_subscriptions(_credentials())
|
||||||
|
|
||||||
assert [r["youtube_channel_id"] for r in result] == ["chanA", "chanB"]
|
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[0]["thumbnail_url"] == "http://thumb/a.jpg"
|
||||||
assert result[1]["thumbnail_url"] == "http://thumb/b.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"
|
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):
|
def test_fetch_uploads_playlists_batches(monkeypatch):
|
||||||
response = FakeResponse(
|
response = FakeResponse(
|
||||||
200,
|
200,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue