forked from TheLanc3/MusicTagFixer
25
src/Helpers/CsvParser.cs
Normal file
25
src/Helpers/CsvParser.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
using UniversalTagEditor.Types;
|
||||
|
||||
namespace UniversalTagEditor.Helpers;
|
||||
|
||||
public static class CsvParser
|
||||
{
|
||||
public static TrackInfo[] ParseCsvTable(string path)
|
||||
{
|
||||
CsvConfiguration config = new(CultureInfo.InvariantCulture)
|
||||
{
|
||||
Delimiter = "\t"
|
||||
};
|
||||
using (var reader = new StreamReader(path))
|
||||
using (var csv = new CsvReader(reader, config))
|
||||
{
|
||||
csv.Context.Configuration.Delimiter = "\t";
|
||||
csv.Context.RegisterClassMap<TrackInfoMap>();
|
||||
|
||||
return csv.GetRecords<TrackInfo>().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/Helpers/DiscogsService.cs
Normal file
60
src/Helpers/DiscogsService.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
public class DiscogsService
|
||||
{
|
||||
private readonly HttpClient _client = new HttpClient();
|
||||
private string Token; // Get from Discogs settings
|
||||
private string UserAgent;
|
||||
|
||||
public DiscogsService(string token, string app, string version, string email)
|
||||
{
|
||||
Token = token;
|
||||
UserAgent = $"{app}/{version} (contact: {email})";
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetGenresAsync(string artist, string album)
|
||||
{
|
||||
// 1. Setup Headers (Crucial for Discogs)
|
||||
_client.DefaultRequestHeaders.Clear();
|
||||
_client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
|
||||
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Discogs", $"token={Token}");
|
||||
|
||||
// 2. Search for the release
|
||||
// We use the search endpoint to find the ID of the album first
|
||||
string searchUrl = $"https://api.discogs.com/database/search?artist={Uri.EscapeDataString(artist)}&release_title={Uri.EscapeDataString(album)}&type=release";
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _client.GetStringAsync(searchUrl);
|
||||
using var doc = JsonDocument.Parse(response);
|
||||
var firstResult = doc.RootElement.GetProperty("results").EnumerateArray().FirstOrDefault();
|
||||
|
||||
if (firstResult.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
Console.WriteLine("No match found on Discogs.");
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
// 3. Extract Genre and Style from search result
|
||||
// Discogs provides these directly in the search results for releases!
|
||||
var genres = firstResult.GetProperty("genre").EnumerateArray().Select(g => g.GetString());
|
||||
var styles = firstResult.GetProperty("style").EnumerateArray().Select(s => s.GetString());
|
||||
|
||||
Console.WriteLine($"--- Discogs Results for {album} ---");
|
||||
Console.WriteLine($"Genres: {string.Join(", ", genres)}");
|
||||
Console.WriteLine($"Styles: {string.Join(", ", styles)}");
|
||||
|
||||
return genres.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
138
src/Helpers/TagEditor.cs
Normal file
138
src/Helpers/TagEditor.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using MetaBrainz.MusicBrainz;
|
||||
using MetaBrainz.MusicBrainz.Interfaces.Entities;
|
||||
using TagLib;
|
||||
using UniversalTagEditor.Types;
|
||||
|
||||
public static class TagEditor {
|
||||
public static void Edit(string filePath, AlbumInfo info, int trackNum)
|
||||
{
|
||||
TagLib.File file = TagLib.File.Create(filePath);
|
||||
|
||||
if (info.Name != null)
|
||||
file.Tag.Album = info.Name;
|
||||
|
||||
if (info.Cover != null)
|
||||
{
|
||||
Picture picture = new Picture(info.Cover);
|
||||
picture.Type = PictureType.FrontCover;
|
||||
file.Tag.Pictures = new[] { picture };
|
||||
}
|
||||
|
||||
if (info.Tracks.Length > 0)
|
||||
{
|
||||
file.Tag.Title = info.Tracks[trackNum].Title;
|
||||
file.Tag.Track = uint.Parse(info.Tracks[trackNum].TrackNumber);
|
||||
file.Tag.Year = uint.Parse(info.Tracks[trackNum].Year);
|
||||
file.Tag.Composers = info.Tracks[trackNum].Composers.Split(";");
|
||||
file.Tag.Disc = uint.Parse(info.Tracks[trackNum].Disc);
|
||||
file.Tag.Performers = info.Tracks[trackNum].Artists.Split(";");
|
||||
file.Tag.AlbumArtists = new List<string>(["Microsoft"]).ToArray();
|
||||
file.Tag.Genres = info.Tracks[trackNum].Genres.Split(";");
|
||||
file.Tag.Publisher = info.Tracks[trackNum].Publisher;
|
||||
}
|
||||
|
||||
file.Save();
|
||||
}
|
||||
|
||||
public static async Task Fix(string filePath, Query query, bool enhanceStructure, string format)
|
||||
{
|
||||
TagLib.File file = TagLib.File.Create(filePath);
|
||||
|
||||
if (file.Tag.Performers.Length == 1) {
|
||||
List<string> performers = file.Tag.Performers[0]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.SelectMany(p => p.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => p.Length > 0)
|
||||
.ToList();
|
||||
|
||||
file.Tag.Performers = performers.ToArray();
|
||||
if (file.Tag.AlbumArtists == null || file.Tag.AlbumArtists.Length == 0)
|
||||
file.Tag.AlbumArtists = new [] { performers[0] };
|
||||
else
|
||||
{
|
||||
List<string> albumArtist = file.Tag.AlbumArtists[0]
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.SelectMany(p => p.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(p => p.Trim())
|
||||
.Where(p => p.Length > 0)
|
||||
.ToList();
|
||||
file.Tag.AlbumArtists = albumArtist.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (file.Tag.Genres == null || file.Tag.Genres.Length == 0)
|
||||
{
|
||||
bool noException = false;
|
||||
|
||||
while (!noException)
|
||||
{
|
||||
try
|
||||
{
|
||||
var albumSearch = await query.FindReleaseGroupsAsync($"artist:\"{file.Tag.AlbumArtists[0]}\" AND releasegroup:\"{file.Tag.Album}\"");
|
||||
var albumResult = albumSearch.Results.FirstOrDefault()?.Item;
|
||||
|
||||
if (albumResult != null)
|
||||
{
|
||||
IReleaseGroup fullAlbum = await query.LookupReleaseGroupAsync(albumResult.Id, Include.Genres);
|
||||
file.Tag.Genres = fullAlbum.Genres?.Select(g =>
|
||||
string.Join(" ", g.Name.Split(" ").Select(n => n[0].ToString().ToUpper() + n[1..^0]))
|
||||
).ToArray();
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
noException = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.Save();
|
||||
|
||||
if (enhanceStructure)
|
||||
{
|
||||
FileInfo path = new FileInfo(filePath);
|
||||
Console.WriteLine(file.Tag.Album);
|
||||
string album = file.Tag.Year + " — " + ReplaceSystemSymbols(file.Tag.Album);
|
||||
if (!Directory.Exists(Path.Combine(path.DirectoryName!, album)))
|
||||
Directory.CreateDirectory(Path.Combine(path.DirectoryName!, album));
|
||||
|
||||
string output = Path.Combine(path.DirectoryName!,
|
||||
album,
|
||||
$"{file.Tag.Track}. {string.Join(", ", file.Tag.Performers.Select(p => ReplaceSystemSymbols(p)))} - {ReplaceSystemSymbols(file.Tag.Title)}.{format}");
|
||||
|
||||
if (System.IO.File.Exists(output))
|
||||
System.IO.File.Delete(filePath);
|
||||
else
|
||||
System.IO.File.Move(filePath, output);
|
||||
await Task.Delay(500);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReplaceSystemSymbols(string origin)
|
||||
{
|
||||
string result = origin;
|
||||
|
||||
if (result.Contains("/"))
|
||||
result = result.Replace("/", "-");
|
||||
if (result.Contains(":"))
|
||||
result = result.Replace(":", "-");
|
||||
if (result.Contains("|"))
|
||||
result = result.Replace("|", "-");
|
||||
if (result.Contains("\\"))
|
||||
result = result.Replace("\\", "-");
|
||||
if (result.Contains("?"))
|
||||
result = result.Replace("?", "");
|
||||
if (result.Contains("\""))
|
||||
result = result.Replace("\"", "_");
|
||||
if (result.Contains("*"))
|
||||
result = result.Replace("*", "_");
|
||||
if (result.Contains("<"))
|
||||
result = result.Replace("<", "_");
|
||||
if (result.Contains(">"))
|
||||
result = result.Replace(">", "_");
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
142
src/Program.cs
142
src/Program.cs
@@ -1,59 +1,121 @@
|
||||
using System.Text;
|
||||
using UniversalTagEditor.Core;
|
||||
using UniversalTagEditor.Helpers;
|
||||
using UniversalTagEditor.Types;
|
||||
|
||||
using MetaBrainz.MusicBrainz;
|
||||
|
||||
namespace UniversalTagEditor;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var opt = ParseArgs(args);
|
||||
|
||||
var log = new Progress<string>(Console.WriteLine);
|
||||
var prog = new Progress<ProgressInfo>(p =>
|
||||
Console.Title = $"UniversalTagEditor {p.Done}/{p.Total}");
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
Console.InputEncoding = Encoding.UTF8;
|
||||
|
||||
Console.WriteLine("WORKDIR RAW : " + opt.WorkingDirectory);
|
||||
Console.WriteLine("WORKDIR FULL: " + Path.GetFullPath(opt.WorkingDirectory));
|
||||
Console.WriteLine("EXISTS : " + Directory.Exists(opt.WorkingDirectory));
|
||||
await Runner.RunAsync(opt, log, prog);
|
||||
}
|
||||
|
||||
static UteOptions ParseArgs(string[] args)
|
||||
{
|
||||
string? albumInfo = null;
|
||||
string? albumCover = null;
|
||||
string? format = null;
|
||||
string? workingDirectory = null;
|
||||
string albumInfo = "";
|
||||
string albumCover = "";
|
||||
string format = "";
|
||||
string workingDirectory = "";
|
||||
bool fixTags = false;
|
||||
bool enhanceStructure = false;
|
||||
|
||||
List<decimal> threads = new();
|
||||
|
||||
var query = new Query("NavidromeMetadataRecovery1", "1.0");
|
||||
var query2 = new Query("NavidromeMetadataRecovery2", "1.0");
|
||||
var query3 = new Query("NavidromeMetadataRecovery3", "1.0");
|
||||
var query4 = new Query("NavidromeMetadataRecovery4", "1.0");
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "-w": workingDirectory = args[++i]; break;
|
||||
case "-a": albumInfo = args[++i]; break;
|
||||
case "-f": format = args[++i]; break;
|
||||
case "-c": albumCover = args[++i]; break;
|
||||
case "--fix-tags": fixTags = true; break;
|
||||
case "--enhance-structure": enhanceStructure = true; break;
|
||||
case "-w":
|
||||
if (i + 1 < args.Length) workingDirectory = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
|
||||
case "-a":
|
||||
if (i + 1 < args.Length) albumInfo = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
|
||||
case "-f":
|
||||
if (i + 1 < args.Length) format = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
|
||||
case "-c":
|
||||
if (i + 1 < args.Length) albumCover = args[i + 1];
|
||||
i++;
|
||||
break;
|
||||
|
||||
case "--fix-tags":
|
||||
fixTags = true;
|
||||
break;
|
||||
case "--enhance-structure":
|
||||
enhanceStructure = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(workingDirectory))
|
||||
throw new ArgumentException("Нужен -w <path>");
|
||||
|
||||
return new UteOptions
|
||||
TrackInfo[] records;
|
||||
AlbumInfo album = new();
|
||||
if (albumInfo != "")
|
||||
{
|
||||
WorkingDirectory = workingDirectory,
|
||||
AlbumInfoCsv = albumInfo,
|
||||
AlbumCover = albumCover,
|
||||
Format = format,
|
||||
FixTags = fixTags,
|
||||
EnhanceStructure = enhanceStructure,
|
||||
MaxDegreeOfParallelism = 4
|
||||
records = CsvParser.ParseCsvTable(Path.Combine(workingDirectory, albumInfo));
|
||||
album = new()
|
||||
{
|
||||
Tracks = records
|
||||
};
|
||||
}
|
||||
if (albumCover != "")
|
||||
album.Cover = Path.Combine(workingDirectory, albumCover);
|
||||
|
||||
List<string> files = new();
|
||||
files.AddRange(Directory.GetFiles(workingDirectory, $"*.{format}"));
|
||||
|
||||
if (!enhanceStructure) {
|
||||
string[] directories = Directory.GetDirectories(workingDirectory);
|
||||
foreach(string directory in directories)
|
||||
files.AddRange(Directory.GetFiles(directory, $"*.{format}"));
|
||||
}
|
||||
|
||||
Func<List<string>, Query, int, Task> cycle = async (List<string> chunk, Query query, int thread) =>
|
||||
{
|
||||
threads.Add(thread);
|
||||
for (int i = 0; i < chunk.Count; i++)
|
||||
{
|
||||
if (fixTags)
|
||||
await TagEditor.Fix(chunk[i], query, enhanceStructure, format);
|
||||
else
|
||||
TagEditor.Edit(chunk[i], album, i);
|
||||
Console.WriteLine($"{i+1}. {chunk[i]} successfully edited");
|
||||
}
|
||||
threads.Remove(thread);
|
||||
};
|
||||
|
||||
if ((int)(files.Count / 4) > 1)
|
||||
{
|
||||
Thread thread = new Thread(async () => await cycle(files.GetRange(0, (int)(files.Count / 4)), query, 1)) { IsBackground = true };
|
||||
thread.Start();
|
||||
|
||||
Thread thread2 = new Thread(async () => await cycle(files.GetRange((int)(files.Count / 4), (int)(files.Count / 4)), query2, 2)) { IsBackground = true };
|
||||
thread2.Start();
|
||||
|
||||
if ((int)(files.Count / 4) >= 3) {
|
||||
Thread thread3 = new Thread(async () => await cycle(files.GetRange(((int)(files.Count / 4) * 2), (int)(files.Count / 4)), query3, 3)) { IsBackground = true };
|
||||
thread3.Start();
|
||||
}
|
||||
if ((int)(files.Count / 4) >= 4) {
|
||||
Thread thread4 = new Thread(async () => await cycle(files.GetRange(((int)(files.Count / 4) * 3), files.Count - ((int)(files.Count / 4) * 3)), query4, 4)) { IsBackground = true };
|
||||
thread4.Start();
|
||||
}
|
||||
|
||||
Console.WriteLine("Waiting for threads complete");
|
||||
while(threads.Count > 0) { }
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("\n\nTag fixing completed!");
|
||||
Console.ResetColor();
|
||||
}
|
||||
else
|
||||
await cycle(files, query, 1);
|
||||
}
|
||||
}
|
||||
8
src/Types/AlbumInfo.cs
Normal file
8
src/Types/AlbumInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace UniversalTagEditor.Types;
|
||||
|
||||
public class AlbumInfo
|
||||
{
|
||||
public string Name;
|
||||
public string Cover;
|
||||
public TrackInfo[] Tracks;
|
||||
}
|
||||
35
src/Types/TrackInfo.cs
Normal file
35
src/Types/TrackInfo.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using CsvHelper.Configuration;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace UniversalTagEditor.Types;
|
||||
|
||||
public class TrackInfo
|
||||
{
|
||||
public string TrackNumber;
|
||||
public string Disc;
|
||||
|
||||
public string Title;
|
||||
|
||||
public string Composers;
|
||||
public string Artists;
|
||||
|
||||
public string Publisher;
|
||||
|
||||
public string Year;
|
||||
public string Genres;
|
||||
}
|
||||
|
||||
public class TrackInfoMap : ClassMap<TrackInfo>
|
||||
{
|
||||
public TrackInfoMap()
|
||||
{
|
||||
Map(m => m.TrackNumber).Name("Track #").Index(0);
|
||||
Map(m => m.Disc).Name("Disc").Index(1);
|
||||
Map(m => m.Title).Name("Track Title").Index(2);
|
||||
Map(m => m.Composers).Name("Composers").Index(3);
|
||||
Map(m => m.Artists).Name("Artists").Index(4);
|
||||
Map(m => m.Publisher).Name("Publisher").Index(5);
|
||||
Map(m => m.Year).Name("Copyright Year").Index(6);
|
||||
Map(m => m.Genres).Name("Genres").Index(7);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\UniversalTagEditor.Core\UniversalTagEditor.Core.csproj" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="MetaBrainz.MusicBrainz" Version="7.0.0" />
|
||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user