feat(playlists): unify Saved into a built-in Watch later playlist
The old per-video status='saved' becomes membership in a built-in, undeletable 'watch_later' playlist. Migration 0012 moves existing saved videos into each user's Watch later list and demotes the states to 'new'. The feed/playlist serializers now expose a 'saved' boolean (EXISTS in watch_later); the card bookmark toggles watch_later via new /api/playlists/watch-later add/remove endpoints (create-on-demand) instead of setting a status. Removed the 'saved' show-filter and status. Watch later shows a localized name and hides rename/delete in the Playlists page and add-to-playlist popover.
This commit is contained in:
parent
47bad6d9ce
commit
dea740b728
12 changed files with 260 additions and 36 deletions
65
backend/alembic/versions/0012_watch_later.py
Normal file
65
backend/alembic/versions/0012_watch_later.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""unify Saved into a built-in Watch later playlist
|
||||
|
||||
Revision ID: 0012_watch_later
|
||||
Revises: 0011_playlists
|
||||
Create Date: 2026-06-15
|
||||
|
||||
Migrates the old per-video status='saved' into membership of a built-in 'watch_later'
|
||||
playlist (one per user who had saved videos), then demotes those states to 'new'.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0012_watch_later"
|
||||
down_revision: Union[str, None] = "0011_playlists"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) A Watch later playlist for each user that has saved videos (and none yet).
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO playlists (user_id, name, kind, source, dirty, position, created_at, updated_at)
|
||||
SELECT DISTINCT vs.user_id, 'Watch later', 'watch_later', 'local', false, 0, now(), now()
|
||||
FROM video_states vs
|
||||
WHERE vs.status = 'saved'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM playlists p
|
||||
WHERE p.user_id = vs.user_id AND p.kind = 'watch_later'
|
||||
)
|
||||
"""
|
||||
)
|
||||
# 2) Add each saved video as an item, ordered (newest-saved first → position 0).
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO playlist_items (playlist_id, video_id, position, added_at)
|
||||
SELECT p.id, vs.video_id,
|
||||
row_number() OVER (
|
||||
PARTITION BY vs.user_id
|
||||
ORDER BY COALESCE(vs.watched_at, vs.updated_at) DESC
|
||||
) - 1,
|
||||
COALESCE(vs.watched_at, vs.updated_at, now())
|
||||
FROM video_states vs
|
||||
JOIN playlists p ON p.user_id = vs.user_id AND p.kind = 'watch_later'
|
||||
WHERE vs.status = 'saved'
|
||||
ON CONFLICT (playlist_id, video_id) DO NOTHING
|
||||
"""
|
||||
)
|
||||
# 3) Demote the migrated saved states to the default.
|
||||
op.execute("UPDATE video_states SET status = 'new' WHERE status = 'saved'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Best-effort reverse: restore status='saved' for videos in watch_later lists, then drop them.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE video_states vs SET status = 'saved'
|
||||
FROM playlist_items pi
|
||||
JOIN playlists p ON p.id = pi.playlist_id AND p.kind = 'watch_later'
|
||||
WHERE pi.video_id = vs.video_id AND p.user_id = vs.user_id
|
||||
AND vs.status = 'new'
|
||||
"""
|
||||
)
|
||||
op.execute("DELETE FROM playlists WHERE kind = 'watch_later'")
|
||||
|
|
@ -7,13 +7,24 @@ from sqlalchemy.orm import Session, aliased
|
|||
from app import quota
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User, Video, VideoState
|
||||
from app.models import (
|
||||
Channel,
|
||||
ChannelTag,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
Subscription,
|
||||
Tag,
|
||||
User,
|
||||
Video,
|
||||
VideoState,
|
||||
)
|
||||
from app.sync.videos import parse_iso8601_duration
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["feed"])
|
||||
|
||||
VALID_STATES = {"new", "watched", "saved", "hidden"}
|
||||
# "saved" used to be a status; it's now membership in the built-in Watch later playlist.
|
||||
VALID_STATES = {"new", "watched", "hidden"}
|
||||
HIDDEN_LIVE = ("live", "upcoming")
|
||||
|
||||
# Resume-position thresholds (mirror the client): positions below this are "didn't
|
||||
|
|
@ -29,6 +40,23 @@ def _channel_url(channel_id: str, handle: str | None) -> str:
|
|||
return f"https://www.youtube.com/channel/{channel_id}"
|
||||
|
||||
|
||||
def _saved_expr(user: User):
|
||||
"""A correlated EXISTS labelled `saved`: is this video in the user's Watch later list?
|
||||
(Watch later is the built-in playlist that replaced the old per-video 'saved' status.)"""
|
||||
return (
|
||||
select(1)
|
||||
.select_from(PlaylistItem)
|
||||
.join(Playlist, Playlist.id == PlaylistItem.playlist_id)
|
||||
.where(
|
||||
Playlist.user_id == user.id,
|
||||
Playlist.kind == "watch_later",
|
||||
PlaylistItem.video_id == Video.id,
|
||||
)
|
||||
.exists()
|
||||
.label("saved")
|
||||
)
|
||||
|
||||
|
||||
def _serialize(row) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
|
|
@ -45,6 +73,7 @@ def _serialize(row) -> dict:
|
|||
"live_status": row.live_status,
|
||||
"status": row.status or "new",
|
||||
"position_seconds": row.position_seconds or 0,
|
||||
"saved": bool(getattr(row, "saved", False)),
|
||||
"watch_url": f"https://www.youtube.com/watch?v={row.id}",
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +125,7 @@ def _filtered_query(
|
|||
Video.live_status,
|
||||
status_expr.label("status"),
|
||||
position_expr.label("position_seconds"),
|
||||
_saved_expr(user),
|
||||
).join(Channel, Channel.id == Video.channel_id)
|
||||
|
||||
if scope == "all":
|
||||
|
|
@ -176,7 +206,7 @@ def _filtered_query(
|
|||
|
||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||
explicit_view = show in ("watched", "saved", "hidden")
|
||||
explicit_view = show in ("watched", "hidden")
|
||||
if not explicit_view:
|
||||
type_clauses = []
|
||||
if show_normal:
|
||||
|
|
@ -198,8 +228,6 @@ def _filtered_query(
|
|||
)
|
||||
elif show == "watched":
|
||||
query = query.where(status_expr == "watched")
|
||||
elif show == "saved":
|
||||
query = query.where(status_expr == "saved")
|
||||
elif show == "hidden":
|
||||
query = query.where(status_expr == "hidden")
|
||||
else: # all
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, aliased
|
|||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||
from app.routes.feed import _serialize
|
||||
from app.routes.feed import _saved_expr, _serialize
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
|
||||
|
|
@ -110,6 +110,83 @@ def create_playlist(
|
|||
return _summary(db, pl, 0)
|
||||
|
||||
|
||||
def _watch_later(db: Session, user: User) -> Playlist:
|
||||
"""The user's built-in Watch later playlist, created on first use."""
|
||||
pl = db.scalar(
|
||||
select(Playlist).where(
|
||||
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
||||
)
|
||||
)
|
||||
if pl is None:
|
||||
next_pos = (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(Playlist.position), -1)).where(
|
||||
Playlist.user_id == user.id
|
||||
)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
pl = Playlist(
|
||||
user_id=user.id, name="Watch later", kind="watch_later", position=next_pos
|
||||
)
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
@router.post("/watch-later")
|
||||
def add_watch_later(
|
||||
payload: dict,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Bookmark shortcut: add a video to the built-in Watch later playlist."""
|
||||
video_id = payload.get("video_id")
|
||||
if not video_id or db.get(Video, video_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown video")
|
||||
pl = _watch_later(db, user)
|
||||
exists = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if exists is None:
|
||||
next_pos = (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||
PlaylistItem.playlist_id == pl.id
|
||||
)
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos))
|
||||
db.commit()
|
||||
return {"saved": True, "playlist_id": pl.id}
|
||||
|
||||
|
||||
@router.delete("/watch-later/{video_id}")
|
||||
def remove_watch_later(
|
||||
video_id: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = db.scalar(
|
||||
select(Playlist).where(
|
||||
Playlist.user_id == user.id, Playlist.kind == "watch_later"
|
||||
)
|
||||
)
|
||||
if pl is not None:
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||
)
|
||||
)
|
||||
if item is not None:
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"saved": False}
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
def get_playlist(
|
||||
playlist_id: int,
|
||||
|
|
@ -134,6 +211,7 @@ def get_playlist(
|
|||
Video.live_status,
|
||||
func.coalesce(state.status, "new").label("status"),
|
||||
func.coalesce(state.position_seconds, 0).label("position_seconds"),
|
||||
_saved_expr(user),
|
||||
)
|
||||
.join(PlaylistItem, PlaylistItem.video_id == Video.id)
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
|
|
|
|||
|
|
@ -165,7 +165,9 @@ export default function AddToPlaylist({
|
|||
>
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<span className="truncate">{pl.name}</span>
|
||||
<span className="truncate">
|
||||
{pl.kind === "watch_later" ? t("playlists.watchLater") : pl.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ function matchesView(status: string, show: string): boolean {
|
|||
return status === "hidden";
|
||||
case "watched":
|
||||
return status === "watched";
|
||||
case "saved":
|
||||
return status === "saved";
|
||||
case "unwatched":
|
||||
case "in_progress":
|
||||
// (in_progress is further narrowed server-side by resume position; here we only
|
||||
|
|
@ -42,6 +40,7 @@ export default function Feed({
|
|||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null
|
||||
|
|
@ -64,8 +63,14 @@ export default function Feed({
|
|||
// arrives — after a refetch the server is authoritative, so a stale override
|
||||
// (e.g. a video reverted to "new" from the notification center) won't keep it
|
||||
// filtered out of the current view.
|
||||
useEffect(() => setOverrides({}), [filters]);
|
||||
useEffect(() => setOverrides({}), [query.dataUpdatedAt]);
|
||||
useEffect(() => {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [filters]);
|
||||
useEffect(() => {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
}, [query.dataUpdatedAt]);
|
||||
|
||||
const countQuery = useQuery({
|
||||
queryKey: ["feed-count", filters],
|
||||
|
|
@ -139,10 +144,24 @@ export default function Feed({
|
|||
[filters, setFilters]
|
||||
);
|
||||
|
||||
const onToggleSave = useCallback(
|
||||
(id: string, saved: boolean) => {
|
||||
setSavedOverrides((o) => ({ ...o, [id]: saved }));
|
||||
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
|
||||
.then(() => {
|
||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||
qc.invalidateQueries({ queryKey: ["playlist"] });
|
||||
})
|
||||
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
||||
},
|
||||
[qc]
|
||||
);
|
||||
|
||||
const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
|
||||
loadedRef.current = loaded;
|
||||
const items: Video[] = loaded
|
||||
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||
.map((v) => (savedOverrides[v.id] !== undefined ? { ...v, saved: savedOverrides[v.id] } : v))
|
||||
.filter((v) => matchesView(v.status, filters.show));
|
||||
|
||||
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
|
||||
|
|
@ -192,6 +211,7 @@ export default function Feed({
|
|||
video={v}
|
||||
view="grid"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
|
|
@ -205,6 +225,7 @@ export default function Feed({
|
|||
video={v}
|
||||
view="list"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -137,6 +137,11 @@ export default function Playlists() {
|
|||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
|
||||
);
|
||||
|
||||
// Watch later is a built-in playlist: show a localized name and no rename/delete.
|
||||
const plName = (p: { kind: string; name: string }) =>
|
||||
p.kind === "watch_later" ? t("playlists.watchLater") : p.name;
|
||||
const builtin = detail?.kind === "watch_later";
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (name: string) => api.createPlaylist(name),
|
||||
onSuccess: (pl: Playlist) => {
|
||||
|
|
@ -230,7 +235,7 @@ export default function Playlists() {
|
|||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] text-fg truncate">{pl.name}</div>
|
||||
<div className="text-[13px] text-fg truncate">{plName(pl)}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
</div>
|
||||
|
|
@ -287,7 +292,8 @@ export default function Playlists() {
|
|||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold truncate">{detail.name}</h2>
|
||||
<h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
|
||||
{!builtin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameValue(detail.name);
|
||||
|
|
@ -298,6 +304,7 @@ export default function Playlists() {
|
|||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted mt-0.5">
|
||||
|
|
@ -311,12 +318,14 @@ export default function Playlists() {
|
|||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||
</button>
|
||||
{!builtin && (
|
||||
<button
|
||||
onClick={deletePlaylist}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const SORT_IDS = [
|
|||
"shuffle",
|
||||
];
|
||||
|
||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
||||
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
||||
|
||||
// Fresh shuffle token for the "surprise me" sort.
|
||||
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ import { formatDuration, formatViews, relativeTime } from "../lib/format";
|
|||
function Actions({
|
||||
video,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
}: {
|
||||
video: Video;
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -31,6 +33,11 @@ function Actions({
|
|||
e.stopPropagation();
|
||||
onState(video.id, video.status === status ? "new" : status);
|
||||
};
|
||||
const toggleSave = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSave(video.id, !video.saved);
|
||||
};
|
||||
return (
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
|
|
@ -48,11 +55,11 @@ function Actions({
|
|||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={act("saved")}
|
||||
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
|
||||
onClick={toggleSave}
|
||||
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||
video.status === "saved" && "fill-current text-accent"
|
||||
video.saved && "fill-current text-accent"
|
||||
)}
|
||||
>
|
||||
<Bookmark className="w-4 h-4" />
|
||||
|
|
@ -157,7 +164,7 @@ function Thumb({
|
|||
{t("card.stream")}
|
||||
</span>
|
||||
)}
|
||||
{video.status === "saved" && (
|
||||
{video.saved && (
|
||||
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
|
|
@ -209,12 +216,14 @@ function VideoCard({
|
|||
video,
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
onOpen,
|
||||
}: {
|
||||
video: Video;
|
||||
view: "grid" | "list";
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
|
|
@ -264,7 +273,7 @@ function VideoCard({
|
|||
</a>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
</div>
|
||||
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -295,7 +304,7 @@ function VideoCard({
|
|||
{video.channel_title}
|
||||
</a>
|
||||
<div className="text-xs text-muted mt-0.5">{meta}</div>
|
||||
<Actions video={video} onState={onState} onChannelFilter={onChannelFilter} />
|
||||
<Actions video={video} onState={onState} onToggleSave={onToggleSave} onChannelFilter={onChannelFilter} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"title": "Wiedergabelisten",
|
||||
"watchLater": "Später ansehen",
|
||||
"noneYet": "Noch keine Wiedergabelisten",
|
||||
"newPlaylist": "Neue Liste…",
|
||||
"itemCount_one": "{{count}} Video",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"title": "Playlists",
|
||||
"watchLater": "Watch later",
|
||||
"noneYet": "No playlists yet",
|
||||
"newPlaylist": "New playlist…",
|
||||
"itemCount_one": "{{count}} video",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"title": "Lejátszási listák",
|
||||
"watchLater": "Megnézem később",
|
||||
"noneYet": "Még nincs lejátszási lista",
|
||||
"newPlaylist": "Új lista…",
|
||||
"itemCount_one": "{{count}} videó",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export interface Video {
|
|||
live_status: string;
|
||||
status: string;
|
||||
position_seconds: number;
|
||||
saved: boolean; // is the video in the user's built-in Watch later playlist
|
||||
watch_url: string;
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +336,14 @@ export const api = {
|
|||
method: "PUT",
|
||||
body: JSON.stringify({ video_ids: videoIds }),
|
||||
}),
|
||||
// Bookmark shortcut for the built-in Watch later playlist.
|
||||
watchLaterAdd: (videoId: string) =>
|
||||
req("/api/playlists/watch-later", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ video_id: videoId }),
|
||||
}),
|
||||
watchLaterRemove: (videoId: string) =>
|
||||
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
||||
|
||||
// --- quota usage ---
|
||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue