fix(playlists): a refused push spends none of the push allowance

The cooldown was checked before the list was found pushable and before its sync claim, so Watch later
or a list another sync held used up the push allowance. It now counts only a push that runs. The
docstring that called a YouTube mirror unpushable is corrected: its local edits sync back.
This commit is contained in:
2026-11-07 21:48:38 +01:00
parent 26ed8855c9
commit fc65b8ab83
2 changed files with 48 additions and 4 deletions
+6 -4
View File
@@ -261,8 +261,9 @@ def _check_push_cooldown(user: User) -> None:
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."""
"""A playlist the user may push to YouTube: a local one (created there on its first push) or a
mirror of their own YouTube list (its local edits merged back). Only the built-in Watch later is
never pushable."""
pl = _own_playlist(db, user, playlist_id)
if pl.kind == "watch_later":
raise _refused(400, "watch_later_not_pushable")
@@ -278,8 +279,8 @@ def push_plan(
"""Dry run: what a two-way 'Sync to YouTube' merge would do (what each side gains/loses) +
the quota estimate, so the UI can show a confirm. For a linked playlist this reads the live
YouTube state (cheap, 1 unit/page). Write scope is required — syncing is a YouTube write."""
_check_push_cooldown(user)
pl = _pushable(db, user, playlist_id)
_check_push_cooldown(user) # after the refusal: a list that cannot be pushed spends nothing
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_merge(db, user, pl)
@@ -299,13 +300,14 @@ def push(
"""Two-way sync a local playlist with YouTube (create it on first push, else 3-way merge so
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
big sync can't strand half-applied. Write scope required (require_write_scope)."""
_check_push_cooldown(user)
pl = _pushable(db, user, playlist_id)
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
# client's own in-flight flag only covers the menus of one page.
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
# Spent only by a push that runs, as Revert's pull: a busy list or Watch later costs nothing.
_check_push_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
# Read the live playlist ONCE (linked playlists only) and share it with both the