mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Defer initial queries for genres, studios, watchlist until after startup
This commit is contained in:
1 parent
e434a5461f
commit
e8f9690712
4 files changed
+361
-22
No files matched your search
@@ -6065,6 +6065,10 @@ window.KefinTweaksConfig = ${JSON.stringify(configToBackup, null, 2)};`;
|
||||
// Load user config script after a short delay to ensure this script is fully initialized
|
||||
//setTimeout(loadUserConfigurationScript, 100);
|
||||
|
||||
runStartupDefaultSectionSync().catch(err => ERR('Startup default section sync failed:', err));
|
||||
// Defer admin library/studio catalog sync so it does not compete with home first paint
|
||||
const STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS = 15000;
|
||||
setTimeout(() => {
|
||||
runStartupDefaultSectionSync().catch(err => ERR('Startup default section sync failed:', err));
|
||||
}, STARTUP_DEFAULT_SECTION_SYNC_DELAY_MS);
|
||||
|
||||
})();
|
||||
+178
-12
@@ -1014,23 +1014,37 @@
|
||||
LOG('Queue updated successfully');
|
||||
},
|
||||
/**
|
||||
* Gets a genre ID from cached movie genres
|
||||
* Lookup a genre ID from the local movie-genres catalog (localStorage / memory).
|
||||
* Never awaits network — schedules a background sync when missing or older than 24h.
|
||||
* @param {string} genreName - Name of the genre to find
|
||||
* @returns {Promise<string|null>} - Genre ID or null if not found
|
||||
* @returns {Promise<string|null>} - Genre ID or null if not found / not yet cached
|
||||
*/
|
||||
getGenreId: async function(genreName) {
|
||||
if (!cachedMovieGenres) {
|
||||
const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`;
|
||||
// Use getData to fetch the genres with caching
|
||||
const genres = await this.getData(url, true, GENRE_TTL);
|
||||
cachedMovieGenres = genres.Items;
|
||||
if (!genreName) return null;
|
||||
|
||||
hydrateMovieGenresFromStorage();
|
||||
const empty = !cachedMovieGenres || cachedMovieGenres.length === 0;
|
||||
if (empty || isMovieGenresSyncDue()) {
|
||||
ensureGenreSyncScheduled();
|
||||
}
|
||||
|
||||
// Find the genre by name (case insensitive)
|
||||
const genre = cachedMovieGenres.find(g => g.Name.toLowerCase() === genreName.toLowerCase());
|
||||
if (!cachedMovieGenres || cachedMovieGenres.length === 0) return null;
|
||||
|
||||
const needle = String(genreName).toLowerCase();
|
||||
const genre = cachedMovieGenres.find((g) => g?.Name && g.Name.toLowerCase() === needle);
|
||||
return genre ? genre.Id : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to movie-genres catalog sync completion (localStorage + memory refreshed).
|
||||
* @param {Function} callback
|
||||
* @returns {Function} unsubscribe
|
||||
*/
|
||||
onMovieGenresSynced: function(callback) {
|
||||
if (typeof callback !== 'function') return () => {};
|
||||
movieGenresSyncedListeners.add(callback);
|
||||
return () => movieGenresSyncedListeners.delete(callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* Build query URL from section query configuration
|
||||
* Handles minAge/maxAge conversion to MinPremiereDate/MaxPremiereDate
|
||||
@@ -1336,10 +1350,162 @@
|
||||
};
|
||||
|
||||
const GENRE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const GENRE_SETTLE_DELAY_MS = 1500;
|
||||
let cachedMovieGenres = null;
|
||||
|
||||
let movieGenresLastSyncedAt = null;
|
||||
let genreSyncScheduledPromise = null;
|
||||
let genreSyncInFlightPromise = null;
|
||||
const movieGenresSyncedListeners = new Set();
|
||||
|
||||
function normalizeServerAddressForStorage(serverAddress) {
|
||||
if (!serverAddress) return '';
|
||||
return String(serverAddress).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getMovieGenresStorageKey() {
|
||||
try {
|
||||
const serverAddress = normalizeServerAddressForStorage(
|
||||
(typeof ApiClient !== 'undefined' && ApiClient?.serverAddress?.()) || ''
|
||||
);
|
||||
if (!serverAddress) return null;
|
||||
return `kefinTweaks_movieGenres_${encodeURIComponent(serverAddress)}`;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readMovieGenresFromStorage() {
|
||||
const key = getMovieGenresStorageKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || !Array.isArray(parsed.items)) return null;
|
||||
return {
|
||||
items: parsed.items.filter((g) => g && g.Id && g.Name),
|
||||
lastSyncedAt: Number(parsed.lastSyncedAt) || 0
|
||||
};
|
||||
} catch (err) {
|
||||
WARN('Failed to read movie genres from localStorage:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeMovieGenresToStorage(items, lastSyncedAt) {
|
||||
const key = getMovieGenresStorageKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify({
|
||||
items: (items || []).map((g) => ({ Id: g.Id, Name: g.Name })),
|
||||
lastSyncedAt: lastSyncedAt || Date.now()
|
||||
}));
|
||||
} catch (err) {
|
||||
WARN('Failed to write movie genres to localStorage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateMovieGenresFromStorage() {
|
||||
if (cachedMovieGenres && cachedMovieGenres.length > 0) return true;
|
||||
const stored = readMovieGenresFromStorage();
|
||||
if (!stored?.items?.length) return false;
|
||||
cachedMovieGenres = stored.items;
|
||||
movieGenresLastSyncedAt = stored.lastSyncedAt || movieGenresLastSyncedAt;
|
||||
LOG(`Hydrated movie genres from localStorage: ${cachedMovieGenres.length} items`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMovieGenresSyncDue() {
|
||||
const stored = readMovieGenresFromStorage();
|
||||
const last = movieGenresLastSyncedAt || stored?.lastSyncedAt || 0;
|
||||
const hasItems = (cachedMovieGenres && cachedMovieGenres.length > 0) || (stored?.items?.length > 0);
|
||||
if (!hasItems) return true;
|
||||
if (!last) return true;
|
||||
return (Date.now() - last) >= GENRE_TTL;
|
||||
}
|
||||
|
||||
function waitForDocumentComplete() {
|
||||
if (document.readyState === 'complete') return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
window.addEventListener('load', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function notifyMovieGenresSynced() {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('kefinTweaksMovieGenresSynced'));
|
||||
} catch (_) { /* ignore */ }
|
||||
movieGenresSyncedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
WARN('onMovieGenresSynced listener failed:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runMovieGenresSync() {
|
||||
if (genreSyncInFlightPromise) return genreSyncInFlightPromise;
|
||||
|
||||
genreSyncInFlightPromise = (async () => {
|
||||
try {
|
||||
if (window.userHelper?.waitForLogin) {
|
||||
await window.userHelper.waitForLogin();
|
||||
}
|
||||
ensureApiClient();
|
||||
const url = `${ApiClient.serverAddress()}/Genres?IncludeItemTypes=Movie`;
|
||||
const genres = await apiHelper.getData(url, true, GENRE_TTL);
|
||||
const items = Array.isArray(genres?.Items) ? genres.Items : [];
|
||||
cachedMovieGenres = items;
|
||||
movieGenresLastSyncedAt = Date.now();
|
||||
writeMovieGenresToStorage(items, movieGenresLastSyncedAt);
|
||||
LOG(`Synced movie genres: ${items.length} items`);
|
||||
notifyMovieGenresSynced();
|
||||
} catch (err) {
|
||||
ERR('Failed to sync movie genres:', err);
|
||||
} finally {
|
||||
genreSyncInFlightPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return genreSyncInFlightPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduped: after settle, sync Genres when cache is missing or older than 24h.
|
||||
*/
|
||||
function ensureGenreSyncScheduled() {
|
||||
hydrateMovieGenresFromStorage();
|
||||
if (!isMovieGenresSyncDue()) {
|
||||
return genreSyncScheduledPromise || Promise.resolve();
|
||||
}
|
||||
if (genreSyncScheduledPromise) return genreSyncScheduledPromise;
|
||||
|
||||
genreSyncScheduledPromise = (async () => {
|
||||
try {
|
||||
if (window.userHelper?.waitForLogin) {
|
||||
await window.userHelper.waitForLogin();
|
||||
}
|
||||
await waitForDocumentComplete();
|
||||
await new Promise((resolve) => setTimeout(resolve, GENRE_SETTLE_DELAY_MS));
|
||||
hydrateMovieGenresFromStorage();
|
||||
if (!isMovieGenresSyncDue()) return;
|
||||
await runMovieGenresSync();
|
||||
} catch (err) {
|
||||
ERR('Failed to schedule movie genres sync:', err);
|
||||
} finally {
|
||||
genreSyncScheduledPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return genreSyncScheduledPromise;
|
||||
}
|
||||
|
||||
// Expose apiHelper to global window object
|
||||
window.apiHelper = apiHelper;
|
||||
|
||||
|
||||
// Daily / empty-cache sync without waiting for a getGenreId caller
|
||||
ensureGenreSyncScheduled();
|
||||
|
||||
console.log('[KefinTweaks APIHelper] Module loaded and available at window.apiHelper');
|
||||
})();
|
||||
@@ -95,11 +95,89 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** Section configs waiting for movie-genres catalog before See All can resolve. */
|
||||
const pendingGenreViewMoreConfigs = new Set();
|
||||
|
||||
function sectionNeedsGenreNameResolve(sectionConfig) {
|
||||
if (!sectionConfig || sectionConfig.viewMoreUrl) return false;
|
||||
const genres = sectionConfig.queries?.[0]?.queryOptions?.Genres;
|
||||
if (typeof genres !== 'string' || !genres) return false;
|
||||
return !/^\d+$/.test(genres);
|
||||
}
|
||||
|
||||
function applyViewMoreUrlToSection(sectionConfig, viewMoreUrl) {
|
||||
if (!sectionConfig || !viewMoreUrl) return;
|
||||
sectionConfig.viewMoreUrl = viewMoreUrl;
|
||||
const id = sectionConfig.id;
|
||||
if (!id) return;
|
||||
const escaped = typeof CSS !== 'undefined' && CSS.escape
|
||||
? CSS.escape(id)
|
||||
: String(id).replace(/["\\]/g, '\\$&');
|
||||
document.querySelectorAll(
|
||||
`.verticalSection[data-section-id="${escaped}"], .spotlight-section[data-section-id="${escaped}"]`
|
||||
).forEach((sectionElement) => {
|
||||
const existingLink =
|
||||
sectionElement.querySelector('a.sectionTitle-link') ||
|
||||
sectionElement.querySelector('.spotlight-section-title a.emby-tab-button');
|
||||
if (existingLink) {
|
||||
existingLink.href = viewMoreUrl;
|
||||
return;
|
||||
}
|
||||
const plainTitle = sectionElement.querySelector(
|
||||
'.sectionTitleContainer .sectionTitle.sectionTitle-cards:not(a)'
|
||||
);
|
||||
if (plainTitle) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'sectionTitle sectionTitle-cards sectionTitle-link emby-button';
|
||||
link.href = viewMoreUrl;
|
||||
link.textContent = plainTitle.textContent;
|
||||
plainTitle.replaceWith(link);
|
||||
return;
|
||||
}
|
||||
const spotTitle = sectionElement.querySelector(
|
||||
'.spotlight-section-title .emby-tab-button:not(a)'
|
||||
);
|
||||
if (spotTitle) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'emby-tab-button emby-tab-button-active emby-button';
|
||||
link.href = viewMoreUrl;
|
||||
link.title = 'See All';
|
||||
link.style.textDecoration = 'none';
|
||||
link.textContent = spotTitle.textContent;
|
||||
spotTitle.replaceWith(link);
|
||||
sectionElement.querySelector('.spotlight-section-title')?.classList.remove('spotlight-title-link');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleViewMoreUrl(sectionConfig) {
|
||||
resolveViewMoreUrl(sectionConfig).then((viewMoreUrl) => {
|
||||
if (viewMoreUrl) sectionConfig.viewMoreUrl = viewMoreUrl;
|
||||
if (viewMoreUrl) {
|
||||
applyViewMoreUrlToSection(sectionConfig, viewMoreUrl);
|
||||
pendingGenreViewMoreConfigs.delete(sectionConfig);
|
||||
return;
|
||||
}
|
||||
if (sectionNeedsGenreNameResolve(sectionConfig)) {
|
||||
pendingGenreViewMoreConfigs.add(sectionConfig);
|
||||
}
|
||||
}).catch((err) => {
|
||||
WARN(`Failed to resolve viewMoreUrl for ${sectionConfig?.id}:`, err);
|
||||
if (sectionNeedsGenreNameResolve(sectionConfig)) {
|
||||
pendingGenreViewMoreConfigs.add(sectionConfig);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reschedulePendingGenreViewMoreUrls() {
|
||||
const pending = Array.from(pendingGenreViewMoreConfigs);
|
||||
pending.forEach((sectionConfig) => {
|
||||
resolveViewMoreUrl(sectionConfig).then((viewMoreUrl) => {
|
||||
if (!viewMoreUrl) return;
|
||||
applyViewMoreUrlToSection(sectionConfig, viewMoreUrl);
|
||||
pendingGenreViewMoreConfigs.delete(sectionConfig);
|
||||
}).catch((err) => {
|
||||
WARN(`Failed to re-resolve viewMoreUrl for ${sectionConfig?.id}:`, err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -477,14 +555,13 @@
|
||||
return `#/list.html?type=tag&tag=${encodeURIComponent(queryOptions.Tags)}&serverId=${serverId}`;
|
||||
}
|
||||
|
||||
// Check for Genres
|
||||
// Check for Genres (name → Id via local catalog; never use unresolved names as genreId)
|
||||
if (queryOptions.Genres) {
|
||||
let genreId = queryOptions.Genres;
|
||||
if (typeof genreId === 'string' && !genreId.match(/^\d+$/)) {
|
||||
const resolvedId = await ApiHelper.getGenreId(genreId);
|
||||
if (resolvedId) {
|
||||
genreId = resolvedId;
|
||||
}
|
||||
if (!resolvedId) return null;
|
||||
genreId = resolvedId;
|
||||
}
|
||||
return `#/list.html?genreId=${genreId}&serverId=${serverId}`;
|
||||
}
|
||||
@@ -1252,5 +1329,15 @@
|
||||
isEmptyDiscoverySourceTemplate
|
||||
};
|
||||
|
||||
// After movie-genres catalog sync, fill in See All links that waited on name→Id
|
||||
function bindMovieGenresSynced() {
|
||||
const api = window.apiHelper || ApiHelper;
|
||||
if (api?.onMovieGenresSynced) {
|
||||
api.onMovieGenresSynced(reschedulePendingGenreViewMoreUrls);
|
||||
}
|
||||
window.addEventListener('kefinTweaksMovieGenresSynced', reschedulePendingGenreViewMoreUrls);
|
||||
}
|
||||
bindMovieGenresSynced();
|
||||
|
||||
LOG('sectionHelper ready');
|
||||
})();
|
||||
+86
-4
@@ -3132,11 +3132,67 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
const watchlistLikedIds = new Set();
|
||||
let watchlistLikedIdsLoaded = false;
|
||||
let watchlistLikedIdsLoadPromise = null;
|
||||
let watchlistLikedIdsBaselineScheduled = false;
|
||||
const WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS = 15000;
|
||||
|
||||
function isWatchlistSupportedType(itemType) {
|
||||
return WATCHLIST_SUPPORTED_TYPES.includes(itemType);
|
||||
}
|
||||
|
||||
function normalizeServerAddressForStorage(serverAddress) {
|
||||
if (!serverAddress) return '';
|
||||
return String(serverAddress).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getLikedIdsStorageKey() {
|
||||
const userId = window.ApiClient?.getCurrentUserId?.();
|
||||
const serverAddress = normalizeServerAddressForStorage(window.ApiClient?.serverAddress?.());
|
||||
if (!userId || !serverAddress) return null;
|
||||
return `kefinTweaks_watchlistLikedIds_${userId}_${encodeURIComponent(serverAddress)}`;
|
||||
}
|
||||
|
||||
function readLikedIdsFromStorage() {
|
||||
const key = getLikedIdsStorageKey();
|
||||
if (!key) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((id) => typeof id === 'string' && id);
|
||||
} catch (err) {
|
||||
WARN('Failed to read watchlist liked Ids from localStorage:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeLikedIdsToStorage() {
|
||||
const key = getLikedIdsStorageKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify([...watchlistLikedIds]));
|
||||
} catch (err) {
|
||||
WARN('Failed to write watchlist liked Ids to localStorage:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateWatchlistLikedIdsFromStorage() {
|
||||
const ids = readLikedIdsFromStorage();
|
||||
if (!ids) return false;
|
||||
watchlistLikedIds.clear();
|
||||
ids.forEach((id) => watchlistLikedIds.add(id));
|
||||
watchlistLikedIdsLoaded = true;
|
||||
LOG(`Hydrated watchlist liked Id cache from localStorage: ${watchlistLikedIds.size} items`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function waitForDocumentComplete() {
|
||||
if (document.readyState === 'complete') return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
window.addEventListener('load', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function isItemInLikedIdCache(itemId) {
|
||||
return !!itemId && watchlistLikedIds.has(itemId);
|
||||
}
|
||||
@@ -3145,6 +3201,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
if (!itemId) return;
|
||||
if (isLiked) watchlistLikedIds.add(itemId);
|
||||
else watchlistLikedIds.delete(itemId);
|
||||
writeLikedIdsToStorage();
|
||||
}
|
||||
|
||||
function mergeLikedIdsFromItems(items) {
|
||||
@@ -3152,6 +3209,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
list.forEach((item) => {
|
||||
if (item?.Id) watchlistLikedIds.add(item.Id);
|
||||
});
|
||||
writeLikedIdsToStorage();
|
||||
}
|
||||
|
||||
function applyWatchlistButtonActiveState(button, isActive) {
|
||||
@@ -3167,8 +3225,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWatchlistLikedIdsBaseline() {
|
||||
if (watchlistLikedIdsLoaded) return watchlistLikedIds;
|
||||
/** Network refresh of liked-Id set (always runs when scheduled; not skipped after hydrate). */
|
||||
async function refreshWatchlistLikedIdsFromNetwork() {
|
||||
if (watchlistLikedIdsLoadPromise) return watchlistLikedIdsLoadPromise;
|
||||
|
||||
watchlistLikedIdsLoadPromise = (async () => {
|
||||
@@ -3188,6 +3246,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
watchlistLikedIds.clear();
|
||||
mergeLikedIdsFromItems(result);
|
||||
watchlistLikedIdsLoaded = true;
|
||||
writeLikedIdsToStorage();
|
||||
LOG(`Loaded watchlist liked Id cache: ${watchlistLikedIds.size} items`);
|
||||
syncOverlayWatchlistButtons();
|
||||
processExistingOverlayContainers();
|
||||
@@ -3202,6 +3261,28 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
return watchlistLikedIdsLoadPromise;
|
||||
}
|
||||
|
||||
async function scheduleWatchlistLikedIdsBaseline() {
|
||||
if (watchlistLikedIdsBaselineScheduled) return;
|
||||
watchlistLikedIdsBaselineScheduled = true;
|
||||
|
||||
try {
|
||||
if (window.userHelper?.waitForLogin) {
|
||||
await window.userHelper.waitForLogin();
|
||||
}
|
||||
|
||||
if (hydrateWatchlistLikedIdsFromStorage()) {
|
||||
syncOverlayWatchlistButtons();
|
||||
processExistingOverlayContainers();
|
||||
}
|
||||
|
||||
await waitForDocumentComplete();
|
||||
await new Promise((resolve) => setTimeout(resolve, WATCHLIST_LIKED_IDS_SETTLE_DELAY_MS));
|
||||
await refreshWatchlistLikedIdsFromNetwork();
|
||||
} catch (err) {
|
||||
ERR('Failed to schedule watchlist liked Id baseline:', err);
|
||||
}
|
||||
}
|
||||
|
||||
let watchlistRenderGeneration = 0;
|
||||
|
||||
function resetWatchlistItemsByType() {
|
||||
@@ -4876,6 +4957,7 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
resetWatchlistItemsByType();
|
||||
watchlistLikedIds.clear();
|
||||
watchlistLikedIdsLoaded = true;
|
||||
writeLikedIdsToStorage();
|
||||
syncOverlayWatchlistButtons();
|
||||
|
||||
// Refresh watchlist
|
||||
@@ -7750,8 +7832,8 @@ In the Custom Tabs plugin, add a new tab with the following HTML content:
|
||||
// Initialize the observer
|
||||
setupWatchlistButtonObserver();
|
||||
|
||||
// Baseline liked-Id cache for overlay button state (one query after login)
|
||||
loadWatchlistLikedIdsBaseline();
|
||||
// Hydrate liked-Id cache from localStorage; network refresh after settle + 5s
|
||||
scheduleWatchlistLikedIdsBaseline();
|
||||
|
||||
// Expose helpers so cardBuilder can keep overlay buttons / Set aligned on UserData.Likes
|
||||
window.KefinWatchlistLikedIds = {
|
||||
|
||||
Reference in new issue
Block a user