mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-30 19:54:14 -04:00
Bugfix/release cleanup (#512)
* Lots of cleanup on the warnings in the solution. Deprecated IsLastWriteLessThan and made a new method HasFileBeenModifiedSince. * Added some tests for the new extension method. * Changed filter import to use correct import * Scan Series now uses Refresh Metadata for Series, rather than library one. * Fixed an issue where cover generation wasn't properly taking forced update into consideration. Removed a case of cover generation for no reason. * Fixed series downloads not triggering backend call
This commit is contained in:
parent
2a3a08de74
commit
0d2d73e8ae
@ -1,21 +1,33 @@
|
|||||||
namespace API.Tests.Extensions
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using API.Extensions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace API.Tests.Extensions
|
||||||
{
|
{
|
||||||
public class FileInfoExtensionsTests
|
public class FileInfoExtensionsTests
|
||||||
{
|
{
|
||||||
// [Fact]
|
private static readonly string TestDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Extensions/Test Data/");
|
||||||
// public void DoesLastWriteMatchTest()
|
|
||||||
// {
|
[Fact]
|
||||||
// var fi = Substitute.For<FileInfo>();
|
public void HasFileBeenModifiedSince_ShouldBeFalse()
|
||||||
// fi.LastWriteTime = DateTime.Now;
|
{
|
||||||
//
|
var filepath = Path.Join(TestDirectory, "not modified.txt");
|
||||||
// var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(1));
|
var date = new FileInfo(filepath).LastWriteTime;
|
||||||
// Assert.False(fi.DoesLastWriteMatch(deltaTime));
|
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
|
||||||
// }
|
File.ReadAllText(filepath);
|
||||||
//
|
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
|
||||||
// [Fact]
|
}
|
||||||
// public void IsLastWriteLessThanTest()
|
|
||||||
// {
|
[Fact]
|
||||||
//
|
public void HasFileBeenModifiedSince_ShouldBeTrue()
|
||||||
// }
|
{
|
||||||
|
var filepath = Path.Join(TestDirectory, "modified on run.txt");
|
||||||
|
var date = new FileInfo(filepath).LastWriteTime;
|
||||||
|
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
|
||||||
|
File.AppendAllLines(filepath, new[] { DateTime.Now.ToString(CultureInfo.InvariantCulture) });
|
||||||
|
Assert.True(new FileInfo(filepath).HasFileBeenModifiedSince(date));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
API.Tests/Extensions/Test Data/modified on run.txt
Normal file
2
API.Tests/Extensions/Test Data/modified on run.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
This file should be modified by the unit test08/20/2021 10:26:03
|
||||||
|
08/20/2021 10:26:29
|
1
API.Tests/Extensions/Test Data/not modified.txt
Normal file
1
API.Tests/Extensions/Test Data/not modified.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Hello, this file should not be modified
|
@ -64,6 +64,7 @@ namespace API.Tests.Parser
|
|||||||
[InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")]
|
[InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")]
|
||||||
[InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")]
|
[InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")]
|
||||||
[InlineData("X-Men v1 #201 (September 2007).cbz", "1")]
|
[InlineData("X-Men v1 #201 (September 2007).cbz", "1")]
|
||||||
|
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")]
|
||||||
public void ParseVolumeTest(string filename, string expected)
|
public void ParseVolumeTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
|
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
|
||||||
@ -154,6 +155,7 @@ namespace API.Tests.Parser
|
|||||||
[InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")]
|
[InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")]
|
||||||
[InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")]
|
[InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")]
|
||||||
[InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")]
|
[InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")]
|
||||||
|
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")]
|
||||||
public void ParseSeriesTest(string filename, string expected)
|
public void ParseSeriesTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
|
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
|
||||||
@ -222,6 +224,7 @@ namespace API.Tests.Parser
|
|||||||
[InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")]
|
[InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")]
|
||||||
[InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")]
|
[InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")]
|
||||||
[InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")]
|
[InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")]
|
||||||
|
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "34.5")]
|
||||||
public void ParseChaptersTest(string filename, string expected)
|
public void ParseChaptersTest(string filename, string expected)
|
||||||
{
|
{
|
||||||
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
|
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
|
||||||
|
@ -33,7 +33,6 @@ namespace API.Tests.Services
|
|||||||
private readonly IBookService _bookService = Substitute.For<IBookService>();
|
private readonly IBookService _bookService = Substitute.For<IBookService>();
|
||||||
private readonly IImageService _imageService = Substitute.For<IImageService>();
|
private readonly IImageService _imageService = Substitute.For<IImageService>();
|
||||||
private readonly ILogger<MetadataService> _metadataLogger = Substitute.For<ILogger<MetadataService>>();
|
private readonly ILogger<MetadataService> _metadataLogger = Substitute.For<ILogger<MetadataService>>();
|
||||||
private readonly IDirectoryService _directoryService = Substitute.For<IDirectoryService>();
|
|
||||||
private readonly ICacheService _cacheService = Substitute.For<ICacheService>();
|
private readonly ICacheService _cacheService = Substitute.For<ICacheService>();
|
||||||
|
|
||||||
private readonly DbConnection _connection;
|
private readonly DbConnection _connection;
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
|
@ -70,7 +70,7 @@ namespace API.Controllers
|
|||||||
{
|
{
|
||||||
return await GetFirstFileDownload(files);
|
return await GetFirstFileDownload(files);
|
||||||
}
|
}
|
||||||
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||||
$"download_{User.GetUsername()}_v{volumeId}");
|
$"download_{User.GetUsername()}_v{volumeId}");
|
||||||
return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip");
|
return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip");
|
||||||
}
|
}
|
||||||
@ -116,7 +116,7 @@ namespace API.Controllers
|
|||||||
{
|
{
|
||||||
return await GetFirstFileDownload(files);
|
return await GetFirstFileDownload(files);
|
||||||
}
|
}
|
||||||
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||||
$"download_{User.GetUsername()}_c{chapterId}");
|
$"download_{User.GetUsername()}_c{chapterId}");
|
||||||
return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip");
|
return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip");
|
||||||
}
|
}
|
||||||
@ -137,7 +137,7 @@ namespace API.Controllers
|
|||||||
{
|
{
|
||||||
return await GetFirstFileDownload(files);
|
return await GetFirstFileDownload(files);
|
||||||
}
|
}
|
||||||
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
|
||||||
$"download_{User.GetUsername()}_s{seriesId}");
|
$"download_{User.GetUsername()}_s{seriesId}");
|
||||||
return File(fileBytes, DefaultContentType, $"{series.Name}.zip");
|
return File(fileBytes, DefaultContentType, $"{series.Name}.zip");
|
||||||
}
|
}
|
||||||
@ -194,11 +194,11 @@ namespace API.Controllers
|
|||||||
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
|
||||||
// Filter out images that aren't in bookmarks
|
// Filter out images that aren't in bookmarks
|
||||||
Array.Sort(files, _numericComparer);
|
Array.Sort(files, _numericComparer);
|
||||||
totalFilePaths.AddRange(files.Where((t, i) => chapterPages.Contains(i)));
|
totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(totalFilePaths,
|
var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths,
|
||||||
tempFolder);
|
tempFolder);
|
||||||
DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
|
DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
|
||||||
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
|
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
|
||||||
|
@ -368,7 +368,7 @@ namespace API.Controllers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes all bookmarks for all chapters linked to a Series
|
/// Removes all bookmarks for all chapters linked to a Series
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="seriesId"></param>
|
/// <param name="dto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
[HttpPost("remove-bookmarks")]
|
[HttpPost("remove-bookmarks")]
|
||||||
public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
|
public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
|
||||||
|
@ -12,7 +12,6 @@ using API.Interfaces;
|
|||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Controllers
|
namespace API.Controllers
|
||||||
|
@ -5,7 +5,6 @@ using System.Threading.Tasks;
|
|||||||
using API.DTOs.Stats;
|
using API.DTOs.Stats;
|
||||||
using API.DTOs.Update;
|
using API.DTOs.Update;
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Interfaces;
|
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using API.Services.Tasks;
|
using API.Services.Tasks;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
@ -26,11 +25,10 @@ namespace API.Controllers
|
|||||||
private readonly IBackupService _backupService;
|
private readonly IBackupService _backupService;
|
||||||
private readonly IArchiveService _archiveService;
|
private readonly IArchiveService _archiveService;
|
||||||
private readonly ICacheService _cacheService;
|
private readonly ICacheService _cacheService;
|
||||||
private readonly ITaskScheduler _taskScheduler;
|
|
||||||
private readonly IVersionUpdaterService _versionUpdaterService;
|
private readonly IVersionUpdaterService _versionUpdaterService;
|
||||||
|
|
||||||
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
|
||||||
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, ITaskScheduler taskScheduler,
|
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
|
||||||
IVersionUpdaterService versionUpdaterService)
|
IVersionUpdaterService versionUpdaterService)
|
||||||
{
|
{
|
||||||
_applicationLifetime = applicationLifetime;
|
_applicationLifetime = applicationLifetime;
|
||||||
@ -39,7 +37,6 @@ namespace API.Controllers
|
|||||||
_backupService = backupService;
|
_backupService = backupService;
|
||||||
_archiveService = archiveService;
|
_archiveService = archiveService;
|
||||||
_cacheService = cacheService;
|
_cacheService = cacheService;
|
||||||
_taskScheduler = taskScheduler;
|
|
||||||
_versionUpdaterService = versionUpdaterService;
|
_versionUpdaterService = versionUpdaterService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
namespace API.DTOs
|
||||||
|
|
||||||
namespace API.DTOs
|
|
||||||
{
|
{
|
||||||
public class SeriesByIdsDto
|
public class SeriesByIdsDto
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Interfaces.Repositories;
|
using API.Interfaces.Repositories;
|
||||||
using AutoMapper;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Data
|
namespace API.Data
|
||||||
|
@ -60,11 +60,11 @@ namespace API.Data
|
|||||||
}
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
// Port and LoggingLevel are managed in appSettings.json. Update the DB values to match
|
// Port and LoggingLevel are managed in appSettings.json. Update the DB values to match
|
||||||
context.ServerSetting.FirstOrDefault(s => s.Key == ServerSettingKey.Port).Value =
|
context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value =
|
||||||
Configuration.Port + string.Empty;
|
Configuration.Port + string.Empty;
|
||||||
context.ServerSetting.FirstOrDefault(s => s.Key == ServerSettingKey.LoggingLevel).Value =
|
context.ServerSetting.First(s => s.Key == ServerSettingKey.LoggingLevel).Value =
|
||||||
Configuration.LogLevel + string.Empty;
|
Configuration.LogLevel + string.Empty;
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
@ -74,11 +74,11 @@ namespace API.Data
|
|||||||
public static async Task SeedSeriesMetadata(DataContext context)
|
public static async Task SeedSeriesMetadata(DataContext context)
|
||||||
{
|
{
|
||||||
await context.Database.EnsureCreatedAsync();
|
await context.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
context.Database.EnsureCreated();
|
context.Database.EnsureCreated();
|
||||||
var series = await context.Series
|
var series = await context.Series
|
||||||
.Include(s => s.Metadata).ToListAsync();
|
.Include(s => s.Metadata).ToListAsync();
|
||||||
|
|
||||||
foreach (var s in series)
|
foreach (var s in series)
|
||||||
{
|
{
|
||||||
s.Metadata ??= new SeriesMetadata();
|
s.Metadata ??= new SeriesMetadata();
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.Comparators;
|
using API.Comparators;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Filtering;
|
using API.DTOs.Filtering;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Entities.Enums;
|
|
||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.DTOs;
|
using API.DTOs;
|
||||||
using API.DTOs.Reader;
|
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace API.Entities
|
namespace API.Entities
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using API.Entities.Interfaces;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace API.Entities
|
namespace API.Entities
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.IO;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using API.Comparators;
|
using API.Comparators;
|
||||||
|
|
||||||
|
@ -5,14 +5,15 @@ namespace API.Extensions
|
|||||||
{
|
{
|
||||||
public static class FileInfoExtensions
|
public static class FileInfoExtensions
|
||||||
{
|
{
|
||||||
public static bool DoesLastWriteMatch(this FileInfo fileInfo, DateTime comparison)
|
/// <summary>
|
||||||
|
/// Checks if the last write time of the file is after the passed date
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fileInfo"></param>
|
||||||
|
/// <param name="comparison"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison)
|
||||||
{
|
{
|
||||||
return comparison.Equals(fileInfo.LastWriteTime);
|
return fileInfo?.LastWriteTime > comparison;
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsLastWriteLessThan(this FileInfo fileInfo, DateTime comparison)
|
|
||||||
{
|
|
||||||
return fileInfo.LastWriteTime < comparison;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using API.DTOs.Update;
|
using API.DTOs.Update;
|
||||||
using API.Services.Tasks;
|
|
||||||
|
|
||||||
namespace API.Interfaces.Services
|
namespace API.Interfaces.Services
|
||||||
{
|
{
|
||||||
|
@ -30,6 +30,7 @@ namespace API.Services
|
|||||||
private readonly IDirectoryService _directoryService;
|
private readonly IDirectoryService _directoryService;
|
||||||
private static readonly RecyclableMemoryStreamManager StreamManager = new();
|
private static readonly RecyclableMemoryStreamManager StreamManager = new();
|
||||||
private readonly NaturalSortComparer _comparer;
|
private readonly NaturalSortComparer _comparer;
|
||||||
|
private const string ComicInfoFilename = "comicinfo";
|
||||||
|
|
||||||
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService)
|
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService)
|
||||||
{
|
{
|
||||||
@ -297,7 +298,7 @@ namespace API.Services
|
|||||||
foreach (var entry in entries)
|
foreach (var entry in entries)
|
||||||
{
|
{
|
||||||
var filename = Path.GetFileNameWithoutExtension(entry.Key).ToLower();
|
var filename = Path.GetFileNameWithoutExtension(entry.Key).ToLower();
|
||||||
if (filename.EndsWith("comicinfo")
|
if (filename.EndsWith(ComicInfoFilename)
|
||||||
&& !filename.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
|
&& !filename.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
|
||||||
&& !Parser.Parser.HasBlacklistedFolderInPath(entry.Key)
|
&& !Parser.Parser.HasBlacklistedFolderInPath(entry.Key)
|
||||||
&& Parser.Parser.IsXml(entry.Key))
|
&& Parser.Parser.IsXml(entry.Key))
|
||||||
@ -334,7 +335,7 @@ namespace API.Services
|
|||||||
_logger.LogDebug("Using default compression handling");
|
_logger.LogDebug("Using default compression handling");
|
||||||
using var archive = ZipFile.OpenRead(archivePath);
|
using var archive = ZipFile.OpenRead(archivePath);
|
||||||
var entry = archive.Entries.SingleOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName)
|
var entry = archive.Entries.SingleOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName)
|
||||||
&& Path.GetFileNameWithoutExtension(x.Name).ToLower() == "comicinfo"
|
&& Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename
|
||||||
&& !Path.GetFileNameWithoutExtension(x.Name).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
|
&& !Path.GetFileNameWithoutExtension(x.Name).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
|
||||||
&& Parser.Parser.IsXml(x.FullName));
|
&& Parser.Parser.IsXml(x.FullName));
|
||||||
if (entry != null)
|
if (entry != null)
|
||||||
|
@ -9,7 +9,6 @@ using API.Entities.Enums;
|
|||||||
using API.Extensions;
|
using API.Extensions;
|
||||||
using API.Interfaces;
|
using API.Interfaces;
|
||||||
using API.Interfaces.Services;
|
using API.Interfaces.Services;
|
||||||
using Kavita.Common;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace API.Services
|
namespace API.Services
|
||||||
|
@ -11,7 +11,9 @@ namespace API.Services.Clients
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _client;
|
private readonly HttpClient _client;
|
||||||
private readonly ILogger<StatsApiClient> _logger;
|
private readonly ILogger<StatsApiClient> _logger;
|
||||||
|
#pragma warning disable S1075
|
||||||
private const string ApiUrl = "http://stats.kavitareader.com";
|
private const string ApiUrl = "http://stats.kavitareader.com";
|
||||||
|
#pragma warning restore S1075
|
||||||
|
|
||||||
public StatsApiClient(HttpClient client, ILogger<StatsApiClient> logger)
|
public StatsApiClient(HttpClient client, ILogger<StatsApiClient> logger)
|
||||||
{
|
{
|
||||||
|
@ -320,7 +320,7 @@ namespace API.Services
|
|||||||
var fileCount = 0;
|
var fileCount = 0;
|
||||||
|
|
||||||
// Determine whether to parallelize file processing on each folder based on processor count.
|
// Determine whether to parallelize file processing on each folder based on processor count.
|
||||||
var procCount = Environment.ProcessorCount;
|
//var procCount = Environment.ProcessorCount;
|
||||||
|
|
||||||
// Data structure to hold names of subfolders to be examined for files.
|
// Data structure to hold names of subfolders to be examined for files.
|
||||||
var dirs = new Stack<string>();
|
var dirs = new Stack<string>();
|
||||||
|
@ -67,7 +67,10 @@ namespace API.Services
|
|||||||
public void UpdateMetadata(Chapter chapter, bool forceUpdate)
|
public void UpdateMetadata(Chapter chapter, bool forceUpdate)
|
||||||
{
|
{
|
||||||
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
||||||
if (!chapter.CoverImageLocked && ShouldFindCoverImage(chapter.CoverImage, forceUpdate) && firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
|
if (!chapter.CoverImageLocked
|
||||||
|
&& ShouldFindCoverImage(chapter.CoverImage, forceUpdate)
|
||||||
|
&& firstFile != null
|
||||||
|
&& (forceUpdate || new FileInfo(firstFile.FilePath).HasFileBeenModifiedSince(firstFile.LastModified)))
|
||||||
{
|
{
|
||||||
chapter.Files ??= new List<MangaFile>();
|
chapter.Files ??= new List<MangaFile>();
|
||||||
chapter.CoverImage = GetCoverImage(firstFile);
|
chapter.CoverImage = GetCoverImage(firstFile);
|
||||||
@ -88,19 +91,7 @@ namespace API.Services
|
|||||||
|
|
||||||
if (firstChapter == null) return;
|
if (firstChapter == null) return;
|
||||||
|
|
||||||
// Skip calculating Cover Image (I/O) if the chapter already has it set
|
|
||||||
if (!firstChapter.CoverImageLocked && ShouldFindCoverImage(firstChapter.CoverImage, forceUpdate))
|
|
||||||
{
|
|
||||||
// NOTE: Why do I do this? By the time this method gets executed, the chapter has already been calculated for
|
|
||||||
// Plus how can we have a volume without at least 1 chapter?
|
|
||||||
var firstFile = firstChapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
|
|
||||||
if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
|
|
||||||
{
|
|
||||||
firstChapter.CoverImage = GetCoverImage(firstFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
volume.CoverImage = firstChapter.CoverImage;
|
volume.CoverImage = firstChapter.CoverImage;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -119,7 +119,6 @@ namespace API.Services
|
|||||||
BackgroundJob.Enqueue(() => _cleanupService.Cleanup());
|
BackgroundJob.Enqueue(() => _cleanupService.Cleanup());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void CleanupChapters(int[] chapterIds)
|
public void CleanupChapters(int[] chapterIds)
|
||||||
{
|
{
|
||||||
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
||||||
|
@ -74,7 +74,7 @@ namespace API.Services.Tasks
|
|||||||
totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name);
|
totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name);
|
||||||
|
|
||||||
CleanupDbEntities();
|
CleanupDbEntities();
|
||||||
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
|
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId));
|
||||||
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -132,11 +132,14 @@ namespace API.Services.Tasks
|
|||||||
{
|
{
|
||||||
ScanLibrary(lib.Id, false);
|
ScanLibrary(lib.Id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scans a library for file changes. If force update passed, all entities will be rechecked for new cover images and comicInfo.xml changes.
|
/// Scans a library for file changes.
|
||||||
|
/// Will kick off a scheduled background task to refresh metadata,
|
||||||
|
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="libraryId"></param>
|
/// <param name="libraryId"></param>
|
||||||
/// <param name="forceUpdate"></param>
|
/// <param name="forceUpdate"></param>
|
||||||
|
@ -42,7 +42,7 @@ namespace API.Services.Tasks
|
|||||||
{
|
{
|
||||||
public override HttpMessageHandler CreateMessageHandler() {
|
public override HttpMessageHandler CreateMessageHandler() {
|
||||||
return new HttpClientHandler {
|
return new HttpClientHandler {
|
||||||
ServerCertificateCustomValidationCallback = (a, b, c, d) => true
|
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -87,7 +87,7 @@ namespace API.Services.Tasks
|
|||||||
return updates.Select(CreateDto);
|
return updates.Select(CreateDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UpdateNotificationDto? CreateDto(GithubReleaseMetadata update)
|
private UpdateNotificationDto CreateDto(GithubReleaseMetadata update)
|
||||||
{
|
{
|
||||||
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
if (update == null || string.IsNullOrEmpty(update.Tag_Name)) return null;
|
||||||
var version = update.Tag_Name.Replace("v", string.Empty);
|
var version = update.Tag_Name.Replace("v", string.Empty);
|
||||||
|
@ -49,7 +49,7 @@ namespace API
|
|||||||
services.AddSwaggerGen(c =>
|
services.AddSwaggerGen(c =>
|
||||||
{
|
{
|
||||||
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Kavita API", Version = "v1" });
|
c.SwaggerDoc("v1", new OpenApiInfo { Title = "Kavita API", Version = "v1" });
|
||||||
var filePath = Path.Combine(System.AppContext.BaseDirectory, "API.xml");
|
var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml");
|
||||||
c.IncludeXmlComments(filePath);
|
c.IncludeXmlComments(filePath);
|
||||||
});
|
});
|
||||||
services.AddResponseCompression(options =>
|
services.AddResponseCompression(options =>
|
||||||
|
@ -8,7 +8,7 @@ namespace Kavita.Common
|
|||||||
{
|
{
|
||||||
public static class Configuration
|
public static class Configuration
|
||||||
{
|
{
|
||||||
private static string AppSettingsFilename = GetAppSettingFilename();
|
private static readonly string AppSettingsFilename = GetAppSettingFilename();
|
||||||
public static string Branch
|
public static string Branch
|
||||||
{
|
{
|
||||||
get => GetBranch(GetAppSettingFilename());
|
get => GetBranch(GetAppSettingFilename());
|
||||||
@ -53,6 +53,7 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath);
|
var json = File.ReadAllText(filePath);
|
||||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||||
const string key = "TokenKey";
|
const string key = "TokenKey";
|
||||||
|
if (jsonObj == null) return string.Empty;
|
||||||
|
|
||||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||||
{
|
{
|
||||||
@ -69,7 +70,7 @@ namespace Kavita.Common
|
|||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool SetJwtToken(string filePath, string token)
|
private static void SetJwtToken(string filePath, string token)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -77,53 +78,37 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath)
|
var json = File.ReadAllText(filePath)
|
||||||
.Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token);
|
.Replace("\"TokenKey\": \"" + currentToken, "\"TokenKey\": \"" + token);
|
||||||
File.WriteAllText(filePath, json);
|
File.WriteAllText(filePath, json);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return false;
|
/* Swallow exception */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool CheckIfJwtTokenSet()
|
public static bool CheckIfJwtTokenSet()
|
||||||
{
|
{
|
||||||
//string filePath
|
try
|
||||||
try
|
{
|
||||||
{
|
return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key";
|
||||||
return GetJwtToken(GetAppSettingFilename()) != "super secret unguessable key";
|
}
|
||||||
}
|
catch (Exception ex)
|
||||||
catch (Exception ex)
|
{
|
||||||
{
|
Console.WriteLine("Error writing app settings: " + ex.Message);
|
||||||
Console.WriteLine("Error writing app settings: " + ex.Message);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool UpdateJwtToken(string token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filePath = GetAppSettingFilename();
|
|
||||||
var json = File.ReadAllText(filePath).Replace("super secret unguessable key", token);
|
|
||||||
File.WriteAllText(GetAppSettingFilename(), json);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Port
|
#region Port
|
||||||
|
|
||||||
public static bool SetPort(string filePath, int port)
|
private static void SetPort(string filePath, int port)
|
||||||
{
|
{
|
||||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||||
{
|
{
|
||||||
return true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -131,15 +116,14 @@ namespace Kavita.Common
|
|||||||
var currentPort = GetPort(filePath);
|
var currentPort = GetPort(filePath);
|
||||||
var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port);
|
var json = File.ReadAllText(filePath).Replace("\"Port\": " + currentPort, "\"Port\": " + port);
|
||||||
File.WriteAllText(filePath, json);
|
File.WriteAllText(filePath, json);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return false;
|
/* Swallow Exception */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static int GetPort(string filePath)
|
private static int GetPort(string filePath)
|
||||||
{
|
{
|
||||||
const int defaultPort = 5000;
|
const int defaultPort = 5000;
|
||||||
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
if (new OsInfo(Array.Empty<IOsVersionAdapter>()).IsDocker)
|
||||||
@ -152,6 +136,7 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath);
|
var json = File.ReadAllText(filePath);
|
||||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||||
const string key = "Port";
|
const string key = "Port";
|
||||||
|
if (jsonObj == null) return defaultPort;
|
||||||
|
|
||||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||||
{
|
{
|
||||||
@ -170,7 +155,7 @@ namespace Kavita.Common
|
|||||||
|
|
||||||
#region LogLevel
|
#region LogLevel
|
||||||
|
|
||||||
public static bool SetLogLevel(string filePath, string logLevel)
|
private static void SetLogLevel(string filePath, string logLevel)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -178,20 +163,21 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath)
|
var json = File.ReadAllText(filePath)
|
||||||
.Replace($"\"Default\": \"{currentLevel}\"", $"\"Default\": \"{logLevel}\"");
|
.Replace($"\"Default\": \"{currentLevel}\"", $"\"Default\": \"{logLevel}\"");
|
||||||
File.WriteAllText(filePath, json);
|
File.WriteAllText(filePath, json);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return false;
|
/* Swallow Exception */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetLogLevel(string filePath)
|
private static string GetLogLevel(string filePath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = File.ReadAllText(filePath);
|
var json = File.ReadAllText(filePath);
|
||||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||||
|
if (jsonObj == null) return string.Empty;
|
||||||
|
|
||||||
if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement))
|
if (jsonObj.TryGetProperty("Logging", out JsonElement tokenElement))
|
||||||
{
|
{
|
||||||
foreach (var property in tokenElement.EnumerateObject())
|
foreach (var property in tokenElement.EnumerateObject())
|
||||||
@ -217,7 +203,7 @@ namespace Kavita.Common
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
public static string GetBranch(string filePath)
|
private static string GetBranch(string filePath)
|
||||||
{
|
{
|
||||||
const string defaultBranch = "main";
|
const string defaultBranch = "main";
|
||||||
|
|
||||||
@ -226,6 +212,7 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath);
|
var json = File.ReadAllText(filePath);
|
||||||
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
var jsonObj = JsonSerializer.Deserialize<dynamic>(json);
|
||||||
const string key = "Branch";
|
const string key = "Branch";
|
||||||
|
if (jsonObj == null) return string.Empty;
|
||||||
|
|
||||||
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
if (jsonObj.TryGetProperty(key, out JsonElement tokenElement))
|
||||||
{
|
{
|
||||||
@ -240,7 +227,7 @@ namespace Kavita.Common
|
|||||||
return defaultBranch;
|
return defaultBranch;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool SetBranch(string filePath, string updatedBranch)
|
private static void SetBranch(string filePath, string updatedBranch)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -248,11 +235,10 @@ namespace Kavita.Common
|
|||||||
var json = File.ReadAllText(filePath)
|
var json = File.ReadAllText(filePath)
|
||||||
.Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch);
|
.Replace("\"Branch\": " + currentBranch, "\"Branch\": " + updatedBranch);
|
||||||
File.WriteAllText(filePath, json);
|
File.WriteAllText(filePath, json);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
return false;
|
/* Swallow Exception */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Runtime.Serialization;
|
||||||
|
|
||||||
namespace Kavita.Common
|
namespace Kavita.Common
|
||||||
{
|
{
|
||||||
@ -9,13 +10,16 @@ namespace Kavita.Common
|
|||||||
public class KavitaException : Exception
|
public class KavitaException : Exception
|
||||||
{
|
{
|
||||||
public KavitaException()
|
public KavitaException()
|
||||||
{
|
{ }
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public KavitaException(string message) : base(message)
|
public KavitaException(string message) : base(message)
|
||||||
{
|
{ }
|
||||||
|
|
||||||
}
|
public KavitaException(string message, Exception inner)
|
||||||
|
: base(message, inner) { }
|
||||||
|
|
||||||
|
protected KavitaException(SerializationInfo info, StreamingContext context)
|
||||||
|
: base(info, context)
|
||||||
|
{ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import { MessageHubService } from './_services/message-hub.service';
|
|||||||
import { NavService } from './_services/nav.service';
|
import { NavService } from './_services/nav.service';
|
||||||
import { PresenceHubService } from './_services/presence-hub.service';
|
import { PresenceHubService } from './_services/presence-hub.service';
|
||||||
import { StatsService } from './_services/stats.service';
|
import { StatsService } from './_services/stats.service';
|
||||||
import 'rxjs/add/operator/filter';
|
import { filter } from 'rxjs/operators';
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -23,7 +23,7 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
// Close any open modals when a route change occurs
|
// Close any open modals when a route change occurs
|
||||||
router.events
|
router.events
|
||||||
.filter(event => event instanceof NavigationStart)
|
.pipe(filter(event => event instanceof NavigationStart))
|
||||||
.subscribe((event) => {
|
.subscribe((event) => {
|
||||||
if (this.ngbModal.hasOpenModals()) {
|
if (this.ngbModal.hasOpenModals()) {
|
||||||
this.ngbModal.dismissAll();
|
this.ngbModal.dismissAll();
|
||||||
|
@ -461,7 +461,7 @@ export class SeriesDetailComponent implements OnInit {
|
|||||||
}),
|
}),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
this.downloadInProgress = false;
|
this.downloadInProgress = false;
|
||||||
}));
|
})).subscribe(() => {/* No Operation */});;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user