v1.0.2: Mood mix optimizations and media player improvements
- Fixed player seek flicker on podcasts (30s skip buttons) - Added dual-layer seek lock mechanism to prevent stale time updates - Optimized cached podcast seeking (direct seek before reload fallback) - Large skips now execute immediately for responsive feel - Mood mix performance optimizations
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* Mood Bucket Service
|
||||
*
|
||||
* Handles pre-computed mood assignments for fast mood mix generation.
|
||||
* Tracks are assigned to mood buckets during audio analysis, enabling
|
||||
* instant mood mix generation through simple database lookups.
|
||||
*/
|
||||
|
||||
import { prisma } from "../utils/db";
|
||||
|
||||
// Mood configuration with scoring rules
|
||||
// Primary = uses ML mood predictions (enhanced mode)
|
||||
// Fallback = uses basic audio features (standard mode)
|
||||
export const MOOD_CONFIG = {
|
||||
happy: {
|
||||
name: "Happy & Upbeat",
|
||||
color: "from-yellow-400 to-orange-500",
|
||||
icon: "Smile",
|
||||
// Primary: ML mood prediction
|
||||
primary: { moodHappy: { min: 0.5 }, moodSad: { max: 0.4 } },
|
||||
// Fallback: basic audio features
|
||||
fallback: { valence: { min: 0.6 }, energy: { min: 0.5 } },
|
||||
},
|
||||
sad: {
|
||||
name: "Melancholic",
|
||||
color: "from-blue-600 to-indigo-700",
|
||||
icon: "CloudRain",
|
||||
primary: { moodSad: { min: 0.5 }, moodHappy: { max: 0.4 } },
|
||||
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
|
||||
},
|
||||
chill: {
|
||||
name: "Chill & Relaxed",
|
||||
color: "from-teal-400 to-cyan-500",
|
||||
icon: "Wind",
|
||||
primary: { moodRelaxed: { min: 0.5 }, moodAggressive: { max: 0.3 } },
|
||||
fallback: { energy: { max: 0.5 }, arousal: { max: 0.5 } },
|
||||
},
|
||||
energetic: {
|
||||
name: "High Energy",
|
||||
color: "from-red-500 to-orange-600",
|
||||
icon: "Zap",
|
||||
primary: { arousal: { min: 0.6 }, energy: { min: 0.7 } },
|
||||
fallback: { bpm: { min: 120 }, energy: { min: 0.7 } },
|
||||
},
|
||||
party: {
|
||||
name: "Dance Party",
|
||||
color: "from-pink-500 to-rose-600",
|
||||
icon: "PartyPopper",
|
||||
primary: { moodParty: { min: 0.5 }, danceability: { min: 0.6 } },
|
||||
fallback: { danceability: { min: 0.7 }, energy: { min: 0.6 } },
|
||||
},
|
||||
focus: {
|
||||
name: "Focus Mode",
|
||||
color: "from-purple-600 to-violet-700",
|
||||
icon: "Brain",
|
||||
primary: { instrumentalness: { min: 0.5 }, moodRelaxed: { min: 0.3 } },
|
||||
fallback: {
|
||||
instrumentalness: { min: 0.5 },
|
||||
energy: { min: 0.2, max: 0.6 },
|
||||
},
|
||||
},
|
||||
melancholy: {
|
||||
name: "Deep Feels",
|
||||
color: "from-gray-700 to-slate-800",
|
||||
icon: "Moon",
|
||||
primary: { moodSad: { min: 0.4 }, valence: { max: 0.4 } },
|
||||
fallback: { valence: { max: 0.35 }, keyScale: "minor" },
|
||||
},
|
||||
aggressive: {
|
||||
name: "Intense",
|
||||
color: "from-red-700 to-gray-900",
|
||||
icon: "Flame",
|
||||
primary: { moodAggressive: { min: 0.5 } },
|
||||
fallback: { energy: { min: 0.8 }, arousal: { min: 0.7 } },
|
||||
},
|
||||
acoustic: {
|
||||
name: "Acoustic Vibes",
|
||||
color: "from-amber-500 to-yellow-600",
|
||||
icon: "Guitar",
|
||||
primary: { moodAcoustic: { min: 0.5 }, moodElectronic: { max: 0.4 } },
|
||||
fallback: {
|
||||
acousticness: { min: 0.6 },
|
||||
energy: { min: 0.3, max: 0.6 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type MoodType = keyof typeof MOOD_CONFIG;
|
||||
export const VALID_MOODS = Object.keys(MOOD_CONFIG) as MoodType[];
|
||||
|
||||
// Mood gradient colors for mix display
|
||||
const MOOD_GRADIENTS: Record<MoodType, string> = {
|
||||
happy: "linear-gradient(to bottom, rgba(217, 119, 6, 0.5), rgba(161, 98, 7, 0.4), rgba(68, 64, 60, 0.4))",
|
||||
sad: "linear-gradient(to bottom, rgba(30, 58, 138, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
chill: "linear-gradient(to bottom, rgba(17, 94, 89, 0.6), rgba(22, 78, 99, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
energetic:
|
||||
"linear-gradient(to bottom, rgba(153, 27, 27, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))",
|
||||
party: "linear-gradient(to bottom, rgba(162, 28, 175, 0.6), rgba(131, 24, 67, 0.5), rgba(59, 7, 100, 0.4))",
|
||||
focus: "linear-gradient(to bottom, rgba(91, 33, 182, 0.6), rgba(88, 28, 135, 0.5), rgba(15, 23, 42, 0.4))",
|
||||
melancholy:
|
||||
"linear-gradient(to bottom, rgba(51, 65, 85, 0.6), rgba(30, 58, 138, 0.5), rgba(17, 24, 39, 0.4))",
|
||||
aggressive:
|
||||
"linear-gradient(to bottom, rgba(69, 10, 10, 0.7), rgba(17, 24, 39, 0.6), rgba(0, 0, 0, 0.5))",
|
||||
acoustic:
|
||||
"linear-gradient(to bottom, rgba(146, 64, 14, 0.6), rgba(124, 45, 18, 0.5), rgba(68, 64, 60, 0.4))",
|
||||
};
|
||||
|
||||
interface TrackWithAnalysis {
|
||||
id: string;
|
||||
analysisMode: string | null;
|
||||
moodHappy: number | null;
|
||||
moodSad: number | null;
|
||||
moodRelaxed: number | null;
|
||||
moodAggressive: number | null;
|
||||
moodParty: number | null;
|
||||
moodAcoustic: number | null;
|
||||
moodElectronic: number | null;
|
||||
valence: number | null;
|
||||
energy: number | null;
|
||||
arousal: number | null;
|
||||
danceability: number | null;
|
||||
acousticness: number | null;
|
||||
instrumentalness: number | null;
|
||||
bpm: number | null;
|
||||
keyScale: string | null;
|
||||
}
|
||||
|
||||
export class MoodBucketService {
|
||||
/**
|
||||
* Calculate mood scores for a track and assign to appropriate buckets
|
||||
* Called after audio analysis completes
|
||||
* Returns array of mood names the track was assigned to
|
||||
*/
|
||||
async assignTrackToMoods(trackId: string): Promise<string[]> {
|
||||
const track = await prisma.track.findUnique({
|
||||
where: { id: trackId },
|
||||
select: {
|
||||
id: true,
|
||||
analysisStatus: true,
|
||||
analysisMode: true,
|
||||
moodHappy: true,
|
||||
moodSad: true,
|
||||
moodRelaxed: true,
|
||||
moodAggressive: true,
|
||||
moodParty: true,
|
||||
moodAcoustic: true,
|
||||
moodElectronic: true,
|
||||
valence: true,
|
||||
energy: true,
|
||||
arousal: true,
|
||||
danceability: true,
|
||||
acousticness: true,
|
||||
instrumentalness: true,
|
||||
bpm: true,
|
||||
keyScale: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!track || track.analysisStatus !== "completed") {
|
||||
console.log(
|
||||
`[MoodBucket] Track ${trackId} not analyzed yet, skipping`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const moodScores = this.calculateMoodScores(track);
|
||||
|
||||
// Upsert mood bucket entries for each mood with score > 0
|
||||
const upsertPromises = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood, score]) =>
|
||||
prisma.moodBucket.upsert({
|
||||
where: {
|
||||
trackId_mood: { trackId, mood },
|
||||
},
|
||||
create: {
|
||||
trackId,
|
||||
mood,
|
||||
score,
|
||||
},
|
||||
update: {
|
||||
score,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Also delete mood buckets where score dropped to 0
|
||||
const deletePromises = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score === 0)
|
||||
.map(([mood]) =>
|
||||
prisma.moodBucket.deleteMany({
|
||||
where: { trackId, mood },
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all([...upsertPromises, ...deletePromises]);
|
||||
|
||||
const assignedMoods = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood]) => mood);
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Track ${trackId} assigned to moods: ${
|
||||
assignedMoods.join(", ") || "none"
|
||||
}`
|
||||
);
|
||||
|
||||
return assignedMoods;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate mood scores for a track based on its audio features
|
||||
* Returns a score 0-1 for each mood (0 = not matching, 1 = perfect match)
|
||||
*/
|
||||
calculateMoodScores(track: TrackWithAnalysis): Record<MoodType, number> {
|
||||
const isEnhanced = track.analysisMode === "enhanced";
|
||||
const scores: Record<MoodType, number> = {
|
||||
happy: 0,
|
||||
sad: 0,
|
||||
chill: 0,
|
||||
energetic: 0,
|
||||
party: 0,
|
||||
focus: 0,
|
||||
melancholy: 0,
|
||||
aggressive: 0,
|
||||
acoustic: 0,
|
||||
};
|
||||
|
||||
for (const [mood, config] of Object.entries(MOOD_CONFIG)) {
|
||||
const rules = isEnhanced ? config.primary : config.fallback;
|
||||
const score = this.evaluateMoodRules(track, rules);
|
||||
scores[mood as MoodType] = score;
|
||||
}
|
||||
|
||||
return scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate mood rules against track features
|
||||
* Returns a score 0-1 based on how well the track matches the rules
|
||||
*/
|
||||
private evaluateMoodRules(
|
||||
track: TrackWithAnalysis,
|
||||
rules: Record<string, any>
|
||||
): number {
|
||||
let totalScore = 0;
|
||||
let ruleCount = 0;
|
||||
|
||||
for (const [field, constraints] of Object.entries(rules)) {
|
||||
const value = track[field as keyof TrackWithAnalysis];
|
||||
|
||||
// Skip if value is null
|
||||
if (value === null || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ruleCount++;
|
||||
|
||||
// Handle string equality (e.g., keyScale: "minor")
|
||||
if (typeof constraints === "string") {
|
||||
totalScore += value === constraints ? 1 : 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle numeric range constraints
|
||||
const numValue = value as number;
|
||||
const { min, max } = constraints as { min?: number; max?: number };
|
||||
|
||||
// Calculate how well the value matches the constraint
|
||||
let fieldScore = 0;
|
||||
|
||||
if (min !== undefined && max !== undefined) {
|
||||
// Range constraint - value should be between min and max
|
||||
if (numValue >= min && numValue <= max) {
|
||||
// Perfect match in range
|
||||
fieldScore = 1;
|
||||
} else if (numValue < min) {
|
||||
// Below range - linearly decrease score
|
||||
fieldScore = Math.max(0, 1 - (min - numValue) * 2);
|
||||
} else {
|
||||
// Above range - linearly decrease score
|
||||
fieldScore = Math.max(0, 1 - (numValue - max) * 2);
|
||||
}
|
||||
} else if (min !== undefined) {
|
||||
// Minimum constraint - higher is better
|
||||
if (numValue >= min) {
|
||||
// Score increases with value above threshold
|
||||
fieldScore = Math.min(1, 0.5 + (numValue - min) * 0.5);
|
||||
} else {
|
||||
// Below minimum - partial credit
|
||||
fieldScore = Math.max(0, (numValue / min) * 0.5);
|
||||
}
|
||||
} else if (max !== undefined) {
|
||||
// Maximum constraint - lower is better
|
||||
if (numValue <= max) {
|
||||
// Score increases as value decreases below threshold
|
||||
fieldScore = Math.min(1, 0.5 + (max - numValue) * 0.5);
|
||||
} else {
|
||||
// Above maximum - partial credit
|
||||
fieldScore = Math.max(
|
||||
0,
|
||||
((1 - numValue) / (1 - max)) * 0.5
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
totalScore += fieldScore;
|
||||
}
|
||||
|
||||
// No rules matched (missing data)
|
||||
if (ruleCount === 0) return 0;
|
||||
|
||||
// Average score across all rules, with minimum threshold
|
||||
const avgScore = totalScore / ruleCount;
|
||||
|
||||
// Only assign to mood if score is above 0.5 threshold
|
||||
return avgScore >= 0.5 ? avgScore : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mood presets with track counts for the UI
|
||||
*/
|
||||
async getMoodPresets(): Promise<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
trackCount: number;
|
||||
}[]
|
||||
> {
|
||||
// Count tracks per mood in parallel
|
||||
const countPromises = VALID_MOODS.map(async (mood) => {
|
||||
const count = await prisma.moodBucket.count({
|
||||
where: { mood, score: { gte: 0.5 } },
|
||||
});
|
||||
const config = MOOD_CONFIG[mood];
|
||||
return {
|
||||
id: mood,
|
||||
name: config.name,
|
||||
color: config.color,
|
||||
icon: config.icon,
|
||||
trackCount: count,
|
||||
};
|
||||
});
|
||||
|
||||
return Promise.all(countPromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a mood mix for a specific mood
|
||||
* Fast lookup from pre-computed MoodBucket table
|
||||
*/
|
||||
async getMoodMix(
|
||||
mood: MoodType,
|
||||
limit: number = 15
|
||||
): Promise<{
|
||||
id: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
} | null> {
|
||||
if (!VALID_MOODS.includes(mood)) {
|
||||
throw new Error(`Invalid mood: ${mood}`);
|
||||
}
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
|
||||
// Get top tracks for this mood, randomly sampled
|
||||
// First get IDs with high scores, then randomly select
|
||||
const moodBuckets = await prisma.moodBucket.findMany({
|
||||
where: { mood, score: { gte: 0.5 } },
|
||||
select: { trackId: true, score: true },
|
||||
orderBy: { score: "desc" },
|
||||
take: 100, // Pool to sample from
|
||||
});
|
||||
|
||||
if (moodBuckets.length < 8) {
|
||||
console.log(
|
||||
`[MoodBucket] Not enough tracks for mood ${mood}: ${moodBuckets.length}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Randomly sample from the pool
|
||||
const shuffled = [...moodBuckets].sort(() => Math.random() - 0.5);
|
||||
const selectedIds = shuffled.slice(0, limit).map((b) => b.trackId);
|
||||
|
||||
// Get cover URLs for the selected tracks
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { id: { in: selectedIds } },
|
||||
select: {
|
||||
id: true,
|
||||
album: { select: { coverUrl: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Preserve order of selectedIds
|
||||
const orderedTracks = selectedIds
|
||||
.map((id) => tracks.find((t) => t.id === id))
|
||||
.filter(Boolean);
|
||||
const coverUrls = orderedTracks
|
||||
.filter((t) => t?.album.coverUrl)
|
||||
.slice(0, 4)
|
||||
.map((t) => t!.album.coverUrl!);
|
||||
|
||||
const timestamp = Date.now();
|
||||
return {
|
||||
id: `mood-${mood}-${timestamp}`,
|
||||
mood,
|
||||
name: `${config.name} Mix`,
|
||||
description: `Tracks that match your ${config.name.toLowerCase()} vibe`,
|
||||
trackIds: orderedTracks.map((t) => t!.id),
|
||||
coverUrls,
|
||||
trackCount: orderedTracks.length,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a mood mix as the user's active mood mix
|
||||
* Returns the saved mix for immediate UI update
|
||||
*/
|
||||
async saveUserMoodMix(
|
||||
userId: string,
|
||||
mood: MoodType,
|
||||
limit: number = 15
|
||||
): Promise<{
|
||||
id: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
generatedAt: string;
|
||||
} | null> {
|
||||
// Generate a fresh mix
|
||||
const mix = await this.getMoodMix(mood, limit);
|
||||
if (!mix) return null;
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
const generatedAt = new Date();
|
||||
|
||||
// Upsert the user's mood mix
|
||||
await prisma.userMoodMix.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
mood,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
generatedAt,
|
||||
},
|
||||
update: {
|
||||
mood,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
generatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Saved ${mood} mix for user ${userId} (${mix.trackCount} tracks)`
|
||||
);
|
||||
|
||||
// Return with user-specific naming
|
||||
return {
|
||||
id: `your-mood-mix-${generatedAt.getTime()}`,
|
||||
mood,
|
||||
name: `Your ${config.name} Mix`,
|
||||
description: `Based on your ${config.name.toLowerCase()} preferences`,
|
||||
trackIds: mix.trackIds,
|
||||
coverUrls: mix.coverUrls,
|
||||
trackCount: mix.trackCount,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current saved mood mix for display on home page
|
||||
*/
|
||||
async getUserMoodMix(userId: string): Promise<{
|
||||
id: string;
|
||||
type: string;
|
||||
mood: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trackIds: string[];
|
||||
coverUrls: string[];
|
||||
trackCount: number;
|
||||
color: string;
|
||||
} | null> {
|
||||
const userMix = await prisma.userMoodMix.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
if (!userMix) return null;
|
||||
|
||||
const mood = userMix.mood as MoodType;
|
||||
if (!VALID_MOODS.includes(mood)) return null;
|
||||
|
||||
const config = MOOD_CONFIG[mood];
|
||||
|
||||
return {
|
||||
id: `your-mood-mix-${userMix.generatedAt.getTime()}`,
|
||||
type: "mood",
|
||||
mood,
|
||||
name: `Your ${config.name} Mix`,
|
||||
description: `Based on your ${config.name.toLowerCase()} preferences`,
|
||||
trackIds: userMix.trackIds,
|
||||
coverUrls: userMix.coverUrls,
|
||||
trackCount: userMix.trackIds.length,
|
||||
color: MOOD_GRADIENTS[mood],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill mood buckets for all analyzed tracks
|
||||
* Used for initial population or after schema changes
|
||||
*/
|
||||
async backfillAllTracks(
|
||||
batchSize: number = 100
|
||||
): Promise<{ processed: number; assigned: number }> {
|
||||
let processed = 0;
|
||||
let assigned = 0;
|
||||
let skip = 0;
|
||||
|
||||
console.log("[MoodBucket] Starting backfill of all analyzed tracks...");
|
||||
|
||||
while (true) {
|
||||
const tracks = await prisma.track.findMany({
|
||||
where: { analysisStatus: "completed" },
|
||||
select: {
|
||||
id: true,
|
||||
analysisMode: true,
|
||||
moodHappy: true,
|
||||
moodSad: true,
|
||||
moodRelaxed: true,
|
||||
moodAggressive: true,
|
||||
moodParty: true,
|
||||
moodAcoustic: true,
|
||||
moodElectronic: true,
|
||||
valence: true,
|
||||
energy: true,
|
||||
arousal: true,
|
||||
danceability: true,
|
||||
acousticness: true,
|
||||
instrumentalness: true,
|
||||
bpm: true,
|
||||
keyScale: true,
|
||||
},
|
||||
skip,
|
||||
take: batchSize,
|
||||
});
|
||||
|
||||
if (tracks.length === 0) break;
|
||||
|
||||
for (const track of tracks) {
|
||||
const moodScores = this.calculateMoodScores(track);
|
||||
const moodsToAssign = Object.entries(moodScores)
|
||||
.filter(([_, score]) => score > 0)
|
||||
.map(([mood, score]) => ({
|
||||
trackId: track.id,
|
||||
mood,
|
||||
score,
|
||||
}));
|
||||
|
||||
if (moodsToAssign.length > 0) {
|
||||
// Use upsert for each mood
|
||||
await Promise.all(
|
||||
moodsToAssign.map((data) =>
|
||||
prisma.moodBucket.upsert({
|
||||
where: {
|
||||
trackId_mood: {
|
||||
trackId: data.trackId,
|
||||
mood: data.mood,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
trackId: data.trackId,
|
||||
mood: data.mood,
|
||||
score: data.score,
|
||||
},
|
||||
update: {
|
||||
score: data.score,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
assigned += moodsToAssign.length;
|
||||
}
|
||||
|
||||
processed++;
|
||||
}
|
||||
|
||||
skip += batchSize;
|
||||
console.log(
|
||||
`[MoodBucket] Backfill progress: ${processed} tracks processed, ${assigned} mood assignments`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[MoodBucket] Backfill complete: ${processed} tracks processed, ${assigned} mood assignments`
|
||||
);
|
||||
return { processed, assigned };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mood bucket data for a track
|
||||
* Used when a track is re-analyzed
|
||||
*/
|
||||
async clearTrackMoods(trackId: string): Promise<void> {
|
||||
await prisma.moodBucket.deleteMany({
|
||||
where: { trackId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const moodBucketService = new MoodBucketService();
|
||||
Reference in New Issue
Block a user