Skip to content

Commit 73e704a

Browse files
authored
Merge pull request #57 from melodee-project/copilot/import-m3u-playlists
Complete M3U/M3U8 playlist import: backend services, API, Blazor UI, tests, and documentation
2 parents cf6f0d9 + a65c738 commit 73e704a

133 files changed

Lines changed: 10717 additions & 19 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,3 +515,7 @@ compose.override.yml
515515
secrets.json
516516
*.secrets.json
517517
.aider*
518+
519+
# Exclude build artifacts with unusual path separators
520+
**/bin\\*
521+
**/obj\\*

docs/pages/playlists.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,61 @@ You can create playlists through:
7373

7474
- **Melodee UI**: Navigate to Playlists and click "Create New Playlist"
7575
- **Music Clients**: Most Subsonic-compatible clients support playlist creation
76+
- **M3U Import**: Upload existing M3U/M3U8 playlist files (see below)
77+
78+
### Importing M3U/M3U8 Playlists
79+
80+
Melodee can import your existing M3U or M3U8 playlist files, making it easy to migrate from other music players.
81+
82+
**Supported formats:**
83+
- `.m3u` - Standard M3U playlists
84+
- `.m3u8` - UTF-8 encoded M3U playlists
85+
86+
**How it works:**
87+
88+
1. **Upload your playlist file** via the Melodee API or UI
89+
2. **Automatic song matching** - Melodee tries to match each entry to songs in your library using:
90+
- Exact file path matching (highest priority)
91+
- Filename with artist/album folder hints
92+
- Song metadata matching (title + artist + album)
93+
3. **Instant playability** - Matched songs are immediately available in your new playlist
94+
4. **Background reconciliation** - Missing songs are tracked and automatically added as you add music to your library
95+
96+
**Import via API:**
97+
```bash
98+
curl -X POST https://your-melodee-server/api/v1/playlists/import \
99+
-H "Authorization: Bearer YOUR_TOKEN" \
100+
-F "file=@my-playlist.m3u8"
101+
```
102+
103+
**Response includes:**
104+
- Playlist ID and name
105+
- Total entries found
106+
- Successfully matched songs
107+
- Missing songs (tracked for future reconciliation)
108+
109+
**Example response:**
110+
```json
111+
{
112+
"playlistId": "abc123...",
113+
"playlistName": "My Favorite Mix",
114+
"totalEntries": 25,
115+
"matchedEntries": 20,
116+
"missingEntries": 5
117+
}
118+
```
119+
120+
**Background reconciliation:**
121+
122+
Missing playlist items are automatically resolved when:
123+
- New music is added to your library
124+
- The reconciliation job runs periodically (hourly by default)
125+
126+
The reconciliation process:
127+
- Re-attempts matching for missing items
128+
- Adds newly found songs to the playlist
129+
- Maintains the original sort order
130+
- Runs idempotently (no duplicates)
76131

77132
### Managing Playlists via Clients
78133

@@ -170,6 +225,13 @@ POST /api/v1/Songs/starred/{songId}/{isStarred}
170225
171226
# Set rating
172227
POST /api/v1/Songs/setrating/{songId}/{rating}
228+
229+
# Import M3U/M3U8 playlist
230+
POST /api/v1/playlists/import
231+
Content-Type: multipart/form-data
232+
Body: file=<M3U/M3U8 file>
233+
234+
# Response includes match statistics and playlist ID
173235
```
174236

175237
## Best Practices
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
@inherits MelodeeComponentBase
2+
@using Melodee.Blazor.Controllers.Melodee.Models
3+
@using Melodee.Common.Models
4+
@using Microsoft.AspNetCore.Components.Forms
5+
@using Microsoft.Extensions.Logging
6+
7+
@inject PlaylistService PlaylistService
8+
@inject NotificationService NotificationService
9+
@inject DialogService DialogService
10+
@inject IJSRuntime JS
11+
@inject ILogger<M3UPlaylistImportDialog> Logger
12+
13+
<RadzenStack Gap="1rem">
14+
<RadzenAlert AlertStyle="AlertStyle.Info" Shade="Shade.Lighter" AllowClose="false">
15+
<RadzenText TextStyle="TextStyle.Subtitle2">@L("M3UImportDialog.Title")</RadzenText>
16+
<RadzenText TextStyle="TextStyle.Body2">
17+
@L("M3UImportDialog.Description")
18+
</RadzenText>
19+
<RadzenText TextStyle="TextStyle.Body2">
20+
<strong>@L("M3UImportDialog.SupportedFormats"):</strong>
21+
<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">
22+
<li>.m3u</li>
23+
<li>.m3u8</li>
24+
</ul>
25+
</RadzenText>
26+
</RadzenAlert>
27+
28+
<RadzenFormField Text="@L("M3UImportDialog.SelectFile")" Variant="Variant.Outlined" Style="width: 100%;">
29+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" AlignItems="AlignItems.Center">
30+
<InputFile OnChange="@OnFileSelected" accept=".m3u,.m3u8" style="display: none;" id="m3uFileInput"/>
31+
<RadzenButton Text="@L("Actions.Upload")" Icon="upload_file" ButtonStyle="ButtonStyle.Primary" Click="@TriggerFileInput" Disabled="@_isUploading"/>
32+
@if (!string.IsNullOrEmpty(_fileName))
33+
{
34+
<RadzenText TextStyle="TextStyle.Body2" Style="align-self: center;">@_fileName</RadzenText>
35+
<RadzenText TextStyle="TextStyle.Caption" Style="align-self: center;">(@_fileSize)</RadzenText>
36+
}
37+
</RadzenStack>
38+
</RadzenFormField>
39+
40+
@if (_isUploading)
41+
{
42+
<RadzenProgressBar Value="100" ShowValue="false" Mode="ProgressBarMode.Indeterminate" Style="margin-top: 1rem;"/>
43+
<RadzenText TextStyle="TextStyle.Body2" Style="text-align: center;">@L("M3UImportDialog.Uploading")</RadzenText>
44+
}
45+
46+
@if (_validationErrors.Any())
47+
{
48+
<RadzenAlert AlertStyle="AlertStyle.Danger" Shade="Shade.Lighter" AllowClose="false">
49+
<RadzenText TextStyle="TextStyle.Subtitle2">@L("M3UImportDialog.ValidationErrors")</RadzenText>
50+
<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">
51+
@foreach (var error in _validationErrors)
52+
{
53+
<li><RadzenText TextStyle="TextStyle.Body2">@error</RadzenText></li>
54+
}
55+
</ul>
56+
</RadzenAlert>
57+
}
58+
59+
@if (_importResult != null)
60+
{
61+
<RadzenAlert AlertStyle="AlertStyle.Success" Shade="Shade.Lighter" AllowClose="false">
62+
<RadzenText TextStyle="TextStyle.Subtitle2">@L("M3UImportDialog.ImportSuccess")</RadzenText>
63+
<RadzenText TextStyle="TextStyle.Body2">
64+
<strong>@L("Common.Name"):</strong> @_importResult.PlaylistName<br/>
65+
<strong>@L("M3UImportDialog.TotalEntries"):</strong> @_importResult.TotalEntries<br/>
66+
<strong>@L("M3UImportDialog.MatchedSongs"):</strong> @_importResult.MatchedEntries (@GetMatchPercentage()%)<br/>
67+
<strong>@L("M3UImportDialog.MissingSongs"):</strong> @_importResult.MissingEntries<br/>
68+
</RadzenText>
69+
@if (_importResult.MissingEntries > 0)
70+
{
71+
<RadzenText TextStyle="TextStyle.Caption" Style="margin-top: 0.5rem;">
72+
@L("M3UImportDialog.MissingItemsNote")
73+
</RadzenText>
74+
}
75+
</RadzenAlert>
76+
}
77+
78+
<RadzenStack Orientation="Orientation.Horizontal" JustifyContent="JustifyContent.End" Gap="0.5rem">
79+
<RadzenButton Text="@L("Actions.Cancel")" ButtonStyle="ButtonStyle.Light" Click="@CancelClick"/>
80+
<RadzenButton Text="@L("Actions.Close")" ButtonStyle="ButtonStyle.Primary" Click="@CloseClick" Visible="@(_importResult != null)"/>
81+
</RadzenStack>
82+
</RadzenStack>
83+
84+
@code {
85+
private string _fileName = string.Empty;
86+
private string _fileSize = string.Empty;
87+
private IBrowserFile? _selectedFile;
88+
private bool _isUploading;
89+
private readonly List<string> _validationErrors = new();
90+
private PlaylistImportResponse? _importResult;
91+
92+
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
93+
94+
private async Task TriggerFileInput()
95+
{
96+
await JS.InvokeVoidAsync("document.getElementById('m3uFileInput').click");
97+
}
98+
99+
private async Task OnFileSelected(InputFileChangeEventArgs e)
100+
{
101+
_selectedFile = e.File;
102+
_fileName = _selectedFile.Name;
103+
_fileSize = FormatFileSize(_selectedFile.Size);
104+
_validationErrors.Clear();
105+
_importResult = null;
106+
107+
// Validate file
108+
var extension = Path.GetExtension(_fileName).ToLowerInvariant();
109+
if (extension != ".m3u" && extension != ".m3u8")
110+
{
111+
_validationErrors.Add(L("M3UImportDialog.InvalidFileType"));
112+
_selectedFile = null;
113+
_fileName = string.Empty;
114+
_fileSize = string.Empty;
115+
StateHasChanged();
116+
return;
117+
}
118+
119+
if (_selectedFile.Size > MaxFileSize)
120+
{
121+
_validationErrors.Add(L("M3UImportDialog.FileTooLarge"));
122+
_selectedFile = null;
123+
_fileName = string.Empty;
124+
_fileSize = string.Empty;
125+
StateHasChanged();
126+
return;
127+
}
128+
129+
// Auto-upload if validation passes
130+
await UploadFile();
131+
}
132+
133+
private async Task UploadFile()
134+
{
135+
if (_selectedFile == null || _isUploading)
136+
{
137+
return;
138+
}
139+
140+
try
141+
{
142+
_isUploading = true;
143+
_validationErrors.Clear();
144+
StateHasChanged();
145+
146+
using var stream = _selectedFile.OpenReadStream(MaxFileSize);
147+
using var memoryStream = new MemoryStream();
148+
await stream.CopyToAsync(memoryStream);
149+
memoryStream.Position = 0;
150+
151+
var result = await PlaylistService.ImportPlaylistAsync(
152+
CurrentUser!.UserId(),
153+
memoryStream,
154+
_selectedFile.Name,
155+
CancellationToken.None);
156+
157+
if (!result.IsSuccess || result.Data == null)
158+
{
159+
var errorMessage = result.Errors?.FirstOrDefault()?.Message ?? L("M3UImportDialog.ImportFailed");
160+
_validationErrors.Add(errorMessage);
161+
return;
162+
}
163+
164+
_importResult = new PlaylistImportResponse
165+
{
166+
PlaylistId = result.Data.PlaylistApiKey,
167+
PlaylistName = result.Data.PlaylistName,
168+
TotalEntries = result.Data.TotalEntries,
169+
MatchedEntries = result.Data.MatchedEntries,
170+
MissingEntries = result.Data.MissingEntries
171+
};
172+
173+
NotificationService.Notify(new NotificationMessage
174+
{
175+
Severity = NotificationSeverity.Success,
176+
Summary = L("M3UImportDialog.ImportSuccess"),
177+
Detail = L("M3UImportDialog.ImportSummary", _importResult.MatchedEntries.ToString(), _importResult.TotalEntries.ToString()),
178+
Duration = 5000
179+
});
180+
}
181+
catch (Exception ex)
182+
{
183+
Logger.LogError(ex, "Failed to import M3U playlist");
184+
_validationErrors.Add(L("M3UImportDialog.ImportFailed") + ": " + ex.Message);
185+
NotificationService.Notify(new NotificationMessage
186+
{
187+
Severity = NotificationSeverity.Error,
188+
Summary = L("M3UImportDialog.ImportFailed"),
189+
Detail = ex.Message,
190+
Duration = 8000
191+
});
192+
}
193+
finally
194+
{
195+
_isUploading = false;
196+
StateHasChanged();
197+
}
198+
}
199+
200+
private string FormatFileSize(long bytes)
201+
{
202+
string[] sizes = { "B", "KB", "MB", "GB" };
203+
double len = bytes;
204+
int order = 0;
205+
while (len >= 1024 && order < sizes.Length - 1)
206+
{
207+
order++;
208+
len = len / 1024;
209+
}
210+
return $"{len:0.##} {sizes[order]}";
211+
}
212+
213+
private string GetMatchPercentage()
214+
{
215+
if (_importResult == null || _importResult.TotalEntries == 0)
216+
{
217+
return "0";
218+
}
219+
return ((double)_importResult.MatchedEntries / _importResult.TotalEntries * 100).ToString("0.#");
220+
}
221+
222+
private void CancelClick()
223+
{
224+
DialogService.Close(false);
225+
}
226+
227+
private void CloseClick()
228+
{
229+
DialogService.Close(true);
230+
}
231+
}

src/Melodee.Blazor/Components/Pages/Data/Playlists.razor

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@
3232
AlignItems="AlignItems.Center"
3333
JustifyContent="JustifyContent.End"
3434
Gap="0.5rem">
35+
<RadzenButton Icon="playlist_add" Text="@L("Actions.ImportM3UPlaylist")"
36+
Click="@ImportM3UPlaylistButtonClick"
37+
Size="ButtonSize.Small"
38+
ButtonStyle="ButtonStyle.Success"/>
3539
<RadzenButton Icon="upload" Text="@L("Actions.ImportDynamicPlaylist")"
3640
Click="@ImportDynamicPlaylistButtonClick"
3741
Size="ButtonSize.Small"
@@ -213,6 +217,19 @@
213217
}
214218
}
215219

220+
private async Task ImportM3UPlaylistButtonClick()
221+
{
222+
var result = await DialogService.OpenAsync<M3UPlaylistImportDialog>(
223+
L("M3UImportDialog.Title"),
224+
null,
225+
new DialogOptions { Width = "700px", Height = "auto", Resizable = true, Draggable = true });
226+
227+
if (result is true)
228+
{
229+
await _grid.RefreshDataAsync();
230+
}
231+
}
232+
216233
private async Task DeleteSelectedButtonClick()
217234
{
218235
var confirm = await DialogService.Confirm(L("Messages.ConfirmDelete"), L("Data.ConfirmDelete"), new ConfirmOptions { OkButtonText = L("Actions.Yes"), CancelButtonText = L("Actions.No") });
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Melodee.Blazor.Controllers.Melodee.Models;
2+
3+
public sealed class PlaylistImportResponse
4+
{
5+
public required Guid PlaylistId { get; init; }
6+
public required string PlaylistName { get; init; }
7+
public required int TotalEntries { get; init; }
8+
public required int MatchedEntries { get; init; }
9+
public required int MissingEntries { get; init; }
10+
}

0 commit comments

Comments
 (0)