fix: a first watch state and a first Watch later survive two requests at once

The first checkpoint of an item and the final flush beside it both read no
state and both inserted; the loser failed on uq_plex_user_item. Two first saves
each made a Watch later list. Both are created by an ON CONFLICT DO NOTHING
insert and then read; a partial unique index holds one Watch later per user, and
the migration folds an existing second list into the first. Plex keeps one
per-item state map (rollup.item_state_map).
This commit is contained in:
2026-10-20 00:45:22 +02:00
parent b376373af5
commit 3a5212cd8a
11 changed files with 215 additions and 40 deletions
@@ -0,0 +1,55 @@
"""One Watch later list per user, held by the database
Revision ID: 0112_one_watch_later
Revises: 0111_audit_request_id
Create Date: 2026-10-20
Watch later was made on a user's first save by a read then an insert, so two first saves at once
each made a list, and the reader then picked between them at random. A partial unique index holds
"one per user" now. An instance that already has a second list keeps the oldest: the others' videos
move into it (after its own, in their order; one it already holds is not added twice), then they go.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0112_one_watch_later"
down_revision: str | None = "0111_audit_request_id"
branch_labels: str | None = None
depends_on: str | None = None
_EXTRA = """
SELECT p.id, k.keep_id
FROM playlists p
JOIN (
SELECT user_id, min(id) AS keep_id FROM playlists WHERE kind = 'watch_later' GROUP BY user_id
) k ON k.user_id = p.user_id
WHERE p.kind = 'watch_later' AND p.id <> k.keep_id
"""
def upgrade() -> None:
op.execute(
f"""
INSERT INTO playlist_items (playlist_id, video_id, position, added_at)
SELECT e.keep_id, i.video_id,
(SELECT coalesce(max(position), -1) FROM playlist_items WHERE playlist_id = e.keep_id)
+ row_number() OVER (PARTITION BY e.keep_id ORDER BY i.playlist_id, i.position, i.id),
i.added_at
FROM playlist_items i
JOIN ({_EXTRA}) e ON e.id = i.playlist_id
ON CONFLICT ON CONSTRAINT uq_playlist_video DO NOTHING
"""
)
op.execute(f"DELETE FROM playlists WHERE id IN (SELECT id FROM ({_EXTRA}) e)")
op.create_index(
"uq_playlist_watch_later",
"playlists",
["user_id"],
unique=True,
postgresql_where=sa.text("kind = 'watch_later'"),
)
def downgrade() -> None:
op.drop_index("uq_playlist_watch_later", table_name="playlists")