This commit is contained in:
Курнат Андрей
2026-03-08 23:45:38 +03:00
parent 7393230696
commit c40df55ce7
9 changed files with 734 additions and 115 deletions

View File

@@ -1,4 +1,5 @@
using BookReader.Services; using BookReader.Platforms.Android.Services;
using BookReader.Services;
using BookReader.ViewModels; using BookReader.ViewModels;
using BookReader.Views; using BookReader.Views;
using CommunityToolkit.Maui; using CommunityToolkit.Maui;
@@ -26,24 +27,21 @@ public static class MauiProgram
builder.Logging.AddDebug(); builder.Logging.AddDebug();
#endif #endif
// Register Services (Singleton for shared state)
builder.Services.AddSingleton<IDatabaseService, DatabaseService>(); builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
builder.Services.AddSingleton<IBookParserService, BookParserService>(); builder.Services.AddSingleton<IBookParserService, BookParserService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>(); builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton<INavigationService, NavigationService>(); builder.Services.AddSingleton<INavigationService, NavigationService>();
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>(); builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>(); builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
builder.Services.AddSingleton<IBookImportPickerService, AndroidBookImportPickerService>();
// HTTP Client for Calibre
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>(); builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
// Register ViewModels
builder.Services.AddTransient<BookshelfViewModel>(); builder.Services.AddTransient<BookshelfViewModel>();
builder.Services.AddTransient<ReaderViewModel>(); builder.Services.AddTransient<ReaderViewModel>();
builder.Services.AddTransient<SettingsViewModel>(); builder.Services.AddTransient<SettingsViewModel>();
builder.Services.AddTransient<CalibreLibraryViewModel>(); builder.Services.AddTransient<CalibreLibraryViewModel>();
// Register Pages
builder.Services.AddTransient<BookshelfPage>(); builder.Services.AddTransient<BookshelfPage>();
builder.Services.AddTransient<ReaderPage>(); builder.Services.AddTransient<ReaderPage>();
builder.Services.AddTransient<SettingsPage>(); builder.Services.AddTransient<SettingsPage>();
@@ -51,4 +49,4 @@ public static class MauiProgram
return builder.Build(); return builder.Build();
} }
} }

View File

@@ -1,6 +1,8 @@
using Android.App; using Android.App;
using Android.Content;
using Android.Content.PM; using Android.Content.PM;
using Android.OS; using Android.OS;
using BookReader.Platforms.Android.Services;
namespace BookReader; namespace BookReader;
@@ -16,7 +18,12 @@ public class MainActivity : MauiAppCompatActivity
{ {
base.OnCreate(savedInstanceState); base.OnCreate(savedInstanceState);
// Keep screen on while reading
Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn); Window?.AddFlags(Android.Views.WindowManagerFlags.KeepScreenOn);
} }
}
protected override void OnActivityResult(int requestCode, Result resultCode, Intent? data)
{
base.OnActivityResult(requestCode, resultCode, data);
AndroidActivityResultRegistry.Dispatch(requestCode, resultCode, data);
}
}

View File

@@ -0,0 +1,214 @@
using Android.App;
using Android.Content;
using Android.Database;
using Android.Provider;
using Android.Webkit;
using BookReader.Services;
using Microsoft.Maui.ApplicationModel;
using ActivityResult = Android.App.Result;
using AndroidUri = global::Android.Net.Uri;
namespace BookReader.Platforms.Android.Services;
internal sealed class AndroidActivityResultEventArgs : EventArgs
{
public AndroidActivityResultEventArgs(int requestCode, ActivityResult resultCode, Intent? data)
{
RequestCode = requestCode;
ResultCode = resultCode;
Data = data;
}
public int RequestCode { get; }
public ActivityResult ResultCode { get; }
public Intent? Data { get; }
}
internal static class AndroidActivityResultRegistry
{
public static event EventHandler<AndroidActivityResultEventArgs>? ActivityResultReceived;
public static void Dispatch(int requestCode, ActivityResult resultCode, Intent? data)
{
ActivityResultReceived?.Invoke(null, new AndroidActivityResultEventArgs(requestCode, resultCode, data));
}
}
public sealed class AndroidBookImportPickerService : IBookImportPickerService
{
private const int PickBookRequestCode = 41071;
private static readonly string[] SupportedMimeTypes =
{
"application/epub+zip",
"application/x-fictionbook+xml",
"application/xml",
"text/xml",
"application/octet-stream"
};
private readonly SemaphoreSlim _pickLock = new(1, 1);
private TaskCompletionSource<(ActivityResult ResultCode, Intent? Data)>? _pendingPick;
public AndroidBookImportPickerService()
{
AndroidActivityResultRegistry.ActivityResultReceived += OnActivityResultReceived;
}
public async Task<SelectedBookFile?> PickBookFileAsync(CancellationToken cancellationToken = default)
{
await _pickLock.WaitAsync(cancellationToken);
try
{
var activity = Platform.CurrentActivity
?? throw new InvalidOperationException("Текущая Android activity недоступна.");
var pickCompletionSource = new TaskCompletionSource<(ActivityResult ResultCode, Intent? Data)>(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingPick = pickCompletionSource;
using var registration = cancellationToken.Register(() => pickCompletionSource.TrySetCanceled(cancellationToken));
await MainThread.InvokeOnMainThreadAsync(() =>
{
var intent = new Intent(Intent.ActionOpenDocument);
intent.AddCategory(Intent.CategoryOpenable);
intent.SetType("*/*");
intent.PutExtra(Intent.ExtraMimeTypes, SupportedMimeTypes);
intent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantPersistableUriPermission);
#pragma warning disable CS0618
activity.StartActivityForResult(Intent.CreateChooser(intent, "Выберите книгу"), PickBookRequestCode);
#pragma warning restore CS0618
});
var (resultCode, data) = await pickCompletionSource.Task;
if (resultCode != ActivityResult.Ok || data?.Data == null)
{
return null;
}
var documentUri = data.Data;
TryTakePersistablePermission(activity, documentUri, data.Flags);
return await CopyDocumentToCacheAsync(activity, documentUri, cancellationToken);
}
finally
{
Interlocked.Exchange(ref _pendingPick, null);
_pickLock.Release();
}
}
private void OnActivityResultReceived(object? sender, AndroidActivityResultEventArgs args)
{
if (args.RequestCode != PickBookRequestCode)
{
return;
}
_pendingPick?.TrySetResult((args.ResultCode, args.Data));
}
private static void TryTakePersistablePermission(Activity activity, AndroidUri documentUri, ActivityFlags grantedFlags)
{
var persistableFlags = grantedFlags & (ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantWriteUriPermission);
if ((persistableFlags & ActivityFlags.GrantReadUriPermission) == 0)
{
persistableFlags |= ActivityFlags.GrantReadUriPermission;
}
try
{
activity.ContentResolver?.TakePersistableUriPermission(documentUri, persistableFlags);
}
catch (Java.Lang.SecurityException)
{
}
catch (Java.Lang.UnsupportedOperationException)
{
}
}
private static async Task<SelectedBookFile> CopyDocumentToCacheAsync(Activity activity, AndroidUri documentUri, CancellationToken cancellationToken)
{
var contentResolver = activity.ContentResolver
?? throw new InvalidOperationException("ContentResolver недоступен.");
var originalFileName = ResolveFileName(contentResolver, documentUri);
var extension = Path.GetExtension(originalFileName);
if (string.IsNullOrWhiteSpace(extension))
{
var mimeType = contentResolver.GetType(documentUri);
var inferredExtension = MimeTypeMap.Singleton?.GetExtensionFromMimeType(mimeType);
if (!string.IsNullOrWhiteSpace(inferredExtension))
{
extension = $".{inferredExtension}";
originalFileName = $"{originalFileName}{extension}";
}
}
var tempPath = Path.Combine(FileSystem.CacheDirectory, $"{Guid.NewGuid()}{extension}");
try
{
await using var destinationStream = File.Create(tempPath);
await using var sourceStream = await OpenReadStreamAsync(contentResolver, documentUri, cancellationToken);
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
await destinationStream.FlushAsync(cancellationToken);
return new SelectedBookFile(originalFileName, tempPath);
}
catch
{
try
{
if (File.Exists(tempPath))
{
File.Delete(tempPath);
}
}
catch
{
}
throw;
}
}
private static Task<Stream> OpenReadStreamAsync(ContentResolver contentResolver, AndroidUri documentUri, CancellationToken cancellationToken)
{
return Task.Run(() =>
{
cancellationToken.ThrowIfCancellationRequested();
return contentResolver.OpenInputStream(documentUri)
?? throw new InvalidOperationException("Не удалось открыть выбранный документ.");
}, cancellationToken);
}
private static string ResolveFileName(ContentResolver contentResolver, AndroidUri documentUri)
{
try
{
using ICursor? cursor = contentResolver.Query(documentUri, new[] { IOpenableColumns.DisplayName }, null, null, null);
if (cursor != null && cursor.MoveToFirst())
{
var displayNameIndex = cursor.GetColumnIndex(IOpenableColumns.DisplayName);
if (displayNameIndex >= 0)
{
var displayName = cursor.GetString(displayNameIndex);
if (!string.IsNullOrWhiteSpace(displayName))
{
return displayName;
}
}
}
}
catch
{
}
return documentUri.LastPathSegment?.Split('/').LastOrDefault() ?? "book";
}
}

View File

@@ -1,15 +1,31 @@
using BookReader.Models; using BookReader.Models;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace BookReader.Services; namespace BookReader.Services;
public class CalibreWebService : ICalibreWebService public class CalibreWebService : ICalibreWebService
{ {
private static readonly XNamespace AtomNamespace = "http://www.w3.org/2005/Atom";
private const string OpdsAcquisitionRel = "http://opds-spec.org/acquisition";
private const string OpdsImageRel = "http://opds-spec.org/image";
private const string OpdsThumbnailRel = "http://opds-spec.org/image/thumbnail";
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ICoverCacheService _coverCacheService; private readonly ICoverCacheService _coverCacheService;
private string _baseUrl = string.Empty; private string _baseUrl = string.Empty;
private CalibreBackendKind _backendKind = CalibreBackendKind.Unknown;
private enum CalibreBackendKind
{
Unknown,
ContentServerAjax,
Opds
}
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService) public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
{ {
@@ -20,7 +36,13 @@ public class CalibreWebService : ICalibreWebService
public void Configure(string url, string username, string password) public void Configure(string url, string username, string password)
{ {
_baseUrl = url.Trim().TrimEnd('/'); if (!TryCreateSupportedRemoteUri(url, out var serverUri))
{
throw new ArgumentException("Адрес сервера Calibre должен начинаться с http:// или https://.");
}
_baseUrl = serverUri.ToString().TrimEnd('/');
_backendKind = CalibreBackendKind.Unknown;
if (!string.IsNullOrWhiteSpace(username)) if (!string.IsNullOrWhiteSpace(username))
{ {
@@ -39,8 +61,8 @@ public class CalibreWebService : ICalibreWebService
try try
{ {
Configure(url, username, password); Configure(url, username, password);
var response = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1"); var backendKind = await DetectBackendAsync(forceRefresh: true);
return response.IsSuccessStatusCode; return backendKind != CalibreBackendKind.Unknown;
} }
catch catch
{ {
@@ -55,52 +77,209 @@ public class CalibreWebService : ICalibreWebService
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках."); return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
} }
var books = new List<CalibreBook>();
try try
{ {
var offset = page * pageSize; var backendKind = await DetectBackendAsync();
var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim()); return backendKind switch
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
var response = await _httpClient.GetStringAsync(url);
var json = JObject.Parse(response);
var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>();
var semaphore = new SemaphoreSlim(4);
var tasks = bookIds.Select(async bookId =>
{ {
await semaphore.WaitAsync(); CalibreBackendKind.ContentServerAjax => await GetBooksFromAjaxAsync(searchQuery, page, pageSize),
try CalibreBackendKind.Opds => await GetBooksFromOpdsAsync(searchQuery, page, pageSize),
{ _ => Result<List<CalibreBook>>.Failure("Не удалось определить API сервера Calibre. Для calibre-web нужен доступный OPDS, для calibre-server — API /ajax.")
return await LoadBookDataAsync(bookId); };
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
books.AddRange(results.Where(book => book != null)!);
return Result<List<CalibreBook>>.Success(books);
} }
catch (TaskCanceledException ex) catch (TaskCanceledException ex)
{ {
return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex)); return Result<List<CalibreBook>>.Failure(new TimeoutException("Сервер Calibre отвечает слишком долго.", ex));
} }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized || ex.StatusCode == HttpStatusCode.Forbidden)
{
return Result<List<CalibreBook>>.Failure("Сервер Calibre отклонил авторизацию. Проверьте логин и пароль.");
}
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
return Result<List<CalibreBook>>.Failure(new HttpRequestException("Не удалось подключиться к серверу Calibre.", ex)); return Result<List<CalibreBook>>.Failure($"Не удалось загрузить каталог Calibre: {ex.Message}");
}
catch (ArgumentException ex)
{
return Result<List<CalibreBook>>.Failure(ex.Message);
}
catch (InvalidOperationException ex)
{
return Result<List<CalibreBook>>.Failure(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
return Result<List<CalibreBook>>.Failure(new Exception("Не удалось загрузить каталог Calibre.", ex)); return Result<List<CalibreBook>>.Failure($"Не удалось загрузить каталог Calibre: {ex.GetBaseException().Message}");
} }
} }
private async Task<CalibreBook?> LoadBookDataAsync(int bookId) public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null)
{
if (string.IsNullOrWhiteSpace(book.DownloadUrl))
{
throw new InvalidOperationException("У книги нет ссылки на скачивание.");
}
if (!TryCreateSupportedRemoteUri(book.DownloadUrl, out var downloadUri))
{
throw new InvalidOperationException("Сервер Calibre вернул неподдерживаемую ссылку на скачивание. Поддерживаются только http:// и https://.");
}
var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder);
Directory.CreateDirectory(booksDir);
var fileName = $"{Guid.NewGuid()}.{book.Format}";
var filePath = Path.Combine(booksDir, fileName);
using var response = await _httpClient.GetAsync(downloadUri, HttpCompletionOption.ResponseHeadersRead);
var hasAuthorizationHeader = _httpClient.DefaultRequestHeaders.Authorization != null;
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
{
var message = hasAuthorizationHeader
? $"Сервер Calibre отклонил авторизацию при скачивании книги. Проверьте логин и пароль в настройках. URL: {downloadUri}"
: $"Сервер Calibre требует авторизацию для скачивания книги. Укажите логин и пароль в настройках. URL: {downloadUri}";
throw new UnauthorizedAccessException(message);
}
if (!response.IsSuccessStatusCode)
{
var errorSnippet = await ReadErrorSnippetAsync(response);
var authHint = !hasAuthorizationHeader && IsOpdsDownloadUri(downloadUri)
? " Каталог OPDS может быть публичным, а скачивание требовать логин и пароль."
: string.Empty;
var responseHint = string.IsNullOrWhiteSpace(errorSnippet)
? string.Empty
: $" Ответ сервера: {errorSnippet}";
throw new HttpRequestException(
$"Сервер Calibre вернул {(int)response.StatusCode} {response.ReasonPhrase} при скачивании книги. URL: {downloadUri}.{authHint}{responseHint}",
null,
response.StatusCode);
}
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var bytesRead = 0L;
using var contentStream = await response.Content.ReadAsStreamAsync();
using var fileStream = File.Create(filePath);
var buffer = new byte[Constants.Network.DownloadBufferSize];
int read;
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{
await fileStream.WriteAsync(buffer, 0, read);
bytesRead += read;
if (totalBytes > 0)
{
progress?.Report((double)bytesRead / totalBytes);
}
}
return filePath;
}
private async Task<CalibreBackendKind> DetectBackendAsync(bool forceRefresh = false)
{
if (!forceRefresh && _backendKind != CalibreBackendKind.Unknown)
{
return _backendKind;
}
if (string.IsNullOrWhiteSpace(_baseUrl))
{
return CalibreBackendKind.Unknown;
}
using var ajaxResponse = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1");
if (ajaxResponse.IsSuccessStatusCode)
{
_backendKind = CalibreBackendKind.ContentServerAjax;
return _backendKind;
}
using var opdsResponse = await _httpClient.GetAsync($"{_baseUrl}/opds");
if (opdsResponse.IsSuccessStatusCode)
{
var responseBody = await opdsResponse.Content.ReadAsStringAsync();
if (IsOpdsFeed(responseBody))
{
_backendKind = CalibreBackendKind.Opds;
return _backendKind;
}
}
_backendKind = CalibreBackendKind.Unknown;
return _backendKind;
}
private async Task<Result<List<CalibreBook>>> GetBooksFromAjaxAsync(string? searchQuery, int page, int pageSize)
{
var books = new List<CalibreBook>();
var offset = page * pageSize;
var query = string.IsNullOrWhiteSpace(searchQuery) ? string.Empty : Uri.EscapeDataString(searchQuery.Trim());
var url = $"{_baseUrl}/ajax/search?query={query}&num={pageSize}&offset={offset}&sort=timestamp&sort_order=desc";
var response = await _httpClient.GetStringAsync(url);
var json = JObject.Parse(response);
var bookIds = json["book_ids"]?.ToObject<List<int>>() ?? new List<int>();
var semaphore = new SemaphoreSlim(4);
var tasks = bookIds.Select(async bookId =>
{
await semaphore.WaitAsync();
try
{
return await LoadBookDataFromAjaxAsync(bookId);
}
finally
{
semaphore.Release();
}
});
var results = await Task.WhenAll(tasks);
books.AddRange(results.Where(book => book != null)!);
return Result<List<CalibreBook>>.Success(books);
}
private async Task<Result<List<CalibreBook>>> GetBooksFromOpdsAsync(string? searchQuery, int page, int pageSize)
{
var requestUrl = string.IsNullOrWhiteSpace(searchQuery)
? ToAbsoluteUrl("/opds/books/letter/00")
: ToAbsoluteUrl($"/opds/search/{Uri.EscapeDataString(searchQuery.Trim())}");
using var response = await _httpClient.GetAsync(requestUrl);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return Result<List<CalibreBook>>.Failure("На сервере calibre-web не найден каталог OPDS.");
}
response.EnsureSuccessStatusCode();
var xml = await response.Content.ReadAsStringAsync();
var document = XDocument.Parse(xml);
var allBooks = document.Root?
.Elements(AtomNamespace + "entry")
.Select(ParseOpdsBook)
.Where(book => book != null)
.Cast<CalibreBook>()
.ToList() ?? new List<CalibreBook>();
var pagedBooks = allBooks
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
await PopulateCoverImagesAsync(pagedBooks);
return Result<List<CalibreBook>>.Success(pagedBooks);
}
private async Task<CalibreBook?> LoadBookDataFromAjaxAsync(int bookId)
{ {
try try
{ {
@@ -130,21 +309,7 @@ public class CalibreWebService : ICalibreWebService
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}" DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
}; };
var cacheKey = $"cover_{bookId}"; await PopulateCoverImageAsync(calibreBook);
calibreBook.CoverImage = await _coverCacheService.GetCoverAsync(cacheKey);
if (calibreBook.CoverImage == null)
{
try
{
calibreBook.CoverImage = await _httpClient.GetByteArrayAsync(calibreBook.CoverUrl);
await _coverCacheService.SetCoverAsync(cacheKey, calibreBook.CoverImage);
}
catch
{
}
}
return calibreBook; return calibreBook;
} }
catch catch
@@ -153,37 +318,243 @@ public class CalibreWebService : ICalibreWebService
} }
} }
public async Task<string> DownloadBookAsync(CalibreBook book, IProgress<double>? progress = null) private CalibreBook? ParseOpdsBook(XElement entry)
{ {
var booksDir = Path.Combine(FileSystem.AppDataDirectory, Constants.Files.BooksFolder); try
Directory.CreateDirectory(booksDir);
var fileName = $"{Guid.NewGuid()}.{book.Format}";
var filePath = Path.Combine(booksDir, fileName);
using var response = await _httpClient.GetAsync(book.DownloadUrl, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var bytesRead = 0L;
using var contentStream = await response.Content.ReadAsStreamAsync();
using var fileStream = File.Create(filePath);
var buffer = new byte[Constants.Network.DownloadBufferSize];
int read;
while ((read = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0)
{ {
await fileStream.WriteAsync(buffer, 0, read); var links = entry.Elements(AtomNamespace + "link").ToList();
bytesRead += read; var acquisitionLink = links.FirstOrDefault(link =>
if (totalBytes > 0)
{ {
progress?.Report((double)bytesRead / totalBytes); var rel = (string?)link.Attribute("rel") ?? string.Empty;
return rel.Contains(OpdsAcquisitionRel, StringComparison.OrdinalIgnoreCase) &&
DetermineSupportedFormat(link) != null;
});
if (acquisitionLink == null)
{
return null;
} }
var downloadHref = (string?)acquisitionLink.Attribute("href");
var format = DetermineSupportedFormat(acquisitionLink);
if (string.IsNullOrWhiteSpace(downloadHref) || string.IsNullOrWhiteSpace(format))
{
return null;
}
var title = entry.Element(AtomNamespace + "title")?.Value?.Trim();
var authors = entry.Elements(AtomNamespace + "author")
.Select(author => author.Element(AtomNamespace + "name")?.Value?.Trim())
.Where(name => !string.IsNullOrWhiteSpace(name))
.Cast<string>()
.ToList();
var coverHref = links
.FirstOrDefault(link => string.Equals((string?)link.Attribute("rel"), OpdsImageRel, StringComparison.OrdinalIgnoreCase))?
.Attribute("href")?.Value
?? links.FirstOrDefault(link => string.Equals((string?)link.Attribute("rel"), OpdsThumbnailRel, StringComparison.OrdinalIgnoreCase))?
.Attribute("href")?.Value;
string? coverUrl = null;
if (!string.IsNullOrWhiteSpace(coverHref))
{
coverUrl = TryToAbsoluteHttpUrl(coverHref);
}
var downloadUrl = TryToAbsoluteHttpUrl(downloadHref);
if (string.IsNullOrWhiteSpace(downloadUrl))
{
return null;
}
return new CalibreBook
{
Id = ExtractBookId(downloadHref) ?? ExtractBookId(coverHref) ?? Guid.NewGuid().ToString("N"),
Title = string.IsNullOrWhiteSpace(title) ? "Без названия" : title,
Author = authors.Count > 0 ? string.Join(", ", authors) : "Неизвестный автор",
Format = format,
CoverUrl = coverUrl,
DownloadUrl = downloadUrl
};
}
catch
{
return null;
}
}
private async Task PopulateCoverImagesAsync(IEnumerable<CalibreBook> books)
{
var semaphore = new SemaphoreSlim(4);
var tasks = books.Select(async book =>
{
await semaphore.WaitAsync();
try
{
await PopulateCoverImageAsync(book);
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
}
private async Task PopulateCoverImageAsync(CalibreBook book)
{
if (string.IsNullOrWhiteSpace(book.CoverUrl))
{
return;
} }
return filePath; if (!TryCreateSupportedRemoteUri(book.CoverUrl, out var coverUri))
{
return;
}
book.CoverImage = await _coverCacheService.GetCoverAsync(book.CoverCacheKey);
if (book.CoverImage != null)
{
return;
}
try
{
book.CoverImage = await _httpClient.GetByteArrayAsync(coverUri);
if (book.CoverImage != null && book.CoverImage.Length > 0)
{
await _coverCacheService.SetCoverAsync(book.CoverCacheKey, book.CoverImage);
}
}
catch
{
}
}
private static bool IsOpdsFeed(string responseBody)
{
try
{
var document = XDocument.Parse(responseBody);
return document.Root?.Name == AtomNamespace + "feed";
}
catch
{
return false;
}
}
private static string? DetermineSupportedFormat(XElement link)
{
var type = (string?)link.Attribute("type");
var title = (string?)link.Attribute("title");
var href = (string?)link.Attribute("href");
var candidates = new[] { type, title, href };
if (candidates.Any(candidate => candidate?.Contains("epub", StringComparison.OrdinalIgnoreCase) == true))
{
return "epub";
}
if (candidates.Any(candidate =>
candidate?.Contains("fb2", StringComparison.OrdinalIgnoreCase) == true ||
candidate?.Contains("fictionbook", StringComparison.OrdinalIgnoreCase) == true))
{
return "fb2";
}
return null;
}
private string ToAbsoluteUrl(string relativeOrAbsoluteUrl)
{
var absoluteUrl = TryToAbsoluteHttpUrl(relativeOrAbsoluteUrl);
if (string.IsNullOrWhiteSpace(absoluteUrl))
{
throw new InvalidOperationException($"Сервер Calibre вернул ссылку с неподдерживаемой схемой. BaseUrl: {_baseUrl}; Link: {relativeOrAbsoluteUrl}");
}
return absoluteUrl;
}
private string? TryToAbsoluteHttpUrl(string relativeOrAbsoluteUrl)
{
if (string.IsNullOrWhiteSpace(relativeOrAbsoluteUrl) || string.IsNullOrWhiteSpace(_baseUrl))
{
return null;
}
if (HasExplicitUriScheme(relativeOrAbsoluteUrl))
{
return TryCreateSupportedRemoteUri(relativeOrAbsoluteUrl, out var absoluteUri)
? absoluteUri.ToString()
: null;
}
var baseUri = new Uri($"{_baseUrl.TrimEnd('/')}/");
var combinedUri = new Uri(baseUri, relativeOrAbsoluteUrl.TrimStart('/'));
return IsSupportedRemoteScheme(combinedUri) ? combinedUri.ToString() : null;
}
private static bool HasExplicitUriScheme(string value)
{
return Regex.IsMatch(value, @"^[a-zA-Z][a-zA-Z0-9+.-]*:");
}
private static bool TryCreateSupportedRemoteUri(string? value, out Uri uri)
{
if (Uri.TryCreate(value, UriKind.Absolute, out uri!))
{
return IsSupportedRemoteScheme(uri);
}
uri = null!;
return false;
}
private static bool IsSupportedRemoteScheme(Uri uri)
{
return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
}
private static bool IsOpdsDownloadUri(Uri uri)
{
return uri.AbsolutePath.Contains("/opds/download/", StringComparison.OrdinalIgnoreCase);
}
private static async Task<string?> ReadErrorSnippetAsync(HttpResponseMessage response)
{
try
{
var body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
var normalized = Regex.Replace(body, @"\s+", " ").Trim();
return normalized.Length <= 180 ? normalized : normalized[..180];
}
catch
{
return null;
}
}
private static string? ExtractBookId(string? href)
{
if (string.IsNullOrWhiteSpace(href))
{
return null;
}
var match = Regex.Match(href, @"/(?:opds/)?(?:download|cover)/(\d+)", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : null;
} }
} }

View File

@@ -0,0 +1,8 @@
namespace BookReader.Services;
public sealed record SelectedBookFile(string OriginalFileName, string TempFilePath);
public interface IBookImportPickerService
{
Task<SelectedBookFile?> PickBookFileAsync(CancellationToken cancellationToken = default);
}

View File

@@ -11,6 +11,7 @@ public partial class BookshelfViewModel : BaseViewModel
private readonly IDatabaseService _databaseService; private readonly IDatabaseService _databaseService;
private readonly IBookParserService _bookParserService; private readonly IBookParserService _bookParserService;
private readonly INavigationService _navigationService; private readonly INavigationService _navigationService;
private readonly IBookImportPickerService _bookImportPickerService;
public ObservableCollection<Book> Books { get; } = new(); public ObservableCollection<Book> Books { get; } = new();
@@ -48,11 +49,13 @@ public partial class BookshelfViewModel : BaseViewModel
public BookshelfViewModel( public BookshelfViewModel(
IDatabaseService databaseService, IDatabaseService databaseService,
IBookParserService bookParserService, IBookParserService bookParserService,
INavigationService navigationService) INavigationService navigationService,
IBookImportPickerService bookImportPickerService)
{ {
_databaseService = databaseService; _databaseService = databaseService;
_bookParserService = bookParserService; _bookParserService = bookParserService;
_navigationService = navigationService; _navigationService = navigationService;
_bookImportPickerService = bookImportPickerService;
Title = "Библиотека"; Title = "Библиотека";
} }
@@ -103,25 +106,17 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public async Task AddBookFromFileAsync() public async Task AddBookFromFileAsync()
{ {
SelectedBookFile? selectedBookFile = null;
try try
{ {
var customFileTypes = new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>> selectedBookFile = await _bookImportPickerService.PickBookFileAsync();
{ if (selectedBookFile == null)
{ DevicePlatform.Android, new[] { "application/epub+zip", "application/x-fictionbook+xml", "application/octet-stream", "*/*" } }
});
var result = await FilePicker.Default.PickAsync(new PickOptions
{
PickerTitle = "Выберите книгу",
FileTypes = customFileTypes
});
if (result == null)
{ {
return; return;
} }
var extension = Path.GetExtension(result.FileName).ToLowerInvariant(); var extension = Path.GetExtension(selectedBookFile.OriginalFileName).ToLowerInvariant();
if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension) if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
{ {
await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK"); await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
@@ -131,24 +126,9 @@ public partial class BookshelfViewModel : BaseViewModel
IsBusy = true; IsBusy = true;
StatusMessage = "Добавляю книгу..."; StatusMessage = "Добавляю книгу...";
using var sourceStream = await result.OpenReadAsync(); var book = await _bookParserService.ParseAndStoreBookAsync(selectedBookFile.TempFilePath, selectedBookFile.OriginalFileName);
var tempPath = Path.Combine(FileSystem.CacheDirectory, result.FileName);
using (var tempFileStream = File.Create(tempPath))
{
await sourceStream.CopyToAsync(tempFileStream);
}
var book = await _bookParserService.ParseAndStoreBookAsync(tempPath, result.FileName);
Books.Insert(0, book); Books.Insert(0, book);
RefreshLibraryState(); RefreshLibraryState();
try
{
File.Delete(tempPath);
}
catch
{
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -156,6 +136,7 @@ public partial class BookshelfViewModel : BaseViewModel
} }
finally finally
{ {
TryDeleteTempFile(selectedBookFile?.TempFilePath);
IsBusy = false; IsBusy = false;
StatusMessage = string.Empty; StatusMessage = string.Empty;
} }
@@ -214,6 +195,25 @@ public partial class BookshelfViewModel : BaseViewModel
[RelayCommand] [RelayCommand]
public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre"); public Task OpenCalibreLibraryAsync() => _navigationService.GoToAsync("//calibre");
private static void TryDeleteTempFile(string? tempFilePath)
{
if (string.IsNullOrWhiteSpace(tempFilePath))
{
return;
}
try
{
if (File.Exists(tempFilePath))
{
File.Delete(tempFilePath);
}
}
catch
{
}
}
private void RefreshLibraryState() private void RefreshLibraryState()
{ {
IsEmpty = Books.Count == 0; IsEmpty = Books.Count == 0;

View File

@@ -68,7 +68,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel
if (IsConfigured) if (IsConfigured)
{ {
_calibreWebService.Configure(url, username, password); if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
Books.Clear();
ConnectionErrorMessage = $"Сохранён некорректный адрес Calibre: {url}";
return;
}
_calibreWebService.Configure(uri.ToString(), username, password);
await LoadBooksAsync(); await LoadBooksAsync();
} }
else else
@@ -226,3 +234,4 @@ public partial class CalibreLibraryViewModel : BaseViewModel
OnPropertyChanged(nameof(HasBooks)); OnPropertyChanged(nameof(HasBooks));
} }
} }

View File

@@ -129,8 +129,17 @@ public partial class SettingsViewModel : BaseViewModel
try try
{ {
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword); var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен."; if (success)
ConnectionStatusColor = success ? SuccessTone : DangerTone; {
await PersistSettingsAsync(showNotification: false);
ConnectionStatus = "Соединение установлено. Настройки сохранены.";
ConnectionStatusColor = SuccessTone;
}
else
{
ConnectionStatus = "Сервер ответил ошибкой или недоступен.";
ConnectionStatusColor = DangerTone;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -236,3 +245,4 @@ public partial class SettingsViewModel : BaseViewModel
_ => Constants.Reader.DefaultTheme _ => Constants.Reader.DefaultTheme
}; };
} }

View File

@@ -76,6 +76,7 @@
IsRefreshing="{Binding IsRefreshing}" IsRefreshing="{Binding IsRefreshing}"
Margin="0,0,0,24"> Margin="0,0,0,24">
<CollectionView ItemsSource="{Binding Books}" <CollectionView ItemsSource="{Binding Books}"
IsVisible="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
SelectionMode="None" SelectionMode="None"
RemainingItemsThreshold="5" RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}"> RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
@@ -167,3 +168,4 @@
</Grid> </Grid>
</Grid> </Grid>
</ContentPage> </ContentPage>