feat: collection CRUD with artwork caching, rip override and match links
This commit is contained in:
@@ -6,6 +6,7 @@ import { SerialQueue } from './queue.js'
|
|||||||
import { registerAuthRoutes } from './routes/authRoutes.js'
|
import { registerAuthRoutes } from './routes/authRoutes.js'
|
||||||
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
import { registerSettingsRoutes } from './routes/settingsRoutes.js'
|
||||||
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
import { registerLookupRoutes } from './routes/lookupRoutes.js'
|
||||||
|
import { registerCollectionRoutes } from './routes/collectionRoutes.js'
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
@@ -36,6 +37,7 @@ export async function buildApp(opts: AppOptions): Promise<FastifyInstance> {
|
|||||||
await registerAuthRoutes(app)
|
await registerAuthRoutes(app)
|
||||||
await registerSettingsRoutes(app)
|
await registerSettingsRoutes(app)
|
||||||
await registerLookupRoutes(app)
|
await registerLookupRoutes(app)
|
||||||
|
await registerCollectionRoutes(app)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
return app
|
return app
|
||||||
|
|||||||
211
server/src/routes/collectionRoutes.ts
Normal file
211
server/src/routes/collectionRoutes.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { FastifyInstance } from 'fastify'
|
||||||
|
import type { DB } from '../db.js'
|
||||||
|
import { cacheArtwork } from '../artwork.js'
|
||||||
|
import { resolveRipStatus } from '../ripstatus.js'
|
||||||
|
import { requireAuth } from './authRoutes.js'
|
||||||
|
import { discogsClientFor, discogsErrorStatus } from './lookupRoutes.js'
|
||||||
|
|
||||||
|
interface ItemRow {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
discogs_release_id: number
|
||||||
|
title: string
|
||||||
|
artist: string
|
||||||
|
year: number | null
|
||||||
|
formats: string
|
||||||
|
genres: string
|
||||||
|
labels: string
|
||||||
|
tracklist: string
|
||||||
|
catno: string | null
|
||||||
|
country: string | null
|
||||||
|
cover_url: string | null
|
||||||
|
local_artwork_path: string | null
|
||||||
|
barcodes: string
|
||||||
|
rip_override: number | null
|
||||||
|
date_added: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rowToItem(db: DB, row: ItemRow) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
discogsReleaseId: row.discogs_release_id,
|
||||||
|
title: row.title,
|
||||||
|
artist: row.artist,
|
||||||
|
year: row.year,
|
||||||
|
formats: JSON.parse(row.formats),
|
||||||
|
genres: JSON.parse(row.genres),
|
||||||
|
labels: JSON.parse(row.labels),
|
||||||
|
tracklist: JSON.parse(row.tracklist),
|
||||||
|
catno: row.catno,
|
||||||
|
country: row.country,
|
||||||
|
artworkUrl: row.local_artwork_path ? `/artwork/${row.local_artwork_path}` : row.cover_url,
|
||||||
|
barcodes: JSON.parse(row.barcodes),
|
||||||
|
dateAdded: row.date_added,
|
||||||
|
ripOverride: row.rip_override === null ? null : row.rip_override === 1,
|
||||||
|
ripStatus: resolveRipStatus(db, row.user_id, row.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItem(db: DB, userId: number, id: number): ItemRow | undefined {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT * FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.get(id, userId) as ItemRow | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCollectionRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.post('/api/collection', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const client = discogsClientFor(request)
|
||||||
|
if (!client) return reply.code(409).send({ error: 'no_discogs_token' })
|
||||||
|
const { releaseId, barcode, matchAlbumId } = (request.body ?? {}) as {
|
||||||
|
releaseId?: number
|
||||||
|
barcode?: string
|
||||||
|
matchAlbumId?: number
|
||||||
|
}
|
||||||
|
if (typeof releaseId !== 'number') return reply.code(400).send({ error: 'invalid_input' })
|
||||||
|
|
||||||
|
let release
|
||||||
|
try {
|
||||||
|
release = await client.getRelease(releaseId)
|
||||||
|
} catch (err) {
|
||||||
|
const { code, body } = discogsErrorStatus(err)
|
||||||
|
return reply.code(code).send(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const artworkFile = release.coverUrl
|
||||||
|
? await cacheArtwork(request.server.config.artworkDir, release.coverUrl, request.server.fetchImpl)
|
||||||
|
: null
|
||||||
|
const barcodes = barcode && !release.barcodes.includes(barcode) ? [...release.barcodes, barcode] : release.barcodes
|
||||||
|
|
||||||
|
let itemId: number
|
||||||
|
try {
|
||||||
|
const info = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO collection_items
|
||||||
|
(user_id, discogs_release_id, title, artist, year, formats, genres, labels, tracklist, catno, country, cover_url, local_artwork_path, barcodes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
userId,
|
||||||
|
release.id,
|
||||||
|
release.title,
|
||||||
|
release.artist,
|
||||||
|
release.year,
|
||||||
|
JSON.stringify(release.formats),
|
||||||
|
JSON.stringify(release.genres),
|
||||||
|
JSON.stringify(release.labels),
|
||||||
|
JSON.stringify(release.tracklist),
|
||||||
|
release.catno,
|
||||||
|
release.country,
|
||||||
|
release.coverUrl,
|
||||||
|
artworkFile,
|
||||||
|
JSON.stringify(barcodes)
|
||||||
|
)
|
||||||
|
itemId = Number(info.lastInsertRowid)
|
||||||
|
} catch (err: any) {
|
||||||
|
if (String(err.message).includes('UNIQUE constraint failed')) {
|
||||||
|
return reply.code(409).send({ error: 'duplicate' })
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchAlbumId != null) {
|
||||||
|
const album = db
|
||||||
|
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
|
||||||
|
.get(matchAlbumId, userId)
|
||||||
|
if (!album) return reply.code(404).send({ error: 'album_not_found' })
|
||||||
|
db.prepare('INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)').run(
|
||||||
|
userId,
|
||||||
|
itemId,
|
||||||
|
matchAlbumId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = getItem(db, userId, itemId) as ItemRow
|
||||||
|
return reply.code(200).send(rowToItem(db, row))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/collection', { preHandler: [requireAuth] }, async (request) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT * FROM collection_items WHERE user_id = ? ORDER BY date_added DESC, id DESC')
|
||||||
|
.all(userId) as ItemRow[]
|
||||||
|
const items = rows.map((row) => rowToItem(db, row))
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: items.length,
|
||||||
|
ripped: items.filter((i) => i.ripStatus === 'ripped').length,
|
||||||
|
notRipped: items.filter((i) => i.ripStatus === 'not_ripped').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { format, ripped, q } = request.query as { format?: string; ripped?: string; q?: string }
|
||||||
|
let filtered = items
|
||||||
|
if (format) filtered = filtered.filter((i) => i.formats.some((f: string) => f.includes(format)))
|
||||||
|
if (ripped === 'ripped' || ripped === 'not_ripped') {
|
||||||
|
filtered = filtered.filter((i) => i.ripStatus === ripped)
|
||||||
|
}
|
||||||
|
if (q) {
|
||||||
|
const needle = q.toLowerCase()
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(i) => i.title.toLowerCase().includes(needle) || i.artist.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { items: filtered, counts }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const row = getItem(db, request.user!.id, Number((request.params as { id: string }).id))
|
||||||
|
if (!row) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
return rowToItem(db, row)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete('/api/collection/:id', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const info = db
|
||||||
|
.prepare('DELETE FROM collection_items WHERE id = ? AND user_id = ?')
|
||||||
|
.run(Number((request.params as { id: string }).id), request.user!.id)
|
||||||
|
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.patch('/api/collection/:id/rip', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
const { ripped } = (request.body ?? {}) as { ripped?: boolean | null }
|
||||||
|
if (ripped !== true && ripped !== false && ripped !== null) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_input' })
|
||||||
|
}
|
||||||
|
const value = ripped === null ? null : ripped ? 1 : 0
|
||||||
|
const info = db
|
||||||
|
.prepare('UPDATE collection_items SET rip_override = ? WHERE id = ? AND user_id = ?')
|
||||||
|
.run(value, id, userId)
|
||||||
|
if (info.changes === 0) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
return rowToItem(db, getItem(db, userId, id) as ItemRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/collection/:id/match', { preHandler: [requireAuth] }, async (request, reply) => {
|
||||||
|
const db = request.server.db
|
||||||
|
const userId = request.user!.id
|
||||||
|
const id = Number((request.params as { id: string }).id)
|
||||||
|
if (!getItem(db, userId, id)) return reply.code(404).send({ error: 'not_found' })
|
||||||
|
const { albumId } = (request.body ?? {}) as { albumId?: number | null }
|
||||||
|
|
||||||
|
if (albumId === null || albumId === undefined) {
|
||||||
|
db.prepare('DELETE FROM match_links WHERE user_id = ? AND item_id = ?').run(userId, id)
|
||||||
|
} else {
|
||||||
|
const album = db
|
||||||
|
.prepare('SELECT id FROM digital_albums WHERE id = ? AND user_id = ?')
|
||||||
|
.get(albumId, userId)
|
||||||
|
if (!album) return reply.code(404).send({ error: 'album_not_found' })
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO match_links (user_id, item_id, album_id) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, item_id) DO UPDATE SET album_id = excluded.album_id`
|
||||||
|
).run(userId, id, albumId)
|
||||||
|
}
|
||||||
|
return rowToItem(db, getItem(db, userId, id) as ItemRow)
|
||||||
|
})
|
||||||
|
}
|
||||||
215
server/test/collection.test.ts
Normal file
215
server/test/collection.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildTestApp, setupAdmin, auth, getCookie } from './helpers.js'
|
||||||
|
import { discogsReleaseFixture } from './fixtures.js'
|
||||||
|
|
||||||
|
function discogsStub(): typeof fetch {
|
||||||
|
return (async (input: any) => {
|
||||||
|
const url = String(input)
|
||||||
|
if (url.includes('/releases/1001')) {
|
||||||
|
return new Response(JSON.stringify(discogsReleaseFixture), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (url.includes('img.discogs.com')) {
|
||||||
|
return new Response(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'image/jpeg' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return new Response('nope', { status: 404 })
|
||||||
|
}) as typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appWithToken() {
|
||||||
|
const app = await buildTestApp(discogsStub())
|
||||||
|
const cookie = await setupAdmin(app)
|
||||||
|
await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { discogsToken: 'testtoken' },
|
||||||
|
})
|
||||||
|
return { app, cookie }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collection routes', () => {
|
||||||
|
it('adds a release from discogs, returns item with artwork and rip status', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/collection',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { releaseId: 1001, barcode: '5021592210629' },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(200)
|
||||||
|
const item = res.json()
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
discogsReleaseId: 1001,
|
||||||
|
title: 'Motion',
|
||||||
|
artist: 'The Cinematic Orchestra',
|
||||||
|
year: 1999,
|
||||||
|
formats: ['CD'],
|
||||||
|
labels: ['Ninja Tune'],
|
||||||
|
catno: 'ZENCD012',
|
||||||
|
barcodes: ['5021592210629'],
|
||||||
|
ripOverride: null,
|
||||||
|
ripStatus: 'not_ripped',
|
||||||
|
})
|
||||||
|
expect(item.artworkUrl).toMatch(/^\/artwork\/[0-9a-f]{64}\.jpg$/)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects duplicate add with 409', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
const again = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/collection',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { releaseId: 1001 },
|
||||||
|
})
|
||||||
|
expect(again.statusCode).toBe(409)
|
||||||
|
expect(again.json()).toEqual({ error: 'duplicate' })
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists items with counts and filters (format, ripped, q)', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
|
||||||
|
const all = await app.inject({ method: 'GET', url: '/api/collection', ...auth(cookie) })
|
||||||
|
expect(all.json().counts).toEqual({ total: 1, ripped: 0, notRipped: 1 })
|
||||||
|
expect(all.json().items).toHaveLength(1)
|
||||||
|
|
||||||
|
const cd = await app.inject({ method: 'GET', url: '/api/collection?format=CD', ...auth(cookie) })
|
||||||
|
expect(cd.json().items).toHaveLength(1)
|
||||||
|
const vinyl = await app.inject({ method: 'GET', url: '/api/collection?format=Vinyl', ...auth(cookie) })
|
||||||
|
expect(vinyl.json().items).toHaveLength(0)
|
||||||
|
|
||||||
|
const ripped = await app.inject({ method: 'GET', url: '/api/collection?ripped=ripped', ...auth(cookie) })
|
||||||
|
expect(ripped.json().items).toHaveLength(0)
|
||||||
|
const notRipped = await app.inject({ method: 'GET', url: '/api/collection?ripped=not_ripped', ...auth(cookie) })
|
||||||
|
expect(notRipped.json().items).toHaveLength(1)
|
||||||
|
|
||||||
|
const q = await app.inject({ method: 'GET', url: '/api/collection?q=motio', ...auth(cookie) })
|
||||||
|
expect(q.json().items).toHaveLength(1)
|
||||||
|
const qMiss = await app.inject({ method: 'GET', url: '/api/collection?q=zzz', ...auth(cookie) })
|
||||||
|
expect(qMiss.json().items).toHaveLength(0)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detail, rip override, match link, re-match search, delete', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
const added = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/collection',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { releaseId: 1001 },
|
||||||
|
})
|
||||||
|
const id = added.json().id as number
|
||||||
|
|
||||||
|
const detail = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(detail.statusCode).toBe(200)
|
||||||
|
expect(detail.json().tracklist).toEqual([
|
||||||
|
{ position: '1', title: 'Overture' },
|
||||||
|
{ position: '2', title: 'Theme de Yoyo' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// manual rip override
|
||||||
|
const rip = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/collection/${id}/rip`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { ripped: true },
|
||||||
|
})
|
||||||
|
expect(rip.json().ripOverride).toBe(true)
|
||||||
|
expect(rip.json().ripStatus).toBe('ripped')
|
||||||
|
const clear = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/collection/${id}/rip`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { ripped: null },
|
||||||
|
})
|
||||||
|
expect(clear.json().ripOverride).toBeNull()
|
||||||
|
expect(clear.json().ripStatus).toBe('not_ripped')
|
||||||
|
|
||||||
|
// match link (simulates confirmed ambiguous match)
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/library/albums/test-seed',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albums: [{ subsonicId: 'a1', title: 'Motion (Remaster)', artist: 'The Cinematic Orchestra' }] },
|
||||||
|
})
|
||||||
|
const albums = await app.inject({ method: 'GET', url: '/api/library/albums?q=Motion', ...auth(cookie) })
|
||||||
|
const albumId = albums.json().albums[0].id as number
|
||||||
|
const linked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${id}/match`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albumId },
|
||||||
|
})
|
||||||
|
expect(linked.json().ripStatus).toBe('ripped')
|
||||||
|
|
||||||
|
// clear link
|
||||||
|
const unlinked = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${id}/match`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albumId: null },
|
||||||
|
})
|
||||||
|
expect(unlinked.json().ripStatus).toBe('not_ripped')
|
||||||
|
|
||||||
|
const del = await app.inject({ method: 'DELETE', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(del.statusCode).toBe(200)
|
||||||
|
const after = await app.inject({ method: 'GET', url: `/api/collection/${id}`, ...auth(cookie) })
|
||||||
|
expect(after.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects match to another users album', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
const added = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/collection',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { releaseId: 1001 },
|
||||||
|
})
|
||||||
|
const id = added.json().id as number
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/library/albums/test-seed',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albums: [{ subsonicId: 'a1', title: 'X', artist: 'Y' }] },
|
||||||
|
})
|
||||||
|
// album id 99 does not exist for this user
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/collection/${id}/match`,
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { albumId: 99 },
|
||||||
|
})
|
||||||
|
expect(res.statusCode).toBe(404)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('second user cannot see first users items', async () => {
|
||||||
|
const { app, cookie } = await appWithToken()
|
||||||
|
await app.inject({ method: 'POST', url: '/api/collection', ...auth(cookie), payload: { releaseId: 1001 } })
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/users',
|
||||||
|
...auth(cookie),
|
||||||
|
payload: { username: 'bob', password: 'bobpass123' },
|
||||||
|
})
|
||||||
|
const bobLogin = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/login',
|
||||||
|
payload: { username: 'bob', password: 'bobpass123' },
|
||||||
|
})
|
||||||
|
const bobCookie = getCookie(bobLogin)
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/collection', ...auth(bobCookie) })
|
||||||
|
expect(list.json().items).toHaveLength(0)
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user