Files
pipu-music/services/api/internal/social/activity.go
T
mrleo1nid 11a16cd023
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
feat(social): add listening activity, shared rooms and modern chat
2026-10-23 10:45:40 +03:00

211 lines
7.0 KiB
Go

package social
import (
"context"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
func validateActivity(a gen.ListeningActivityInput) error {
if a.DeviceId == uuid.Nil || a.Sequence < 1 {
return invalid()
}
if a.PositionMs != nil && (*a.PositionMs < 0 || *a.PositionMs > 86400000) {
return invalid()
}
if a.Context != "other" && a.Context != "wave" && a.Context != "playlist" {
return invalid()
}
if a.Playing && (a.TrackId == nil || *a.TrackId == uuid.Nil) {
return invalid()
}
if a.Context == "playlist" && (a.PlaylistId == nil || *a.PlaylistId == uuid.Nil) {
return invalid()
}
if a.Context != "playlist" && a.PlaylistId != nil {
return invalid()
}
return nil
}
func (h *Handler) SetFriendsRecommendations(ctx context.Context, r gen.SetFriendsRecommendationsRequestObject) (gen.SetFriendsRecommendationsResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
_, e = h.store.pool.Exec(ctx, `UPDATE users SET friends_recommendations=$2 WHERE id=$1`, id, r.Body.Enabled)
return gen.SetFriendsRecommendations204Response{}, e
}
func (h *Handler) PublishListeningActivity(ctx context.Context, r gen.PublishListeningActivityRequestObject) (gen.PublishListeningActivityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
if e = validateActivity(*r.Body); e != nil {
return nil, e
}
if e = h.store.publishActivity(ctx, id, *r.Body); e != nil {
return nil, e
}
return gen.PublishListeningActivity204Response{}, nil
}
func (h *Handler) SetActivityVisibility(ctx context.Context, r gen.SetActivityVisibilityRequestObject) (gen.SetActivityVisibilityResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
if r.Body == nil {
return nil, invalid()
}
v := r.Body.Visibility
if v != "nobody" && v != "friends" && v != "everyone" {
return nil, invalid()
}
tx, e := h.store.pool.Begin(ctx)
if e != nil {
return nil, e
}
defer tx.Rollback(ctx)
if _, e = tx.Exec(ctx, `UPDATE users SET activity_visibility=$2 WHERE id=$1`, id, v); e != nil {
return nil, e
}
if v == "nobody" {
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1`, id); e != nil {
return nil, e
}
}
if e = tx.Commit(ctx); e != nil {
return nil, e
}
return gen.SetActivityVisibility204Response{}, nil
}
func (h *Handler) GetActivityPlaylist(ctx context.Context, r gen.GetActivityPlaylistRequestObject) (gen.GetActivityPlaylistResponseObject, error) {
id, e := userID(ctx)
if e != nil {
return nil, e
}
activity, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
a := activity[r.UserId]
if a == nil || a.PlaylistId == nil {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
p, e := h.playlists.ReadSocialPlaylist(ctx, *a.PlaylistId)
if e != nil {
return nil, e
}
// Recheck after projecting the playlist: a concurrent privacy change must close the view.
current, e := h.store.activities(ctx, id, []uuid.UUID{r.UserId})
if e != nil {
return nil, e
}
now := current[r.UserId]
if now == nil || now.PlaylistId == nil || *now.PlaylistId != *a.PlaylistId {
return nil, apperr.NotFound("social.activity_unavailable", "activity unavailable")
}
return gen.GetActivityPlaylist200JSONResponse(p), nil
}
func (s *Store) publishActivity(ctx context.Context, id uuid.UUID, a gen.ListeningActivityInput) error {
tx, e := s.pool.Begin(ctx)
if e != nil {
return e
}
defer tx.Rollback(ctx)
var visibility string
if e = tx.QueryRow(ctx, `SELECT activity_visibility FROM users WHERE id=$1 FOR UPDATE`, id).Scan(&visibility); e != nil {
return e
}
if visibility == "nobody" {
return tx.Commit(ctx)
}
var track, playlist *uuid.UUID
kind := "other"
if a.Playing {
var exists bool
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM tracks WHERE id=$1)`, a.TrackId).Scan(&exists); e != nil {
return e
}
if !exists {
return invalid()
}
track = a.TrackId
kind = string(a.Context)
if kind == "playlist" {
if e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM playlists WHERE id=$2 AND (user_id=$1 OR share_id IS NOT NULL))`, id, a.PlaylistId).Scan(&exists); e != nil {
return e
}
if exists {
playlist = a.PlaylistId
} else {
kind = "other"
}
}
}
position := 0
if a.PositionMs != nil {
position = *a.PositionMs
}
_, e = tx.Exec(ctx, `INSERT INTO listening_activity(user_id,device_id,sequence,track_id,context,playlist_id,position_ms) VALUES($1,$2,$3,$4,$5,$6,$7)
ON CONFLICT(user_id,device_id) DO UPDATE SET sequence=EXCLUDED.sequence,track_id=EXCLUDED.track_id,context=EXCLUDED.context,playlist_id=EXCLUDED.playlist_id,position_ms=EXCLUDED.position_ms,updated_at=now()
WHERE EXCLUDED.sequence>listening_activity.sequence`, id, a.DeviceId, a.Sequence, track, kind, playlist, position)
if e != nil {
return e
}
if _, e = tx.Exec(ctx, `DELETE FROM listening_activity WHERE user_id=$1 AND updated_at<now()-interval '1 day'`, id); e != nil {
return e
}
return tx.Commit(ctx)
}
func (s *Store) activities(ctx context.Context, viewer uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]*gen.ListeningActivity, error) {
out := map[uuid.UUID]*gen.ListeningActivity{}
if len(ids) == 0 {
return out, nil
}
rows, e := s.pool.Query(ctx, `SELECT u.id,t.id,t.title,
COALESCE((SELECT string_agg(ar.name,', ' ORDER BY ta.position) FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=t.id AND ta.role='main'),''),
CASE WHEN a.context='playlist' AND p.id IS NULL THEN 'other' ELSE a.context END,p.id,p.name
FROM users u JOIN LATERAL(SELECT * FROM listening_activity la WHERE la.user_id=u.id AND la.track_id IS NOT NULL AND la.updated_at>now()-interval '90 seconds' ORDER BY la.updated_at DESC,la.device_id LIMIT 1) a ON true
JOIN tracks t ON t.id=a.track_id
LEFT JOIN playlists p ON p.id=a.playlist_id AND (p.user_id=u.id OR p.share_id IS NOT NULL)
WHERE u.id=ANY($2::uuid[]) AND NOT u.is_blocked
AND (u.id=$1 OR u.activity_visibility='everyone' OR (u.activity_visibility='friends' AND EXISTS(SELECT 1 FROM friendships f WHERE f.low_id=LEAST($1::uuid,u.id) AND f.high_id=GREATEST($1::uuid,u.id) AND f.accepted)))
AND NOT EXISTS(SELECT 1 FROM social_blocks b WHERE (b.user_id=$1 AND b.peer_id=u.id) OR (b.user_id=u.id AND b.peer_id=$1))`, viewer, ids)
if e != nil {
return nil, e
}
defer rows.Close()
for rows.Next() {
var id uuid.UUID
var a gen.ListeningActivity
if e = rows.Scan(&id, &a.TrackId, &a.TrackTitle, &a.Artist, &a.Context, &a.PlaylistId, &a.PlaylistName); e != nil {
return nil, e
}
out[id] = &a
}
return out, rows.Err()
}
func (s *Store) attachActivity(ctx context.Context, viewer uuid.UUID, people []gen.SocialPerson) error {
ids := make([]uuid.UUID, 0, len(people))
for _, p := range people {
ids = append(ids, p.Id)
}
activities, e := s.activities(ctx, viewer, ids)
if e != nil {
return e
}
for i := range people {
people[i].Activity = activities[people[i].Id]
}
return nil
}