package uploads import ( "context" "errors" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // EnsureReleaseGroups assigns a stable membership once the complete batch has been processed. // Metadata confirmation cannot accidentally merge two groups or change another group's job. func (st *Store) EnsureReleaseGroups(ctx context.Context, batch uuid.UUID) error { tx, err := st.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() var cover *string if err = tx.QueryRow(ctx, `SELECT cover_path FROM upload_batches WHERE id=$1 FOR UPDATE`, batch).Scan(&cover); err != nil { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return err } var pending bool if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM upload_files WHERE batch_id=$1 AND status IN ('uploading','processing'))`, batch).Scan(&pending); err != nil { return err } if pending { return tx.Commit(ctx) } rows, err := tx.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 AND f.release_group_id IS NULL ORDER BY f.id FOR UPDATE OF f`, batch) if err != nil { return err } files := []File{} for rows.Next() { f, e := scanFile(rows) if e != nil { rows.Close() return e } files = append(files, f) } err = rows.Err() rows.Close() if err != nil { return err } drafts := groupReleaseFiles(files) var existing int if err = tx.QueryRow(ctx, `SELECT count(*) FROM upload_release_groups WHERE batch_id=$1`, batch).Scan(&existing); err != nil { return err } for _, draft := range drafts { group := newID() var groupCover *string if existing == 0 && len(drafts) == 1 { groupCover = cover } err = tx.QueryRow(ctx, `INSERT INTO upload_release_groups(id,batch_id,grouping_key,artist,album,cover_path) VALUES($1,$2,$3,$4,$5,$6) ON CONFLICT(batch_id,grouping_key) DO UPDATE SET grouping_key=EXCLUDED.grouping_key RETURNING id`, group, batch, draft.Key, draft.Artist, draft.Album, groupCover).Scan(&group) if err != nil { return err } if _, err = tx.Exec(ctx, `UPDATE upload_files SET release_group_id=$2 WHERE batch_id=$1 AND id=ANY($3::uuid[]) AND release_group_id IS NULL`, batch, group, draft.Files); err != nil { return err } if _, err = tx.Exec(ctx, `INSERT INTO upload_release_jobs(batch_id,group_key) VALUES($1,$2) ON CONFLICT DO NOTHING`, batch, group.String()); err != nil { return err } } if _, err = tx.Exec(ctx, `DELETE FROM upload_release_jobs WHERE batch_id=$1 AND group_key=''`, batch); err != nil { return err } return tx.Commit(ctx) } func (st *Store) ReleaseGroups(ctx context.Context, batch uuid.UUID) ([]ReleaseGroup, error) { rows, err := st.pool.Query(ctx, `SELECT g.id::text,g.artist,g.album,g.cover_path IS NOT NULL,coalesce(j.state,'queued'),array_agg(f.id ORDER BY f.id) FROM upload_release_groups g JOIN upload_files f ON f.release_group_id=g.id LEFT JOIN upload_release_jobs j ON j.batch_id=g.batch_id AND j.group_key=g.id::text WHERE g.batch_id=$1 AND f.status NOT IN ('withdrawn','rejected','expired','invalid') GROUP BY g.id,j.state ORDER BY min(f.id::text)`, batch) if err != nil { return nil, err } defer rows.Close() out := []ReleaseGroup{} for rows.Next() { var g ReleaseGroup if err = rows.Scan(&g.ID, &g.Artist, &g.Album, &g.HasCover, &g.State, &g.FileIDs); err != nil { return nil, err } out = append(out, g) } return out, rows.Err() } func (st *Store) resolveGroupKey(ctx context.Context, batch uuid.UUID, key string) (string, error) { if key != "" { return key, nil } err := st.pool.QueryRow(ctx, `SELECT CASE WHEN count(*)=1 THEN min(id::text) ELSE '' END FROM upload_release_groups WHERE batch_id=$1`, batch).Scan(&key) return key, err } func (st *Store) AllInReleaseGroup(ctx context.Context, batch uuid.UUID, group string) ([]File, error) { rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+` WHERE f.batch_id=$1 AND f.release_group_id::text=$2 ORDER BY f.id`, batch, group) if err != nil { return nil, err } defer rows.Close() out := []File{} for rows.Next() { f, e := scanFile(rows) if e != nil { return nil, e } out = append(out, f) } if err = rows.Err(); err != nil { return nil, err } if len(out) == 0 { return nil, ErrNotFound } return out, nil } func (st *Store) UnusedReleaseCovers(ctx context.Context, batch uuid.UUID) ([]string, error) { rows, err := st.pool.Query(ctx, `SELECT DISTINCT cover_path FROM upload_release_groups g WHERE g.batch_id=$1 AND g.cover_path IS NOT NULL AND NOT EXISTS(SELECT 1 FROM upload_files f WHERE f.release_group_id=g.id AND f.status::text=ANY($2)) AND NOT EXISTS(SELECT 1 FROM upload_files active LEFT JOIN upload_release_groups ag ON ag.id=active.release_group_id JOIN upload_batches ab ON ab.id=active.batch_id WHERE active.status::text=ANY($2) AND (CASE WHEN active.release_group_id IS NULL THEN ab.cover_path ELSE ag.cover_path END)=g.cover_path)`, batch, occupyingStatuses) if err != nil { return nil, err } defer rows.Close() return pgx.CollectRows(rows, pgx.RowTo[string]) }