diff --git a/README.md b/README.md index 309dd548..d388b0a3 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 ee22118e..52c4d273 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 72397c0c..f47607d7 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 b98fd81f..802a6917 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 ); - if (downloading.length > 0) { - const allBytesDownloaded = torrent.downloads.sum( - (m) => m.bytesDownloaded - ); - const allBytesSize = torrent.downloads.sum((m) => m.bytesSize); + 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 - ); + let progress = 0; + let allSpeeds = 0; + + if (allBytesSize > 0) { + progress = (allBytesDownloaded / allBytesSize) * 100; + allSpeeds = downloading.sum((m) => m.speed) / downloading.length; + } - const allSpeeds = - downloading.sum((m) => m.speed) / downloading.length; - const speed = this.pipe.transform(allSpeeds, 'filesize'); + console.log(allBytesDownloaded, allBytesSize, progress, allSpeeds); - return `Downloading (${progress || 0}% - ${speed}/s)`; + let speed: string | string[] = '0'; + if (allSpeeds > 0) { + speed = this.pipe.transform(allSpeeds, 'filesize'); + } + + if (downloading.length > 0) { + if (allBytesSize > 0) { + 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 be601549..11c6db25 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 5edfa327..49e81c5f 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 6932a283..e4ba6bab 100644 --- a/server/RdtClient.Data/Data/TorrentData.cs +++ b/server/RdtClient.Data/Data/TorrentData.cs @@ -13,7 +13,7 @@ public interface ITorrentData 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 @@ public async Task GetByHash(String hash) 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 00000000..18804650 --- /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 96697ffc..86f1474e 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 @@ public DownloadManager() 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 @@ public async Task Start(String destinationFolderPath) ActiveDownload.BytesDownloaded = 0; ActiveDownload.BytesSize = 0; ActiveDownload.Speed = 0; - + _bytesLastUpdate = 0; _nextUpdate = DateTime.UtcNow.AddSeconds(1); @@ -107,20 +113,27 @@ public async Task Start(String destinationFolderPath) await using (Stream stream = File.OpenRead(filePath)) { - var reader = ReaderFactory.Open(stream); - while (reader.MoveToNextEntry()) + using var archive = RarArchive.Open(stream); + + 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) { - if (reader.Entry.IsDirectory) - { - continue; - } - - reader.WriteEntryToDirectory(destinationFolderPath, - new ExtractionOptions - { - ExtractFullPath = true, - Overwrite = true - }); + _rarCurrentEntry = entry; + + entry.WriteToDirectory(destinationFolderPath, + new ExtractionOptions + { + ExtractFullPath = true, + Overwrite = true + }); } } @@ -145,7 +158,21 @@ public async Task Start(String destinationFolderPath) // 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 0ba95f0c..6ce51194 100644 --- a/server/RdtClient.Service/Services/TaskRunner.cs +++ b/server/RdtClient.Service/Services/TaskRunner.cs @@ -75,6 +75,8 @@ private async void DoWork(Object state) return; } + await torrents.Update(); + await ProcessAutoDownloads(downloads, settings, torrents); await ProcessDownloads(downloads, settings, torrents); await ProcessStatus(downloads, settings, torrents); @@ -88,8 +90,6 @@ private async void DoWork(Object state) 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 @@ private async Task ProcessAutoDownloads(IDownloads downloads, ISettings settings 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 @@ private async Task ProcessDownloads(IDownloads downloads, ISettings settings, IT // 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 @@ await Task.Factory.StartNew(async delegate { 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 d68cef18..cf4b9fce 100644 --- a/server/RdtClient.Service/Services/Torrents.cs +++ b/server/RdtClient.Service/Services/Torrents.cs @@ -88,6 +88,11 @@ public async Task> Get() 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 @@ public async Task> Update() 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 @@ public async Task GetProfile() 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 10ddc911..88ee5804 100644 --- a/server/RdtClient.Web/Controllers/TorrentsController.cs +++ b/server/RdtClient.Web/Controllers/TorrentsController.cs @@ -6,6 +6,7 @@ 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 @@ public async Task> Get(Guid id) [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 @@ public async Task UploadFile([FromForm] IFormFile file) 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 @@ public async Task UploadMagnet([FromBody] TorrentControllerUploadM { try { - await _torrents.UploadMagnet(request.MagnetLink, false, false); + await _torrents.UploadMagnet(request.MagnetLink, request.AutoDownload, request.AutoDelete); return Ok(); } @@ -112,12 +115,12 @@ public async Task Delete(Guid id) 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 @@ public async Task Download(Guid id) 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 6a6dba00..68e4626e 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 00000000..6ccfe3cf 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 00000000..0c86a209 --- /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 00000000..4420b528 --- /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 fc5ca188..65a724a7 100644 --- a/server/RdtClient.Web/wwwroot/index.html +++ b/server/RdtClient.Web/wwwroot/index.html @@ -9,5 +9,5 @@ - +