diff --git a/README.md b/README.md index 309dd54..d388b0a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ # Real-Debrid Torrent Client -Manage your Real-Debrid torrents easily with this client. +This is a web interface to manage your torrents on Real-Debrid. It supports the following features: + +- Add new torrents through magnet or files +- Download all files from Real Debrid to your local machine automatically +- Unpack all files when finished downloading +- Implements a fake qBittorrent API so you can hook up other applications like Sonarr or Couchpotato. +- Build with Angular 9 and .NET Core 3.1 + +**You will need a Premium service at Real-Debrid!** + +## Installation + +1. Unpack the latest release from the releases folder and run `startup.bat`. This will start the application on port 6500. +2. Browse to http://127.0.0.1:6500 +3. The very first credentials you type in will be remembered for future logins. +4. Click on Settings on the top and enter your Real-Debrid API key. +5. Change your download path if needed. +6. To install as service on Windows, exit the console and run `serviceinstall.bat` as administrator. + +## Troubleshooting + +- If you forgot your logins simply delete the `database.db` and restart the service. + +## Connecting Sonarr + +1. Login to Sonarr and click Settings +2. Go to the Download Client tab and click the plus to add +3. Click "qBittorrent" in the list +4. Enter the IP or hostname of the RealDebridClient in the Host field +5. Enter the port 6500 in the Port field +6. Enter your Username/Password you setup in step 3 above in the Username/Password field. +7. Leave the other settings as is. +8. Hit Test and then Save if all is well. +9. Sonarr will now think you have a regular Torrent client hooked up. + +Notice: the progress and ETA reported in Sonarr's Activity tab will not be very accurate, but it will report the torrent as completed so it can be processed by Sonarr when done downloading. + +## Build instructions + +1. Open the client folder project in VS Code and run `npm install` +2. To debug run `ng serve`, to build run `ng build --prod` +3. Open the Visual Studio 2019 project `RdtClient.sln` and `Publish` +4. The result is found in `\rdt-client\server\RdtClient.Web\bin\Release\netcoreapp3.1\publish` diff --git a/client/src/app/navbar/add-new-torrent/add-new-torrent.component.html b/client/src/app/navbar/add-new-torrent/add-new-torrent.component.html index ee22118..52c4d27 100644 --- a/client/src/app/navbar/add-new-torrent/add-new-torrent.component.html +++ b/client/src/app/navbar/add-new-torrent/add-new-torrent.component.html @@ -43,6 +43,17 @@ +
+
+ + +
Cannot add torrent: {{ error }}
diff --git a/client/src/app/navbar/add-new-torrent/add-new-torrent.component.ts b/client/src/app/navbar/add-new-torrent/add-new-torrent.component.ts index 72397c0..f47607d 100644 --- a/client/src/app/navbar/add-new-torrent/add-new-torrent.component.ts +++ b/client/src/app/navbar/add-new-torrent/add-new-torrent.component.ts @@ -4,7 +4,7 @@ import { TorrentService } from 'src/app/torrent.service'; @Component({ selector: 'app-add-new-torrent', templateUrl: './add-new-torrent.component.html', - styleUrls: ['./add-new-torrent.component.scss'] + styleUrls: ['./add-new-torrent.component.scss'], }) export class AddNewTorrentComponent implements OnInit { @Input() @@ -24,6 +24,9 @@ export class AddNewTorrentComponent implements OnInit { public fileName: string; public magnetLink: string; + public autoDownload: boolean; + public autoDelete: boolean; + public saving = false; public error: string; @@ -36,6 +39,9 @@ export class AddNewTorrentComponent implements OnInit { public reset(): void { this.fileName = ''; this.magnetLink = ''; + this.autoDelete = false; + this.autoDownload = true; + this.saving = false; this.selectedFile = null; this.error = null; @@ -60,25 +66,29 @@ export class AddNewTorrentComponent implements OnInit { this.error = null; if (this.magnetLink) { - this.torrentService.uploadMagnet(this.magnetLink).subscribe( - () => { - this.cancel(); - }, - err => { - this.error = err.error; - this.saving = false; - } - ); + this.torrentService + .uploadMagnet(this.magnetLink, this.autoDownload, this.autoDelete) + .subscribe( + () => { + this.cancel(); + }, + (err) => { + this.error = err.error; + this.saving = false; + } + ); } else if (this.selectedFile) { - this.torrentService.uploadFile(this.selectedFile).subscribe( - () => { - this.cancel(); - }, - err => { - this.error = err.error; - this.saving = false; - } - ); + this.torrentService + .uploadFile(this.selectedFile, this.autoDownload, this.autoDelete) + .subscribe( + () => { + this.cancel(); + }, + (err) => { + this.error = err.error; + this.saving = false; + } + ); } else { this.cancel(); } diff --git a/client/src/app/torrent-status.pipe.ts b/client/src/app/torrent-status.pipe.ts index b98fd81..802a691 100644 --- a/client/src/app/torrent-status.pipe.ts +++ b/client/src/app/torrent-status.pipe.ts @@ -25,29 +25,36 @@ export class TorrentStatusPipe implements PipeTransform { (m) => m.status === DownloadStatus.Unpacking ); + const allBytesDownloaded = torrent.downloads.sum( + (m) => m.bytesDownloaded + ); + const allBytesSize = torrent.downloads.sum((m) => m.bytesSize); + + let progress = 0; + let allSpeeds = 0; + + if (allBytesSize > 0) { + progress = (allBytesDownloaded / allBytesSize) * 100; + allSpeeds = downloading.sum((m) => m.speed) / downloading.length; + } + + console.log(allBytesDownloaded, allBytesSize, progress, allSpeeds); + + let speed: string | string[] = '0'; + if (allSpeeds > 0) { + speed = this.pipe.transform(allSpeeds, 'filesize'); + } + if (downloading.length > 0) { - const allBytesDownloaded = torrent.downloads.sum( - (m) => m.bytesDownloaded - ); - const allBytesSize = torrent.downloads.sum((m) => m.bytesSize); - if (allBytesSize > 0) { - const progress = ((allBytesDownloaded / allBytesSize) * 100).toFixed( - 2 - ); - - const allSpeeds = - downloading.sum((m) => m.speed) / downloading.length; - const speed = this.pipe.transform(allSpeeds, 'filesize'); - - return `Downloading (${progress || 0}% - ${speed}/s)`; + return `Downloading (${progress.toFixed(2)}% - ${speed}/s)`; } return `Preparing download`; } if (unpacking.length > 0) { - return `Unpacking`; + return `Unpacking (${progress.toFixed(2)}% - ${speed}/s)`; } return 'Pending download'; @@ -58,7 +65,7 @@ export class TorrentStatusPipe implements PipeTransform { const speed = this.pipe.transform(torrent.rdSpeed, 'filesize'); return `Torrent downloading (${torrent.rdProgress}% - ${speed}/s)`; case TorrentStatus.WaitingForDownload: - return `Waiting to download`; + return `Ready to download, press the download icon to start`; case TorrentStatus.DownloadQueued: return `Download queued`; case TorrentStatus.Downloading: diff --git a/client/src/app/torrent-table/torrent-table.component.html b/client/src/app/torrent-table/torrent-table.component.html index be60154..11c6db2 100644 --- a/client/src/app/torrent-table/torrent-table.component.html +++ b/client/src/app/torrent-table/torrent-table.component.html @@ -5,9 +5,9 @@
- - - + + + diff --git a/client/src/app/torrent.service.ts b/client/src/app/torrent.service.ts index 5edfa32..49e81c5 100644 --- a/client/src/app/torrent.service.ts +++ b/client/src/app/torrent.service.ts @@ -17,13 +17,26 @@ export class TorrentService { return this.http.get(`/Api/Torrents/${torrentId}`); } - public uploadMagnet(magnetLink: string): Observable { - return this.http.post(`/Api/Torrents/UploadMagnet`, { magnetLink }); + public uploadMagnet( + magnetLink: string, + autoDownload: boolean, + autoDelete: boolean + ): Observable { + return this.http.post(`/Api/Torrents/UploadMagnet`, { + magnetLink, + autoDownload, + autoDelete, + }); } - public uploadFile(file: File): Observable { + public uploadFile( + file: File, + autoDownload: boolean, + autoDelete: boolean + ): Observable { const formData: FormData = new FormData(); formData.append('file', file); + formData.append('formData', JSON.stringify({ autoDownload, autoDelete })); return this.http.post(`/Api/Torrents/UploadFile`, formData); } diff --git a/server/RdtClient.Data/Data/TorrentData.cs b/server/RdtClient.Data/Data/TorrentData.cs index 6932a28..e4ba6ba 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -13,7 +13,7 @@ namespace RdtClient.Data.Data Task> Get(); Task GetById(Guid id); Task GetByHash(String hash); - Task Add(String realDebridId, String hash); + Task Add(String realDebridId, String hash, Boolean autoDownload, Boolean autoDelete); Task UpdateRdData(Torrent torrent); Task UpdateStatus(Guid torrentId, TorrentStatus status); Task UpdateCategory(Guid torrentId, String category); @@ -80,14 +80,16 @@ namespace RdtClient.Data.Data return results; } - public async Task Add(String realDebridId, String hash) + public async Task Add(String realDebridId, String hash, Boolean autoDownload, Boolean autoDelete) { var torrent = new Torrent { TorrentId = Guid.NewGuid(), RdId = realDebridId, Hash = hash, - Status = TorrentStatus.RealDebrid + Status = TorrentStatus.RealDebrid, + AutoDownload = autoDownload, + AutoDelete = autoDelete }; _dataContext.Torrents.Add(torrent); diff --git a/server/RdtClient.Service/Helpers/JsonModelBinder.cs b/server/RdtClient.Service/Helpers/JsonModelBinder.cs new file mode 100644 index 0000000..1880465 --- /dev/null +++ b/server/RdtClient.Service/Helpers/JsonModelBinder.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace RdtClient.Service.Helpers +{ + public class JsonModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName); + if (valueProviderResult != ValueProviderResult.None) + { + bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult); + + var valueAsString = valueProviderResult.FirstValue; + var result = Newtonsoft.Json.JsonConvert.DeserializeObject(valueAsString, bindingContext.ModelType); + if (result != null) + { + bindingContext.Result = ModelBindingResult.Success(result); + return Task.CompletedTask; + } + } + + return Task.CompletedTask; + } + } +} diff --git a/server/RdtClient.Service/Services/DownloadManager.cs b/server/RdtClient.Service/Services/DownloadManager.cs index 96697ff..86f1474 100644 --- a/server/RdtClient.Service/Services/DownloadManager.cs +++ b/server/RdtClient.Service/Services/DownloadManager.cs @@ -1,25 +1,25 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Threading.Tasks; using RdtClient.Data.Enums; using RdtClient.Data.Models.Data; +using SharpCompress.Archives; +using SharpCompress.Archives.Rar; using SharpCompress.Common; -using SharpCompress.Readers; namespace RdtClient.Service.Services { public class DownloadManager { - public DownloadStatus? NewStatus { get; set; } - public Download Download { get; set; } - public Int64 Speed { get; private set; } - public Int64 BytesDownloaded { get; private set; } - public Int64 BytesSize { get; private set; } + private Int64 _bytesLastUpdate; private DateTime _nextUpdate; - private Int64 _bytesLastUpdate; + + private RarArchiveEntry _rarCurrentEntry; + private Dictionary _rarfileStatus; public DownloadManager() { @@ -28,6 +28,12 @@ namespace RdtClient.Service.Services ServicePointManager.MaxServicePointIdleTime = 1000; } + public DownloadStatus? NewStatus { get; set; } + public Download Download { get; set; } + public Int64 Speed { get; private set; } + public Int64 BytesDownloaded { get; private set; } + public Int64 BytesSize { get; private set; } + private DownloadManager ActiveDownload => TaskRunner.ActiveDownloads[Download.DownloadId]; public async Task Start(String destinationFolderPath) @@ -36,7 +42,7 @@ namespace RdtClient.Service.Services ActiveDownload.BytesDownloaded = 0; ActiveDownload.BytesSize = 0; ActiveDownload.Speed = 0; - + _bytesLastUpdate = 0; _nextUpdate = DateTime.UtcNow.AddSeconds(1); @@ -107,20 +113,27 @@ namespace RdtClient.Service.Services await using (Stream stream = File.OpenRead(filePath)) { - var reader = ReaderFactory.Open(stream); - while (reader.MoveToNextEntry()) - { - if (reader.Entry.IsDirectory) - { - continue; - } + using var archive = RarArchive.Open(stream); - reader.WriteEntryToDirectory(destinationFolderPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true - }); + ActiveDownload.BytesSize = archive.TotalSize; + + var entries = archive.Entries.Where(entry => !entry.IsDirectory) + .ToList(); + + _rarfileStatus = entries.ToDictionary(entry => entry.Key, entry => 0L); + _rarCurrentEntry = null; + archive.CompressedBytesRead += ArchiveOnCompressedBytesRead; + + foreach (var entry in entries) + { + _rarCurrentEntry = entry; + + entry.WriteToDirectory(destinationFolderPath, + new ExtractionOptions + { + ExtractFullPath = true, + Overwrite = true + }); } } @@ -145,7 +158,21 @@ namespace RdtClient.Service.Services // ignored } + ActiveDownload.Speed = 0; + ActiveDownload.BytesDownloaded = ActiveDownload.BytesSize; ActiveDownload.NewStatus = DownloadStatus.Finished; } + + private void ArchiveOnCompressedBytesRead(Object sender, CompressedBytesReadEventArgs e) + { + if (_rarCurrentEntry == null) + { + return; + } + + _rarfileStatus[_rarCurrentEntry.Key] = e.CompressedBytesRead; + + ActiveDownload.BytesDownloaded = _rarfileStatus.Sum(m => m.Value); + } } } \ No newline at end of file diff --git a/server/RdtClient.Service/Services/TaskRunner.cs b/server/RdtClient.Service/Services/TaskRunner.cs index 0ba95f0..6ce5119 100644 --- a/server/RdtClient.Service/Services/TaskRunner.cs +++ b/server/RdtClient.Service/Services/TaskRunner.cs @@ -75,6 +75,8 @@ namespace RdtClient.Service.Services return; } + await torrents.Update(); + await ProcessAutoDownloads(downloads, settings, torrents); await ProcessDownloads(downloads, settings, torrents); await ProcessStatus(downloads, settings, torrents); @@ -88,8 +90,6 @@ namespace RdtClient.Service.Services private async Task ProcessAutoDownloads(IDownloads downloads, ISettings settings, ITorrents torrents) { - await torrents.Update(); - var allTorrents = await torrents.Get(); allTorrents = allTorrents.Where(m => m.Status == TorrentStatus.WaitingForDownload && m.AutoDownload && m.Downloads.Count == 0) @@ -103,8 +103,6 @@ namespace RdtClient.Service.Services private async Task ProcessDownloads(IDownloads downloads, ISettings settings, ITorrents torrents) { - await torrents.Update(); - var allDownloads = await downloads.Get(); allDownloads = allDownloads.Where(m => m.Status != DownloadStatus.Finished) @@ -130,6 +128,8 @@ namespace RdtClient.Service.Services // Prevent circular references download.Torrent.Downloads = null; + await torrents.UpdateStatus(download.TorrentId, TorrentStatus.Downloading); + await Task.Factory.StartNew(async delegate { var downloadManager = new DownloadManager(); @@ -138,8 +138,6 @@ namespace RdtClient.Service.Services { downloadManager.Download = download; await downloadManager.Start(destinationFolderPath); - - await torrents.UpdateStatus(download.TorrentId, TorrentStatus.Downloading); } }); } diff --git a/server/RdtClient.Service/Services/Torrents.cs b/server/RdtClient.Service/Services/Torrents.cs index d68cef1..cf4b9fc 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -88,6 +88,11 @@ namespace RdtClient.Service.Services download.Speed = activeDownload.Speed; download.BytesSize = activeDownload.BytesSize; download.BytesDownloaded = activeDownload.BytesDownloaded; + + if (activeDownload.NewStatus.HasValue) + { + download.Status = activeDownload.NewStatus.Value; + } } } } @@ -143,7 +148,7 @@ namespace RdtClient.Service.Services if (torrent == null) { - var newTorrent = await _torrentData.Add(rdTorrent.Id, rdTorrent.Hash); + var newTorrent = await _torrentData.Add(rdTorrent.Id, rdTorrent.Hash, false, false); await GetById(newTorrent.TorrentId); } else @@ -262,7 +267,7 @@ namespace RdtClient.Service.Services private async Task Add(String rdTorrentId, String infoHash, Boolean autoDownload, Boolean autoDelete) { - var newTorrent = await _torrentData.Add(rdTorrentId, infoHash); + var newTorrent = await _torrentData.Add(rdTorrentId, infoHash, autoDownload, autoDelete); var rdTorrent = await RdNetClient.GetTorrentInfoAsync(rdTorrentId); diff --git a/server/RdtClient.Web/Controllers/TorrentsController.cs b/server/RdtClient.Web/Controllers/TorrentsController.cs index 10ddc91..88ee580 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using RdtClient.Data.Models.Data; +using RdtClient.Service.Helpers; using RdtClient.Service.Services; namespace RdtClient.Web.Controllers @@ -59,7 +60,9 @@ namespace RdtClient.Web.Controllers [HttpPost] [Route("UploadFile")] - public async Task UploadFile([FromForm] IFormFile file) + public async Task UploadFile([FromForm] IFormFile file, + [ModelBinder(BinderType = typeof(JsonModelBinder))] + TorrentControllerUploadFileRequest formData) { try { @@ -75,8 +78,8 @@ namespace RdtClient.Web.Controllers fileStream.CopyTo(memoryStream); var bytes = memoryStream.ToArray(); - - await _torrents.UploadFile(bytes, false, false); + + await _torrents.UploadFile(bytes, formData.AutoDownload, formData.AutoDelete); return Ok(); } @@ -92,7 +95,7 @@ namespace RdtClient.Web.Controllers { try { - await _torrents.UploadMagnet(request.MagnetLink, false, false); + await _torrents.UploadMagnet(request.MagnetLink, request.AutoDownload, request.AutoDelete); return Ok(); } @@ -112,12 +115,12 @@ namespace RdtClient.Web.Controllers return Ok(); } - catch(Exception ex) + catch (Exception ex) { return BadRequest(ex.Message); } } - + [HttpGet] [Route("Download/{id}")] public async Task Download(Guid id) @@ -128,15 +131,23 @@ namespace RdtClient.Web.Controllers return Ok(); } - catch(Exception ex) + catch (Exception ex) { return BadRequest(ex.Message); } } } + public class TorrentControllerUploadFileRequest + { + public Boolean AutoDownload { get; set; } + public Boolean AutoDelete { get; set; } + } + public class TorrentControllerUploadMagnetRequest { public String MagnetLink { get; set; } + public Boolean AutoDownload { get; set; } + public Boolean AutoDelete { get; set; } } -} +} \ No newline at end of file diff --git a/server/RdtClient.Web/RdtClient.Web.csproj b/server/RdtClient.Web/RdtClient.Web.csproj index 6a6dba0..68e4626 100644 --- a/server/RdtClient.Web/RdtClient.Web.csproj +++ b/server/RdtClient.Web/RdtClient.Web.csproj @@ -4,6 +4,24 @@ netcoreapp3.1 + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + diff --git a/server/RdtClient.Web/nssm.exe b/server/RdtClient.Web/nssm.exe new file mode 100644 index 0000000..6ccfe3c Binary files /dev/null and b/server/RdtClient.Web/nssm.exe differ diff --git a/server/RdtClient.Web/serviceinstall.bat b/server/RdtClient.Web/serviceinstall.bat new file mode 100644 index 0000000..0c86a20 --- /dev/null +++ b/server/RdtClient.Web/serviceinstall.bat @@ -0,0 +1,44 @@ +echo OFF +set installpath=%~dp0 +NET SESSION >nul 2>&1 +IF %ERRORLEVEL% EQU 0 ( + echo adding firewall rules... + netsh.exe advfirewall firewall add rule name="RealDebridClient" dir=in action=allow program="%installpath%RdtClient.Web.exe" enable=yes > nul + + @echo off + sc query | findstr /C:"SERVICE_NAME: RealDebridClient" + IF ERRORLEVEL 1 ( + echo installing service... + nssm install RealDebridClient "%installpath%startup.bat" + ) + IF ERRORLEVEL 0 ( + echo service already installed + ) + + echo.>"%installpath%RdtClient.Web.exe":Zone.Identifier + + echo starting service... + net start "RealDebridClient" + echo Starting web app, remember, to set your credentials login with any credentials for the first time. + ping 127.0.0.1 -n 10 > nul + start "" http://127.0.0.1:6500/ +) ELSE ( + echo ######## ######## ######## ####### ######## + echo ## ## ## ## ## ## ## ## ## + echo ## ## ## ## ## ## ## ## ## + echo ###### ######## ######## ## ## ######## + echo ## ## ## ## ## ## ## ## ## + echo ## ## ## ## ## ## ## ## ## + echo ######## ## ## ## ## ####### ## ## + echo. + echo. + echo ####### ERROR: ADMINISTRATOR PRIVILEGES REQUIRED ######### + echo This script must be run as administrator to work properly! + echo If you're seeing this after clicking on a start menu icon, + echo then right click on the shortcut and select "Run As Administrator". + echo ########################################################## + echo. + PAUSE + EXIT /B 1 +) +@echo ON \ No newline at end of file diff --git a/server/RdtClient.Web/startup.bat b/server/RdtClient.Web/startup.bat new file mode 100644 index 0000000..4420b52 --- /dev/null +++ b/server/RdtClient.Web/startup.bat @@ -0,0 +1 @@ +dotnet RdtClient.Web.dll \ No newline at end of file diff --git a/server/RdtClient.Web/wwwroot/index.html b/server/RdtClient.Web/wwwroot/index.html index fc5ca18..65a724a 100644 --- a/server/RdtClient.Web/wwwroot/index.html +++ b/server/RdtClient.Web/wwwroot/index.html @@ -9,5 +9,5 @@ - +