Merge feature/s4d-youtube-playlist-push: YouTube playlist write-back
This commit is contained in:
commit
7a7a373983
9 changed files with 543 additions and 13 deletions
|
|
@ -6,11 +6,12 @@ from sqlalchemy import func, select, and_
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import current_user, has_read_scope
|
from app.auth import current_user, has_read_scope, has_write_scope
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||||
from app.routes.feed import _saved_expr, _serialize
|
from app.routes.feed import _saved_expr, _serialize
|
||||||
from app.sync.playlists import sync_user_playlists
|
from app.sync.playlists import plan_push, push_playlist, sync_user_playlists
|
||||||
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||||
|
|
||||||
|
|
@ -22,6 +23,13 @@ def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
return pl
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_dirty(pl: Playlist) -> None:
|
||||||
|
"""Flag a YouTube-linked local playlist as having unpushed local edits, so the UI can
|
||||||
|
offer a 'Sync to YouTube'. No-op for un-linked or mirrored playlists."""
|
||||||
|
if pl.source == "local" and pl.yt_playlist_id:
|
||||||
|
pl.dirty = True
|
||||||
|
|
||||||
|
|
||||||
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
|
def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = None) -> dict:
|
||||||
cover = db.scalar(
|
cover = db.scalar(
|
||||||
select(Video.thumbnail_url)
|
select(Video.thumbnail_url)
|
||||||
|
|
@ -36,6 +44,7 @@ def _summary(db: Session, pl: Playlist, count: int, has_video: bool | None = Non
|
||||||
"kind": pl.kind,
|
"kind": pl.kind,
|
||||||
"source": pl.source,
|
"source": pl.source,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
"dirty": pl.dirty,
|
||||||
"item_count": count,
|
"item_count": count,
|
||||||
"cover_thumbnail": cover,
|
"cover_thumbnail": cover,
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +214,72 @@ def sync_youtube(
|
||||||
return sync_user_playlists(db, user)
|
return sync_user_playlists(db, user)
|
||||||
|
|
||||||
|
|
||||||
|
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||||
|
"""A local playlist the user may push to YouTube (create or update). The built-in
|
||||||
|
Watch later and read-only YouTube mirrors are never pushable."""
|
||||||
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
|
if pl.kind == "watch_later":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="The Watch later list can't be sent to YouTube."
|
||||||
|
)
|
||||||
|
if pl.source == "youtube":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="This playlist already mirrors a YouTube playlist."
|
||||||
|
)
|
||||||
|
return pl
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{playlist_id}/push-plan")
|
||||||
|
def push_plan(
|
||||||
|
playlist_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Dry run: what 'Sync to YouTube' would do + the quota estimate, so the UI can show a
|
||||||
|
confirm. For a linked playlist this reads the live YouTube state (cheap, 1 unit/page)."""
|
||||||
|
pl = _pushable(db, user, playlist_id)
|
||||||
|
if not has_write_scope(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, "playlist_push"):
|
||||||
|
plan = plan_push(db, user, pl)
|
||||||
|
except YouTubeError:
|
||||||
|
raise HTTPException(status_code=502, detail="Couldn't read the playlist on YouTube.")
|
||||||
|
plan["remaining_today"] = quota.remaining_today(db)
|
||||||
|
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{playlist_id}/push")
|
||||||
|
def push(
|
||||||
|
playlist_id: int,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""Push a local playlist to YouTube (create it on first push, else reconcile to match
|
||||||
|
local — local wins). Refuses if the estimated quota exceeds what's left today, so a
|
||||||
|
big playlist can't strand half-pushed."""
|
||||||
|
pl = _pushable(db, user, playlist_id)
|
||||||
|
if not has_write_scope(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, "playlist_push"):
|
||||||
|
plan = plan_push(db, user, pl)
|
||||||
|
if plan["units_estimate"] > quota.remaining_today(db):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Not enough YouTube quota left today for this sync.",
|
||||||
|
)
|
||||||
|
return push_playlist(db, user, pl)
|
||||||
|
except YouTubeError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=502, detail="YouTube playlist sync failed.")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{playlist_id}")
|
@router.get("/{playlist_id}")
|
||||||
def get_playlist(
|
def get_playlist(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
|
|
@ -243,6 +318,7 @@ def get_playlist(
|
||||||
"kind": pl.kind,
|
"kind": pl.kind,
|
||||||
"source": pl.source,
|
"source": pl.source,
|
||||||
"yt_playlist_id": pl.yt_playlist_id,
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
"dirty": pl.dirty,
|
||||||
"items": [_serialize(r) for r in rows],
|
"items": [_serialize(r) for r in rows],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -270,12 +346,31 @@ def rename_playlist(
|
||||||
@router.delete("/{playlist_id}")
|
@router.delete("/{playlist_id}")
|
||||||
def delete_playlist(
|
def delete_playlist(
|
||||||
playlist_id: int,
|
playlist_id: int,
|
||||||
|
on_youtube: bool = False,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
pl = _own_playlist(db, user, playlist_id)
|
pl = _own_playlist(db, user, playlist_id)
|
||||||
if pl.kind == "watch_later":
|
if pl.kind == "watch_later":
|
||||||
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
|
raise HTTPException(status_code=400, detail="The Watch later list can't be deleted.")
|
||||||
|
if on_youtube:
|
||||||
|
if pl.source == "youtube":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This is a mirror of a YouTube playlist; manage it on YouTube.",
|
||||||
|
)
|
||||||
|
if not pl.yt_playlist_id:
|
||||||
|
raise HTTPException(status_code=400, detail="This playlist isn't on YouTube.")
|
||||||
|
if not has_write_scope(user):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail="Enable YouTube editing in Settings first."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with quota.attribute(user.id, "playlist_delete"):
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
yt.delete_playlist(pl.yt_playlist_id)
|
||||||
|
except YouTubeError:
|
||||||
|
raise HTTPException(status_code=502, detail="YouTube delete failed.")
|
||||||
db.delete(pl) # items cascade
|
db.delete(pl) # items cascade
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"deleted": playlist_id}
|
return {"deleted": playlist_id}
|
||||||
|
|
@ -309,6 +404,7 @@ def add_item(
|
||||||
db.add(
|
db.add(
|
||||||
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
PlaylistItem(playlist_id=pl.id, video_id=video_id, position=next_pos)
|
||||||
)
|
)
|
||||||
|
_mark_dirty(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
@ -328,6 +424,7 @@ def remove_item(
|
||||||
)
|
)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
db.delete(item)
|
db.delete(item)
|
||||||
|
_mark_dirty(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"playlist_id": pl.id, "video_id": video_id}
|
return {"playlist_id": pl.id, "video_id": video_id}
|
||||||
|
|
||||||
|
|
@ -353,5 +450,6 @@ def reorder_items(
|
||||||
if it is not None:
|
if it is not None:
|
||||||
it.position = pos
|
it.position = pos
|
||||||
pos += 1
|
pos += 1
|
||||||
|
_mark_dirty(pl)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"playlist_id": pl.id, "count": pos}
|
return {"playlist_id": pl.id, "count": pos}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,16 @@ from sqlalchemy import delete, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import quota
|
from app import quota
|
||||||
from app.auth import has_read_scope
|
from app.auth import has_read_scope, has_write_scope
|
||||||
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
from app.models import Channel, OAuthToken, Playlist, PlaylistItem, User, Video
|
||||||
from app.sync.videos import apply_video_details, parse_dt
|
from app.sync.videos import apply_video_details, parse_dt
|
||||||
from app.youtube.client import YouTubeClient, YouTubeError
|
from app.youtube.client import YouTubeClient, YouTubeError
|
||||||
|
|
||||||
log = logging.getLogger("subfeed.sync")
|
log = logging.getLogger("subfeed.sync")
|
||||||
|
|
||||||
|
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
|
||||||
|
WRITE_UNIT_COST = 50
|
||||||
|
|
||||||
|
|
||||||
def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
|
def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None:
|
||||||
"""Make sure every given video id exists in the catalog, fetching + ingesting the
|
"""Make sure every given video id exists in the catalog, fetching + ingesting the
|
||||||
|
|
@ -81,6 +84,19 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
)
|
)
|
||||||
).scalars()
|
).scalars()
|
||||||
}
|
}
|
||||||
|
# YouTube playlists that we already own as local (exported) playlists must not be
|
||||||
|
# re-mirrored, or every exported list would also show up as a read-only duplicate.
|
||||||
|
owned_yt_ids = set(
|
||||||
|
db.execute(
|
||||||
|
select(Playlist.yt_playlist_id).where(
|
||||||
|
Playlist.user_id == user.id,
|
||||||
|
Playlist.source == "local",
|
||||||
|
Playlist.yt_playlist_id.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
# Drop mirrors whose YouTube playlist no longer exists.
|
# Drop mirrors whose YouTube playlist no longer exists.
|
||||||
for ytid, pl in existing.items():
|
for ytid, pl in existing.items():
|
||||||
if ytid not in yt_ids:
|
if ytid not in yt_ids:
|
||||||
|
|
@ -89,7 +105,7 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
|
|
||||||
for idx, p in enumerate(yt_playlists):
|
for idx, p in enumerate(yt_playlists):
|
||||||
ytid = p.get("id")
|
ytid = p.get("id")
|
||||||
if not ytid:
|
if not ytid or ytid in owned_yt_ids:
|
||||||
continue
|
continue
|
||||||
pl = existing.get(ytid)
|
pl = existing.get(ytid)
|
||||||
if pl is None:
|
if pl is None:
|
||||||
|
|
@ -127,6 +143,154 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||||
return {"synced": synced}
|
return {"synced": synced}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_video_ids(db: Session, pl: Playlist) -> list[str]:
|
||||||
|
return list(
|
||||||
|
db.execute(
|
||||||
|
select(PlaylistItem.video_id)
|
||||||
|
.where(PlaylistItem.playlist_id == pl.id)
|
||||||
|
.order_by(PlaylistItem.position)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reorder_moves(current: list[str], desired: list[str]) -> int:
|
||||||
|
"""Count the playlistItems.update moves an insertion-sort would need to turn
|
||||||
|
`current` order into `desired` order (both already the same set of video ids).
|
||||||
|
Items already in place are skipped; this matches what apply_push actually executes."""
|
||||||
|
model = list(current)
|
||||||
|
moves = 0
|
||||||
|
for i, want in enumerate(desired):
|
||||||
|
if i < len(model) and model[i] == want:
|
||||||
|
continue
|
||||||
|
j = model.index(want, i)
|
||||||
|
model.pop(j)
|
||||||
|
model.insert(i, want)
|
||||||
|
moves += 1
|
||||||
|
return moves
|
||||||
|
|
||||||
|
|
||||||
|
def plan_push(db: Session, user: User, pl: Playlist) -> dict:
|
||||||
|
"""Compute (without writing) what pushing this local playlist to YouTube would do:
|
||||||
|
create vs update, how many inserts/deletes/reorders, the quota estimate, and whether
|
||||||
|
YouTube has diverged (items present there that local will remove). Costs read units
|
||||||
|
(1/page) for a linked playlist; nothing for a fresh export."""
|
||||||
|
desired = _local_video_ids(db, pl)
|
||||||
|
if not pl.yt_playlist_id:
|
||||||
|
ops = 1 + len(desired) # create the playlist + one insert per video
|
||||||
|
return {
|
||||||
|
"action": "create",
|
||||||
|
"to_insert": len(desired),
|
||||||
|
"to_delete": 0,
|
||||||
|
"to_reorder": 0,
|
||||||
|
"yt_extra": 0,
|
||||||
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
|
}
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
|
cur_vids = [it["video_id"] for it in current]
|
||||||
|
cur_set = set(cur_vids)
|
||||||
|
desired_set = set(desired)
|
||||||
|
to_insert = [v for v in desired if v not in cur_set]
|
||||||
|
to_delete = [v for v in cur_vids if v not in desired_set]
|
||||||
|
# After membership reconciliation, the YT order is current-minus-deletes plus inserts
|
||||||
|
# appended; the reorder pass then moves items into the desired order.
|
||||||
|
after_membership = [v for v in cur_vids if v in desired_set] + to_insert
|
||||||
|
reorders = _reorder_moves(after_membership, desired)
|
||||||
|
ops = len(to_insert) + len(to_delete) + reorders
|
||||||
|
return {
|
||||||
|
"action": "update",
|
||||||
|
"to_insert": len(to_insert),
|
||||||
|
"to_delete": len(to_delete),
|
||||||
|
"to_reorder": reorders,
|
||||||
|
"yt_extra": len(to_delete),
|
||||||
|
"units_estimate": ops * WRITE_UNIT_COST,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def push_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
||||||
|
"""Push a local playlist to YouTube: create it if needed, then reconcile membership
|
||||||
|
and order so YouTube matches local (local wins). Marks the playlist clean on success.
|
||||||
|
Caller must have checked the write scope and the quota budget."""
|
||||||
|
desired = _local_video_ids(db, pl)
|
||||||
|
inserted = deleted = reordered = created = 0
|
||||||
|
failures: list[str] = []
|
||||||
|
with YouTubeClient(db, user) as yt:
|
||||||
|
if not pl.yt_playlist_id:
|
||||||
|
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
||||||
|
db.commit()
|
||||||
|
created = 1
|
||||||
|
# Brand-new playlist: just append every video in order.
|
||||||
|
for vid in desired:
|
||||||
|
try:
|
||||||
|
yt.add_playlist_item(pl.yt_playlist_id, vid)
|
||||||
|
inserted += 1
|
||||||
|
except YouTubeError:
|
||||||
|
failures.append(vid)
|
||||||
|
pl.dirty = False
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"created": created,
|
||||||
|
"inserted": inserted,
|
||||||
|
"deleted": deleted,
|
||||||
|
"reordered": reordered,
|
||||||
|
"failures": failures,
|
||||||
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Existing link: diff against the live YouTube state.
|
||||||
|
current = list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id))
|
||||||
|
item_id_by_vid = {it["video_id"]: it["item_id"] for it in current}
|
||||||
|
cur_vids = [it["video_id"] for it in current]
|
||||||
|
desired_set = set(desired)
|
||||||
|
# Delete extras (local is authoritative).
|
||||||
|
for it in current:
|
||||||
|
if it["video_id"] not in desired_set:
|
||||||
|
try:
|
||||||
|
yt.delete_playlist_item(it["item_id"])
|
||||||
|
deleted += 1
|
||||||
|
except YouTubeError:
|
||||||
|
failures.append(it["video_id"])
|
||||||
|
# Insert missing (append; the reorder pass below fixes positions).
|
||||||
|
cur_set = set(cur_vids)
|
||||||
|
for vid in desired:
|
||||||
|
if vid not in cur_set:
|
||||||
|
try:
|
||||||
|
item_id_by_vid[vid] = yt.add_playlist_item(pl.yt_playlist_id, vid)
|
||||||
|
inserted += 1
|
||||||
|
except YouTubeError:
|
||||||
|
failures.append(vid)
|
||||||
|
# Reorder to match `desired` via insertion-sort moves over a local model.
|
||||||
|
model = [v for v in cur_vids if v in desired_set] + [
|
||||||
|
v for v in desired if v not in cur_set and v in item_id_by_vid
|
||||||
|
]
|
||||||
|
for i, want in enumerate(desired):
|
||||||
|
if want not in item_id_by_vid:
|
||||||
|
continue # failed insert; skip
|
||||||
|
if i < len(model) and model[i] == want:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
yt.move_playlist_item(item_id_by_vid[want], pl.yt_playlist_id, want, i)
|
||||||
|
reordered += 1
|
||||||
|
except YouTubeError:
|
||||||
|
failures.append(want)
|
||||||
|
continue
|
||||||
|
j = model.index(want)
|
||||||
|
model.pop(j)
|
||||||
|
model.insert(i, want)
|
||||||
|
pl.dirty = False
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"created": created,
|
||||||
|
"inserted": inserted,
|
||||||
|
"deleted": deleted,
|
||||||
|
"reordered": reordered,
|
||||||
|
"failures": failures,
|
||||||
|
"yt_playlist_id": pl.yt_playlist_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def sync_all_playlists(db: Session) -> dict:
|
def sync_all_playlists(db: Session) -> dict:
|
||||||
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
|
"""Scheduler entry point: mirror playlists for every user with a read scope + token."""
|
||||||
users = (
|
users = (
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,93 @@ class YouTubeClient:
|
||||||
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
|
f"DELETE subscriptions -> {resp.status_code}: {resp.text[:300]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def iter_playlist_items_with_ids(self, playlist_id: str) -> Iterator[dict]:
|
||||||
|
"""Yield {item_id, video_id} for each item of one of the user's playlists, in
|
||||||
|
playlist order. `item_id` is the playlistItem resource id, needed to delete or
|
||||||
|
reposition the item via the write API. OAuth (private playlists work)."""
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
params = {
|
||||||
|
"part": "contentDetails",
|
||||||
|
"playlistId": playlist_id,
|
||||||
|
"maxResults": 50,
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
data = self._get("playlistItems", params, cost=1, allow_key=False)
|
||||||
|
for item in data.get("items", []):
|
||||||
|
vid = item.get("contentDetails", {}).get("videoId")
|
||||||
|
if vid:
|
||||||
|
yield {"item_id": item.get("id"), "video_id": vid}
|
||||||
|
page_token = data.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
# --- write endpoints (OAuth only, never the API key; each costs 50 quota units) ---
|
||||||
|
def _write(self, method: str, path: str, *, params: dict, json: dict | None = None) -> dict:
|
||||||
|
headers = {"Authorization": f"Bearer {self._access_token()}"}
|
||||||
|
resp = self._http.request(
|
||||||
|
method, f"{API_BASE}/{path}", params=params, json=json, headers=headers
|
||||||
|
)
|
||||||
|
quota.record_usage(self.db, 50)
|
||||||
|
if resp.status_code not in (200, 204):
|
||||||
|
log.warning("YouTube %s %s -> %s: %s", method, path, resp.status_code, resp.text[:200])
|
||||||
|
raise YouTubeError(f"{method} {path} -> {resp.status_code}: {resp.text[:300]}")
|
||||||
|
return resp.json() if resp.content else {}
|
||||||
|
|
||||||
|
def create_playlist(self, title: str, description: str = "", privacy: str = "private") -> str:
|
||||||
|
"""Create a new YouTube playlist; returns its id. 50 units."""
|
||||||
|
data = self._write(
|
||||||
|
"POST",
|
||||||
|
"playlists",
|
||||||
|
params={"part": "snippet,status"},
|
||||||
|
json={
|
||||||
|
"snippet": {"title": title[:150], "description": description[:5000]},
|
||||||
|
"status": {"privacyStatus": privacy},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data["id"]
|
||||||
|
|
||||||
|
def add_playlist_item(self, playlist_id: str, video_id: str, position: int | None = None) -> str:
|
||||||
|
"""Append (or insert at `position`) a video into a playlist; returns the new
|
||||||
|
playlistItem id. 50 units."""
|
||||||
|
snippet = {
|
||||||
|
"playlistId": playlist_id,
|
||||||
|
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
||||||
|
}
|
||||||
|
if position is not None:
|
||||||
|
snippet["position"] = position
|
||||||
|
data = self._write(
|
||||||
|
"POST", "playlistItems", params={"part": "snippet"}, json={"snippet": snippet}
|
||||||
|
)
|
||||||
|
return data["id"]
|
||||||
|
|
||||||
|
def move_playlist_item(
|
||||||
|
self, item_id: str, playlist_id: str, video_id: str, position: int
|
||||||
|
) -> None:
|
||||||
|
"""Reposition an existing playlistItem to absolute 0-based `position`. 50 units."""
|
||||||
|
self._write(
|
||||||
|
"PUT",
|
||||||
|
"playlistItems",
|
||||||
|
params={"part": "snippet"},
|
||||||
|
json={
|
||||||
|
"id": item_id,
|
||||||
|
"snippet": {
|
||||||
|
"playlistId": playlist_id,
|
||||||
|
"position": position,
|
||||||
|
"resourceId": {"kind": "youtube#video", "videoId": video_id},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_playlist_item(self, item_id: str) -> None:
|
||||||
|
"""Remove a playlistItem from its playlist. 50 units."""
|
||||||
|
self._write("DELETE", "playlistItems", params={"id": item_id})
|
||||||
|
|
||||||
|
def delete_playlist(self, playlist_id: str) -> None:
|
||||||
|
"""Delete a whole playlist on YouTube. 50 units."""
|
||||||
|
self._write("DELETE", "playlists", params={"id": playlist_id})
|
||||||
|
|
||||||
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
def get_videos(self, video_ids: list[str]) -> list[dict]:
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
for batch in _chunks(video_ids, 50):
|
for batch in _chunks(video_ids, 50):
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ export default function App() {
|
||||||
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
) : page === "stats" && meQuery.data!.role === "admin" ? (
|
||||||
<Stats />
|
<Stats />
|
||||||
) : page === "playlists" ? (
|
) : page === "playlists" ? (
|
||||||
<Playlists />
|
<Playlists canWrite={meQuery.data!.can_write} />
|
||||||
) : (
|
) : (
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
X,
|
X,
|
||||||
Youtube,
|
Youtube,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -107,7 +108,7 @@ function Row({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Playlists() {
|
export default function Playlists({ canWrite }: { canWrite: boolean }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
|
|
@ -163,7 +164,63 @@ export default function Playlists() {
|
||||||
// YouTube-mirrored playlists are read-only here until the write-back phase.
|
// YouTube-mirrored playlists are read-only here until the write-back phase.
|
||||||
const mirrored = detail?.source === "youtube";
|
const mirrored = detail?.source === "youtube";
|
||||||
const editable = !builtin && !mirrored;
|
const editable = !builtin && !mirrored;
|
||||||
|
const linked = editable && !!detail?.yt_playlist_id;
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [pushing, setPushing] = useState(false);
|
||||||
|
|
||||||
|
async function pushToYoutube() {
|
||||||
|
if (selectedId == null || !detail || pushing) return;
|
||||||
|
setPushing(true);
|
||||||
|
try {
|
||||||
|
const plan = await api.playlistPushPlan(selectedId);
|
||||||
|
if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
|
||||||
|
notify({ level: "info", message: t("playlists.pushUpToDate") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!plan.affordable) {
|
||||||
|
notify({
|
||||||
|
level: "warning",
|
||||||
|
message: t("playlists.pushNoQuota", {
|
||||||
|
left: plan.remaining_today,
|
||||||
|
units: plan.units_estimate,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let message =
|
||||||
|
plan.action === "create"
|
||||||
|
? t("playlists.pushPlanCreate", {
|
||||||
|
count: plan.to_insert,
|
||||||
|
units: plan.units_estimate,
|
||||||
|
left: plan.remaining_today,
|
||||||
|
})
|
||||||
|
: t("playlists.pushPlanUpdate", {
|
||||||
|
insert: plan.to_insert,
|
||||||
|
del: plan.to_delete,
|
||||||
|
reorder: plan.to_reorder,
|
||||||
|
units: plan.units_estimate,
|
||||||
|
left: plan.remaining_today,
|
||||||
|
});
|
||||||
|
if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
|
||||||
|
const ok = await confirm({
|
||||||
|
title: t("playlists.pushTitle"),
|
||||||
|
message,
|
||||||
|
confirmLabel: t("playlists.pushConfirm"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const r = await api.pushPlaylist(selectedId);
|
||||||
|
refreshAll();
|
||||||
|
if (r.failures.length) {
|
||||||
|
notify({ level: "warning", message: t("playlists.pushPartial", { count: r.failures.length }) });
|
||||||
|
} else {
|
||||||
|
notify({ level: "success", message: t("playlists.pushDone") });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify({ level: "warning", message: t("playlists.pushFailed") });
|
||||||
|
} finally {
|
||||||
|
setPushing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function syncYoutube() {
|
async function syncYoutube() {
|
||||||
if (syncing) return;
|
if (syncing) return;
|
||||||
|
|
@ -232,12 +289,27 @@ export default function Playlists() {
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
// For a YouTube-linked playlist, offer to delete it on YouTube too (Cancel = here only).
|
||||||
|
let onYoutube = false;
|
||||||
|
if (linked && canWrite) {
|
||||||
|
onYoutube = await confirm({
|
||||||
|
title: t("playlists.deleteOnYoutubeTitle"),
|
||||||
|
message: t("playlists.deleteOnYoutubeMsg", { name: detail.name }),
|
||||||
|
confirmLabel: t("playlists.deleteOnYoutubeConfirm"),
|
||||||
|
cancelLabel: t("playlists.deleteHereOnly"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
const deletedId = selectedId;
|
const deletedId = selectedId;
|
||||||
// Pick the next selection from what's left *now* (don't go via null, or the
|
// Pick the next selection from what's left *now* (don't go via null, or the
|
||||||
// auto-select effect would re-pick the just-deleted id from the stale list).
|
// auto-select effect would re-pick the just-deleted id from the stale list).
|
||||||
const remaining = playlists.filter((p) => p.id !== deletedId);
|
const remaining = playlists.filter((p) => p.id !== deletedId);
|
||||||
setSelectedId(remaining[0]?.id ?? null);
|
setSelectedId(remaining[0]?.id ?? null);
|
||||||
await api.deletePlaylist(deletedId);
|
try {
|
||||||
|
await api.deletePlaylist(deletedId, onYoutube);
|
||||||
|
} catch {
|
||||||
|
notify({ level: "warning", message: t("playlists.deleteYtFailed") });
|
||||||
|
}
|
||||||
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
qc.removeQueries({ queryKey: ["playlist", deletedId] });
|
||||||
qc.invalidateQueries({ queryKey: ["playlists"] });
|
qc.invalidateQueries({ queryKey: ["playlists"] });
|
||||||
}
|
}
|
||||||
|
|
@ -287,8 +359,10 @@ export default function Playlists() {
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1 text-[13px] text-fg">
|
<div className="flex items-center gap-1 text-[13px] text-fg">
|
||||||
<span className="truncate">{plName(pl)}</span>
|
<span className="truncate">{plName(pl)}</span>
|
||||||
{pl.source === "youtube" && (
|
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
<Youtube
|
||||||
|
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-muted">
|
<div className="text-[11px] text-muted">
|
||||||
|
|
@ -373,6 +447,27 @@ export default function Playlists() {
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
|
||||||
</button>
|
</button>
|
||||||
|
{editable && canWrite && (
|
||||||
|
<button
|
||||||
|
onClick={pushToYoutube}
|
||||||
|
disabled={pushing}
|
||||||
|
title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition disabled:opacity-40 ${
|
||||||
|
detail.dirty
|
||||||
|
? "border-accent text-accent hover:bg-accent/10"
|
||||||
|
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pushing ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : linked ? (
|
||||||
|
<Youtube className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
{linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{editable && (
|
{editable && (
|
||||||
<button
|
<button
|
||||||
onClick={deletePlaylist}
|
onClick={deletePlaylist}
|
||||||
|
|
@ -381,6 +476,11 @@ export default function Playlists() {
|
||||||
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
<Trash2 className="w-3.5 h-3.5" /> {t("playlists.delete")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{linked && detail.dirty && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-accent">
|
||||||
|
{t("playlists.unsynced")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{mirrored && (
|
{mirrored && (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
|
<span className="inline-flex items-center gap-1.5 text-[11px] px-2.5 py-1.5 rounded-lg text-muted">
|
||||||
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}
|
<Youtube className="w-3.5 h-3.5" /> {t("playlists.ytReadonly")}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,23 @@
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"rename": "Umbenennen",
|
"rename": "Umbenennen",
|
||||||
"confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.",
|
"confirmDelete": "Die Wiedergabeliste „{{name}}“ löschen? Kann nicht rückgängig gemacht werden.",
|
||||||
"addToPlaylist": "Zur Wiedergabeliste hinzufügen"
|
"addToPlaylist": "Zur Wiedergabeliste hinzufügen",
|
||||||
|
"exportToYoutube": "Zu YouTube exportieren",
|
||||||
|
"pushToYoutube": "Mit YouTube synchronisieren",
|
||||||
|
"unsynced": "Nicht synchronisierte Änderungen",
|
||||||
|
"pushTitle": "Mit YouTube synchronisieren",
|
||||||
|
"pushConfirm": "Synchronisieren",
|
||||||
|
"pushPlanCreate": "Eine neue YouTube-Wiedergabeliste mit {{count}} Videos erstellen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
|
||||||
|
"pushPlanUpdate": "Auf YouTube aktualisieren — {{insert}} hinzufügen, {{del}} entfernen, {{reorder}} neu anordnen? (~{{units}} Kontingenteinheiten; heute noch {{left}} übrig.)",
|
||||||
|
"pushDiverged": "{{count}} Video(s), die derzeit auf YouTube sind, werden entfernt.",
|
||||||
|
"pushNoQuota": "Nicht genug YouTube-Kontingent für heute ({{left}}) für diese Synchronisierung (~{{units}} benötigt). Versuche es morgen erneut.",
|
||||||
|
"pushUpToDate": "Bereits mit YouTube synchronisiert.",
|
||||||
|
"pushDone": "Mit YouTube synchronisiert ✓",
|
||||||
|
"pushPartial": "Synchronisiert, aber {{count}} Element(e) wurden übersprungen.",
|
||||||
|
"pushFailed": "Synchronisierung mit YouTube fehlgeschlagen.",
|
||||||
|
"deleteOnYoutubeTitle": "Auch auf YouTube löschen?",
|
||||||
|
"deleteOnYoutubeMsg": "„{{name}}“ auch aus deinem YouTube-Konto löschen? Wähle „Nur hier“, um sie auf YouTube zu behalten.",
|
||||||
|
"deleteOnYoutubeConfirm": "Auf YouTube löschen",
|
||||||
|
"deleteHereOnly": "Nur hier",
|
||||||
|
"deleteYtFailed": "Löschen auf YouTube fehlgeschlagen; nur hier entfernt."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,23 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
|
"confirmDelete": "Delete the playlist “{{name}}”? This can't be undone.",
|
||||||
"addToPlaylist": "Add to playlist"
|
"addToPlaylist": "Add to playlist",
|
||||||
|
"exportToYoutube": "Export to YouTube",
|
||||||
|
"pushToYoutube": "Sync to YouTube",
|
||||||
|
"unsynced": "Unsynced changes",
|
||||||
|
"pushTitle": "Sync to YouTube",
|
||||||
|
"pushConfirm": "Sync",
|
||||||
|
"pushPlanCreate": "Create a new YouTube playlist with {{count}} videos? (~{{units}} quota units; {{left}} left today.)",
|
||||||
|
"pushPlanUpdate": "Update on YouTube — add {{insert}}, remove {{del}}, reorder {{reorder}}? (~{{units}} quota units; {{left}} left today.)",
|
||||||
|
"pushDiverged": "{{count}} video(s) currently on YouTube will be removed.",
|
||||||
|
"pushNoQuota": "Not enough YouTube quota left today ({{left}}) for this sync (~{{units}} needed). Try again tomorrow.",
|
||||||
|
"pushUpToDate": "Already in sync with YouTube.",
|
||||||
|
"pushDone": "Synced to YouTube ✓",
|
||||||
|
"pushPartial": "Synced, but {{count}} item(s) were skipped.",
|
||||||
|
"pushFailed": "Couldn't sync to YouTube.",
|
||||||
|
"deleteOnYoutubeTitle": "Delete on YouTube too?",
|
||||||
|
"deleteOnYoutubeMsg": "Also delete “{{name}}” from your YouTube account? Choose “Here only” to keep it on YouTube.",
|
||||||
|
"deleteOnYoutubeConfirm": "Delete on YouTube",
|
||||||
|
"deleteHereOnly": "Here only",
|
||||||
|
"deleteYtFailed": "Couldn't delete on YouTube; removed here only."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,23 @@
|
||||||
"delete": "Törlés",
|
"delete": "Törlés",
|
||||||
"rename": "Átnevezés",
|
"rename": "Átnevezés",
|
||||||
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
|
"confirmDelete": "Törlöd a(z) „{{name}}” listát? Nem vonható vissza.",
|
||||||
"addToPlaylist": "Hozzáadás listához"
|
"addToPlaylist": "Hozzáadás listához",
|
||||||
|
"exportToYoutube": "Exportálás YouTube-ra",
|
||||||
|
"pushToYoutube": "Szinkron YouTube-ra",
|
||||||
|
"unsynced": "Nem szinkronizált változások",
|
||||||
|
"pushTitle": "Szinkron YouTube-ra",
|
||||||
|
"pushConfirm": "Szinkron",
|
||||||
|
"pushPlanCreate": "Létrehozol egy új YouTube-listát {{count}} videóval? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
|
"pushPlanUpdate": "Frissítés a YouTube-on — {{insert}} hozzáadása, {{del}} eltávolítása, {{reorder}} átrendezése? (~{{units}} kvótaegység; ma még {{left}} maradt.)",
|
||||||
|
"pushDiverged": "{{count}} videó, amely jelenleg a YouTube-on van, el lesz távolítva.",
|
||||||
|
"pushNoQuota": "Nincs elég YouTube-kvóta mára ({{left}}) ehhez a szinkronhoz (~{{units}} kell). Próbáld újra holnap.",
|
||||||
|
"pushUpToDate": "Már szinkronban van a YouTube-bal.",
|
||||||
|
"pushDone": "Szinkronizálva a YouTube-ra ✓",
|
||||||
|
"pushPartial": "Szinkronizálva, de {{count}} elem kimaradt.",
|
||||||
|
"pushFailed": "Nem sikerült a YouTube-ra szinkronizálás.",
|
||||||
|
"deleteOnYoutubeTitle": "Törlöd a YouTube-on is?",
|
||||||
|
"deleteOnYoutubeMsg": "Törlöd a(z) „{{name}}” listát a YouTube-fiókodból is? A „Csak itt” megtartja a YouTube-on.",
|
||||||
|
"deleteOnYoutubeConfirm": "Törlés a YouTube-on",
|
||||||
|
"deleteHereOnly": "Csak itt",
|
||||||
|
"deleteYtFailed": "Nem sikerült törölni a YouTube-on; csak itt lett eltávolítva."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ export interface Playlist {
|
||||||
kind: string; // "user" | "watch_later"
|
kind: string; // "user" | "watch_later"
|
||||||
source: string; // "local" | "youtube"
|
source: string; // "local" | "youtube"
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
|
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
||||||
item_count: number;
|
item_count: number;
|
||||||
cover_thumbnail: string | null;
|
cover_thumbnail: string | null;
|
||||||
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
has_video?: boolean; // only set when listed with ?contains=<video_id>
|
||||||
|
|
@ -86,9 +87,30 @@ export interface PlaylistDetail {
|
||||||
kind: string;
|
kind: string;
|
||||||
source: string;
|
source: string;
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
|
dirty: boolean;
|
||||||
items: Video[];
|
items: Video[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PushPlan {
|
||||||
|
action: "create" | "update";
|
||||||
|
to_insert: number;
|
||||||
|
to_delete: number;
|
||||||
|
to_reorder: number;
|
||||||
|
yt_extra: number; // items on YouTube that the push would remove (divergence)
|
||||||
|
units_estimate: number;
|
||||||
|
remaining_today: number;
|
||||||
|
affordable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushResult {
|
||||||
|
created: number;
|
||||||
|
inserted: number;
|
||||||
|
deleted: number;
|
||||||
|
reordered: number;
|
||||||
|
failures: string[];
|
||||||
|
yt_playlist_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FeedFilters {
|
export interface FeedFilters {
|
||||||
tags: number[];
|
tags: number[];
|
||||||
tagMode: "or" | "and";
|
tagMode: "or" | "and";
|
||||||
|
|
@ -323,7 +345,8 @@ export const api = {
|
||||||
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
|
||||||
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
renamePlaylist: (id: number, name: string): Promise<Playlist> =>
|
||||||
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
|
||||||
deletePlaylist: (id: number) => req(`/api/playlists/${id}`, { method: "DELETE" }),
|
deletePlaylist: (id: number, onYoutube = false) =>
|
||||||
|
req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
|
||||||
addToPlaylist: (id: number, videoId: string) =>
|
addToPlaylist: (id: number, videoId: string) =>
|
||||||
req(`/api/playlists/${id}/items`, {
|
req(`/api/playlists/${id}/items`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -346,6 +369,10 @@ export const api = {
|
||||||
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
|
||||||
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
|
||||||
req("/api/playlists/sync-youtube", { method: "POST" }),
|
req("/api/playlists/sync-youtube", { method: "POST" }),
|
||||||
|
playlistPushPlan: (id: number): Promise<PushPlan> =>
|
||||||
|
req(`/api/playlists/${id}/push-plan`),
|
||||||
|
pushPlaylist: (id: number): Promise<PushResult> =>
|
||||||
|
req(`/api/playlists/${id}/push`, { method: "POST" }),
|
||||||
|
|
||||||
// --- quota usage ---
|
// --- quota usage ---
|
||||||
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue