mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Open media item share sessions shown on listening sessions page, create device info for share sessions
This commit is contained in:
parent
d7ace4d1dc
commit
8e286a6070
@ -80,8 +80,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full md:w-1/3">
|
<div class="w-full md:w-1/3">
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||||
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||||
|
|
||||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||||
<p class="mb-1">{{ playMethodName }}</p>
|
<p class="mb-1">{{ playMethodName }}</p>
|
||||||
@ -99,8 +99,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@ -166,6 +166,9 @@ export default {
|
|||||||
},
|
},
|
||||||
isOpenSession() {
|
isOpenSession() {
|
||||||
return !!this._session.open
|
return !!this._session.open
|
||||||
|
},
|
||||||
|
isMediaItemShareSession() {
|
||||||
|
return this._session.mediaPlayer === 'web-share'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
<p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p>
|
||||||
|
|
||||||
<div class="w-full my-8 h-px bg-white/10" />
|
<div v-if="openListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
|
||||||
|
|
||||||
<!-- open listening sessions table -->
|
<!-- open listening sessions table -->
|
||||||
<p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p>
|
<p v-if="openListeningSessions.length" class="text-lg my-4">Open Listening Sessions</p>
|
||||||
@ -144,6 +144,45 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="openShareListeningSessions.length" class="w-full my-8 h-px bg-white/10" />
|
||||||
|
|
||||||
|
<!-- open share listening sessions table -->
|
||||||
|
<p v-if="openShareListeningSessions.length" class="text-lg my-4">Open Share Listening Sessions</p>
|
||||||
|
<div v-if="openShareListeningSessions.length" class="block max-w-full">
|
||||||
|
<table class="userSessionsTable">
|
||||||
|
<tr class="bg-primary bg-opacity-40">
|
||||||
|
<th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th>
|
||||||
|
<th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th>
|
||||||
|
<th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th>
|
||||||
|
<th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th>
|
||||||
|
<th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-for="session in openShareListeningSessions" :key="`open-${session.id}`" class="cursor-pointer" @click="showSession(session)">
|
||||||
|
<td class="py-1 max-w-48">
|
||||||
|
<p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p>
|
||||||
|
<p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden md:table-cell"></td>
|
||||||
|
<td class="hidden md:table-cell">
|
||||||
|
<p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="hidden sm:table-cell max-w-32 min-w-32">
|
||||||
|
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||||
|
</td>
|
||||||
|
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||||
|
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-center hidden sm:table-cell">
|
||||||
|
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
|
||||||
|
<p class="text-xs text-gray-200">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
||||||
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
<modals-listening-session-modal v-model="showSessionModal" :session="selectedSession" @removedSession="removedSession" @closedSession="closedSession" />
|
||||||
@ -180,6 +219,7 @@ export default {
|
|||||||
selectedSession: null,
|
selectedSession: null,
|
||||||
listeningSessions: [],
|
listeningSessions: [],
|
||||||
openListeningSessions: [],
|
openListeningSessions: [],
|
||||||
|
openShareListeningSessions: [],
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
@ -455,6 +495,7 @@ export default {
|
|||||||
s.open = true
|
s.open = true
|
||||||
return s
|
return s
|
||||||
})
|
})
|
||||||
|
this.openShareListeningSessions = data.shareSessions || []
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.loadSessions(0)
|
this.loadSessions(0)
|
||||||
|
@ -26,7 +26,7 @@ export default {
|
|||||||
if (query.t && !isNaN(query.t)) {
|
if (query.t && !isNaN(query.t)) {
|
||||||
endpoint += `?t=${query.t}`
|
endpoint += `?t=${query.t}`
|
||||||
}
|
}
|
||||||
const mediaItemShare = await app.$axios.$get(endpoint).catch((error) => {
|
const mediaItemShare = await app.$axios.$get(endpoint, { timeout: 10000 }).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
@ -81,7 +81,7 @@ class Server {
|
|||||||
// Routers
|
// Routers
|
||||||
this.apiRouter = new ApiRouter(this)
|
this.apiRouter = new ApiRouter(this)
|
||||||
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
this.hlsRouter = new HlsRouter(this.auth, this.playbackSessionManager)
|
||||||
this.publicRouter = new PublicRouter()
|
this.publicRouter = new PublicRouter(this.playbackSessionManager)
|
||||||
|
|
||||||
Logger.logManager = new LogManager()
|
Logger.logManager = new LogManager()
|
||||||
|
|
||||||
|
@ -2,8 +2,10 @@ const Logger = require('../Logger')
|
|||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { toNumber, isUUID } = require('../utils/index')
|
const { toNumber, isUUID } = require('../utils/index')
|
||||||
|
|
||||||
|
const ShareManager = require('../managers/ShareManager')
|
||||||
|
|
||||||
class SessionController {
|
class SessionController {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
return res.json(req.playbackSession)
|
return res.json(req.playbackSession)
|
||||||
@ -68,15 +70,13 @@ class SessionController {
|
|||||||
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
include,
|
include,
|
||||||
order: [
|
order: [[orderKey, orderDesc]],
|
||||||
[orderKey, orderDesc]
|
|
||||||
],
|
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
offset: itemsPerPage * page
|
offset: itemsPerPage * page
|
||||||
})
|
})
|
||||||
|
|
||||||
// Map playback sessions to old playback sessions
|
// Map playback sessions to old playback sessions
|
||||||
const sessions = rows.map(session => {
|
const sessions = rows.map((session) => {
|
||||||
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
|
const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session)
|
||||||
if (session.user) {
|
if (session.user) {
|
||||||
return {
|
return {
|
||||||
@ -112,15 +112,18 @@ class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects()
|
||||||
const openSessions = this.playbackSessionManager.sessions.map(se => {
|
const openSessions = this.playbackSessionManager.sessions.map((se) => {
|
||||||
return {
|
return {
|
||||||
...se.toJSON(),
|
...se.toJSON(),
|
||||||
user: minifiedUserObjects.find(u => u.id === se.userId) || null
|
user: minifiedUserObjects.find((u) => u.id === se.userId) || null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const shareSessions = ShareManager.openSharePlaybackSessions.map((se) => se.toJSON())
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
sessions: openSessions
|
sessions: openSessions,
|
||||||
|
shareSessions
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,7 +173,7 @@ class SessionController {
|
|||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
// Validate session ids
|
// Validate session ids
|
||||||
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) {
|
if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some((s) => !isUUID(s))) {
|
||||||
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
|
Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body)
|
||||||
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
|
return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.')
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
const uuidv4 = require('uuid').v4
|
const uuid = require('uuid')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const { Op } = require('sequelize')
|
const { Op } = require('sequelize')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
@ -18,6 +18,8 @@ class ShareController {
|
|||||||
* GET: /api/share/:slug
|
* GET: /api/share/:slug
|
||||||
* Get media item share by slug
|
* Get media item share by slug
|
||||||
*
|
*
|
||||||
|
* @this {import('../routers/PublicRouter')}
|
||||||
|
*
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
@ -28,7 +30,8 @@ class ShareController {
|
|||||||
|
|
||||||
const mediaItemShare = ShareManager.findBySlug(slug)
|
const mediaItemShare = ShareManager.findBySlug(slug)
|
||||||
if (!mediaItemShare) {
|
if (!mediaItemShare) {
|
||||||
return res.status(404)
|
Logger.warn(`[ShareController] Media item share not found with slug ${slug}`)
|
||||||
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
|
if (mediaItemShare.expiresAt && mediaItemShare.expiresAt.valueOf() < Date.now()) {
|
||||||
ShareManager.removeMediaItemShare(mediaItemShare.id)
|
ShareManager.removeMediaItemShare(mediaItemShare.id)
|
||||||
@ -43,9 +46,12 @@ class ShareController {
|
|||||||
return res.json(mediaItemShare)
|
return res.json(mediaItemShare)
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`)
|
Logger.info(`[ShareController] Share playback session not found with id ${req.cookies.share_session_id}`)
|
||||||
|
if (!uuid.validate(req.cookies.share_session_id) || uuid.version(req.cookies.share_session_id) !== 4) {
|
||||||
|
Logger.warn(`[ShareController] Invalid share session id ${req.cookies.share_session_id}`)
|
||||||
res.clearCookie('share_session_id')
|
res.clearCookie('share_session_id')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType)
|
||||||
@ -75,11 +81,18 @@ class ShareController {
|
|||||||
startTime = 0
|
startTime = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shareSessionId = req.cookies.share_session_id || uuid.v4()
|
||||||
|
const clientDeviceInfo = {
|
||||||
|
clientName: 'Abs Web Share',
|
||||||
|
deviceId: shareSessionId
|
||||||
|
}
|
||||||
|
const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo)
|
||||||
|
|
||||||
const newPlaybackSession = new PlaybackSession()
|
const newPlaybackSession = new PlaybackSession()
|
||||||
newPlaybackSession.setData(oldLibraryItem, null, 'web-public', null, startTime)
|
newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime)
|
||||||
newPlaybackSession.audioTracks = publicTracks
|
newPlaybackSession.audioTracks = publicTracks
|
||||||
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
|
||||||
newPlaybackSession.shareSessionId = uuidv4() // New share session id
|
newPlaybackSession.shareSessionId = shareSessionId
|
||||||
newPlaybackSession.mediaItemShareId = mediaItemShare.id
|
newPlaybackSession.mediaItemShareId = mediaItemShare.id
|
||||||
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
|
newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio
|
||||||
|
|
||||||
@ -119,7 +132,6 @@ class ShareController {
|
|||||||
|
|
||||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||||
res.clearCookie('share_session_id')
|
|
||||||
return res.status(404).send('Share session not found')
|
return res.status(404).send('Share session not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +172,6 @@ class ShareController {
|
|||||||
|
|
||||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||||
res.clearCookie('share_session_id')
|
|
||||||
return res.status(404).send('Share session not found')
|
return res.status(404).send('Share session not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,7 +222,6 @@ class ShareController {
|
|||||||
|
|
||||||
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id)
|
||||||
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) {
|
||||||
res.clearCookie('share_session_id')
|
|
||||||
return res.status(404).send('Share session not found')
|
return res.status(404).send('Share session not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,14 +35,18 @@ class PlaybackSessionManager {
|
|||||||
return session?.stream || null
|
return session?.stream || null
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceInfo(req) {
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('express').Request} req
|
||||||
|
* @param {Object} [clientDeviceInfo]
|
||||||
|
* @returns {Promise<DeviceInfo>}
|
||||||
|
*/
|
||||||
|
async getDeviceInfo(req, clientDeviceInfo = null) {
|
||||||
const ua = uaParserJs(req.headers['user-agent'])
|
const ua = uaParserJs(req.headers['user-agent'])
|
||||||
const ip = requestIp.getClientIp(req)
|
const ip = requestIp.getClientIp(req)
|
||||||
|
|
||||||
const clientDeviceInfo = req.body?.deviceInfo || null
|
|
||||||
|
|
||||||
const deviceInfo = new DeviceInfo()
|
const deviceInfo = new DeviceInfo()
|
||||||
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user.id)
|
deviceInfo.setData(ip, ua, clientDeviceInfo, serverVersion, req.user?.id)
|
||||||
|
|
||||||
if (clientDeviceInfo?.deviceId) {
|
if (clientDeviceInfo?.deviceId) {
|
||||||
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
|
const existingDevice = await Database.getDeviceByDeviceId(clientDeviceInfo.deviceId)
|
||||||
@ -66,7 +70,7 @@ class PlaybackSessionManager {
|
|||||||
* @param {string} [episodeId]
|
* @param {string} [episodeId]
|
||||||
*/
|
*/
|
||||||
async startSessionRequest(req, res, episodeId) {
|
async startSessionRequest(req, res, episodeId) {
|
||||||
const deviceInfo = await this.getDeviceInfo(req)
|
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
|
||||||
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
|
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
|
||||||
const { user, libraryItem, body: options } = req
|
const { user, libraryItem, body: options } = req
|
||||||
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
|
const session = await this.startSession(user, deviceInfo, libraryItem, episodeId, options)
|
||||||
@ -82,7 +86,7 @@ class PlaybackSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncLocalSessionsRequest(req, res) {
|
async syncLocalSessionsRequest(req, res) {
|
||||||
const deviceInfo = await this.getDeviceInfo(req)
|
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
|
||||||
const user = req.user
|
const user = req.user
|
||||||
const sessions = req.body.sessions || []
|
const sessions = req.body.sessions || []
|
||||||
|
|
||||||
@ -199,7 +203,7 @@ class PlaybackSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncLocalSessionRequest(req, res) {
|
async syncLocalSessionRequest(req, res) {
|
||||||
const deviceInfo = await this.getDeviceInfo(req)
|
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
|
||||||
const user = req.user
|
const user = req.user
|
||||||
const sessionJson = req.body
|
const sessionJson = req.body
|
||||||
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
const result = await this.syncLocalSession(user, sessionJson, deviceInfo)
|
||||||
|
@ -2,7 +2,10 @@ const express = require('express')
|
|||||||
const ShareController = require('../controllers/ShareController')
|
const ShareController = require('../controllers/ShareController')
|
||||||
|
|
||||||
class PublicRouter {
|
class PublicRouter {
|
||||||
constructor() {
|
constructor(playbackSessionManager) {
|
||||||
|
/** @type {import('../managers/PlaybackSessionManager')} */
|
||||||
|
this.playbackSessionManager = playbackSessionManager
|
||||||
|
|
||||||
this.router = express()
|
this.router = express()
|
||||||
this.router.disable('x-powered-by')
|
this.router.disable('x-powered-by')
|
||||||
this.init()
|
this.init()
|
||||||
|
Loading…
Reference in New Issue
Block a user