add scrobblingt
This commit is contained in:
@@ -48,7 +48,7 @@ var (
|
||||
type lastAlbum struct {
|
||||
coverModTime time.Time // 1st needed for cover insertion
|
||||
coverPath string // 2rd needed for cover insertion
|
||||
id uint // 3nd needed for cover insertion
|
||||
id int // 3nd needed for cover insertion
|
||||
}
|
||||
|
||||
func (l *lastAlbum) isEmpty() bool {
|
||||
@@ -133,18 +133,18 @@ func handleFile(fullPath string, info *godirwalk.Dirent) error {
|
||||
return fmt.Errorf("when reading tags: %v", err)
|
||||
}
|
||||
trackNumber, totalTracks := tags.Track()
|
||||
discNumber, TotalDiscs := tags.Disc()
|
||||
discNumber, totalDiscs := tags.Disc()
|
||||
track.Path = fullPath
|
||||
track.Title = tags.Title()
|
||||
track.Artist = tags.Artist()
|
||||
track.DiscNumber = uint(discNumber)
|
||||
track.TotalDiscs = uint(TotalDiscs)
|
||||
track.TotalTracks = uint(totalTracks)
|
||||
track.TrackNumber = uint(trackNumber)
|
||||
track.Year = uint(tags.Year())
|
||||
track.DiscNumber = discNumber
|
||||
track.TotalDiscs = totalDiscs
|
||||
track.TotalTracks = totalTracks
|
||||
track.TrackNumber = trackNumber
|
||||
track.Year = tags.Year()
|
||||
track.Suffix = extension
|
||||
track.ContentType = mime
|
||||
track.Size = uint(stat.Size())
|
||||
track.Size = int(stat.Size())
|
||||
// set album artist {
|
||||
albumArtist := db.AlbumArtist{
|
||||
Name: tags.AlbumArtist(),
|
||||
@@ -188,6 +188,7 @@ func main() {
|
||||
&db.Cover{},
|
||||
&db.User{},
|
||||
&db.Setting{},
|
||||
&db.Play{},
|
||||
)
|
||||
// 🤫🤫🤫
|
||||
orm.Exec(`
|
||||
|
||||
@@ -46,6 +46,8 @@ func setSubsonicRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/rest/stream.view", withWare(cont.Stream))
|
||||
mux.HandleFunc("/rest/download", withWare(cont.Stream))
|
||||
mux.HandleFunc("/rest/download.view", withWare(cont.Stream))
|
||||
mux.HandleFunc("/rest/scrobble", withWare(cont.Scrobble))
|
||||
mux.HandleFunc("/rest/scrobble.view", withWare(cont.Scrobble))
|
||||
mux.HandleFunc("/rest/getCoverArt", withWare(cont.GetCoverArt))
|
||||
mux.HandleFunc("/rest/getCoverArt.view", withWare(cont.GetCoverArt))
|
||||
mux.HandleFunc("/rest/getArtists", withWare(cont.GetArtists))
|
||||
|
||||
@@ -11,11 +11,5 @@ type CrudBase struct {
|
||||
}
|
||||
|
||||
type IDBase struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
}
|
||||
|
||||
// Base is the base model with an auto incrementing primary key
|
||||
type Base struct {
|
||||
IDBase
|
||||
CrudBase
|
||||
ID int `gorm:"primary_key"`
|
||||
}
|
||||
|
||||
48
db/model.go
48
db/model.go
@@ -1,48 +1,53 @@
|
||||
package db
|
||||
|
||||
import "time"
|
||||
|
||||
// Album represents the albums table
|
||||
type Album struct {
|
||||
Base
|
||||
IDBase
|
||||
CrudBase
|
||||
AlbumArtist AlbumArtist
|
||||
AlbumArtistID uint
|
||||
AlbumArtistID int
|
||||
Title string `gorm:"not null;index"`
|
||||
Tracks []Track
|
||||
}
|
||||
|
||||
// AlbumArtist represents the AlbumArtists table
|
||||
type AlbumArtist struct {
|
||||
Base
|
||||
IDBase
|
||||
CrudBase
|
||||
Albums []Album
|
||||
Name string `gorm:"not null;unique_index"`
|
||||
}
|
||||
|
||||
// Track represents the tracks table
|
||||
type Track struct {
|
||||
Base
|
||||
IDBase
|
||||
CrudBase
|
||||
Album Album
|
||||
AlbumID uint
|
||||
AlbumID int
|
||||
AlbumArtist AlbumArtist
|
||||
AlbumArtistID uint
|
||||
AlbumArtistID int
|
||||
Artist string
|
||||
Bitrate uint
|
||||
Bitrate int
|
||||
Codec string
|
||||
DiscNumber uint
|
||||
Duration uint
|
||||
DiscNumber int
|
||||
Duration int
|
||||
Title string
|
||||
TotalDiscs uint
|
||||
TotalTracks uint
|
||||
TrackNumber uint
|
||||
Year uint
|
||||
TotalDiscs int
|
||||
TotalTracks int
|
||||
TrackNumber int
|
||||
Year int
|
||||
Suffix string
|
||||
ContentType string
|
||||
Size uint
|
||||
Size int
|
||||
Path string `gorm:"not null;unique_index"`
|
||||
}
|
||||
|
||||
// Cover represents the covers table
|
||||
type Cover struct {
|
||||
CrudBase
|
||||
AlbumID uint `gorm:"primary_key;auto_increment:false"`
|
||||
AlbumID int `gorm:"primary_key;auto_increment:false"`
|
||||
Album Album
|
||||
Image []byte
|
||||
Path string `gorm:"not null;unique_index"`
|
||||
@@ -50,7 +55,8 @@ type Cover struct {
|
||||
|
||||
// User represents the users table
|
||||
type User struct {
|
||||
Base
|
||||
IDBase
|
||||
CrudBase
|
||||
Name string `gorm:"not null;unique_index"`
|
||||
Password string
|
||||
LastFMSession string
|
||||
@@ -63,3 +69,13 @@ type Setting struct {
|
||||
Key string `gorm:"primary_key;auto_increment:false"`
|
||||
Value string
|
||||
}
|
||||
|
||||
// Play represents the settings table
|
||||
type Play struct {
|
||||
IDBase
|
||||
User User
|
||||
UserID int
|
||||
Track Track
|
||||
TrackID int
|
||||
Time time.Time
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ type templateData struct {
|
||||
User *db.User
|
||||
SelectedUser *db.User
|
||||
AllUsers []*db.User
|
||||
ArtistCount uint
|
||||
AlbumCount uint
|
||||
TrackCount uint
|
||||
ArtistCount int
|
||||
AlbumCount int
|
||||
TrackCount int
|
||||
CurrentLastFMAPIKey string
|
||||
CurrentLastFMAPISecret string
|
||||
RequestRoot string
|
||||
@@ -96,6 +96,14 @@ func getStrParam(r *http.Request, key string) string {
|
||||
return r.URL.Query().Get(key)
|
||||
}
|
||||
|
||||
func getStrParamOr(r *http.Request, key, or string) string {
|
||||
val := getStrParam(r, key)
|
||||
if val == "" {
|
||||
return or
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func getIntParam(r *http.Request, key string) (int, error) {
|
||||
strVal := r.URL.Query().Get(key)
|
||||
if strVal == "" {
|
||||
@@ -156,7 +164,7 @@ func respond(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
func respondError(w http.ResponseWriter, r *http.Request,
|
||||
code uint64, message string) {
|
||||
code int, message string) {
|
||||
respondRaw(w, r, http.StatusBadRequest, subsonic.NewError(
|
||||
code, message,
|
||||
))
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"github.com/sentriz/gonic/db"
|
||||
"github.com/sentriz/gonic/lastfm"
|
||||
"github.com/sentriz/gonic/subsonic"
|
||||
|
||||
"github.com/mozillazg/go-unidecode"
|
||||
@@ -210,6 +212,51 @@ func (c *Controller) GetLicence(w http.ResponseWriter, r *http.Request) {
|
||||
respond(w, r, sub)
|
||||
}
|
||||
|
||||
func (c *Controller) Scrobble(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := getIntParam(r, "id")
|
||||
if err != nil {
|
||||
respondError(w, r, 10, "please provide an `id` parameter")
|
||||
return
|
||||
}
|
||||
// fetch user to get lastfm session
|
||||
username := getStrParam(r, "u")
|
||||
user := c.GetUserFromName(username)
|
||||
if user == nil {
|
||||
respondError(w, r, 10, "could not find a user with that name")
|
||||
return
|
||||
}
|
||||
if user.LastFMSession == "" {
|
||||
respondError(w, r, 0, fmt.Sprintf("no last.fm session for this user: %v", err))
|
||||
return
|
||||
}
|
||||
// fetch track for getting info to send to last.fm function
|
||||
var track db.Track
|
||||
c.DB.
|
||||
Preload("Album").
|
||||
Preload("AlbumArtist").
|
||||
First(&track, id)
|
||||
// get time from args or use now
|
||||
time := getIntParamOr(r, "time", int(time.Now().Unix()))
|
||||
// get submission, where the default is true. we will
|
||||
// check if it's false later
|
||||
submission := getStrParamOr(r, "submission", "true")
|
||||
// scrobble with above info
|
||||
err = lastfm.Scrobble(
|
||||
c.GetSetting("lastfm_api_key"),
|
||||
c.GetSetting("lastfm_secret"),
|
||||
user.LastFMSession,
|
||||
&track,
|
||||
time,
|
||||
submission != "false",
|
||||
)
|
||||
if err != nil {
|
||||
respondError(w, r, 0, fmt.Sprintf("error when submitting: %v", err))
|
||||
return
|
||||
}
|
||||
sub := subsonic.NewResponse()
|
||||
respond(w, r, sub)
|
||||
}
|
||||
|
||||
func (c *Controller) NotFound(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, r, 0, "unknown route")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sentriz/gonic/db"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -18,38 +22,72 @@ var (
|
||||
)
|
||||
|
||||
func getParamSignature(params url.Values, secret string) string {
|
||||
// the parameters must be in order before hashing
|
||||
paramKeys := make([]string, 0)
|
||||
for k, _ := range params {
|
||||
paramKeys = append(paramKeys, k)
|
||||
}
|
||||
sort.Strings(paramKeys)
|
||||
toHash := ""
|
||||
for k, v := range params {
|
||||
for _, k := range paramKeys {
|
||||
toHash += k
|
||||
toHash += v[0]
|
||||
toHash += params[k][0]
|
||||
}
|
||||
toHash += secret
|
||||
hash := md5.Sum([]byte(toHash))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func GetSession(apiKey, secret, token string) (string, error) {
|
||||
params := url.Values{}
|
||||
// the first 3 parameters here must be in alphabetical order
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("method", "auth.getSession")
|
||||
params.Add("token", token)
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
req, _ := http.NewRequest("GET", baseURL, nil)
|
||||
func makeRequest(method string, params url.Values) (*LastFM, error) {
|
||||
req, _ := http.NewRequest(method, baseURL, nil)
|
||||
req.URL.RawQuery = params.Encode()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error when making request to last.fm: %v", err)
|
||||
return nil, fmt.Errorf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
decoder := xml.NewDecoder(resp.Body)
|
||||
var lastfm LastFM
|
||||
err = decoder.Decode(&lastfm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error when decoding last.fm response: %v", err)
|
||||
return nil, fmt.Errorf("decoding: %v", err)
|
||||
}
|
||||
if lastfm.Error != nil {
|
||||
return "", fmt.Errorf("error when parsing last.fm response: %v", lastfm.Error.Value)
|
||||
return nil, fmt.Errorf("parsing: %v", lastfm.Error.Value)
|
||||
}
|
||||
return lastfm.Session.Key, nil
|
||||
return &lastfm, nil
|
||||
}
|
||||
|
||||
func GetSession(apiKey, secret, token string) (string, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "auth.getSession")
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("token", token)
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
resp, err := makeRequest("GET", params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error when getting session step '%v'", err)
|
||||
}
|
||||
return resp.Session.Key, nil
|
||||
}
|
||||
|
||||
func Scrobble(apiKey, secret, session string,
|
||||
track *db.Track, stamp int, submission bool) error {
|
||||
params := url.Values{}
|
||||
if submission {
|
||||
params.Add("method", "track.Scrobble")
|
||||
params.Add("timestamp", strconv.Itoa(stamp))
|
||||
} else {
|
||||
params.Add("method", "track.updateNowPlaying")
|
||||
}
|
||||
params.Add("api_key", apiKey)
|
||||
params.Add("sk", session)
|
||||
params.Add("artist", track.Artist)
|
||||
params.Add("track", track.Title)
|
||||
params.Add("album", track.Album.Title)
|
||||
params.Add("albumArtist", track.AlbumArtist.Name)
|
||||
params.Add("trackNumber", strconv.Itoa(track.TrackNumber))
|
||||
params.Add("api_sig", getParamSignature(params, secret))
|
||||
_, err := makeRequest("POST", params)
|
||||
return err
|
||||
}
|
||||
|
||||
BIN
static/images/favicon.ico
Normal file
BIN
static/images/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -3,13 +3,13 @@ package subsonic
|
||||
import "time"
|
||||
|
||||
type Album struct {
|
||||
ID uint `xml:"id,attr" json:"id"`
|
||||
ID int `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
ArtistID uint `xml:"artistId,attr" json:"artistId"`
|
||||
ArtistID int `xml:"artistId,attr" json:"artistId"`
|
||||
Artist string `xml:"artist,attr" json:"artist"`
|
||||
TrackCount uint `xml:"songCount,attr" json:"songCount"`
|
||||
Duration uint `xml:"duration,attr" json:"duration"`
|
||||
CoverID uint `xml:"coverArt,attr" json:"coverArt"`
|
||||
TrackCount int `xml:"songCount,attr" json:"songCount"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
CoverID int `xml:"coverArt,attr" json:"coverArt"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Tracks []*Track `xml:"song" json:"song,omitempty"`
|
||||
}
|
||||
@@ -19,38 +19,38 @@ type RandomTracks struct {
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID uint `xml:"id,attr" json:"id"`
|
||||
Parent uint `xml:"parent,attr" json:"parent"`
|
||||
ID int `xml:"id,attr" json:"id"`
|
||||
Parent int `xml:"parent,attr" json:"parent"`
|
||||
Title string `xml:"title,attr" json:"title"`
|
||||
Album string `xml:"album,attr" json:"album"`
|
||||
Artist string `xml:"artist,attr" json:"artist"`
|
||||
IsDir bool `xml:"isDir,attr" json:"isDir"`
|
||||
CoverID uint `xml:"coverArt,attr" json:"coverArt"`
|
||||
CoverID int `xml:"coverArt,attr" json:"coverArt"`
|
||||
Created time.Time `xml:"created,attr" json:"created"`
|
||||
Duration uint `xml:"duration,attr" json:"duration"`
|
||||
Duration int `xml:"duration,attr" json:"duration"`
|
||||
Genre string `xml:"genre,attr" json:"genre"`
|
||||
BitRate uint `xml:"bitRate,attr" json:"bitRate"`
|
||||
Size uint `xml:"size,attr" json:"size"`
|
||||
BitRate int `xml:"bitRate,attr" json:"bitRate"`
|
||||
Size int `xml:"size,attr" json:"size"`
|
||||
Suffix string `xml:"suffix,attr" json:"suffix"`
|
||||
ContentType string `xml:"contentType,attr" json:"contentType"`
|
||||
IsVideo bool `xml:"isVideo,attr" json:"isVideo"`
|
||||
Path string `xml:"path,attr" json:"path"`
|
||||
AlbumID uint `xml:"albumId,attr" json:"albumId"`
|
||||
ArtistID uint `xml:"artistId,attr" json:"artistId"`
|
||||
TrackNo uint `xml:"track,attr" json:"track"`
|
||||
AlbumID int `xml:"albumId,attr" json:"albumId"`
|
||||
ArtistID int `xml:"artistId,attr" json:"artistId"`
|
||||
TrackNo int `xml:"track,attr" json:"track"`
|
||||
Type string `xml:"type,attr" json:"type"`
|
||||
}
|
||||
|
||||
type Artist struct {
|
||||
ID uint `xml:"id,attr" json:"id"`
|
||||
ID int `xml:"id,attr" json:"id"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
CoverID uint `xml:"coverArt,attr" json:"coverArt,omitempty"`
|
||||
AlbumCount uint `xml:"albumCount,attr" json:"albumCount,omitempty"`
|
||||
CoverID int `xml:"coverArt,attr" json:"coverArt,omitempty"`
|
||||
AlbumCount int `xml:"albumCount,attr" json:"albumCount,omitempty"`
|
||||
Albums []*Album `xml:"album,omitempty" json:"album,omitempty"`
|
||||
}
|
||||
|
||||
type Indexes struct {
|
||||
LastModified uint `xml:"lastModified,attr" json:"lastModified"`
|
||||
LastModified int `xml:"lastModified,attr" json:"lastModified"`
|
||||
Index []*Index `xml:"index" json:"index"`
|
||||
}
|
||||
|
||||
@@ -60,36 +60,36 @@ type Index struct {
|
||||
}
|
||||
|
||||
type Directory struct {
|
||||
ID uint `xml:"id,attr" json:"id"`
|
||||
Parent uint `xml:"parent,attr" json:"parent"`
|
||||
ID int `xml:"id,attr" json:"id"`
|
||||
Parent int `xml:"parent,attr" json:"parent"`
|
||||
Name string `xml:"name,attr" json:"name"`
|
||||
Starred string `xml:"starred,attr,omitempty" json:"starred,omitempty"`
|
||||
Children []Child `xml:"child" json:"child"`
|
||||
}
|
||||
|
||||
type Child struct {
|
||||
ID uint `xml:"id,attr" json:"id,omitempty"`
|
||||
Parent uint `xml:"parent,attr" json:"parent,omitempty"`
|
||||
ID int `xml:"id,attr" json:"id,omitempty"`
|
||||
Parent int `xml:"parent,attr" json:"parent,omitempty"`
|
||||
Title string `xml:"title,attr" json:"title,omitempty"`
|
||||
IsDir bool `xml:"isDir,attr" json:"isDir,omitempty"`
|
||||
Album string `xml:"album,attr,omitempty" json:"album,omitempty"`
|
||||
AlbumID uint `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
|
||||
AlbumID int `xml:"albumId,attr,omitempty" json:"albumId,omitempty"`
|
||||
Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"`
|
||||
ArtistID uint `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
Track uint `xml:"track,attr,omitempty" json:"track,omitempty"`
|
||||
Year uint `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
ArtistID int `xml:"artistId,attr,omitempty" json:"artistId,omitempty"`
|
||||
Track int `xml:"track,attr,omitempty" json:"track,omitempty"`
|
||||
Year int `xml:"year,attr,omitempty" json:"year,omitempty"`
|
||||
Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"`
|
||||
CoverID uint `xml:"coverArt,attr" json:"coverArt,omitempty"`
|
||||
Size uint `xml:"size,attr,omitempty" json:"size,omitempty"`
|
||||
CoverID int `xml:"coverArt,attr" json:"coverArt,omitempty"`
|
||||
Size int `xml:"size,attr,omitempty" json:"size,omitempty"`
|
||||
ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"`
|
||||
Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"`
|
||||
Duration uint `xml:"duration,attr,omitempty" json:"duration"`
|
||||
BitRate uint `xml:"bitRate,attr,omitempty" json:"bitrate,omitempty"`
|
||||
Duration int `xml:"duration,attr,omitempty" json:"duration"`
|
||||
BitRate int `xml:"bitRate,attr,omitempty" json:"bitrate,omitempty"`
|
||||
Path string `xml:"path,attr,omitempty" json:"path,omitempty"`
|
||||
}
|
||||
|
||||
type MusicFolder struct {
|
||||
ID uint `xml:"id,attr" json:"id,omitempty"`
|
||||
ID int `xml:"id,attr" json:"id,omitempty"`
|
||||
Name string `xml:"name,attr" json:"name,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ type Response struct {
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code uint64 `xml:"code,attr" json:"code"`
|
||||
Code int `xml:"code,attr" json:"code"`
|
||||
Message string `xml:"message,attr" json:"message"`
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func NewResponse() *Response {
|
||||
}
|
||||
}
|
||||
|
||||
func NewError(code uint64, message string) *Response {
|
||||
func NewError(code int, message string) *Response {
|
||||
return &Response{
|
||||
Status: "failed",
|
||||
XMLNS: xmlns,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<title>{{ template "title" }}</title>
|
||||
<link rel="stylesheet" href="/admin/static/stylesheets/awsm.css">
|
||||
<link rel="stylesheet" href="/admin/static/stylesheets/main.css">
|
||||
<link rel="shortcut icon" href="/admin/static/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/admin/static/images/favicon.ico" type="image/x-icon">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div class="right">
|
||||
{{ range $user := .AllUsers }}
|
||||
{{ $user.Name }} <span class="light">created</span> <u>{{ $user.CreatedAt.Format "Jan 02, 2006" }}</u> <a href="/admin/change_password?user={{ $user.Name }}">change password</a><br/>
|
||||
<i>{{ $user.Name }}</i> <span class="light">created</span> {{ $user.CreatedAt.Format "Jan 02, 2006" }} <a href="/admin/change_password?user={{ $user.Name }}">change password</a><br/>
|
||||
{{ end }}
|
||||
<a href="/admin/create_user" class="button">create new</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user