mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
Add API abstraction layer for mediafiles in native API
Introduce a mapping layer between internal model.MediaFile and the API response type. The native API /song endpoint now returns apimodel.Song instead of exposing the database model directly. This decouples the API surface from the persistence layer, enabling calculated fields and hiding internal details (Path, FolderID, LibraryPath, PID, etc.). The architecture is designed for expansion to other models: - mappedRepository: generic REST repository wrapper that transforms Read/ReadAll results through mapping functions - apimodel package: dedicated API response types with explicit field selection and JSON tags independent of the model https://claude.ai/code/session_01TvZ2CgPPfFoxzNYoWuPUBg
This commit is contained in:
parent
f00af7f983
commit
68591dab06
184
server/nativeapi/apimodel/song.go
Normal file
184
server/nativeapi/apimodel/song.go
Normal file
@ -0,0 +1,184 @@
|
||||
package apimodel
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
)
|
||||
|
||||
// Song is the API representation of a media file. It decouples the API response
|
||||
// from the internal model.MediaFile, allowing us to expose calculated fields,
|
||||
// hide internal details, and evolve the API independently of the database schema.
|
||||
type Song struct {
|
||||
ID string `json:"id"`
|
||||
LibraryID int `json:"libraryId"`
|
||||
LibraryName string `json:"libraryName"`
|
||||
|
||||
Title string `json:"title"`
|
||||
Album string `json:"album"`
|
||||
AlbumID string `json:"albumId"`
|
||||
Artist string `json:"artist"`
|
||||
ArtistID string `json:"artistId"`
|
||||
AlbumArtist string `json:"albumArtist"`
|
||||
AlbumArtistID string `json:"albumArtistId"`
|
||||
Compilation bool `json:"compilation"`
|
||||
TrackNumber int `json:"trackNumber"`
|
||||
DiscNumber int `json:"discNumber"`
|
||||
DiscSubtitle string `json:"discSubtitle,omitempty"`
|
||||
|
||||
// Dates
|
||||
Year int `json:"year"`
|
||||
Date string `json:"date,omitempty"`
|
||||
OriginalYear int `json:"originalYear"`
|
||||
OriginalDate string `json:"originalDate,omitempty"`
|
||||
ReleaseYear int `json:"releaseYear"`
|
||||
ReleaseDate string `json:"releaseDate,omitempty"`
|
||||
|
||||
// Audio properties
|
||||
Duration float32 `json:"duration"`
|
||||
Size int64 `json:"size"`
|
||||
Suffix string `json:"suffix"`
|
||||
BitRate int `json:"bitRate"`
|
||||
SampleRate int `json:"sampleRate"`
|
||||
BitDepth int `json:"bitDepth"`
|
||||
Channels int `json:"channels"`
|
||||
|
||||
// Metadata
|
||||
Genre string `json:"genre"`
|
||||
Genres model.Genres `json:"genres,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
BPM int `json:"bpm,omitempty"`
|
||||
ExplicitStatus string `json:"explicitStatus"`
|
||||
CatalogNum string `json:"catalogNum,omitempty"`
|
||||
Tags model.Tags `json:"tags,omitempty"`
|
||||
Participants model.Participants `json:"participants"`
|
||||
|
||||
// Sort fields
|
||||
SortTitle string `json:"sortTitle,omitempty"`
|
||||
SortAlbumName string `json:"sortAlbumName,omitempty"`
|
||||
SortArtistName string `json:"sortArtistName,omitempty"`
|
||||
SortAlbumArtistName string `json:"sortAlbumArtistName,omitempty"`
|
||||
|
||||
// MusicBrainz IDs
|
||||
MbzRecordingID string `json:"mbzRecordingID,omitempty"`
|
||||
MbzReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"`
|
||||
MbzAlbumID string `json:"mbzAlbumId,omitempty"`
|
||||
MbzReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"`
|
||||
MbzArtistID string `json:"mbzArtistId,omitempty"`
|
||||
MbzAlbumArtistID string `json:"mbzAlbumArtistId,omitempty"`
|
||||
MbzAlbumType string `json:"mbzAlbumType,omitempty"`
|
||||
MbzAlbumComment string `json:"mbzAlbumComment,omitempty"`
|
||||
|
||||
// ReplayGain
|
||||
RGAlbumGain *float64 `json:"rgAlbumGain"`
|
||||
RGAlbumPeak *float64 `json:"rgAlbumPeak"`
|
||||
RGTrackGain *float64 `json:"rgTrackGain"`
|
||||
RGTrackPeak *float64 `json:"rgTrackPeak"`
|
||||
|
||||
// Lyrics
|
||||
Lyrics string `json:"lyrics"`
|
||||
|
||||
HasCoverArt bool `json:"hasCoverArt"`
|
||||
|
||||
// User annotations
|
||||
PlayCount int64 `json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `json:"playDate,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Starred bool `json:"starred,omitempty"`
|
||||
StarredAt *time.Time `json:"starredAt,omitempty"`
|
||||
|
||||
// Bookmark
|
||||
BookmarkPosition int64 `json:"bookmarkPosition"`
|
||||
|
||||
// Timestamps
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// FromMediaFile converts a model.MediaFile into the API Song representation.
|
||||
func FromMediaFile(mf model.MediaFile) Song {
|
||||
return Song{
|
||||
ID: mf.ID,
|
||||
LibraryID: mf.LibraryID,
|
||||
LibraryName: mf.LibraryName,
|
||||
|
||||
Title: mf.Title,
|
||||
Album: mf.Album,
|
||||
AlbumID: mf.AlbumID,
|
||||
Artist: mf.Artist,
|
||||
ArtistID: mf.ArtistID,
|
||||
AlbumArtist: mf.AlbumArtist,
|
||||
AlbumArtistID: mf.AlbumArtistID,
|
||||
Compilation: mf.Compilation,
|
||||
TrackNumber: mf.TrackNumber,
|
||||
DiscNumber: mf.DiscNumber,
|
||||
DiscSubtitle: mf.DiscSubtitle,
|
||||
|
||||
Year: mf.Year,
|
||||
Date: mf.Date,
|
||||
OriginalYear: mf.OriginalYear,
|
||||
OriginalDate: mf.OriginalDate,
|
||||
ReleaseYear: mf.ReleaseYear,
|
||||
ReleaseDate: mf.ReleaseDate,
|
||||
|
||||
Duration: mf.Duration,
|
||||
Size: mf.Size,
|
||||
Suffix: mf.Suffix,
|
||||
BitRate: mf.BitRate,
|
||||
SampleRate: mf.SampleRate,
|
||||
BitDepth: mf.BitDepth,
|
||||
Channels: mf.Channels,
|
||||
|
||||
Genre: mf.Genre,
|
||||
Genres: mf.Genres,
|
||||
Comment: mf.Comment,
|
||||
BPM: mf.BPM,
|
||||
ExplicitStatus: mf.ExplicitStatus,
|
||||
CatalogNum: mf.CatalogNum,
|
||||
Tags: mf.Tags,
|
||||
Participants: mf.Participants,
|
||||
|
||||
SortTitle: mf.SortTitle,
|
||||
SortAlbumName: mf.SortAlbumName,
|
||||
SortArtistName: mf.SortArtistName,
|
||||
SortAlbumArtistName: mf.SortAlbumArtistName,
|
||||
|
||||
MbzRecordingID: mf.MbzRecordingID,
|
||||
MbzReleaseTrackID: mf.MbzReleaseTrackID,
|
||||
MbzAlbumID: mf.MbzAlbumID,
|
||||
MbzReleaseGroupID: mf.MbzReleaseGroupID,
|
||||
MbzArtistID: mf.MbzArtistID,
|
||||
MbzAlbumArtistID: mf.MbzAlbumArtistID,
|
||||
MbzAlbumType: mf.MbzAlbumType,
|
||||
MbzAlbumComment: mf.MbzAlbumComment,
|
||||
|
||||
RGAlbumGain: mf.RGAlbumGain,
|
||||
RGAlbumPeak: mf.RGAlbumPeak,
|
||||
RGTrackGain: mf.RGTrackGain,
|
||||
RGTrackPeak: mf.RGTrackPeak,
|
||||
|
||||
Lyrics: mf.Lyrics,
|
||||
|
||||
HasCoverArt: mf.HasCoverArt,
|
||||
|
||||
PlayCount: mf.PlayCount,
|
||||
PlayDate: mf.PlayDate,
|
||||
Rating: mf.Rating,
|
||||
Starred: mf.Starred,
|
||||
StarredAt: mf.StarredAt,
|
||||
|
||||
BookmarkPosition: mf.BookmarkPosition,
|
||||
|
||||
CreatedAt: mf.CreatedAt,
|
||||
UpdatedAt: mf.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// FromMediaFiles converts a slice of model.MediaFile into a slice of API Songs.
|
||||
func FromMediaFiles(mfs model.MediaFiles) []Song {
|
||||
songs := make([]Song, len(mfs))
|
||||
for i, mf := range mfs {
|
||||
songs[i] = FromMediaFile(mf)
|
||||
}
|
||||
return songs
|
||||
}
|
||||
132
server/nativeapi/apimodel/song_test.go
Normal file
132
server/nativeapi/apimodel/song_test.go
Normal file
@ -0,0 +1,132 @@
|
||||
package apimodel_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
)
|
||||
|
||||
func TestFromMediaFile(t *testing.T) {
|
||||
now := time.Now()
|
||||
playDate := now.Add(-time.Hour)
|
||||
starredAt := now.Add(-2 * time.Hour)
|
||||
rgGain := 1.5
|
||||
rgPeak := 0.9
|
||||
|
||||
mf := model.MediaFile{
|
||||
ID: "mf-1",
|
||||
LibraryID: 1,
|
||||
LibraryName: "My Music",
|
||||
LibraryPath: "/music",
|
||||
FolderID: "folder-1",
|
||||
Path: "/music/song.mp3",
|
||||
Title: "Test Song",
|
||||
Album: "Test Album",
|
||||
AlbumID: "album-1",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "artist-1",
|
||||
AlbumArtist: "Test Album Artist",
|
||||
Duration: 180.5,
|
||||
Size: 5242880,
|
||||
Suffix: "mp3",
|
||||
BitRate: 320,
|
||||
SampleRate: 44100,
|
||||
BitDepth: 16,
|
||||
Channels: 2,
|
||||
Year: 2024,
|
||||
TrackNumber: 3,
|
||||
DiscNumber: 1,
|
||||
Genre: "Rock",
|
||||
HasCoverArt: true,
|
||||
RGAlbumGain: &rgGain,
|
||||
RGAlbumPeak: &rgPeak,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
mf.PlayCount = 5
|
||||
mf.PlayDate = &playDate
|
||||
mf.Starred = true
|
||||
mf.StarredAt = &starredAt
|
||||
mf.Rating = 4
|
||||
mf.BookmarkPosition = 1000
|
||||
|
||||
song := apimodel.FromMediaFile(mf)
|
||||
|
||||
// Verify mapped fields
|
||||
if song.ID != mf.ID {
|
||||
t.Errorf("ID: got %q, want %q", song.ID, mf.ID)
|
||||
}
|
||||
if song.Title != mf.Title {
|
||||
t.Errorf("Title: got %q, want %q", song.Title, mf.Title)
|
||||
}
|
||||
if song.Artist != mf.Artist {
|
||||
t.Errorf("Artist: got %q, want %q", song.Artist, mf.Artist)
|
||||
}
|
||||
if song.Album != mf.Album {
|
||||
t.Errorf("Album: got %q, want %q", song.Album, mf.Album)
|
||||
}
|
||||
if song.Duration != mf.Duration {
|
||||
t.Errorf("Duration: got %v, want %v", song.Duration, mf.Duration)
|
||||
}
|
||||
if song.BitRate != mf.BitRate {
|
||||
t.Errorf("BitRate: got %d, want %d", song.BitRate, mf.BitRate)
|
||||
}
|
||||
if song.LibraryID != mf.LibraryID {
|
||||
t.Errorf("LibraryID: got %d, want %d", song.LibraryID, mf.LibraryID)
|
||||
}
|
||||
if song.LibraryName != mf.LibraryName {
|
||||
t.Errorf("LibraryName: got %q, want %q", song.LibraryName, mf.LibraryName)
|
||||
}
|
||||
|
||||
// Verify annotations are mapped
|
||||
if song.PlayCount != mf.PlayCount {
|
||||
t.Errorf("PlayCount: got %d, want %d", song.PlayCount, mf.PlayCount)
|
||||
}
|
||||
if song.Starred != mf.Starred {
|
||||
t.Errorf("Starred: got %v, want %v", song.Starred, mf.Starred)
|
||||
}
|
||||
if song.Rating != mf.Rating {
|
||||
t.Errorf("Rating: got %d, want %d", song.Rating, mf.Rating)
|
||||
}
|
||||
|
||||
// Verify bookmark is mapped
|
||||
if song.BookmarkPosition != mf.BookmarkPosition {
|
||||
t.Errorf("BookmarkPosition: got %d, want %d", song.BookmarkPosition, mf.BookmarkPosition)
|
||||
}
|
||||
|
||||
// Verify replay gain pointers are mapped
|
||||
if song.RGAlbumGain == nil || *song.RGAlbumGain != rgGain {
|
||||
t.Errorf("RGAlbumGain: got %v, want %v", song.RGAlbumGain, &rgGain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMediaFiles(t *testing.T) {
|
||||
mfs := model.MediaFiles{
|
||||
{ID: "1", Title: "Song 1"},
|
||||
{ID: "2", Title: "Song 2"},
|
||||
{ID: "3", Title: "Song 3"},
|
||||
}
|
||||
|
||||
songs := apimodel.FromMediaFiles(mfs)
|
||||
|
||||
if len(songs) != 3 {
|
||||
t.Fatalf("expected 3 songs, got %d", len(songs))
|
||||
}
|
||||
for i, song := range songs {
|
||||
if song.ID != mfs[i].ID {
|
||||
t.Errorf("song[%d].ID: got %q, want %q", i, song.ID, mfs[i].ID)
|
||||
}
|
||||
if song.Title != mfs[i].Title {
|
||||
t.Errorf("song[%d].Title: got %q, want %q", i, song.Title, mfs[i].Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMediaFilesEmpty(t *testing.T) {
|
||||
songs := apimodel.FromMediaFiles(model.MediaFiles{})
|
||||
if len(songs) != 0 {
|
||||
t.Fatalf("expected 0 songs, got %d", len(songs))
|
||||
}
|
||||
}
|
||||
49
server/nativeapi/mapped_repository.go
Normal file
49
server/nativeapi/mapped_repository.go
Normal file
@ -0,0 +1,49 @@
|
||||
package nativeapi
|
||||
|
||||
import (
|
||||
"github.com/deluan/rest"
|
||||
)
|
||||
|
||||
// mappedRepository wraps a rest.Repository and transforms its Read/ReadAll results
|
||||
// using a mapping function. This allows the native API to return different types than
|
||||
// what the persistence layer provides, enabling calculated fields and decoupling the
|
||||
// API response shape from the database model.
|
||||
//
|
||||
// The mapFunc receives the raw result from the underlying repository and returns the
|
||||
// transformed result for the API response. It handles both single items (from Read)
|
||||
// and collections (from ReadAll).
|
||||
type mappedRepository struct {
|
||||
repo rest.Repository
|
||||
mapOne func(any) (any, error)
|
||||
mapMany func(any) (any, error)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) Count(options ...rest.QueryOptions) (int64, error) {
|
||||
return m.repo.Count(options...)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) Read(id string) (any, error) {
|
||||
result, err := m.repo.Read(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.mapOne(result)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) ReadAll(options ...rest.QueryOptions) (any, error) {
|
||||
result, err := m.repo.ReadAll(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.mapMany(result)
|
||||
}
|
||||
|
||||
func (m *mappedRepository) EntityName() string {
|
||||
return m.repo.EntityName()
|
||||
}
|
||||
|
||||
func (m *mappedRepository) NewInstance() any {
|
||||
return m.repo.NewInstance()
|
||||
}
|
||||
|
||||
var _ rest.Repository = (*mappedRepository)(nil)
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
)
|
||||
|
||||
// PluginManager defines the interface for plugin management operations.
|
||||
@ -63,7 +64,7 @@ func (api *Router) routes() http.Handler {
|
||||
r.Use(server.JWTRefresher)
|
||||
r.Use(server.UpdateLastAccessMiddleware(api.ds))
|
||||
api.RX(r, "/user", api.users.NewRepository, true)
|
||||
api.R(r, "/song", model.MediaFile{}, false)
|
||||
api.addSongRoute(r)
|
||||
api.R(r, "/album", model.Album{}, false)
|
||||
api.R(r, "/artist", model.Artist{}, false)
|
||||
api.R(r, "/genre", model.Genre{}, false)
|
||||
@ -102,6 +103,24 @@ func (api *Router) R(r chi.Router, pathPrefix string, model any, persistable boo
|
||||
api.RX(r, pathPrefix, constructor, persistable)
|
||||
}
|
||||
|
||||
func (api *Router) addSongRoute(r chi.Router) {
|
||||
constructor := func(ctx context.Context) rest.Repository {
|
||||
repo := api.ds.Resource(ctx, model.MediaFile{})
|
||||
return &mappedRepository{
|
||||
repo: repo,
|
||||
mapOne: func(v any) (any, error) {
|
||||
mf := v.(*model.MediaFile)
|
||||
return apimodel.FromMediaFile(*mf), nil
|
||||
},
|
||||
mapMany: func(v any) (any, error) {
|
||||
mfs := v.(model.MediaFiles)
|
||||
return apimodel.FromMediaFiles(mfs), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
api.RX(r, "/song", constructor, false)
|
||||
}
|
||||
|
||||
func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
|
||||
r.Route(pathPrefix, func(r chi.Router) {
|
||||
r.Get("/", rest.GetAll(constructor))
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/nativeapi/apimodel"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
@ -127,13 +128,13 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Describe("GET /song", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns all songs", func() {
|
||||
It("returns all songs as apimodel.Song types", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -144,6 +145,22 @@ var _ = Describe("Song Endpoints", func() {
|
||||
Expect(response[1].Title).To(Equal("Test Song 2"))
|
||||
})
|
||||
|
||||
It("does not expose internal model fields like Path", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Parse into a raw map to check that Path is not present
|
||||
var rawResponse []map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &rawResponse)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(rawResponse).To(HaveLen(2))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("path"))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("folderId"))
|
||||
Expect(rawResponse[0]).ToNot(HaveKey("libraryPath"))
|
||||
})
|
||||
|
||||
It("handles repository errors gracefully", func() {
|
||||
mfRepo.SetError(true)
|
||||
|
||||
@ -166,13 +183,13 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Describe("GET /song/{id}", func() {
|
||||
Context("when user is authenticated", func() {
|
||||
It("returns the specific song", func() {
|
||||
It("returns the specific song as apimodel.Song type", func() {
|
||||
req := createAuthenticatedRequest("GET", "/song/song-1", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response model.MediaFile
|
||||
var response apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -265,7 +282,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -280,7 +297,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -299,7 +316,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
@ -349,7 +366,7 @@ var _ = Describe("Song Endpoints", func() {
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var response []model.MediaFile
|
||||
var response []apimodel.Song
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user