edit
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using BookReader.Services;
|
||||
using BookReader.Platforms.Android.Services;
|
||||
using BookReader.Services;
|
||||
using BookReader.ViewModels;
|
||||
using BookReader.Views;
|
||||
using CommunityToolkit.Maui;
|
||||
@@ -26,24 +27,21 @@ public static class MauiProgram
|
||||
builder.Logging.AddDebug();
|
||||
#endif
|
||||
|
||||
// Register Services (Singleton for shared state)
|
||||
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
|
||||
builder.Services.AddSingleton<IBookParserService, BookParserService>();
|
||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||
builder.Services.AddSingleton<INavigationService, NavigationService>();
|
||||
builder.Services.AddSingleton<ICoverCacheService, LruCoverCacheService>();
|
||||
builder.Services.AddSingleton<ICachedImageLoadingService, CachedImageLoadingService>();
|
||||
builder.Services.AddSingleton<IBookImportPickerService, AndroidBookImportPickerService>();
|
||||
|
||||
// HTTP Client for Calibre
|
||||
builder.Services.AddHttpClient<ICalibreWebService, CalibreWebService>();
|
||||
|
||||
// Register ViewModels
|
||||
builder.Services.AddTransient<BookshelfViewModel>();
|
||||
builder.Services.AddTransient<ReaderViewModel>();
|
||||
builder.Services.AddTransient<SettingsViewModel>();
|
||||
builder.Services.AddTransient<CalibreLibraryViewModel>();
|
||||
|
||||
// Register Pages
|
||||
builder.Services.AddTransient<BookshelfPage>();
|
||||
builder.Services.AddTransient<ReaderPage>();
|
||||
builder.Services.AddTransient<SettingsPage>();
|
||||
@@ -51,4 +49,4 @@ public static class MauiProgram
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Android.App;
|
||||
using Android.Content;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using BookReader.Platforms.Android.Services;
|
||||
|
||||
namespace BookReader;
|
||||
|
||||
@@ -16,7 +18,12 @@ public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
// Keep screen on while reading
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
using BookReader.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
|
||||
namespace BookReader.Services;
|
||||
|
||||
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 ICoverCacheService _coverCacheService;
|
||||
private string _baseUrl = string.Empty;
|
||||
private CalibreBackendKind _backendKind = CalibreBackendKind.Unknown;
|
||||
|
||||
private enum CalibreBackendKind
|
||||
{
|
||||
Unknown,
|
||||
ContentServerAjax,
|
||||
Opds
|
||||
}
|
||||
|
||||
public CalibreWebService(HttpClient httpClient, ICoverCacheService coverCacheService)
|
||||
{
|
||||
@@ -20,7 +36,13 @@ public class CalibreWebService : ICalibreWebService
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -39,8 +61,8 @@ public class CalibreWebService : ICalibreWebService
|
||||
try
|
||||
{
|
||||
Configure(url, username, password);
|
||||
var response = await _httpClient.GetAsync($"{_baseUrl}/ajax/search?query=&num=1");
|
||||
return response.IsSuccessStatusCode;
|
||||
var backendKind = await DetectBackendAsync(forceRefresh: true);
|
||||
return backendKind != CalibreBackendKind.Unknown;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -55,52 +77,209 @@ public class CalibreWebService : ICalibreWebService
|
||||
return Result<List<CalibreBook>>.Failure("Сначала укажите адрес сервера Calibre в настройках.");
|
||||
}
|
||||
|
||||
var books = new List<CalibreBook>();
|
||||
|
||||
try
|
||||
{
|
||||
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 =>
|
||||
var backendKind = await DetectBackendAsync();
|
||||
return backendKind switch
|
||||
{
|
||||
await semaphore.WaitAsync();
|
||||
try
|
||||
{
|
||||
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);
|
||||
CalibreBackendKind.ContentServerAjax => await GetBooksFromAjaxAsync(searchQuery, page, pageSize),
|
||||
CalibreBackendKind.Opds => await GetBooksFromOpdsAsync(searchQuery, page, pageSize),
|
||||
_ => Result<List<CalibreBook>>.Failure("Не удалось определить API сервера Calibre. Для calibre-web нужен доступный OPDS, для calibre-server — API /ajax.")
|
||||
};
|
||||
}
|
||||
catch (TaskCanceledException 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
@@ -130,21 +309,7 @@ public class CalibreWebService : ICalibreWebService
|
||||
DownloadUrl = $"{_baseUrl}/get/{supportedFormat}/{bookId}"
|
||||
};
|
||||
|
||||
var cacheKey = $"cover_{bookId}";
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
await PopulateCoverImageAsync(calibreBook);
|
||||
return calibreBook;
|
||||
}
|
||||
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);
|
||||
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)
|
||||
try
|
||||
{
|
||||
await fileStream.WriteAsync(buffer, 0, read);
|
||||
bytesRead += read;
|
||||
|
||||
if (totalBytes > 0)
|
||||
var links = entry.Elements(AtomNamespace + "link").ToList();
|
||||
var acquisitionLink = links.FirstOrDefault(link =>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8
BookReader/Services/IBookImportPickerService.cs
Normal file
8
BookReader/Services/IBookImportPickerService.cs
Normal 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);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
private readonly IDatabaseService _databaseService;
|
||||
private readonly IBookParserService _bookParserService;
|
||||
private readonly INavigationService _navigationService;
|
||||
private readonly IBookImportPickerService _bookImportPickerService;
|
||||
|
||||
public ObservableCollection<Book> Books { get; } = new();
|
||||
|
||||
@@ -48,11 +49,13 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
public BookshelfViewModel(
|
||||
IDatabaseService databaseService,
|
||||
IBookParserService bookParserService,
|
||||
INavigationService navigationService)
|
||||
INavigationService navigationService,
|
||||
IBookImportPickerService bookImportPickerService)
|
||||
{
|
||||
_databaseService = databaseService;
|
||||
_bookParserService = bookParserService;
|
||||
_navigationService = navigationService;
|
||||
_bookImportPickerService = bookImportPickerService;
|
||||
Title = "Библиотека";
|
||||
}
|
||||
|
||||
@@ -103,25 +106,17 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
[RelayCommand]
|
||||
public async Task AddBookFromFileAsync()
|
||||
{
|
||||
SelectedBookFile? selectedBookFile = null;
|
||||
|
||||
try
|
||||
{
|
||||
var customFileTypes = new FilePickerFileType(new Dictionary<DevicePlatform, IEnumerable<string>>
|
||||
{
|
||||
{ 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)
|
||||
selectedBookFile = await _bookImportPickerService.PickBookFileAsync();
|
||||
if (selectedBookFile == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(result.FileName).ToLowerInvariant();
|
||||
var extension = Path.GetExtension(selectedBookFile.OriginalFileName).ToLowerInvariant();
|
||||
if (extension != Constants.Files.EpubExtension && extension != Constants.Files.Fb2Extension)
|
||||
{
|
||||
await _navigationService.DisplayAlertAsync("Формат не поддерживается", "Сейчас можно добавить только EPUB и FB2.", "OK");
|
||||
@@ -131,24 +126,9 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
IsBusy = true;
|
||||
StatusMessage = "Добавляю книгу...";
|
||||
|
||||
using var sourceStream = await result.OpenReadAsync();
|
||||
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);
|
||||
var book = await _bookParserService.ParseAndStoreBookAsync(selectedBookFile.TempFilePath, selectedBookFile.OriginalFileName);
|
||||
Books.Insert(0, book);
|
||||
RefreshLibraryState();
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(tempPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -156,6 +136,7 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteTempFile(selectedBookFile?.TempFilePath);
|
||||
IsBusy = false;
|
||||
StatusMessage = string.Empty;
|
||||
}
|
||||
@@ -214,6 +195,25 @@ public partial class BookshelfViewModel : BaseViewModel
|
||||
[RelayCommand]
|
||||
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()
|
||||
{
|
||||
IsEmpty = Books.Count == 0;
|
||||
|
||||
@@ -68,7 +68,15 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
||||
|
||||
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();
|
||||
}
|
||||
else
|
||||
@@ -226,3 +234,4 @@ public partial class CalibreLibraryViewModel : BaseViewModel
|
||||
OnPropertyChanged(nameof(HasBooks));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,8 +129,17 @@ public partial class SettingsViewModel : BaseViewModel
|
||||
try
|
||||
{
|
||||
var success = await _calibreWebService.TestConnectionAsync(CalibreUrl, CalibreUsername, CalibrePassword);
|
||||
ConnectionStatus = success ? "Соединение установлено." : "Сервер ответил ошибкой или недоступен.";
|
||||
ConnectionStatusColor = success ? SuccessTone : DangerTone;
|
||||
if (success)
|
||||
{
|
||||
await PersistSettingsAsync(showNotification: false);
|
||||
ConnectionStatus = "Соединение установлено. Настройки сохранены.";
|
||||
ConnectionStatusColor = SuccessTone;
|
||||
}
|
||||
else
|
||||
{
|
||||
ConnectionStatus = "Сервер ответил ошибкой или недоступен.";
|
||||
ConnectionStatusColor = DangerTone;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -236,3 +245,4 @@ public partial class SettingsViewModel : BaseViewModel
|
||||
_ => Constants.Reader.DefaultTheme
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
IsRefreshing="{Binding IsRefreshing}"
|
||||
Margin="0,0,0,24">
|
||||
<CollectionView ItemsSource="{Binding Books}"
|
||||
IsVisible="{Binding HasConnectionError, Converter={StaticResource InvertedBoolConverter}}"
|
||||
SelectionMode="None"
|
||||
RemainingItemsThreshold="5"
|
||||
RemainingItemsThresholdReachedCommand="{Binding LoadMoreBooksCommand}">
|
||||
@@ -167,3 +168,4 @@
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user