mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-07-08 02:34:19 -04:00
Backport pull request #13227 from jellyfin/release-10.10.z
Fix EPG image caching Original-merge: b9881b8bdf650a39cbf8f0f98d9a970266fec90a Merged-by: crobibero <cody@robibe.ro> Backported-by: Bond_009 <bond.009@outlook.com>
This commit is contained in:
parent
eac491fbd3
commit
c44006c20d
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Data.Entities.Libraries;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using Jellyfin.LiveTv.Configuration;
|
using Jellyfin.LiveTv.Configuration;
|
||||||
@ -39,6 +40,11 @@ public class GuideManager : IGuideManager
|
|||||||
private readonly IRecordingsManager _recordingsManager;
|
private readonly IRecordingsManager _recordingsManager;
|
||||||
private readonly LiveTvDtoService _tvDtoService;
|
private readonly LiveTvDtoService _tvDtoService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Amount of days images are pre-cached from external sources.
|
||||||
|
/// </summary>
|
||||||
|
public const int MaxCacheDays = 2;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="GuideManager"/> class.
|
/// Initializes a new instance of the <see cref="GuideManager"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -204,14 +210,14 @@ public class GuideManager : IGuideManager
|
|||||||
progress.Report(15);
|
progress.Report(15);
|
||||||
|
|
||||||
numComplete = 0;
|
numComplete = 0;
|
||||||
var programs = new List<Guid>();
|
var programs = new List<LiveTvProgram>();
|
||||||
var channels = new List<Guid>();
|
var channels = new List<Guid>();
|
||||||
|
|
||||||
var guideDays = GetGuideDays();
|
var guideDays = GetGuideDays();
|
||||||
|
|
||||||
_logger.LogInformation("Refreshing guide with {0} days of guide data", guideDays);
|
_logger.LogInformation("Refreshing guide with {Days} days of guide data", guideDays);
|
||||||
|
|
||||||
var maxCacheDate = DateTime.UtcNow.AddDays(2);
|
var maxCacheDate = DateTime.UtcNow.AddDays(MaxCacheDays);
|
||||||
foreach (var currentChannel in list)
|
foreach (var currentChannel in list)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
@ -237,22 +243,23 @@ public class GuideManager : IGuideManager
|
|||||||
DtoOptions = new DtoOptions(true)
|
DtoOptions = new DtoOptions(true)
|
||||||
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
|
}).Cast<LiveTvProgram>().ToDictionary(i => i.Id);
|
||||||
|
|
||||||
var newPrograms = new List<LiveTvProgram>();
|
var newPrograms = new List<Guid>();
|
||||||
var updatedPrograms = new List<BaseItem>();
|
var updatedPrograms = new List<Guid>();
|
||||||
|
|
||||||
foreach (var program in channelPrograms)
|
foreach (var program in channelPrograms)
|
||||||
{
|
{
|
||||||
var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
|
var (programItem, isNew, isUpdated) = GetProgram(program, existingPrograms, currentChannel);
|
||||||
|
var id = programItem.Id;
|
||||||
if (isNew)
|
if (isNew)
|
||||||
{
|
{
|
||||||
newPrograms.Add(programItem);
|
newPrograms.Add(id);
|
||||||
}
|
}
|
||||||
else if (isUpdated)
|
else if (isUpdated)
|
||||||
{
|
{
|
||||||
updatedPrograms.Add(programItem);
|
updatedPrograms.Add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
programs.Add(programItem.Id);
|
programs.Add(programItem);
|
||||||
|
|
||||||
isMovie |= program.IsMovie;
|
isMovie |= program.IsMovie;
|
||||||
isSeries |= program.IsSeries;
|
isSeries |= program.IsSeries;
|
||||||
@ -261,24 +268,30 @@ public class GuideManager : IGuideManager
|
|||||||
isKids |= program.IsKids;
|
isKids |= program.IsKids;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Channel {0} has {1} new programs and {2} updated programs", currentChannel.Name, newPrograms.Count, updatedPrograms.Count);
|
_logger.LogDebug(
|
||||||
|
"Channel {Name} has {NewCount} new programs and {UpdatedCount} updated programs",
|
||||||
|
currentChannel.Name,
|
||||||
|
newPrograms.Count,
|
||||||
|
updatedPrograms.Count);
|
||||||
|
|
||||||
if (newPrograms.Count > 0)
|
if (newPrograms.Count > 0)
|
||||||
{
|
{
|
||||||
_libraryManager.CreateOrUpdateItems(newPrograms, null, cancellationToken);
|
var newProgramDtos = programs.Where(b => newPrograms.Contains(b.Id)).ToList();
|
||||||
await PrecacheImages(newPrograms, maxCacheDate).ConfigureAwait(false);
|
_libraryManager.CreateItems(newProgramDtos, null, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedPrograms.Count > 0)
|
if (updatedPrograms.Count > 0)
|
||||||
{
|
{
|
||||||
|
var updatedProgramDtos = programs.Where(b => updatedPrograms.Contains(b.Id)).ToList();
|
||||||
await _libraryManager.UpdateItemsAsync(
|
await _libraryManager.UpdateItemsAsync(
|
||||||
updatedPrograms,
|
updatedProgramDtos,
|
||||||
currentChannel,
|
currentChannel,
|
||||||
ItemUpdateType.MetadataImport,
|
ItemUpdateType.MetadataImport,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
await PrecacheImages(updatedPrograms, maxCacheDate).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await PreCacheImages(programs, maxCacheDate).ConfigureAwait(false);
|
||||||
|
|
||||||
currentChannel.IsMovie = isMovie;
|
currentChannel.IsMovie = isMovie;
|
||||||
currentChannel.IsNews = isNews;
|
currentChannel.IsNews = isNews;
|
||||||
currentChannel.IsSports = isSports;
|
currentChannel.IsSports = isSports;
|
||||||
@ -313,7 +326,8 @@ public class GuideManager : IGuideManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress.Report(100);
|
progress.Report(100);
|
||||||
return new Tuple<List<Guid>, List<Guid>>(channels, programs);
|
var programIds = programs.Select(p => p.Id).ToList();
|
||||||
|
return new Tuple<List<Guid>, List<Guid>>(channels, programIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
|
private void CleanDatabase(Guid[] currentIdList, BaseItemKind[] validTypes, IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
@ -618,77 +632,17 @@ public class GuideManager : IGuideManager
|
|||||||
item.IndexNumber = info.EpisodeNumber;
|
item.IndexNumber = info.EpisodeNumber;
|
||||||
item.ParentIndexNumber = info.SeasonNumber;
|
item.ParentIndexNumber = info.SeasonNumber;
|
||||||
|
|
||||||
if (!item.HasImage(ImageType.Primary))
|
forceUpdate = forceUpdate || UpdateImages(item, info);
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(info.ImagePath))
|
|
||||||
{
|
|
||||||
item.SetImage(
|
|
||||||
new ItemImageInfo
|
|
||||||
{
|
|
||||||
Path = info.ImagePath,
|
|
||||||
Type = ImageType.Primary
|
|
||||||
},
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(info.ImageUrl))
|
|
||||||
{
|
|
||||||
item.SetImage(
|
|
||||||
new ItemImageInfo
|
|
||||||
{
|
|
||||||
Path = info.ImageUrl,
|
|
||||||
Type = ImageType.Primary
|
|
||||||
},
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.HasImage(ImageType.Thumb))
|
if (isNew)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(info.ThumbImageUrl))
|
item.OnMetadataChanged();
|
||||||
{
|
|
||||||
item.SetImage(
|
|
||||||
new ItemImageInfo
|
|
||||||
{
|
|
||||||
Path = info.ThumbImageUrl,
|
|
||||||
Type = ImageType.Thumb
|
|
||||||
},
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.HasImage(ImageType.Logo))
|
return (item, isNew, false);
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(info.LogoImageUrl))
|
|
||||||
{
|
|
||||||
item.SetImage(
|
|
||||||
new ItemImageInfo
|
|
||||||
{
|
|
||||||
Path = info.LogoImageUrl,
|
|
||||||
Type = ImageType.Logo
|
|
||||||
},
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.HasImage(ImageType.Backdrop))
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(info.BackdropImageUrl))
|
|
||||||
{
|
|
||||||
item.SetImage(
|
|
||||||
new ItemImageInfo
|
|
||||||
{
|
|
||||||
Path = info.BackdropImageUrl,
|
|
||||||
Type = ImageType.Backdrop
|
|
||||||
},
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var isUpdated = false;
|
var isUpdated = false;
|
||||||
if (isNew)
|
if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
|
||||||
{
|
|
||||||
}
|
|
||||||
else if (forceUpdate || string.IsNullOrWhiteSpace(info.Etag))
|
|
||||||
{
|
{
|
||||||
isUpdated = true;
|
isUpdated = true;
|
||||||
}
|
}
|
||||||
@ -703,7 +657,7 @@ public class GuideManager : IGuideManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNew || isUpdated)
|
if (isUpdated)
|
||||||
{
|
{
|
||||||
item.OnMetadataChanged();
|
item.OnMetadataChanged();
|
||||||
}
|
}
|
||||||
@ -711,7 +665,80 @@ public class GuideManager : IGuideManager
|
|||||||
return (item, isNew, isUpdated);
|
return (item, isNew, isUpdated);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PrecacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
|
private static bool UpdateImages(BaseItem item, ProgramInfo info)
|
||||||
|
{
|
||||||
|
var updated = false;
|
||||||
|
|
||||||
|
// Primary
|
||||||
|
updated |= UpdateImage(ImageType.Primary, item, info);
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
updated |= UpdateImage(ImageType.Thumb, item, info);
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
updated |= UpdateImage(ImageType.Logo, item, info);
|
||||||
|
|
||||||
|
// Backdrop
|
||||||
|
return updated || UpdateImage(ImageType.Backdrop, item, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool UpdateImage(ImageType imageType, BaseItem item, ProgramInfo info)
|
||||||
|
{
|
||||||
|
var image = item.GetImages(imageType).FirstOrDefault();
|
||||||
|
var currentImagePath = image?.Path;
|
||||||
|
var newImagePath = imageType switch
|
||||||
|
{
|
||||||
|
ImageType.Primary => info.ImagePath,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
var newImageUrl = imageType switch
|
||||||
|
{
|
||||||
|
ImageType.Backdrop => info.BackdropImageUrl,
|
||||||
|
ImageType.Logo => info.LogoImageUrl,
|
||||||
|
ImageType.Primary => info.ImageUrl,
|
||||||
|
ImageType.Thumb => info.ThumbImageUrl,
|
||||||
|
_ => string.Empty
|
||||||
|
};
|
||||||
|
|
||||||
|
var differentImage = newImageUrl?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false
|
||||||
|
|| newImagePath?.Equals(currentImagePath, StringComparison.OrdinalIgnoreCase) == false;
|
||||||
|
if (!differentImage)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newImagePath))
|
||||||
|
{
|
||||||
|
item.SetImage(
|
||||||
|
new ItemImageInfo
|
||||||
|
{
|
||||||
|
Path = newImagePath,
|
||||||
|
Type = imageType
|
||||||
|
},
|
||||||
|
0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newImageUrl))
|
||||||
|
{
|
||||||
|
item.SetImage(
|
||||||
|
new ItemImageInfo
|
||||||
|
{
|
||||||
|
Path = newImageUrl,
|
||||||
|
Type = imageType
|
||||||
|
},
|
||||||
|
0);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.RemoveImage(image);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PreCacheImages(IReadOnlyList<BaseItem> programs, DateTime maxCacheDate)
|
||||||
{
|
{
|
||||||
await Parallel.ForEachAsync(
|
await Parallel.ForEachAsync(
|
||||||
programs
|
programs
|
||||||
@ -741,7 +768,7 @@ public class GuideManager : IGuideManager
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Unable to precache {Url}", imageInfo.Path);
|
_logger.LogWarning(ex, "Unable to pre-cache {Url}", imageInfo.Path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ using System.Threading.Tasks;
|
|||||||
using AsyncKeyedLock;
|
using AsyncKeyedLock;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
|
using Jellyfin.LiveTv.Guide;
|
||||||
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller.Authentication;
|
using MediaBrowser.Controller.Authentication;
|
||||||
@ -38,7 +39,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly AsyncNonKeyedLocker _tokenLock = new(1);
|
private readonly AsyncNonKeyedLocker _tokenLock = new(1);
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>();
|
private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
|
||||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
private DateTime _lastErrorResponse;
|
private DateTime _lastErrorResponse;
|
||||||
private bool _disposed = false;
|
private bool _disposed = false;
|
||||||
@ -86,7 +87,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
{
|
{
|
||||||
_logger.LogWarning("SchedulesDirect token is empty, returning empty program list");
|
_logger.LogWarning("SchedulesDirect token is empty, returning empty program list");
|
||||||
|
|
||||||
return Enumerable.Empty<ProgramInfo>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
|
var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
|
||||||
@ -94,7 +95,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
_logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
|
_logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
|
||||||
var requestList = new List<RequestScheduleForChannelDto>()
|
var requestList = new List<RequestScheduleForChannelDto>()
|
||||||
{
|
{
|
||||||
new RequestScheduleForChannelDto()
|
new()
|
||||||
{
|
{
|
||||||
StationId = channelId,
|
StationId = channelId,
|
||||||
Date = dates
|
Date = dates
|
||||||
@ -109,7 +110,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureAwait(false);
|
var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureAwait(false);
|
||||||
if (dailySchedules is null)
|
if (dailySchedules is null)
|
||||||
{
|
{
|
||||||
return Array.Empty<ProgramInfo>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId);
|
_logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId);
|
||||||
@ -120,17 +121,17 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
|
var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
|
||||||
programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
|
programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
|
||||||
|
|
||||||
var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken)
|
var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (programDetails is null)
|
if (programDetails is null)
|
||||||
{
|
{
|
||||||
return Array.Empty<ProgramInfo>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y);
|
var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y);
|
||||||
|
|
||||||
var programIdsWithImages = programDetails
|
var programIdsWithImages = programDetails
|
||||||
.Where(p => p.HasImageArtwork).Select(p => p.ProgramId)
|
.Where(p => p.HasImageArtwork)
|
||||||
|
.Select(p => p.ProgramId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false);
|
var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false);
|
||||||
@ -138,17 +139,15 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
var programsInfo = new List<ProgramInfo>();
|
var programsInfo = new List<ProgramInfo>();
|
||||||
foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs))
|
foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs))
|
||||||
{
|
{
|
||||||
// _logger.LogDebug("Processing Schedule for station ID " + stationID +
|
|
||||||
// " which corresponds to channel " + channelNumber + " and program id " +
|
|
||||||
// schedule.ProgramId + " which says it has images? " +
|
|
||||||
// programDict[schedule.ProgramId].hasImageArtwork);
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(schedule.ProgramId))
|
if (string.IsNullOrEmpty(schedule.ProgramId))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (images is not null)
|
// Only add images which will be pre-cached until we can implement dynamic token fetching
|
||||||
|
var endDate = schedule.AirDateTime?.AddSeconds(schedule.Duration);
|
||||||
|
var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays);
|
||||||
|
if (willBeCached && images is not null)
|
||||||
{
|
{
|
||||||
var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
|
var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
|
||||||
if (imageIndex > -1)
|
if (imageIndex > -1)
|
||||||
@ -456,7 +455,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
|
|
||||||
if (programIds.Count == 0)
|
if (programIds.Count == 0)
|
||||||
{
|
{
|
||||||
return Array.Empty<ShowImagesDto>();
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
|
StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
|
||||||
@ -483,7 +482,7 @@ namespace Jellyfin.LiveTv.Listings
|
|||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error getting image info from schedules direct");
|
_logger.LogError(ex, "Error getting image info from schedules direct");
|
||||||
|
|
||||||
return Array.Empty<ShowImagesDto>();
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user