Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions SaveHere/SaveHere.Tests/SaveHere.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.22" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.23" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.11" />
</ItemGroup>

Expand Down
49 changes: 35 additions & 14 deletions SaveHere/SaveHere/Components/Download/DownloadVideoAudio.razor
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@
private string userId { get; set; } = "";
private string userFolderPath { get; set; } = string.Empty;
private string YoutubeUrl { get; set; } = "";
private string ProxyUrl { get; set; } = @"http://localhost:8086";
private string ProxyUrl { get; set; } = string.Empty;
private string SelectedQuality { get; set; } = "Best";
private string ErrorMessage { get; set; } = "";

Expand All @@ -251,7 +251,7 @@
private string? DownloadFolderName { get; set; }

private List<YoutubeDownloadQueueItem> _youtubeDownloadQueueItems = new();
private bool _isLoadingTheList = false;

Check warning on line 254 in SaveHere/SaveHere/Components/Download/DownloadVideoAudio.razor

View workflow job for this annotation

GitHub Actions / Build

The field 'DownloadVideoAudio._isLoadingTheList' is assigned but its value is never used

Check warning on line 254 in SaveHere/SaveHere/Components/Download/DownloadVideoAudio.razor

View workflow job for this annotation

GitHub Actions / Code Analysis

The field 'DownloadVideoAudio._isLoadingTheList' is assigned but its value is never used

Check warning on line 254 in SaveHere/SaveHere/Components/Download/DownloadVideoAudio.razor

View workflow job for this annotation

GitHub Actions / Test

The field 'DownloadVideoAudio._isLoadingTheList' is assigned but its value is never used

private DateTime _lastUiUpdateTime = DateTime.MinValue;
private readonly TimeSpan _uiUpdateInterval = TimeSpan.FromMilliseconds(500);
Expand All @@ -278,12 +278,19 @@
if (_persistentState.TryTakeFromJson<List<YoutubeDownloadQueueItem>>(nameof(_youtubeDownloadQueueItems), out var savedItems) && savedItems != null)
{
_youtubeDownloadQueueItems = savedItems;
await StartPausedDownloads();
}
else
{
await LoadDownloadItemsList();
}

// Restore proxy setting if saved
if (_persistentState.TryTakeFromJson<string>(nameof(ProxyUrl), out var savedProxy))
{
ProxyUrl = savedProxy ?? string.Empty;
}

await InitializeHubConnection();
}

Expand Down Expand Up @@ -311,6 +318,7 @@
private Task PersistItems()
{
_persistentState.PersistAsJson(nameof(_youtubeDownloadQueueItems), _youtubeDownloadQueueItems);
_persistentState.PersistAsJson(nameof(ProxyUrl), ProxyUrl);
return Task.CompletedTask;
}

Expand Down Expand Up @@ -357,25 +365,38 @@
private async Task OnDownloadStateUpdateAsync(int itemId, string newStatus)
{
var item = _youtubeDownloadQueueItems.FirstOrDefault(x => x.Id == itemId);
if (item != null)
if (item == null)
{
item.Status = Enum.Parse<EQueueItemStatus>(newStatus);
await LoadDownloadItemsList();
await ThrottleUIUpdateAsync(true);
return;
}

// Always update UI on state change, refresh file manager on completion/cancel
if (newStatus != EQueueItemStatus.Downloading.ToString())
{
await ThrottleUIUpdateAsync(true);
}
else
{
await InvokeAsync(StateHasChanged);
}
if (!Enum.TryParse<EQueueItemStatus>(newStatus, out var parsedStatus))
{
Console.WriteLine($"Warning: Received unknown download status '{newStatus}' for item {itemId}");
return;
}
else

item.Status = parsedStatus;

// Trigger autostart when a download finishes, fails, or is cancelled
if (item.Status == EQueueItemStatus.Finished ||
item.Status == EQueueItemStatus.Paused ||
item.Status == EQueueItemStatus.Cancelled)
{
await StartPausedDownloads();
}

// Always update UI on state change, refresh file manager on completion/cancel
if (item.Status != EQueueItemStatus.Downloading)
{
await LoadDownloadItemsList();
await ThrottleUIUpdateAsync(true);
}
else
{
await InvokeAsync(StateHasChanged);
}
}

private async Task OnDownloadLogUpdateAsync(int itemId, string logLine)
Expand Down
51 changes: 29 additions & 22 deletions SaveHere/SaveHere/Components/Utility/SpotifySearch.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,28 +30,35 @@

@if (SearchResults != null)
{
<MudText Typo="Typo.h6">Results for: @SearchResults.OriginalUrl</MudText>
<MudList T="object">
@foreach (var kvp in SearchResults.Links)
{
<MudListItem Class="py-0">
<MudText Style="display: flex; align-items: center;">
@kvp.Key:
<MudLink Href="@kvp.Value" Target="_blank" Class="mx-3">
Media Link
</MudLink>
<MudButton OnClick="@(async () => await TransferToMediaQueue(kvp.Value))"
Disabled="@string.IsNullOrEmpty(kvp.Value)"
Color="Color.Primary"
Variant="Variant.Filled"
Class="my-3 py-1 px-2"
Style="margin-left: auto;">
Send to Media Queue
</MudButton>
</MudText>
</MudListItem>
}
</MudList>
@if (!string.IsNullOrEmpty(SearchResults.Error))
{
<MudAlert Severity="Severity.Error" Class="my-3">@SearchResults.Error</MudAlert>
}
else
{
<MudText Typo="Typo.h6">Results for: @SearchResults.OriginalUrl</MudText>
<MudList T="object">
@foreach (var kvp in SearchResults.Links)
{
<MudListItem Class="py-0">
<MudText Style="display: flex; align-items: center;">
@kvp.Key:
<MudLink Href="@kvp.Value" Target="_blank" Class="mx-3">
Media Link
</MudLink>
<MudButton OnClick="@(async () => await TransferToMediaQueue(kvp.Value))"
Disabled="@string.IsNullOrEmpty(kvp.Value)"
Color="Color.Primary"
Variant="Variant.Filled"
Class="my-3 py-1 px-2"
Style="margin-left: auto;">
Send to Media Queue
</MudButton>
</MudText>
</MudListItem>
}
</MudList>
}
}

</MudPaper>
Expand Down
2 changes: 1 addition & 1 deletion SaveHere/SaveHere/Helpers/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public static string ExtractFileNameFromUrl(string url)
{ "application/java-archive", ".jar" },
{ "application/json", ".json" },
{ "application/msword", ".doc" },
{ "application/octet-stream", ".exe" }, // can also be .bin (but .exe is more common)
{ "application/octet-stream", ".bin" }, // generic binary data - .bin is safer than .exe
{ "application/ogg", ".ogx" },
{ "application/pdf", ".pdf" },
{ "application/rtf", ".rtf" },
Expand Down
112 changes: 76 additions & 36 deletions SaveHere/SaveHere/Services/SpotifySearchService.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text;

namespace SaveHere.Services
{
public class SpotifySearchService
{
private readonly HttpClient _http;
private readonly ILogger<SpotifySearchService> _logger;

// Inject HttpClient via DI.
public SpotifySearchService(HttpClient http)
// Inject HttpClient and ILogger via DI.
public SpotifySearchService(HttpClient http, ILogger<SpotifySearchService> logger)
{
_http = http;
_logger = logger;
}

public async Task<SpotifySearchResult> ConvertUrlAsync(string spotifyUrl)
Expand All @@ -26,52 +30,87 @@ public async Task<SpotifySearchResult> ConvertUrlAsync(string spotifyUrl)
// Add the original service (if we detected it).
result.Links[inputService] = spotifyUrl;

// Build form data and post to the API.
var formData = new Dictionary<string, string>
{
{ "link", spotifyUrl }
};
var content = new FormUrlEncodedContent(formData);
var response = await _http.PostAsync("https://idonthavespotify.donado.co/search", content);
response.EnsureSuccessStatusCode();
//var html = await response.Content.ReadAsStringAsync();
var bytes = await response.Content.ReadAsByteArrayAsync();
var html = Encoding.UTF8.GetString(bytes);

// Use HtmlAgilityPack to parse the returned HTML.
var doc = new HtmlDocument();
doc.LoadHtml(html);

// Select all list items that represent a media link.
var liNodes = doc.DocumentNode.SelectNodes("//li[@data-controller='search-link']");
if (liNodes != null)
try
{
foreach (var li in liNodes)
// Build form data and post to the API.
var formData = new Dictionary<string, string>
{
{ "link", spotifyUrl }
};
var content = new FormUrlEncodedContent(formData);
var response = await _http.PostAsync("https://idonthavespotify.donado.co/search", content);

if (!response.IsSuccessStatusCode)
{
// Each <li> has an attribute "data-search-link-url-value" with the media URL.
var link = li.GetAttributeValue("data-search-link-url-value", "").Trim();
if (string.IsNullOrEmpty(link))
_logger.LogWarning("Spotify API returned status {StatusCode} for URL: {Url}", (int)response.StatusCode, spotifyUrl);
result.Error = response.StatusCode switch
{
continue;
}
HttpStatusCode.NotFound => "The media link could not be found. Please verify the URL is correct.",
HttpStatusCode.TooManyRequests => "Too many requests. Please wait a moment and try again.",
HttpStatusCode.ServiceUnavailable => "The conversion service is temporarily unavailable. Please try again later.",
_ => $"Service error ({(int)response.StatusCode}): {response.ReasonPhrase}"
};
return result;
}

var bytes = await response.Content.ReadAsByteArrayAsync();
var html = Encoding.UTF8.GetString(bytes);

// Use HtmlAgilityPack to parse the returned HTML.
var doc = new HtmlDocument();
doc.LoadHtml(html);

// Inside each <li>, the <a> tag’s aria-label is something like "Listen on Apple Music".
var aNode = li.SelectSingleNode(".//a[@aria-label]");
if (aNode is not null)
// Select all list items that represent a media link.
var liNodes = doc.DocumentNode.SelectNodes("//li[@data-controller='search-link']");
if (liNodes != null)
{
foreach (var li in liNodes)
{
var ariaLabel = aNode.GetAttributeValue("aria-label", "").Trim();
if (ariaLabel.StartsWith("Listen on ", StringComparison.InvariantCultureIgnoreCase))
// Each <li> has an attribute "data-search-link-url-value" with the media URL.
var link = li.GetAttributeValue("data-search-link-url-value", "").Trim();
if (string.IsNullOrEmpty(link))
{
var serviceName = ariaLabel.Substring("Listen on ".Length).Trim();
// Add to the dictionary if not already there.
if (!result.Links.ContainsKey(serviceName))
continue;
}

// Inside each <li>, the <a> tag's aria-label is something like "Listen on Apple Music".
var aNode = li.SelectSingleNode(".//a[@aria-label]");
if (aNode is not null)
{
var ariaLabel = aNode.GetAttributeValue("aria-label", "").Trim();
if (ariaLabel.StartsWith("Listen on ", StringComparison.InvariantCultureIgnoreCase))
{
result.Links[serviceName] = link;
var serviceName = ariaLabel.Substring("Listen on ".Length).Trim();
// Add to the dictionary if not already there.
if (!result.Links.ContainsKey(serviceName))
{
result.Links[serviceName] = link;
}
}
}
}
}
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Network error calling Spotify API for URL: {Url}", spotifyUrl);
result.Error = $"Network error: {ex.Message}";
}
catch (TaskCanceledException ex) when (ex.CancellationToken.IsCancellationRequested)
{
_logger.LogWarning("Spotify search was cancelled for URL: {Url}", spotifyUrl);
result.Error = "The search was cancelled.";
}
catch (TaskCanceledException)
{
_logger.LogWarning("Spotify search timed out for URL: {Url}", spotifyUrl);
result.Error = "Request timed out. Please try again.";
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during Spotify search for URL: {Url}", spotifyUrl);
result.Error = $"Unexpected error: {ex.Message}";
}

return result;
}
Expand Down Expand Up @@ -102,5 +141,6 @@ public class SpotifySearchResult
{
public string OriginalUrl { get; set; } = string.Empty;
public Dictionary<string, string> Links { get; set; } = new();
public string? Error { get; set; }
}
}
Loading