Skip to content

Commit 24d244e

Browse files
committed
Add CoverArt Discogs command
1 parent 90a0f42 commit 24d244e

11 files changed

Lines changed: 468 additions & 0 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using CliFx;
2+
using CliFx.Attributes;
3+
using CliFx.Infrastructure;
4+
5+
namespace MiniMediaScanner.Commands;
6+
7+
[Command("coverartdiscogs", Description = "Download Cover art from Discogs for Artist and Album")]
8+
public class CoverArtDiscogsCommand : ICommand
9+
{
10+
[CommandOption("connection-string",
11+
'C',
12+
Description = "ConnectionString for Postgres database.",
13+
EnvironmentVariable = "CONNECTIONSTRING",
14+
IsRequired = true)]
15+
public required string ConnectionString { get; init; }
16+
17+
[CommandOption("artist", 'a',
18+
Description = "Artistname",
19+
IsRequired = false,
20+
EnvironmentVariable = "COVERARTDISCOGS_ARTIST")]
21+
public string Artist { get; set; }
22+
23+
[CommandOption("album", 'b',
24+
Description = "target Album",
25+
IsRequired = false,
26+
EnvironmentVariable = "COVERARTDISCOGS_ALBUM")]
27+
public string Album { get; set; }
28+
29+
[CommandOption("album-filename", 'f',
30+
Description = "Filename e.g. cover.jpg.",
31+
IsRequired = false,
32+
EnvironmentVariable = "COVERARTDISCOGS_ALBUM_FILENAME")]
33+
public string AlbumFilename { get; set; } = "cover.jpg";
34+
35+
[CommandOption("artist-filename", 'g',
36+
Description = "Filename e.g. cover.jpg.",
37+
IsRequired = false,
38+
EnvironmentVariable = "COVERARTDISCOGS_ARTIST_FILENAME")]
39+
public string ArtistFilename { get; set; } = "cover.jpg";
40+
41+
[CommandOption("discogs-token",
42+
Description = "The Discogs token required to get the covers of Discogs",
43+
IsRequired = true,
44+
EnvironmentVariable = "COVERARTDISCOGS_TOKEN")]
45+
public string DiscogsToken { get; set; }
46+
47+
public async ValueTask ExecuteAsync(IConsole console)
48+
{
49+
var handler = new CoverArtDiscogsCommandHandler(ConnectionString, DiscogsToken);
50+
51+
if (string.IsNullOrWhiteSpace(Artist))
52+
{
53+
await handler.CheckAllMissingCoversAsync(Album, AlbumFilename, ArtistFilename);
54+
}
55+
else
56+
{
57+
await handler.CheckAllMissingCoversAsync(Artist, Album, AlbumFilename, ArtistFilename);
58+
}
59+
}
60+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
using MiniMediaScanner.Helpers;
2+
using MiniMediaScanner.Models;
3+
using MiniMediaScanner.Repositories;
4+
using MiniMediaScanner.Services;
5+
using RestSharp;
6+
7+
namespace MiniMediaScanner.Commands;
8+
9+
public class CoverArtDiscogsCommandHandler
10+
{
11+
private readonly MetadataRepository _metadataRepository;
12+
private readonly ArtistRepository _artistRepository;
13+
private readonly MatchRepository _matchRepository;
14+
private readonly DiscogsRepository _discogsRepository;
15+
private readonly DiscogsAPIService _discogsApiService;
16+
17+
public CoverArtDiscogsCommandHandler(string connectionString, string discogsToken)
18+
{
19+
_metadataRepository = new MetadataRepository(connectionString);
20+
_artistRepository = new ArtistRepository(connectionString);
21+
_matchRepository = new MatchRepository(connectionString);
22+
_discogsRepository = new DiscogsRepository(connectionString);
23+
_discogsApiService = new DiscogsAPIService(discogsToken);
24+
}
25+
26+
public async Task CheckAllMissingCoversAsync(string album, string coverAlbumFileName, string coverArtistFileName)
27+
{
28+
await ParallelHelper.ForEachAsync(await _artistRepository.GetAllArtistNamesAsync(), 1, async artist =>
29+
{
30+
try
31+
{
32+
await CheckAllMissingCoversAsync(artist, album, coverAlbumFileName, coverArtistFileName);
33+
}
34+
catch (Exception e)
35+
{
36+
Console.WriteLine(e.Message);
37+
}
38+
});
39+
}
40+
41+
public async Task CheckAllMissingCoversAsync(string artist, string album, string coverAlbumFileName, string coverArtistFileName)
42+
{
43+
Console.WriteLine($"Checking artist '{artist}'");
44+
var coverModels = (await _metadataRepository.GetFolderPathsByArtistForCoversAsync(artist, album))
45+
.ToList();
46+
47+
Dictionary<Guid, int> discogsArtistIds = (await Task.WhenAll(
48+
coverModels
49+
.DistinctBy(cover => cover.ArtistId)
50+
.Select(async cover => new
51+
{
52+
DiscogsArtistId = await _matchRepository.GetBestDiscogsMatchAsync(cover.ArtistId, cover.ArtistName),
53+
CoverArtistId = cover.ArtistId
54+
})
55+
))
56+
.Where(cover => cover.DiscogsArtistId > 0)
57+
.ToDictionary(cover => cover.CoverArtistId, cover => cover.DiscogsArtistId ?? 0);
58+
59+
if (discogsArtistIds.Count == 0)
60+
{
61+
Console.WriteLine($"No Discogs artist found in the database for '{artist}'");
62+
return;
63+
}
64+
65+
foreach (MetadataPathCoverModel coverModel in coverModels)
66+
{
67+
await CheckAlbumCoverAsync(coverModel, coverAlbumFileName, discogsArtistIds);
68+
}
69+
70+
await CheckArtistCoverAsync(coverModels, coverArtistFileName, discogsArtistIds);
71+
}
72+
73+
private async Task CheckArtistCoverAsync(List<MetadataPathCoverModel> coverModels,
74+
string coverArtistFileName,
75+
Dictionary<Guid, int> discogsArtistIds)
76+
{
77+
var coverModel = coverModels.FirstOrDefault();
78+
string? subDirPath = coverModel?.FolderPath;
79+
if (string.IsNullOrWhiteSpace(subDirPath) ||
80+
string.IsNullOrWhiteSpace(coverModel?.ArtistName))
81+
{
82+
return;
83+
}
84+
85+
DirectoryInfo subDir = new DirectoryInfo(subDirPath);
86+
DirectoryInfo? artistMusicFolder = GetArtistMusicFolder(subDir, coverModel.ArtistName);
87+
if (artistMusicFolder == null)
88+
{
89+
return;
90+
}
91+
92+
string coverArtistFileNameWithoutExtension = Path.GetFileNameWithoutExtension(coverArtistFileName);
93+
bool exists = artistMusicFolder.GetFiles("*.*", SearchOption.TopDirectoryOnly)
94+
.FirstOrDefault(fileName => fileName.Name.ToLower().StartsWith(coverArtistFileNameWithoutExtension.ToLower())) != null;
95+
96+
if (exists)
97+
{
98+
return;
99+
}
100+
101+
int discogsArtistId = 0;
102+
discogsArtistIds.TryGetValue(coverModel.ArtistId, out discogsArtistId);
103+
104+
if (discogsArtistId == 0)
105+
{
106+
Console.WriteLine($"No artist found by '{coverModel.ArtistName}'");
107+
return;
108+
}
109+
110+
var discogsArtist = await _discogsApiService.GetArtistByIdAsync(discogsArtistId);
111+
string? coverUrl = discogsArtist?.Images?
112+
.FirstOrDefault(img => img.Type == "primary" && !string.IsNullOrWhiteSpace(img.Uri))?.Uri;
113+
114+
if (string.IsNullOrEmpty(coverUrl))
115+
{
116+
Console.WriteLine($"No cover art found for '{coverModel.ArtistName}'");
117+
return;
118+
}
119+
120+
string coverArtPath = Path.Join(artistMusicFolder.FullName, coverArtistFileName);
121+
122+
Console.WriteLine($"Downloading cover art for {coverModel.ArtistName}");
123+
await DownloadImageAsync(coverUrl, coverArtPath);
124+
}
125+
126+
private async Task CheckAlbumCoverAsync(
127+
MetadataPathCoverModel coverModel,
128+
string coverAlbumFileName,
129+
Dictionary<Guid, int> discogsArtistIds)
130+
{
131+
string coverAlbumFileNameWithoutExtension = Path.GetFileNameWithoutExtension(coverAlbumFileName);
132+
DirectoryInfo di = new DirectoryInfo(coverModel.FolderPath);
133+
if (!di.Exists)
134+
{
135+
return;
136+
}
137+
138+
bool exists = di.GetFiles()
139+
.FirstOrDefault(fileName => fileName.Name.ToLower().StartsWith(coverAlbumFileNameWithoutExtension.ToLower())) != null;
140+
141+
if (exists)
142+
{
143+
return;
144+
}
145+
146+
int discogsArtistId = 0;
147+
discogsArtistIds.TryGetValue(coverModel.ArtistId, out discogsArtistId);
148+
149+
if (discogsArtistId == 0)
150+
{
151+
Console.WriteLine($"No artist found by '{coverModel.ArtistName}'");
152+
return;
153+
}
154+
155+
var releaseId = await _discogsRepository.GetAlbumIdByNameAsync(discogsArtistId, coverModel.AlbumName);
156+
if (releaseId == 0)
157+
{
158+
Console.WriteLine($"No album found by '{coverModel.ArtistName}', '{coverModel.AlbumName}'");
159+
return;
160+
}
161+
162+
var discogsRelease = await _discogsApiService.GetReleaseByIdAsync(releaseId.Value);
163+
string? coverUrl = discogsRelease?.Images?
164+
.OrderBy(img => img.Type)
165+
.FirstOrDefault(img => !string.IsNullOrWhiteSpace(img.Uri))?.Uri;
166+
167+
if (string.IsNullOrEmpty(coverUrl))
168+
{
169+
Console.WriteLine($"No cover art found for '{coverModel.ArtistName}', '{coverModel.AlbumName}'");
170+
return;
171+
}
172+
173+
string coverArtPath = Path.Join(coverModel.FolderPath, coverAlbumFileName);
174+
175+
Console.WriteLine($"Downloading cover art for {coverModel.ArtistName}, {coverModel.AlbumName}");
176+
await DownloadImageAsync(coverUrl, coverArtPath);
177+
}
178+
179+
private async Task DownloadImageAsync(string imageUrl, string fileName)
180+
{
181+
try
182+
{
183+
using HttpClient client = new HttpClient();
184+
HttpResponseMessage response = await client.GetAsync(imageUrl);
185+
186+
if (response.IsSuccessStatusCode)
187+
{
188+
byte[] imageBytes = await response.Content.ReadAsByteArrayAsync();
189+
File.WriteAllBytes(fileName, imageBytes);
190+
Console.WriteLine($"Cover art downloaded and saved as: {fileName}");
191+
}
192+
else
193+
{
194+
Console.WriteLine($"Failed to download image. HTTP Status: {response.StatusCode}");
195+
}
196+
}
197+
catch (Exception ex)
198+
{
199+
Console.WriteLine($"An error occurred while downloading the image: {ex.Message}");
200+
}
201+
}
202+
203+
private DirectoryInfo? GetArtistMusicFolder(DirectoryInfo subDirectory, string artistName)
204+
{
205+
subDirectory = subDirectory.Parent;
206+
207+
while (subDirectory != null && !string.Equals(subDirectory.Name, artistName, StringComparison.OrdinalIgnoreCase))
208+
{
209+
subDirectory = subDirectory.Parent;
210+
}
211+
return subDirectory;
212+
}
213+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CREATE INDEX idx_discogs_release_title_lower_trgm ON discogs_release USING gin (lower(title) gin_trgm_ops);

MiniMediaScanner/MiniMediaScanner.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@
8989
<None Update="DbScripts\20251110 index sortname musicbrainz.sql">
9090
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
9191
</None>
92+
<None Update="DbScripts\20251228 index discogs release title.sql">
93+
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
94+
</None>
9295
</ItemGroup>
9396

9497
</Project>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace MiniMediaScanner.Models.Discogs;
2+
3+
public class DiscogsArtistImageModel
4+
{
5+
public string Type { get; set; }
6+
public string Uri { get; set; }
7+
public string Resource_Url { get; set; }
8+
public string Uri150 { get; set; }
9+
public int Width { get; set; }
10+
public int Height { get; set; }
11+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace MiniMediaScanner.Models.Discogs;
2+
3+
public class DiscogsArtistModel
4+
{
5+
public int Id { get; set; }
6+
public string Name { get; set; }
7+
public string Resource_Url { get; set; }
8+
public string Uri { get; set; }
9+
public string Releases_Url { get; set; }
10+
public string Profile { get; set; }
11+
public List<string> Urls { get; set; }
12+
public List<string> Namevariations { get; set; }
13+
public string Data_Quality { get; set; }
14+
15+
public List<DiscogsArtistImageModel> Images { get; set; }
16+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace MiniMediaScanner.Models.Discogs;
2+
3+
public class DiscogsReleaseImageModel
4+
{
5+
public int Height { get; set; }
6+
public int Width { get; set; }
7+
public string Type { get; set; }
8+
public string Resource_Url { get; set; }
9+
public string Uri { get; set; }
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace MiniMediaScanner.Models.Discogs;
2+
3+
public class DiscogsReleaseModel
4+
{
5+
public int Id { get; set; }
6+
public string title { get; set; }
7+
public string Data_Quality { get; set; }
8+
public List<DiscogsReleaseImageModel> Images { get; set; }
9+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Dapper;
2+
using Npgsql;
3+
4+
namespace MiniMediaScanner.Repositories;
5+
6+
public class DiscogsRepository
7+
{
8+
private readonly string _connectionString;
9+
public DiscogsRepository(string connectionString)
10+
{
11+
_connectionString = connectionString;
12+
}
13+
14+
public async Task<int?> GetAlbumIdByNameAsync(int artistId, string albumName)
15+
{
16+
string query = @"select album.ReleaseId
17+
from discogs_release album
18+
join discogs_release_artist dra on dra.releaseid = album.releaseid and dra.artistid = @artistId
19+
where lower(album.Title) = lower(@albumName)
20+
limit 1";
21+
22+
await using var conn = new NpgsqlConnection(_connectionString);
23+
24+
return await conn
25+
.QueryFirstOrDefaultAsync<int>(query,
26+
param: new
27+
{
28+
artistId,
29+
albumName
30+
});
31+
}
32+
}

0 commit comments

Comments
 (0)