mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 18:38:01 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf571148bc | ||
|
|
2c2a720ba9 | ||
|
|
b577ef7187 | ||
|
|
ffbb3c3516 | ||
|
|
2a6cf38677 | ||
|
|
d8104a4d7c | ||
|
|
af85ea9219 | ||
|
|
c30e149a36 | ||
|
|
050a4867b7 | ||
|
|
2bf6f7a4f2 | ||
|
|
788a768271 | ||
|
|
022a6e979d | ||
|
|
9fc5a7d834 |
@@ -101,6 +101,11 @@ libationcli liberate -p
|
||||
libationcli liberate --force
|
||||
libationcli liberate -f
|
||||
```
|
||||
#### Liberate using a license file from the `get-license` command
|
||||
```console
|
||||
libationcli liberate --license /path/to/license.lic
|
||||
libationcli liberate --license - < /path/to/license.lic
|
||||
```
|
||||
#### List Libation Settings
|
||||
```console
|
||||
libationcli get-setting
|
||||
@@ -153,7 +158,7 @@ foreach($q in $Qualities){
|
||||
foreach($x in $xHE_AAC){
|
||||
$license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x
|
||||
echo $($license | ConvertFrom-Json).ContentMetadata.content_reference
|
||||
echo $license | ./libationcli liberate --force
|
||||
echo $license | ./libationcli liberate --force --license -
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -26,7 +26,17 @@ namespace AaxDecrypter
|
||||
protected string OutputDirectory { get; }
|
||||
public IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
|
||||
protected virtual long InputFilePosition => InputFileStream.Position;
|
||||
protected virtual long InputFilePosition
|
||||
{
|
||||
get
|
||||
{
|
||||
//Use try/catch instread of checking CanRead to avoid
|
||||
//a race with the background download completing
|
||||
//between the check and the Position call.
|
||||
try { return InputFileStream.Position; }
|
||||
catch { return InputFileStream.Length; }
|
||||
}
|
||||
}
|
||||
private bool downloadFinished;
|
||||
|
||||
private NetworkFileStreamPersister? m_nfsPersister;
|
||||
|
||||
@@ -209,6 +209,12 @@ namespace AaxDecrypter
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Don't throw from DownloadTask.
|
||||
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
|
||||
Serilog.Log.Error(ex, "An error was encountered during the download process.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeFile.Dispose();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>12.7.2.1</Version>
|
||||
<Version>12.7.4.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="14.0.0" />
|
||||
|
||||
@@ -90,6 +90,7 @@ namespace AppScaffolding
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
|
||||
//
|
||||
@@ -102,6 +103,19 @@ namespace AppScaffolding
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
|
||||
/// </summary>
|
||||
private static void DeleteOpenSqliteFiles(Configuration config)
|
||||
{
|
||||
var walFile = SqliteStorage.DatabasePath + "-wal";
|
||||
var shmFile = SqliteStorage.DatabasePath + "-shm";
|
||||
if (File.Exists(walFile))
|
||||
FileManager.FileUtility.SaferDelete(walFile);
|
||||
if (File.Exists(shmFile))
|
||||
FileManager.FileUtility.SaferDelete(shmFile);
|
||||
}
|
||||
|
||||
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
|
||||
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
|
||||
{
|
||||
|
||||
@@ -586,7 +586,7 @@ namespace ApplicationServices
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable, IEnumerable<LibraryBook> LibraryBooks)
|
||||
{
|
||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||
public bool HasPendingBooks => PendingBooks > 0;
|
||||
@@ -655,7 +655,7 @@ namespace ApplicationServices
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace DataLayer.Postgres
|
||||
namespace DataLayer.Sqlite
|
||||
{
|
||||
public class SqliteContextFactory : IDesignTimeDbContextFactory<LibationContext>
|
||||
{
|
||||
|
||||
@@ -73,8 +73,8 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> SaveAndClose();
|
||||
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await SaveAndCloseAsync();
|
||||
public class liberatedComboBoxItem
|
||||
{
|
||||
public LiberatedStatus Status { get; set; }
|
||||
|
||||
@@ -3,6 +3,7 @@ using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
@@ -67,8 +68,17 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
{
|
||||
if (dialog.TryGetCookieManager() is NativeWebViewCookieManager cookieManager)
|
||||
{
|
||||
foreach (System.Net.Cookie c in shoiceIn.SignInCookies)
|
||||
cookieManager.AddOrUpdateCookie(c);
|
||||
foreach (System.Net.Cookie c in shoiceIn.SignInCookies ?? [])
|
||||
{
|
||||
try
|
||||
{
|
||||
cookieManager.AddOrUpdateCookie(c);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to set cookie {c.Name} for domain {c.Domain}");
|
||||
}
|
||||
}
|
||||
}
|
||||
//Set the source only after loading cookies
|
||||
dialog.Source = new Uri(shoiceIn.LoginUrl);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
@@ -11,7 +10,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
partial class MainVM
|
||||
{
|
||||
private Task<LibraryCommands.LibraryStats>? updateCountsTask;
|
||||
private System.ComponentModel.BackgroundWorker updateCountsBw = new();
|
||||
|
||||
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
|
||||
public string BookBackupsToolStripText { get; private set; } = "Begin Book and PDF Backups: 0";
|
||||
@@ -46,20 +45,40 @@ namespace LibationAvalonia.ViewModels
|
||||
//Pass null to the setup count to get the whole library.
|
||||
LibraryCommands.BookUserDefinedItemCommitted += async (_, _)
|
||||
=> await SetBackupCountsAsync(null);
|
||||
|
||||
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
|
||||
updateCountsBw.RunWorkerCompleted += UpdateCountsBw_Completed; ;
|
||||
}
|
||||
|
||||
|
||||
private bool runBackupCountsAgain;
|
||||
|
||||
public async Task SetBackupCountsAsync(IEnumerable<LibraryBook>? libraryBooks)
|
||||
{
|
||||
if (updateCountsTask?.IsCompleted ?? true)
|
||||
{
|
||||
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts(libraryBooks));
|
||||
var stats = await updateCountsTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats);
|
||||
runBackupCountsAgain = true;
|
||||
|
||||
if (Configuration.Instance.AutoDownloadEpisodes
|
||||
&& stats.PendingBooks + stats.pdfsNotDownloaded > 0)
|
||||
await Dispatcher.UIThread.InvokeAsync(BackupAllBooks);
|
||||
if (!updateCountsBw.IsBusy)
|
||||
updateCountsBw.RunWorkerAsync(libraryBooks);
|
||||
}
|
||||
|
||||
private void UpdateCountsBw_DoWork(object? sender, System.ComponentModel.DoWorkEventArgs e)
|
||||
{
|
||||
while (runBackupCountsAgain)
|
||||
{
|
||||
runBackupCountsAgain = false;
|
||||
e.Result = LibraryCommands.GetCounts(e.Argument as IEnumerable<LibraryBook>);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCountsBw_Completed(object? sender, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
if (e.Result is not LibraryCommands.LibraryStats stats)
|
||||
return;
|
||||
LibraryStats = stats;
|
||||
|
||||
if (Configuration.Instance.AutoDownloadEpisodes
|
||||
&& stats.PendingBooks + stats.pdfsNotDownloaded > 0)
|
||||
BackupAllBooks(stats.LibraryBooks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using DataLayer;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Threading;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.ViewModels
|
||||
@@ -15,14 +16,24 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
public void Configure_Liberate() { }
|
||||
|
||||
/// <summary> This gets called by the "Begin Book and PDF Backups" menu item. </summary>
|
||||
public async Task BackupAllBooks()
|
||||
{
|
||||
var books = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
|
||||
BackupAllBooks(books);
|
||||
}
|
||||
|
||||
private void BackupAllBooks(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
try
|
||||
{
|
||||
var unliberated = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
|
||||
var unliberated = books.UnLiberated().ToArray();
|
||||
|
||||
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
|
||||
setQueueCollapseState(false);
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
if (ProcessQueue.QueueDownloadDecrypt(unliberated))
|
||||
setQueueCollapseState(false);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -30,9 +41,11 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> This gets called by the "Begin PDF Only Backups" menu item. </summary>
|
||||
public async Task BackupAllPdfs()
|
||||
{
|
||||
if (ProcessQueue.QueueDownloadPdf(await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking())))
|
||||
var books = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
|
||||
if (ProcessQueue.QueueDownloadPdf(books))
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ namespace LibationCli
|
||||
|
||||
[Option(shortName: 'f', longName: "force", Required = false, Default = false, HelpText = "Force the book to re-download")]
|
||||
public bool Force { get; set; }
|
||||
|
||||
|
||||
[Option(shortName: 'l', longName: "license", Required = false, Default = null, HelpText = "A license file from the get-license command. Either a file path or dash ('-') to read from standard input.")]
|
||||
public string? LicenseInput { get; set; }
|
||||
|
||||
protected override async Task ProcessAsync()
|
||||
{
|
||||
@@ -32,40 +36,9 @@ namespace LibationCli
|
||||
return;
|
||||
}
|
||||
|
||||
if (Console.IsInputRedirected)
|
||||
if (LicenseInput is string licenseInput)
|
||||
{
|
||||
Console.WriteLine("Reading license file from standard input.");
|
||||
using var reader = new StreamReader(Console.OpenStandardInput());
|
||||
var stdIn = await reader.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
|
||||
var jsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Converters = [new StringEnumConverter(), new ByteArrayHexConverter()]
|
||||
};
|
||||
var licenseInfo = JsonConvert.DeserializeObject<DownloadOptions.LicenseInfo>(stdIn, jsonSettings);
|
||||
|
||||
if (licenseInfo?.ContentMetadata?.ContentReference?.Asin is not string asin)
|
||||
{
|
||||
Console.Error.WriteLine("Error: License file is missing ASIN information.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (DbContexts.GetLibraryBook_Flat_NoTracking(asin) is not LibraryBook libraryBook)
|
||||
{
|
||||
Console.Error.WriteLine($"Book not found with asin={asin}");
|
||||
return;
|
||||
}
|
||||
|
||||
SetDownloadedStatus(libraryBook);
|
||||
await ProcessOneAsync(GetProcessable(licenseInfo), libraryBook, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine("Error: Failed to read license file from standard input. Please ensure the input is a valid license file in JSON format.");
|
||||
}
|
||||
await LiberateFromLicense(licenseInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -73,6 +46,87 @@ namespace LibationCli
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LiberateFromLicense(string licPath)
|
||||
{
|
||||
var licenseInfo = licPath is "-" ? ReadLicenseFromStdIn()
|
||||
: ReadLicenseFromFile(licPath);
|
||||
|
||||
if (licenseInfo is null)
|
||||
return;
|
||||
|
||||
if (licenseInfo?.ContentMetadata?.ContentReference?.Asin is not string asin)
|
||||
{
|
||||
Console.Error.WriteLine("Error: License file is missing ASIN information.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (DbContexts.GetLibraryBook_Flat_NoTracking(asin) is not LibraryBook libraryBook)
|
||||
{
|
||||
Console.Error.WriteLine($"Book not found with asin={asin}");
|
||||
return;
|
||||
}
|
||||
|
||||
SetDownloadedStatus(libraryBook);
|
||||
await ProcessOneAsync(GetProcessable(licenseInfo), libraryBook, true);
|
||||
}
|
||||
|
||||
private static DownloadOptions.LicenseInfo? ReadLicenseFromFile(string licFile)
|
||||
{
|
||||
if (!File.Exists(licFile))
|
||||
{
|
||||
Console.Error.WriteLine("File does not exist: " + licFile);
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.WriteLine("Reading license from file.");
|
||||
try
|
||||
{
|
||||
var serializer = CreateLicenseInfoSerializer();
|
||||
using var reader = new JsonTextReader(new StreamReader(licFile));
|
||||
return serializer.Deserialize<DownloadOptions.LicenseInfo>(reader);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Failed to read license file: {@LicenseFile}", licFile);
|
||||
Console.Error.WriteLine("Error: Failed to read license file. Please ensure the file is a valid license file in JSON format.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DownloadOptions.LicenseInfo? ReadLicenseFromStdIn()
|
||||
{
|
||||
if (!Console.IsInputRedirected)
|
||||
{
|
||||
Console.Error.WriteLine("Ther is nothing in standard input to read.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Console.WriteLine("Reading license from standard input.");
|
||||
try
|
||||
{
|
||||
var serializer = CreateLicenseInfoSerializer();
|
||||
using var reader = new JsonTextReader(new StreamReader(Console.OpenStandardInput()));
|
||||
return serializer.Deserialize<DownloadOptions.LicenseInfo>(reader);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Failed to read license from standard input");
|
||||
Console.Error.WriteLine("Error: Failed to read license file from standard input. Please ensure the input is a valid license file in JSON format.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonSerializer CreateLicenseInfoSerializer()
|
||||
{
|
||||
var jsonSettings = new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Ignore,
|
||||
Converters = [new StringEnumConverter(), new ByteArrayHexConverter()]
|
||||
};
|
||||
|
||||
return JsonSerializer.Create(jsonSettings);
|
||||
}
|
||||
|
||||
private Processable GetProcessable(DownloadOptions.LicenseInfo? licenseInfo = null)
|
||||
=> PdfOnly ? CreateProcessable<DownloadPdf>() : CreateBackupBook(licenseInfo);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace LibationFileManager
|
||||
public static class SqliteStorage
|
||||
{
|
||||
// not customizable. don't move to config
|
||||
private static string databasePath => Path.Combine(Configuration.Instance.LibationFiles.Location, "LibationContext.db");
|
||||
public static string ConnectionString => $"Data Source={databasePath};Foreign Keys=False;Pooling=False;";
|
||||
public static string DatabasePath => Path.Combine(Configuration.Instance.LibationFiles.Location, "LibationContext.db");
|
||||
public static string ConnectionString => $"Data Source={DatabasePath};Foreign Keys=False;Pooling=False;";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,14 @@ namespace LibationWinForms.Login
|
||||
//Load init cookies
|
||||
foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? [])
|
||||
{
|
||||
webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie));
|
||||
try
|
||||
{
|
||||
webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to set cookie {cookie.Name} for domain {cookie.Domain}");
|
||||
}
|
||||
}
|
||||
|
||||
webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DataLayer;
|
||||
using LibationUiBase;
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
@@ -13,12 +14,21 @@ namespace LibationWinForms
|
||||
|
||||
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
|
||||
private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
|
||||
{
|
||||
var library = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
|
||||
BackupAllBooks(library);
|
||||
}
|
||||
|
||||
private void BackupAllBooks(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
try
|
||||
{
|
||||
var unliberated = await Task.Run(() => ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking().UnLiberated().ToArray());
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
|
||||
SetQueueCollapseState(false);
|
||||
var unliberated = books.UnLiberated().ToArray();
|
||||
Invoke(() =>
|
||||
{
|
||||
if (processBookQueue1.ViewModel.QueueDownloadDecrypt(unliberated))
|
||||
SetQueueCollapseState(false);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -28,13 +28,11 @@ namespace LibationWinForms
|
||||
// winforms only. this should NOT be allowed in cli
|
||||
updateCountsBw.RunWorkerCompleted += (object sender, System.ComponentModel.RunWorkerCompletedEventArgs e) =>
|
||||
{
|
||||
if (!Configuration.Instance.AutoDownloadEpisodes)
|
||||
if (!Configuration.Instance.AutoDownloadEpisodes || e.Result is not LibraryCommands.LibraryStats libraryStats)
|
||||
return;
|
||||
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
if ((libraryStats.PendingBooks + libraryStats.pdfsNotDownloaded) > 0)
|
||||
Invoke(() => beginBookBackupsToolStripMenuItem_Click(null, System.EventArgs.Empty));
|
||||
BackupAllBooks(libraryStats.LibraryBooks);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user