Send To Device Support (#1557)
* Tweaked the logging output * Started implementing some basic idea for devices * Updated Email Service with new API routes * Implemented basic DB structure and some APIs to prep for the UI and flows. * Added an abstract class to make Unit testing easier. * Removed dependency we don't need * Updated the UI to be able to show devices and add new devices. Email field will update the platform if the user hasn't interacted with it already. * Added ability to delete a device as well * Basic ability to send files to devices works * Refactored Action code to pass ActionItem back and allow for dynamic children based on an Observable (api). Hooked in ability to send a chapter to a device. There is no logic in the FE to validate type. * Fixed a broken unit test * Implemented the ability to edit a device * Code cleanup * Fixed a bad success message * Fixed broken unit test from updating mock layer
This commit is contained in:
parent
ab0f13ef74
commit
9d7476a367
79 changed files with 3026 additions and 157 deletions
92
API/Controllers/DeviceController.cs
Normal file
92
API/Controllers/DeviceController.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Device;
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible interacting and creating Devices
|
||||
/// </summary>
|
||||
public class DeviceController : BaseApiController
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDeviceService _deviceService;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public DeviceController(IUnitOfWork unitOfWork, IDeviceService deviceService, IEmailService emailService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_deviceService = deviceService;
|
||||
_emailService = emailService;
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<ActionResult> CreateOrUpdateDevice(CreateDeviceDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
|
||||
var device = await _deviceService.Create(dto, user);
|
||||
|
||||
if (device == null) return BadRequest("There was an error when creating the device");
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateDevice(UpdateDeviceDto dto)
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
|
||||
var device = await _deviceService.Update(dto, user);
|
||||
|
||||
if (device == null) return BadRequest("There was an error when updating the device");
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the device from the user
|
||||
/// </summary>
|
||||
/// <param name="deviceId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
public async Task<ActionResult> DeleteDevice(int deviceId)
|
||||
{
|
||||
if (deviceId <= 0) return BadRequest("Not a valid deviceId");
|
||||
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Devices);
|
||||
if (await _deviceService.Delete(user, deviceId)) return Ok();
|
||||
|
||||
return BadRequest("Could not delete device");
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IEnumerable<DeviceDto>>> GetDevices()
|
||||
{
|
||||
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
|
||||
return Ok(await _unitOfWork.DeviceRepository.GetDevicesForUserAsync(userId));
|
||||
}
|
||||
|
||||
[HttpPost("send-to")]
|
||||
public async Task<ActionResult> SendToDevice(SendToDeviceDto dto)
|
||||
{
|
||||
if (dto.ChapterId < 0) return BadRequest("ChapterId must be greater than 0");
|
||||
if (dto.DeviceId < 0) return BadRequest("DeviceId must be greater than 0");
|
||||
|
||||
if (await _emailService.IsDefaultEmailService())
|
||||
return BadRequest("Send to device cannot be used with Kavita's email service. Please configure your own.");
|
||||
|
||||
if (await _deviceService.SendTo(dto.ChapterId, dto.DeviceId)) return Ok();
|
||||
|
||||
return BadRequest("There was an error sending the file to the device");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
using System.IO;
|
||||
using System;
|
||||
using System.IO;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
|
|
|
|||
20
API/DTOs/Device/CreateDeviceDto.cs
Normal file
20
API/DTOs/Device/CreateDeviceDto.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Runtime.InteropServices;
|
||||
using API.Entities.Enums.Device;
|
||||
|
||||
namespace API.DTOs.Device;
|
||||
|
||||
public class CreateDeviceDto
|
||||
{
|
||||
[Required]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Platform of the device. If not know, defaults to "Custom"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DevicePlatform Platform { get; set; }
|
||||
[Required]
|
||||
public string EmailAddress { get; set; }
|
||||
|
||||
|
||||
}
|
||||
33
API/DTOs/Device/DeviceDto.cs
Normal file
33
API/DTOs/Device/DeviceDto.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using API.Entities.Enums.Device;
|
||||
|
||||
namespace API.DTOs.Device;
|
||||
|
||||
/// <summary>
|
||||
/// A Device is an entity that can receive data from Kavita (kindle)
|
||||
/// </summary>
|
||||
public class DeviceDto
|
||||
{
|
||||
/// <summary>
|
||||
/// The device Id
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// A name given to this device
|
||||
/// </summary>
|
||||
/// <remarks>If this device is web, this will be the browser name</remarks>
|
||||
/// <example>Pixel 3a, John's Kindle</example>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
|
||||
/// </summary>
|
||||
public string EmailAddress { get; set; }
|
||||
/// <summary>
|
||||
/// Platform (ie) Windows 10
|
||||
/// </summary>
|
||||
public DevicePlatform Platform { get; set; }
|
||||
/// <summary>
|
||||
/// Last time this device was used to send a file
|
||||
/// </summary>
|
||||
public DateTime LastUsed { get; set; }
|
||||
}
|
||||
7
API/DTOs/Device/SendToDeviceDto.cs
Normal file
7
API/DTOs/Device/SendToDeviceDto.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace API.DTOs.Device;
|
||||
|
||||
public class SendToDeviceDto
|
||||
{
|
||||
public int DeviceId { get; set; }
|
||||
public int ChapterId { get; set; }
|
||||
}
|
||||
19
API/DTOs/Device/UpdateDeviceDto.cs
Normal file
19
API/DTOs/Device/UpdateDeviceDto.cs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities.Enums.Device;
|
||||
|
||||
namespace API.DTOs.Device;
|
||||
|
||||
public class UpdateDeviceDto
|
||||
{
|
||||
[Required]
|
||||
public int Id { get; set; }
|
||||
[Required]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Platform of the device. If not know, defaults to "Custom"
|
||||
/// </summary>
|
||||
[Required]
|
||||
public DevicePlatform Platform { get; set; }
|
||||
[Required]
|
||||
public string EmailAddress { get; set; }
|
||||
}
|
||||
9
API/DTOs/Email/SendToDto.cs
Normal file
9
API/DTOs/Email/SendToDto.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.Email;
|
||||
|
||||
public class SendToDto
|
||||
{
|
||||
public string DestinationEmail { get; set; }
|
||||
public IEnumerable<string> FilePaths { get; set; }
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<SiteTheme> SiteTheme { get; set; }
|
||||
public DbSet<SeriesRelation> SeriesRelation { get; set; }
|
||||
public DbSet<FolderPath> FolderPath { get; set; }
|
||||
public DbSet<Device> Device { get; set; }
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
|
|
|||
|
|
@ -162,7 +162,15 @@ public static class DbFactory
|
|||
FilePath = filePath,
|
||||
Format = format,
|
||||
Pages = pages,
|
||||
LastModified = File.GetLastWriteTime(filePath) // NOTE: Changed this from DateTime.Now
|
||||
LastModified = File.GetLastWriteTime(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
public static Device Device(string name)
|
||||
{
|
||||
return new Device()
|
||||
{
|
||||
Name = name,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
1658
API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs
generated
Normal file
1658
API/Data/Migrations/20220921023455_DeviceSupport.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
73
API/Data/Migrations/20220921023455_DeviceSupport.cs
Normal file
73
API/Data/Migrations/20220921023455_DeviceSupport.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
public partial class DeviceSupport : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_SeriesRelation_Series_TargetSeriesId",
|
||||
table: "SeriesRelation");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Device",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
IpAddress = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EmailAddress = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Platform = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LastUsed = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Device", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Device_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Device_AppUserId",
|
||||
table: "Device",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_SeriesRelation_Series_TargetSeriesId",
|
||||
table: "SeriesRelation",
|
||||
column: "TargetSeriesId",
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_SeriesRelation_Series_TargetSeriesId",
|
||||
table: "SeriesRelation");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Device");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_SeriesRelation_Series_TargetSeriesId",
|
||||
table: "SeriesRelation",
|
||||
column: "TargetSeriesId",
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ namespace API.Data.Migrations
|
|||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.9");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
|
|
@ -442,6 +442,43 @@ namespace API.Data.Migrations
|
|||
b.ToTable("CollectionTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EmailAddress")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("IpAddress")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastUsed")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Platform")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("Device");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
|
@ -1262,6 +1299,17 @@ namespace API.Data.Migrations
|
|||
b.Navigation("Volume");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("Devices")
|
||||
.HasForeignKey("AppUserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("AppUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Library", "Library")
|
||||
|
|
@ -1306,7 +1354,7 @@ namespace API.Data.Migrations
|
|||
b.HasOne("API.Entities.Series", "TargetSeries")
|
||||
.WithMany("RelationOf")
|
||||
.HasForeignKey("TargetSeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.ClientCascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Series");
|
||||
|
|
@ -1551,6 +1599,8 @@ namespace API.Data.Migrations
|
|||
{
|
||||
b.Navigation("Bookmarks");
|
||||
|
||||
b.Navigation("Devices");
|
||||
|
||||
b.Navigation("Progresses");
|
||||
|
||||
b.Navigation("Ratings");
|
||||
|
|
|
|||
50
API/Data/Repositories/DeviceRepository.cs
Normal file
50
API/Data/Repositories/DeviceRepository.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Device;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
|
||||
public interface IDeviceRepository
|
||||
{
|
||||
void Update(Device device);
|
||||
Task<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId);
|
||||
Task<Device> GetDeviceById(int deviceId);
|
||||
}
|
||||
|
||||
public class DeviceRepository : IDeviceRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public DeviceRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Update(Device device)
|
||||
{
|
||||
_context.Entry(device).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<DeviceDto>> GetDevicesForUserAsync(int userId)
|
||||
{
|
||||
return await _context.Device
|
||||
.Where(d => d.AppUserId == userId)
|
||||
.OrderBy(d => d.LastUsed)
|
||||
.ProjectTo<DeviceDto>(_mapper.ConfigurationProvider)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Device> GetDeviceById(int deviceId)
|
||||
{
|
||||
return await _context.Device
|
||||
.Where(d => d.Id == deviceId)
|
||||
.SingleOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ public enum AppUserIncludes
|
|||
UserPreferences = 32,
|
||||
WantToRead = 64,
|
||||
ReadingListsWithItems = 128,
|
||||
Devices = 256,
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +195,11 @@ public class UserRepository : IUserRepository
|
|||
query = query.Include(u => u.WantToRead);
|
||||
}
|
||||
|
||||
if (includeFlags.HasFlag(AppUserIncludes.Devices))
|
||||
{
|
||||
query = query.Include(u => u.Devices);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return query;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public interface IUnitOfWork
|
|||
ITagRepository TagRepository { get; }
|
||||
ISiteThemeRepository SiteThemeRepository { get; }
|
||||
IMangaFileRepository MangaFileRepository { get; }
|
||||
IDeviceRepository DeviceRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
|
|
@ -60,6 +61,7 @@ public class UnitOfWork : IUnitOfWork
|
|||
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
|
||||
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
|
||||
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper);
|
||||
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
public ICollection<AppUserProgress> Progresses { get; set; }
|
||||
public ICollection<AppUserRating> Ratings { get; set; }
|
||||
public AppUserPreferences UserPreferences { get; set; }
|
||||
/// <summary>
|
||||
/// Bookmarks associated with this User
|
||||
/// </summary>
|
||||
public ICollection<AppUserBookmark> Bookmarks { get; set; }
|
||||
/// <summary>
|
||||
/// Reading lists associated with this user
|
||||
|
|
@ -26,6 +29,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
/// </summary>
|
||||
public ICollection<Series> WantToRead { get; set; }
|
||||
/// <summary>
|
||||
/// A list of Devices which allows the user to send files to
|
||||
/// </summary>
|
||||
public ICollection<Device> Devices { get; set; }
|
||||
/// <summary>
|
||||
/// An API Key to interact with external services, like OPDS
|
||||
/// </summary>
|
||||
public string ApiKey { get; set; }
|
||||
|
|
|
|||
49
API/Entities/Device.cs
Normal file
49
API/Entities/Device.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Net;
|
||||
using API.Entities.Enums.Device;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A Device is an entity that can receive data from Kavita (kindle)
|
||||
/// </summary>
|
||||
public class Device : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// Last Seen IP Address of the device
|
||||
/// </summary>
|
||||
public string IpAddress { get; set; }
|
||||
/// <summary>
|
||||
/// A name given to this device
|
||||
/// </summary>
|
||||
/// <remarks>If this device is web, this will be the browser name</remarks>
|
||||
/// <example>Pixel 3a, John's Kindle</example>
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// An email address associated with the device (ie Kindle). Will be used with Send to functionality
|
||||
/// </summary>
|
||||
public string EmailAddress { get; set; }
|
||||
/// <summary>
|
||||
/// Platform (ie) Windows 10
|
||||
/// </summary>
|
||||
public DevicePlatform Platform { get; set; }
|
||||
|
||||
|
||||
//public ICollection<string> SupportedExtensions { get; set; } // TODO: This requires some sort of information at mangaFile level (unless i repack)
|
||||
|
||||
public int AppUserId { get; set; }
|
||||
public AppUser AppUser { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Last time this device was used to send a file
|
||||
/// </summary>
|
||||
public DateTime LastUsed { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
}
|
||||
25
API/Entities/Enums/Device/DevicePlatform.cs
Normal file
25
API/Entities/Enums/Device/DevicePlatform.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums.Device;
|
||||
|
||||
public enum DevicePlatform
|
||||
{
|
||||
[Description("Custom")]
|
||||
Custom = 0,
|
||||
/// <summary>
|
||||
/// PocketBook device, email ends in @pbsync.com
|
||||
/// </summary>
|
||||
[Description("PocketBook")]
|
||||
PocketBook = 1,
|
||||
/// <summary>
|
||||
/// Kindle device, email ends in @kindle.com
|
||||
/// </summary>
|
||||
[Description("Kindle")]
|
||||
Kindle = 2,
|
||||
/// <summary>
|
||||
/// Kobo device,
|
||||
/// </summary>
|
||||
[Description("Kobo")]
|
||||
Kobo = 3,
|
||||
|
||||
}
|
||||
|
|
@ -8,18 +8,18 @@ namespace API.Entities.Metadata;
|
|||
/// A relation flows between one series and another.
|
||||
/// Series ---kind---> target
|
||||
/// </summary>
|
||||
public class SeriesRelation
|
||||
public sealed class SeriesRelation
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public RelationKind RelationKind { get; set; }
|
||||
|
||||
public virtual Series TargetSeries { get; set; }
|
||||
public Series TargetSeries { get; set; }
|
||||
/// <summary>
|
||||
/// A is Sequel to B. In this example, TargetSeries is A. B will hold the foreign key.
|
||||
/// </summary>
|
||||
public int TargetSeriesId { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual Series Series { get; set; }
|
||||
public Series Series { get; set; }
|
||||
public int SeriesId { get; set; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ public static class ApplicationServiceExtensions
|
|||
services.AddScoped<ISeriesService, SeriesService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
services.AddScoped<IReadingListService, ReadingListService>();
|
||||
services.AddScoped<IDeviceService, DeviceService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IMetadataService, MetadataService>();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using System.Linq;
|
||||
using API.DTOs;
|
||||
using API.DTOs.CollectionTags;
|
||||
using API.DTOs.Device;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.DTOs.ReadingLists;
|
||||
|
|
@ -97,11 +98,6 @@ public class AutoMapperProfiles : Profile
|
|||
opt =>
|
||||
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Editor)));
|
||||
|
||||
// CreateMap<SeriesRelation, RelatedSeriesDto>()
|
||||
// .ForMember(dest => dest.Adaptations,
|
||||
// opt =>
|
||||
// opt.MapFrom(src => src.Where(p => p.Role == PersonRole.Writer)))
|
||||
|
||||
CreateMap<AppUser, UserDto>();
|
||||
CreateMap<SiteTheme, SiteThemeDto>();
|
||||
CreateMap<AppUserPreferences, UserPreferencesDto>()
|
||||
|
|
@ -144,5 +140,7 @@ public class AutoMapperProfiles : Profile
|
|||
CreateMap<IEnumerable<ServerSetting>, ServerSettingDto>()
|
||||
.ConvertUsing<ServerSettingConverter>();
|
||||
|
||||
CreateMap<Device, DeviceDto>();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ public interface ICacheHelper
|
|||
|
||||
bool CoverImageExists(string path);
|
||||
|
||||
bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
|
||||
bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
|
||||
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
|
||||
|
||||
}
|
||||
|
|
@ -49,13 +49,13 @@ public class CacheHelper : ICacheHelper
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has the file been modified since last scan or is user forcing an update
|
||||
/// Has the file been not been modified since last scan or is user forcing an update
|
||||
/// </summary>
|
||||
/// <param name="chapter"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
/// <param name="firstFile"></param>
|
||||
/// <returns></returns>
|
||||
public bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
|
||||
public bool IsFileUnmodifiedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile)
|
||||
{
|
||||
return firstFile != null &&
|
||||
(!forceUpdate &&
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ public class Program
|
|||
Log.Fatal(ex, "Host terminated unexpectedly");
|
||||
} finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
await Log.CloseAndFlushAsync();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
123
API/Services/DeviceService.cs
Normal file
123
API/Services/DeviceService.cs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Device;
|
||||
using API.DTOs.Email;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using Kavita.Common;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IDeviceService
|
||||
{
|
||||
Task<Device> Create(CreateDeviceDto dto, AppUser userWithDevices);
|
||||
Task<Device> Update(UpdateDeviceDto dto, AppUser userWithDevices);
|
||||
Task<bool> Delete(AppUser userWithDevices, int deviceId);
|
||||
Task<bool> SendTo(int chapterId, int deviceId);
|
||||
}
|
||||
|
||||
public class DeviceService : IDeviceService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<DeviceService> _logger;
|
||||
private readonly IEmailService _emailService;
|
||||
|
||||
public DeviceService(IUnitOfWork unitOfWork, ILogger<DeviceService> logger, IEmailService emailService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
_emailService = emailService;
|
||||
}
|
||||
#nullable enable
|
||||
public async Task<Device?> Create(CreateDeviceDto dto, AppUser userWithDevices)
|
||||
{
|
||||
try
|
||||
{
|
||||
userWithDevices.Devices ??= new List<Device>();
|
||||
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Name.Equals(dto.Name));
|
||||
if (existingDevice != null) throw new KavitaException("A device with this name already exists");
|
||||
|
||||
existingDevice = DbFactory.Device(dto.Name);
|
||||
existingDevice.Platform = dto.Platform;
|
||||
existingDevice.EmailAddress = dto.EmailAddress;
|
||||
|
||||
|
||||
userWithDevices.Devices.Add(existingDevice);
|
||||
_unitOfWork.UserRepository.Update(userWithDevices);
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return existingDevice;
|
||||
if (await _unitOfWork.CommitAsync()) return existingDevice;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error when creating your device");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<Device?> Update(UpdateDeviceDto dto, AppUser userWithDevices)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingDevice = userWithDevices.Devices.SingleOrDefault(d => d.Id == dto.Id);
|
||||
if (existingDevice == null) throw new KavitaException("This device doesn't exist yet. Please create first");
|
||||
|
||||
existingDevice.Name = dto.Name;
|
||||
existingDevice.Platform = dto.Platform;
|
||||
existingDevice.EmailAddress = dto.EmailAddress;
|
||||
|
||||
if (!_unitOfWork.HasChanges()) return existingDevice;
|
||||
if (await _unitOfWork.CommitAsync()) return existingDevice;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an error when updating your device");
|
||||
await _unitOfWork.RollbackAsync();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
#nullable disable
|
||||
|
||||
public async Task<bool> Delete(AppUser userWithDevices, int deviceId)
|
||||
{
|
||||
try
|
||||
{
|
||||
userWithDevices.Devices = userWithDevices.Devices.Where(d => d.Id != deviceId).ToList();
|
||||
_unitOfWork.UserRepository.Update(userWithDevices);
|
||||
if (!_unitOfWork.HasChanges()) return true;
|
||||
if (await _unitOfWork.CommitAsync()) return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue with deleting the device, {DeviceId} for user {UserName}", deviceId, userWithDevices.UserName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> SendTo(int chapterId, int deviceId)
|
||||
{
|
||||
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
|
||||
if (files.Any(f => f.Format is not (MangaFormat.Epub or MangaFormat.Pdf)))
|
||||
throw new KavitaException("Cannot Send non Epub or Pdf to devices as not supported");
|
||||
|
||||
var device = await _unitOfWork.DeviceRepository.GetDeviceById(deviceId);
|
||||
if (device == null) throw new KavitaException("Device doesn't exist");
|
||||
device.LastUsed = DateTime.Now;
|
||||
_unitOfWork.DeviceRepository.Update(device);
|
||||
await _unitOfWork.CommitAsync();
|
||||
var success = await _emailService.SendFilesToEmail(new SendToDto()
|
||||
{
|
||||
DestinationEmail = device.EmailAddress,
|
||||
FilePaths = files.Select(m => m.FilePath)
|
||||
});
|
||||
return success;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Email;
|
||||
|
|
@ -11,6 +14,7 @@ using Kavita.Common.EnvironmentInfo;
|
|||
using Kavita.Common.Helpers;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
|
|
@ -20,23 +24,27 @@ public interface IEmailService
|
|||
Task<bool> CheckIfAccessible(string host);
|
||||
Task<bool> SendMigrationEmail(EmailMigrationDto data);
|
||||
Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data);
|
||||
Task<bool> SendFilesToEmail(SendToDto data);
|
||||
Task<EmailTestResultDto> TestConnectivity(string emailUrl);
|
||||
Task<bool> IsDefaultEmailService();
|
||||
}
|
||||
|
||||
public class EmailService : IEmailService
|
||||
{
|
||||
private readonly ILogger<EmailService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IDownloadService _downloadService;
|
||||
|
||||
/// <summary>
|
||||
/// This is used to initially set or reset the ServerSettingKey. Do not access from the code, access via UnitOfWork
|
||||
/// </summary>
|
||||
public const string DefaultApiUrl = "https://email.kavitareader.com";
|
||||
|
||||
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork)
|
||||
public EmailService(ILogger<EmailService> logger, IUnitOfWork unitOfWork, IDownloadService downloadService)
|
||||
{
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_downloadService = downloadService;
|
||||
|
||||
FlurlHttp.ConfigureClient(DefaultApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
|
|
@ -58,7 +66,7 @@ public class EmailService : IEmailService
|
|||
result.Successful = false;
|
||||
result.ErrorMessage = "This is a local IP address";
|
||||
}
|
||||
result.Successful = await SendEmailWithGet(emailUrl + "/api/email/test");
|
||||
result.Successful = await SendEmailWithGet(emailUrl + "/api/test");
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
|
|
@ -69,10 +77,16 @@ public class EmailService : IEmailService
|
|||
return result;
|
||||
}
|
||||
|
||||
public async Task<bool> IsDefaultEmailService()
|
||||
{
|
||||
return (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value
|
||||
.Equals(DefaultApiUrl);
|
||||
}
|
||||
|
||||
public async Task SendConfirmationEmail(ConfirmationEmailDto data)
|
||||
{
|
||||
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
||||
var success = await SendEmailWithPost(emailLink + "/api/email/confirm", data);
|
||||
var success = await SendEmailWithPost(emailLink + "/api/invite/confirm", data);
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogError("There was a critical error sending Confirmation email");
|
||||
|
|
@ -85,7 +99,7 @@ public class EmailService : IEmailService
|
|||
try
|
||||
{
|
||||
if (IsLocalIpAddress(host)) return false;
|
||||
return await SendEmailWithGet(DefaultApiUrl + "/api/email/reachable?host=" + host);
|
||||
return await SendEmailWithGet(DefaultApiUrl + "/api/reachable?host=" + host);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
|
@ -96,13 +110,20 @@ public class EmailService : IEmailService
|
|||
public async Task<bool> SendMigrationEmail(EmailMigrationDto data)
|
||||
{
|
||||
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
||||
return await SendEmailWithPost(emailLink + "/api/email/email-migration", data);
|
||||
return await SendEmailWithPost(emailLink + "/api/invite/email-migration", data);
|
||||
}
|
||||
|
||||
public async Task<bool> SendPasswordResetEmail(PasswordResetEmailDto data)
|
||||
{
|
||||
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
||||
return await SendEmailWithPost(emailLink + "/api/email/email-password-reset", data);
|
||||
return await SendEmailWithPost(emailLink + "/api/invite/email-password-reset", data);
|
||||
}
|
||||
|
||||
public async Task<bool> SendFilesToEmail(SendToDto data)
|
||||
{
|
||||
if (await IsDefaultEmailService()) return false;
|
||||
var emailLink = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.EmailServiceUrl)).Value;
|
||||
return await SendEmailWithFiles(emailLink + "/api/sendto", data.FilePaths, data.DestinationEmail);
|
||||
}
|
||||
|
||||
private static async Task<bool> SendEmailWithGet(string url, int timeoutSecs = 30)
|
||||
|
|
@ -156,6 +177,41 @@ public class EmailService : IEmailService
|
|||
return true;
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> SendEmailWithFiles(string url, IEnumerable<string> filePaths, string destEmail, int timeoutSecs = 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await (url)
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-api-key", "MsnvA2DfQqxSK5jh")
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithTimeout(TimeSpan.FromSeconds(timeoutSecs))
|
||||
.PostMultipartAsync(mp =>
|
||||
{
|
||||
mp.AddString("email", destEmail);
|
||||
var index = 1;
|
||||
foreach (var filepath in filePaths)
|
||||
{
|
||||
mp.AddFile("file" + index, filepath, _downloadService.GetContentTypeFromFile(filepath));
|
||||
index++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.StatusCode != StatusCodes.Status200OK)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an exception when sending Email for SendTo");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsLocalIpAddress(string url)
|
||||
{
|
||||
var host = url.Split(':')[0];
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public class MetadataService : IMetadataService
|
|||
private void UpdateChapterLastModified(Chapter chapter, bool forceUpdate)
|
||||
{
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
|
||||
if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, forceUpdate, firstFile)) return;
|
||||
|
||||
firstFile.UpdateLastModified();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ public class ReaderService : IReaderService
|
|||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ReaderService> _logger;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
|
||||
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
|
||||
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
|
||||
|
||||
private const float MinWordsPerHour = 10260F;
|
||||
private const float MaxWordsPerHour = 30000F;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,8 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
watcher.Created += OnCreated;
|
||||
watcher.Deleted += OnDeleted;
|
||||
watcher.Error += OnError;
|
||||
watcher.Disposed += (sender, args) =>
|
||||
_logger.LogError("[LibraryWatcher] watcher was disposed when it shouldn't have been");
|
||||
|
||||
watcher.Filter = "*.*";
|
||||
watcher.IncludeSubdirectories = true;
|
||||
|
|
@ -108,7 +110,6 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
fileSystemWatcher.Created -= OnCreated;
|
||||
fileSystemWatcher.Deleted -= OnDeleted;
|
||||
fileSystemWatcher.Error -= OnError;
|
||||
fileSystemWatcher.Dispose();
|
||||
}
|
||||
FileWatchers.Clear();
|
||||
WatcherDictionary.Clear();
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ public class ProcessSeries : IProcessSeries
|
|||
foreach (var chapter in volume.Chapters)
|
||||
{
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null || _cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) continue;
|
||||
if (firstFile == null || _cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) continue;
|
||||
try
|
||||
{
|
||||
var firstChapterInfo = infos.SingleOrDefault(i => i.FullFilePath.Equals(firstFile.FilePath));
|
||||
|
|
@ -583,7 +583,7 @@ public class ProcessSeries : IProcessSeries
|
|||
{
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
if (firstFile == null ||
|
||||
_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false, firstFile)) return;
|
||||
_cacheHelper.IsFileUnmodifiedSinceCreationOrLastScan(chapter, false, firstFile)) return;
|
||||
|
||||
var comicInfo = info;
|
||||
if (info == null)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ public class Startup
|
|||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddApplicationServices(_config, _env);
|
||||
|
||||
services.AddControllers(options =>
|
||||
{
|
||||
options.CacheProfiles.Add("Images",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue