feat: expose and bulk-update subscription group memberships - #80
Conversation
Add an account-scoped read model for subscription channels and their complete group assignments so the frontend can render and edit memberships without issuing one filtered subscription request per group. Constraint: Keep the shared SubscriptionItem unchanged because feeds, backups, imports, RSS, and recommendations reuse it Rejected: Add groupIds to SubscriptionItem | would query and serialize group data in unrelated paths Rejected: Fetch each group projection from the frontend | creates N requests for N groups Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep groupIds account-scoped and include empty arrays for ungrouped subscriptions Tested: JDK 25 clean check, 1,132 tests, shadowJar, OpenAPI validation, and live HTTP QA Not-tested: Frontend integration is intentionally deferred
Let clients add or remove many subscribed channels from one group in a single account-scoped transaction while preserving the shipped singular request forms. Constraint: The singular membership contract shipped in v1.6.0 and must remain compatible Rejected: Cross-group membership delta endpoint | bulk organization is naturally scoped to one group Rejected: Repeated delete query parameters | encoded channel URLs can exceed practical URL limits Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep batch writes bounded, atomic, idempotent, and protected by SubscriptionMutationLock Tested: JDK 25 clean check, 1,134 tests, coverage, shadowJar, OpenAPI validation, focused retry regression, and live HTTP QA Not-tested: Frontend and Android integration are intentionally deferred
Return a client error when a DELETE supplies both the legacy URL query parameter and a JSON membership body so the server cannot silently apply only part of the requested mutation. Constraint: Preserve both shipped query-only deletion and the new body-only batch contract Rejected: Give the query parameter precedence | silently ignores a valid batch body Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the two DELETE input forms mutually exclusive at the HTTP boundary Tested: Red-green route regression, JDK 25 check with 1,135 tests, coverage, shadowJar, OpenAPI validation, and live HTTP QA Not-tested: Frontend integration remains intentionally deferred
|
would you preferr over-load extended for singleton and arrays or do we prefer |
Reject oversized membership bodies before they can be fully buffered and validate each submitted channel URL at the API boundary. Treat any non-empty DELETE body as present so whitespace cannot bypass query/body exclusivity. Constraint: Preserve the existing singular and batch membership contracts Rejected: Rely on Content-Length alone | chunked requests can omit the header Confidence: high Scope-risk: narrow Directive: Keep request limits aligned with the OpenAPI membership schemas Tested: ./gradlew --no-daemon clean check shadowJar validateOpenApi Tested: Live HTTP checks for body limits, URL length, and DELETE ambiguity
Priveetee
left a comment
There was a problem hiding this comment.
Hey @kapdon, thx again for pushing this forward!
I reviewed the current head locally. The API shape looks good to me: keeping the existing endpoint while adding the batch form preserves compatibility, and the account scoping, canonicalization, deduplication and transaction handling look solid. The focused tests and full checks pass locally.
I’m not ready to merge this yet because I found one regression:
SubscriptionsService.getAll() now repairs avatars before applying the subscription filter. Previously, filtered requests repaired only the selected subscriptions. With the 25-avatar repair limit, /subscriptions?groupId=... or ungrouped=true can spend the repair budget on unrelated channels, leaving the selected channels with empty avatars. It also updates unrelated subscriptions during a filtered read.
Could u move the avatar repair after the selection filter and add a regression test for this case?
One more consistency concern: getAllWithGroupMemberships() reads memberships and subscriptions without the existing subscription mutation lock. A concurrent group update could make the response contain stale or incomplete groupIds. Please either protect this projection with the same lock or use an explicit consistent snapshot.
For your endpoint question, I’d keep the current overloaded endpoint rather than split it into /channel and /channels. It preserves existing clients and keeps the operation type explicit in the request body.
Thx again, this is close. I’d be happy to review the next revision.
Summary
Adds the follow-up Server contract needed for efficient subscription-group organization in TypeType-Video/TypeType#172.
GET /subscriptions/group-memberships;groupIds;groupIds: []for ungrouped subscriptions;PUT /subscriptions/groups/{groupId}/channelswith a boundedchannelUrlsarray;DELETE /subscriptions/groups/{groupId}/channelswith singular or bounded batch JSON bodies;?url=contract;Why a separate membership projection
The frontend needs a channel-centric view to render each subscription with all of its group assignments. Reusing
SubscriptionItemwould add group queries and fields to unrelated feed, backup, import, RSS, and recommendation paths. Fetching every group separately would instead require N requests for N groups.This endpoint keeps the shared model unchanged and provides the full account-scoped projection in one request. It is intentionally unpaginated, matching the existing full subscription snapshot used by clients; search and filtering can remain local over the cached data.
API changes
GET /subscriptions/group-membershipsPUT /subscriptions/groups/{groupId}/channels{ "channelUrl": "..." }{ "channelUrls": ["...", "..."] }, 1 to 500 entriesDELETE /subscriptions/groups/{groupId}/channels?url=...{ "channelUrl": "..." }{ "channelUrls": ["...", "..."] }, 1 to 500 entriesThe handwritten OpenAPI contract documents the projection and both singular and batch mutation forms.
Commit structure
2e93c961a3a1530aBoth commits stay below the requested 290-insertion review limit.
Verification
Exact head:
a3a1530a92075ca8ad377fc2fad1da6ed58800efUsing the required JDK 25 toolchain:
Live HTTP QA against the production shadow JAR with disposable PostgreSQL and Dragonfly verified:
groupIds;Component follow-up
A separate TypeType-Frontend PR will consume this projection for group management and bulk channel assignment. No Token, Downloader, or Player change is required.