mirror of
https://github.com/navidrome/navidrome.git
synced 2025-08-10 00:52:20 +00:00
feat(server): add index-based play queue endpoints to native API (#4210)
* Add migration converting playqueue current to index * refactor Signed-off-by: Deluan <deluan@navidrome.org> * fix(queue): ensure valid current index and improve test coverage Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
80
db/migrations/20250611010101_playqueue_current_to_index.go
Normal file
80
db/migrations/20250611010101_playqueue_current_to_index.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goose.AddMigrationContext(upPlayQueueCurrentToIndex, downPlayQueueCurrentToIndex)
|
||||
}
|
||||
|
||||
func upPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
create table playqueue_dg_tmp(
|
||||
id varchar(255) not null,
|
||||
user_id varchar(255) not null
|
||||
references user(id)
|
||||
on update cascade on delete cascade,
|
||||
current integer not null default 0,
|
||||
position real,
|
||||
changed_by varchar(255),
|
||||
items varchar(255),
|
||||
created_at datetime,
|
||||
updated_at datetime
|
||||
);`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `select id, user_id, current, position, changed_by, items, created_at, updated_at from playqueue`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `insert into playqueue_dg_tmp(id, user_id, current, position, changed_by, items, created_at, updated_at) values(?,?,?,?,?,?,?,?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id, userID, currentID, changedBy, items string
|
||||
var position sql.NullFloat64
|
||||
var createdAt, updatedAt sql.NullString
|
||||
if err = rows.Scan(&id, &userID, ¤tID, &position, &changedBy, &items, &createdAt, &updatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
index := 0
|
||||
if currentID != "" && items != "" {
|
||||
parts := strings.Split(items, ",")
|
||||
for i, p := range parts {
|
||||
if p == currentID {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err = stmt.Exec(id, userID, index, position, changedBy, items, createdAt, updatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.ExecContext(ctx, `drop table playqueue;`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `alter table playqueue_dg_tmp rename to playqueue;`)
|
||||
return err
|
||||
}
|
||||
|
||||
func downPlayQueueCurrentToIndex(ctx context.Context, tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
type PlayQueue struct {
|
||||
ID string `structs:"id" json:"id"`
|
||||
UserID string `structs:"user_id" json:"userId"`
|
||||
Current string `structs:"current" json:"current"`
|
||||
Current int `structs:"current" json:"current"`
|
||||
Position int64 `structs:"position" json:"position"`
|
||||
ChangedBy string `structs:"changed_by" json:"changedBy"`
|
||||
Items MediaFiles `structs:"-" json:"items,omitempty"`
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueue
|
||||
type playQueue struct {
|
||||
ID string `structs:"id"`
|
||||
UserID string `structs:"user_id"`
|
||||
Current string `structs:"current"`
|
||||
Current int `structs:"current"`
|
||||
Position int64 `structs:"position"`
|
||||
ChangedBy string `structs:"changed_by"`
|
||||
Items string `structs:"items"`
|
||||
|
||||
@@ -32,7 +32,7 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
It("stores and retrieves the playqueue for the user", func() {
|
||||
By("Storing a playqueue for the user")
|
||||
|
||||
expected := aPlayQueue("userid", songDayInALife.ID, 123, songComeTogether, songDayInALife)
|
||||
expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife)
|
||||
Expect(repo.Store(expected)).To(Succeed())
|
||||
|
||||
actual, err := repo.Retrieve("userid")
|
||||
@@ -42,7 +42,7 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
|
||||
By("Storing a new playqueue for the same user")
|
||||
|
||||
another := aPlayQueue("userid", songRadioactivity.ID, 321, songAntenna, songRadioactivity)
|
||||
another := aPlayQueue("userid", 1, 321, songAntenna, songRadioactivity)
|
||||
Expect(repo.Store(another)).To(Succeed())
|
||||
|
||||
actual, err = repo.Retrieve("userid")
|
||||
@@ -62,7 +62,7 @@ var _ = Describe("PlayQueueRepository", func() {
|
||||
Expect(mfRepo.Put(&newSong)).To(Succeed())
|
||||
|
||||
// Create a playqueue with the new song
|
||||
pq := aPlayQueue("userid", newSong.ID, 0, newSong, songAntenna)
|
||||
pq := aPlayQueue("userid", 0, 0, newSong, songAntenna)
|
||||
Expect(repo.Store(pq)).To(Succeed())
|
||||
|
||||
// Retrieve the playqueue
|
||||
@@ -107,7 +107,7 @@ func AssertPlayQueue(expected, actual *model.PlayQueue) {
|
||||
}
|
||||
}
|
||||
|
||||
func aPlayQueue(userId, current string, position int64, items ...model.MediaFile) *model.PlayQueue {
|
||||
func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue {
|
||||
createdAt := time.Now()
|
||||
updatedAt := createdAt.Add(time.Minute)
|
||||
return &model.PlayQueue{
|
||||
|
||||
@@ -60,6 +60,7 @@ func (n *Router) routes() http.Handler {
|
||||
n.addPlaylistRoute(r)
|
||||
n.addPlaylistTrackRoute(r)
|
||||
n.addSongPlaylistsRoute(r)
|
||||
n.addQueueRoute(r)
|
||||
n.addMissingFilesRoute(r)
|
||||
n.addInspectRoute(r)
|
||||
n.addConfigRoute(r)
|
||||
@@ -152,6 +153,13 @@ func (n *Router) addSongPlaylistsRoute(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addQueueRoute(r chi.Router) {
|
||||
r.Route("/queue", func(r chi.Router) {
|
||||
r.Get("/", getQueue(n.ds))
|
||||
r.Post("/", saveQueue(n.ds))
|
||||
})
|
||||
}
|
||||
|
||||
func (n *Router) addMissingFilesRoute(r chi.Router) {
|
||||
r.Route("/missing", func(r chi.Router) {
|
||||
n.RX(r, "/", newMissingRepository(n.ds), false)
|
||||
|
||||
76
server/nativeapi/queue.go
Normal file
76
server/nativeapi/queue.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
type queuePayload struct {
|
||||
Ids []string `json:"ids"`
|
||||
Current int `json:"current"`
|
||||
Position int64 `json:"position"`
|
||||
}
|
||||
|
||||
func getQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
user, _ := request.UserFrom(ctx)
|
||||
repo := ds.PlayQueue(ctx)
|
||||
pq, err := repo.Retrieve(user.ID)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Error(ctx, "Error retrieving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if pq == nil {
|
||||
pq = &model.PlayQueue{}
|
||||
}
|
||||
resp, err := json.Marshal(pq)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error marshalling queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
}
|
||||
|
||||
func saveQueue(ds model.DataStore) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var payload queuePayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
user, _ := request.UserFrom(ctx)
|
||||
client, _ := request.ClientFrom(ctx)
|
||||
items := slice.Map(payload.Ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
if len(payload.Ids) > 0 && (payload.Current < 0 || payload.Current >= len(payload.Ids)) {
|
||||
http.Error(w, "current index out of bounds", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: payload.Current,
|
||||
Position: max(payload.Position, 0),
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
}
|
||||
if err := ds.PlayQueue(ctx).Store(pq); err != nil {
|
||||
log.Error(ctx, "Error saving queue", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
164
server/nativeapi/queue_test.go
Normal file
164
server/nativeapi/queue_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Queue Endpoints", func() {
|
||||
var (
|
||||
ds *tests.MockDataStore
|
||||
repo *tests.MockPlayQueueRepo
|
||||
user model.User
|
||||
userRepo *tests.MockedUserRepo
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
repo = &tests.MockPlayQueueRepo{}
|
||||
user = model.User{ID: "u1", UserName: "user"}
|
||||
userRepo = tests.CreateMockUserRepo()
|
||||
_ = userRepo.Put(&user)
|
||||
ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}}
|
||||
})
|
||||
|
||||
Describe("POST /queue", func() {
|
||||
It("saves the queue", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 1, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
ctx := request.WithUser(req.Context(), user)
|
||||
ctx = request.WithClient(ctx, "TestClient")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Current).To(Equal(1))
|
||||
Expect(repo.Queue.Items).To(HaveLen(2))
|
||||
Expect(repo.Queue.Items[1].ID).To(Equal("s2"))
|
||||
Expect(repo.Queue.ChangedBy).To(Equal("TestClient"))
|
||||
})
|
||||
|
||||
It("saves an empty queue", func() {
|
||||
payload := queuePayload{Ids: []string{}, Current: 0, Position: 0}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusNoContent))
|
||||
Expect(repo.Queue).ToNot(BeNil())
|
||||
Expect(repo.Queue.Items).To(HaveLen(0))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (negative)", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: -1, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for invalid current index (too large)", func() {
|
||||
payload := queuePayload{Ids: []string{"s1", "s2"}, Current: 2, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
Expect(w.Body.String()).To(ContainSubstring("current index out of bounds"))
|
||||
})
|
||||
|
||||
It("returns bad request for malformed JSON", func() {
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader([]byte("invalid json")))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns internal server error when store fails", func() {
|
||||
repo.Err = true
|
||||
payload := queuePayload{Ids: []string{"s1"}, Current: 0, Position: 10}
|
||||
body, _ := json.Marshal(payload)
|
||||
req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body))
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
saveQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GET /queue", func() {
|
||||
It("returns the queue", func() {
|
||||
queue := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: 1,
|
||||
Position: 55,
|
||||
Items: model.MediaFiles{
|
||||
{ID: "track1", Title: "Song 1"},
|
||||
{ID: "track2", Title: "Song 2"},
|
||||
{ID: "track3", Title: "Song 3"},
|
||||
},
|
||||
}
|
||||
repo.Queue = queue
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(w.Header().Get("Content-Type")).To(Equal("application/json"))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Current).To(Equal(1))
|
||||
Expect(resp.Position).To(Equal(int64(55)))
|
||||
Expect(resp.Items).To(HaveLen(3))
|
||||
Expect(resp.Items[0].ID).To(Equal("track1"))
|
||||
Expect(resp.Items[1].ID).To(Equal("track2"))
|
||||
Expect(resp.Items[2].ID).To(Equal("track3"))
|
||||
})
|
||||
|
||||
It("returns empty queue when user has no queue", func() {
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var resp model.PlayQueue
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &resp)).To(Succeed())
|
||||
Expect(resp.Items).To(BeEmpty())
|
||||
Expect(resp.Current).To(Equal(0))
|
||||
Expect(resp.Position).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("returns internal server error when retrieve fails", func() {
|
||||
repo.Err = true
|
||||
req := httptest.NewRequest("GET", "/queue", nil)
|
||||
req = req.WithContext(request.WithUser(req.Context(), user))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
getQueue(ds)(w, req)
|
||||
Expect(w.Code).To(Equal(http.StatusInternalServerError))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -82,9 +82,13 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
}
|
||||
|
||||
response := newResponse()
|
||||
var currentID string
|
||||
if pq.Current >= 0 && pq.Current < len(pq.Items) {
|
||||
currentID = pq.Items[pq.Current].ID
|
||||
}
|
||||
response.PlayQueue = &responses.PlayQueue{
|
||||
Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
|
||||
Current: pq.Current,
|
||||
Current: currentID,
|
||||
Position: pq.Position,
|
||||
Username: user.UserName,
|
||||
Changed: &pq.UpdatedAt,
|
||||
@@ -96,20 +100,27 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
|
||||
p := req.Params(r)
|
||||
ids, _ := p.Strings("id")
|
||||
current, _ := p.String("current")
|
||||
currentID, _ := p.String("current")
|
||||
position := p.Int64Or("position", 0)
|
||||
|
||||
user, _ := request.UserFrom(r.Context())
|
||||
client, _ := request.ClientFrom(r.Context())
|
||||
|
||||
var items model.MediaFiles
|
||||
for _, id := range ids {
|
||||
items = append(items, model.MediaFile{ID: id})
|
||||
items := slice.Map(ids, func(id string) model.MediaFile {
|
||||
return model.MediaFile{ID: id}
|
||||
})
|
||||
|
||||
currentIndex := 0
|
||||
for i, id := range ids {
|
||||
if id == currentID {
|
||||
currentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
pq := &model.PlayQueue{
|
||||
UserID: user.ID,
|
||||
Current: current,
|
||||
Current: currentIndex,
|
||||
Position: position,
|
||||
ChangedBy: client,
|
||||
Items: items,
|
||||
|
||||
@@ -19,6 +19,7 @@ type MockDataStore struct {
|
||||
MockedProperty model.PropertyRepository
|
||||
MockedPlayer model.PlayerRepository
|
||||
MockedPlaylist model.PlaylistRepository
|
||||
MockedPlayQueue model.PlayQueueRepository
|
||||
MockedShare model.ShareRepository
|
||||
MockedTranscoding model.TranscodingRepository
|
||||
MockedUserProps model.UserPropsRepository
|
||||
@@ -115,10 +116,14 @@ func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository
|
||||
}
|
||||
|
||||
func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository {
|
||||
if db.RealDS != nil {
|
||||
return db.RealDS.PlayQueue(ctx)
|
||||
if db.MockedPlayQueue == nil {
|
||||
if db.RealDS != nil {
|
||||
db.MockedPlayQueue = db.RealDS.PlayQueue(ctx)
|
||||
} else {
|
||||
db.MockedPlayQueue = &MockPlayQueueRepo{}
|
||||
}
|
||||
}
|
||||
return struct{ model.PlayQueueRepository }{}
|
||||
return db.MockedPlayQueue
|
||||
}
|
||||
|
||||
func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository {
|
||||
|
||||
39
tests/mock_playqueue_repo.go
Normal file
39
tests/mock_playqueue_repo.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
type MockPlayQueueRepo struct {
|
||||
model.PlayQueueRepository
|
||||
Queue *model.PlayQueue
|
||||
Err bool
|
||||
}
|
||||
|
||||
func (m *MockPlayQueueRepo) Store(q *model.PlayQueue) error {
|
||||
if m.Err {
|
||||
return errors.New("error")
|
||||
}
|
||||
copyItems := make(model.MediaFiles, len(q.Items))
|
||||
copy(copyItems, q.Items)
|
||||
qCopy := *q
|
||||
qCopy.Items = copyItems
|
||||
m.Queue = &qCopy
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockPlayQueueRepo) Retrieve(userId string) (*model.PlayQueue, error) {
|
||||
if m.Err {
|
||||
return nil, errors.New("error")
|
||||
}
|
||||
if m.Queue == nil || m.Queue.UserID != userId {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
copyItems := make(model.MediaFiles, len(m.Queue.Items))
|
||||
copy(copyItems, m.Queue.Items)
|
||||
qCopy := *m.Queue
|
||||
qCopy.Items = copyItems
|
||||
return &qCopy, nil
|
||||
}
|
||||
Reference in New Issue
Block a user