mirror of
https://github.com/jayfunc/BetterLyrics.git
synced 2026-01-12 19:24:55 +08:00
523 lines
20 KiB
C#
523 lines
20 KiB
C#
// 2025/6/23 by Zhe Fang
|
|
|
|
using ATL;
|
|
using BetterLyrics.WinUI3.Enums;
|
|
using BetterLyrics.WinUI3.Helper;
|
|
using BetterLyrics.WinUI3.Helper.BetterLyrics.WinUI3.Helper;
|
|
using BetterLyrics.WinUI3.Models;
|
|
using BetterLyrics.WinUI3.Services.SettingsService;
|
|
using CommunityToolkit.Mvvm.DependencyInjection;
|
|
using Lyricify.Lyrics.Providers.Web.Kugou;
|
|
using Lyricify.Lyrics.Searchers;
|
|
using Microsoft.Extensions.Logging;
|
|
using NTextCat.Commons;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Windows.Storage;
|
|
|
|
namespace BetterLyrics.WinUI3.Services.LyricsSearchService
|
|
{
|
|
public class LyricsSearchService : ILyricsSearchService
|
|
{
|
|
private readonly HttpClient _amllTtmlDbHttpClient;
|
|
private readonly HttpClient _lrcLibHttpClient;
|
|
|
|
private readonly ISettingsService _settingsService;
|
|
private readonly ILogger _logger;
|
|
|
|
public LyricsSearchService(ISettingsService settingsService)
|
|
{
|
|
_settingsService = settingsService;
|
|
_logger = Ioc.Default.GetRequiredService<ILogger<LyricsSearchService>>();
|
|
|
|
_lrcLibHttpClient = new();
|
|
_lrcLibHttpClient.DefaultRequestHeaders.Add(
|
|
"User-Agent",
|
|
$"{Constants.App.AppName} {MetadataHelper.AppVersion} ({Constants.Link.GitHubUrl})"
|
|
);
|
|
_amllTtmlDbHttpClient = new();
|
|
}
|
|
|
|
private static bool IsAmllTtmlDbIndexInvalid()
|
|
{
|
|
bool existed = File.Exists(PathHelper.AmllTtmlDbIndexPath);
|
|
|
|
if (!existed)
|
|
{
|
|
return true;
|
|
}
|
|
else
|
|
{
|
|
long currentTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
string lastUpdatedStr = File.ReadAllText(PathHelper.AmllTtmlDbLastUpdatedPath);
|
|
long lastUpdated = Convert.ToInt64(lastUpdatedStr);
|
|
return currentTs - lastUpdated > 1 * 24 * 60 * 60;
|
|
}
|
|
}
|
|
|
|
public async Task<bool> DownloadAmllTtmlDbIndexAsync()
|
|
{
|
|
try
|
|
{
|
|
using var response = await _amllTtmlDbHttpClient.GetAsync(Constants.AmllTTmlDB.Index, HttpCompletionOption.ResponseHeadersRead);
|
|
if (!response.IsSuccessStatusCode) return false;
|
|
|
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
|
await using var fs = new FileStream(
|
|
PathHelper.AmllTtmlDbIndexPath,
|
|
FileMode.Create,
|
|
FileAccess.Write,
|
|
FileShare.None
|
|
);
|
|
await stream.CopyToAsync(fs);
|
|
|
|
long currentTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
|
File.WriteAllText(PathHelper.AmllTtmlDbLastUpdatedPath, currentTs.ToString());
|
|
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public async Task<LyricsSearchResult> SearchSmartlyAsync(string mediaSessionId, string title, string artist, string album, double durationMs, CancellationToken token)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult();
|
|
|
|
string overridenTitle = title;
|
|
string overridenArtist = artist;
|
|
|
|
_logger.LogInformation("Searching img for: {Title} - {Artist} (Album: {Album}, Duration: {DurationMs}ms)", title, artist, album, durationMs);
|
|
|
|
var found = _settingsService.AppSettings.MappedSongSearchQueries
|
|
.Where(x => x.OriginalTitle == title && x.OriginalArtist == artist)
|
|
.FirstOrDefault();
|
|
|
|
if (found != null)
|
|
{
|
|
overridenTitle = found.MappedTitle;
|
|
overridenArtist = found.MappedArtist;
|
|
|
|
_logger.LogInformation("Found mapped song search query: {MappedSongSearchQuery}", found);
|
|
|
|
var pureMusic = found.IsMarkedAsPureMusic;
|
|
if (pureMusic)
|
|
{
|
|
lyricsSearchResult.Title = overridenTitle;
|
|
lyricsSearchResult.Artist = overridenArtist;
|
|
lyricsSearchResult.Raw = "[99:00.000]🎶🎶🎶";
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
var targetProvider = found.LyricsSearchProvider;
|
|
if (targetProvider != null)
|
|
{
|
|
return await SearchSingleAsync(targetProvider.Value, overridenTitle, overridenArtist, album, durationMs, token);
|
|
}
|
|
}
|
|
|
|
foreach (var provider in _settingsService.AppSettings.MediaSourceProvidersInfo.Where(x => x.Provider == mediaSessionId).FirstOrDefault()?.LyricsSearchProvidersInfo ?? [])
|
|
{
|
|
if (!provider.IsEnabled)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
lyricsSearchResult = await SearchSingleAsync(provider.Provider, overridenTitle, overridenArtist, album, durationMs, token);
|
|
|
|
if (lyricsSearchResult.IsFound)
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
}
|
|
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
public async Task<List<LyricsSearchResult>> SearchAllAsync(string title, string artist, string album, double durationMs, CancellationToken token)
|
|
{
|
|
_logger.LogInformation("Searching all lyrics for: {Title} - {Artist} (Album: {Album}, Duration: {DurationMs}ms)", title, artist, album, durationMs);
|
|
var results = new List<LyricsSearchResult>();
|
|
foreach (var provider in Enum.GetValues<LyricsSearchProvider>())
|
|
{
|
|
var searchResult = await SearchSingleAsync(provider, title, artist, album, durationMs, token);
|
|
results.Add(searchResult);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
private async Task<LyricsSearchResult> SearchSingleAsync(LyricsSearchProvider provider, string title, string artist, string album, double durationMs, CancellationToken token)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult
|
|
{
|
|
Provider = provider,
|
|
};
|
|
|
|
try
|
|
{
|
|
LyricsFormat lyricsFormat = provider.GetLyricsFormat();
|
|
|
|
// Check cache first
|
|
if (provider.IsRemote())
|
|
{
|
|
var cachedLyrics = FileHelper.ReadLyricsCache(title, artist, lyricsFormat, provider.GetCacheDirectory());
|
|
if (!string.IsNullOrWhiteSpace(cachedLyrics))
|
|
{
|
|
lyricsSearchResult.Raw = cachedLyrics;
|
|
lyricsSearchResult.Title = title;
|
|
lyricsSearchResult.Artist = artist;
|
|
return lyricsSearchResult;
|
|
}
|
|
}
|
|
|
|
if (provider.IsLocal())
|
|
{
|
|
if (provider == LyricsSearchProvider.LocalMusicFile)
|
|
{
|
|
lyricsSearchResult = SearchEmbedded(title, artist);
|
|
}
|
|
else
|
|
{
|
|
lyricsSearchResult = await SearchFile(title, artist, lyricsFormat);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
switch (provider)
|
|
{
|
|
case LyricsSearchProvider.LrcLib:
|
|
lyricsSearchResult = await SearchLrcLibAsync(title, artist, album, (int)(durationMs / 1000));
|
|
break;
|
|
case LyricsSearchProvider.QQ:
|
|
lyricsSearchResult = await SearchQQNeteaseKugouAsync(title, artist, album, (int)durationMs, Searchers.QQMusic);
|
|
break;
|
|
case LyricsSearchProvider.Kugou:
|
|
lyricsSearchResult = await SearchQQNeteaseKugouAsync(title, artist, album, (int)durationMs, Searchers.Kugou);
|
|
break;
|
|
case LyricsSearchProvider.Netease:
|
|
lyricsSearchResult = await SearchQQNeteaseKugouAsync(title, artist, album, (int)durationMs, Searchers.Netease);
|
|
break;
|
|
case LyricsSearchProvider.AmllTtmlDb:
|
|
lyricsSearchResult = await SearchAmllTtmlDbAsync(title, artist);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (token.IsCancellationRequested)
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
if (lyricsSearchResult.IsFound)
|
|
{
|
|
if (provider.IsRemote())
|
|
{
|
|
FileHelper.WriteLyricsCache(title, artist, lyricsSearchResult.Raw!, lyricsFormat, provider.GetCacheDirectory());
|
|
}
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
private async Task<LyricsSearchResult> SearchFile(string title, string artist, LyricsFormat format)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult
|
|
{
|
|
Provider = format.ToLyricsSearchProvider(),
|
|
};
|
|
|
|
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
|
{
|
|
if (Directory.Exists(folder.Path) && folder.IsEnabled)
|
|
{
|
|
try
|
|
{
|
|
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path, $"*{format.ToFileExtension()}"))
|
|
{
|
|
if (FileHelper.IsSwitchableNormalizedMatch(Path.GetFileNameWithoutExtension(file), title, artist))
|
|
{
|
|
string? raw = await File.ReadAllTextAsync(file, FileHelper.GetEncoding(file));
|
|
if (raw != null)
|
|
{
|
|
lyricsSearchResult.Raw = raw;
|
|
lyricsSearchResult.Title = title;
|
|
lyricsSearchResult.Artist = artist;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
private LyricsSearchResult SearchEmbedded(string title, string artist)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult
|
|
{
|
|
Provider = LyricsSearchProvider.LocalMusicFile,
|
|
};
|
|
|
|
foreach (var folder in _settingsService.AppSettings.LocalMediaFolders)
|
|
{
|
|
if (Directory.Exists(folder.Path) && folder.IsEnabled)
|
|
{
|
|
foreach (var file in DirectoryHelper.GetAllFiles(folder.Path))
|
|
{
|
|
if (FileHelper.MusicExtensions.Contains(Path.GetExtension(file)))
|
|
{
|
|
var track = new Track(file);
|
|
if ((track.Title == title && track.Artist == artist) || FileHelper.IsSwitchableNormalizedMatch(Path.GetFileNameWithoutExtension(file), title, artist))
|
|
{
|
|
var plain = TagLib.File.Create(file).Tag.Lyrics;
|
|
if (!plain.IsNullOrEmpty())
|
|
{
|
|
lyricsSearchResult.Raw = plain;
|
|
lyricsSearchResult.Title = track.Title;
|
|
lyricsSearchResult.Artist = artist;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
private async Task<LyricsSearchResult> SearchAmllTtmlDbAsync(string title, string artist)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult
|
|
{
|
|
Provider = LyricsSearchProvider.AmllTtmlDb,
|
|
};
|
|
|
|
if (IsAmllTtmlDbIndexInvalid())
|
|
{
|
|
var downloadOk = await DownloadAmllTtmlDbIndexAsync();
|
|
if (!downloadOk)
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
}
|
|
|
|
string? rawLyricFile = null;
|
|
await foreach (var line in File.ReadLinesAsync(PathHelper.AmllTtmlDbIndexPath))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(line))
|
|
continue;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(line);
|
|
var root = doc.RootElement;
|
|
if (!root.TryGetProperty("metadata", out var metadataArr))
|
|
continue;
|
|
string? musicName = null;
|
|
string? artists = null;
|
|
foreach (var meta in metadataArr.EnumerateArray())
|
|
{
|
|
if (meta.GetArrayLength() != 2)
|
|
continue;
|
|
var key = meta[0].GetString();
|
|
var valueArr = meta[1];
|
|
if (key == "musicName" && valueArr.GetArrayLength() > 0)
|
|
musicName = valueArr[0].GetString();
|
|
if (key == "artists" && valueArr.GetArrayLength() > 0)
|
|
artists = valueArr[0].GetString();
|
|
}
|
|
if (musicName == null || artists == null)
|
|
continue;
|
|
|
|
if (FileHelper.IsSwitchableNormalizedMatch($"{artists} - {musicName}", title, artist))
|
|
{
|
|
if (root.TryGetProperty("rawLyricFile", out var rawLyricFileProp))
|
|
{
|
|
rawLyricFile = rawLyricFileProp.GetString();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(rawLyricFile))
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
// 下载歌词内容
|
|
var url = $"{Constants.AmllTTmlDB.QueryPrefix}{rawLyricFile}";
|
|
try
|
|
{
|
|
using var response = await _amllTtmlDbHttpClient.GetAsync(url);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
string lyrics = await response.Content.ReadAsStringAsync();
|
|
|
|
lyricsSearchResult.Raw = lyrics;
|
|
lyricsSearchResult.Title = title;
|
|
lyricsSearchResult.Artist = artist;
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
private async Task<LyricsSearchResult> SearchLrcLibAsync(string title, string artist, string album, int duration)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult
|
|
{
|
|
Provider = LyricsSearchProvider.LrcLib,
|
|
};
|
|
|
|
// Build API query URL
|
|
var url =
|
|
$"https://lrclib.net/api/search?" +
|
|
$"track_name={Uri.EscapeDataString(title)}&" +
|
|
$"artist_name={Uri.EscapeDataString(artist)}&" +
|
|
$"&album_name={Uri.EscapeDataString(album)}" +
|
|
$"&durationMs={Uri.EscapeDataString(duration.ToString())}";
|
|
|
|
using var response = await _lrcLibHttpClient.GetAsync(url);
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
|
|
var jArr = JsonSerializer.Deserialize(
|
|
json,
|
|
Serialization.SourceGenerationContext.Default.JsonElement
|
|
);
|
|
|
|
string? original = null;
|
|
string? searchedTitle = null;
|
|
string? searchedArtist = null;
|
|
|
|
if (jArr.ValueKind == JsonValueKind.Array && jArr.GetArrayLength() > 0)
|
|
{
|
|
var first = jArr[0];
|
|
original = first.GetProperty("syncedLyrics").GetString();
|
|
searchedTitle = first.GetProperty("trackName").GetString();
|
|
searchedArtist = first.GetProperty("artistName").GetString();
|
|
}
|
|
|
|
lyricsSearchResult.Raw = original;
|
|
lyricsSearchResult.Title = searchedTitle;
|
|
lyricsSearchResult.Artist = searchedArtist;
|
|
|
|
return lyricsSearchResult;
|
|
}
|
|
|
|
private static async Task<LyricsSearchResult> SearchQQNeteaseKugouAsync(string title, string artist, string album, int durationMs, Searchers searchers)
|
|
{
|
|
var lyricsSearchResult = new LyricsSearchResult();
|
|
|
|
switch (searchers)
|
|
{
|
|
case Searchers.QQMusic:
|
|
lyricsSearchResult.Provider = LyricsSearchProvider.QQ;
|
|
break;
|
|
case Searchers.Netease:
|
|
lyricsSearchResult.Provider = LyricsSearchProvider.Netease;
|
|
break;
|
|
case Searchers.Kugou:
|
|
lyricsSearchResult.Provider = LyricsSearchProvider.Kugou;
|
|
break;
|
|
case Searchers.Musixmatch:
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
var result = await SearchersHelper.GetSearcher(searchers).SearchForResult(
|
|
new Lyricify.Lyrics.Models.TrackMultiArtistMetadata()
|
|
{
|
|
DurationMs = durationMs,
|
|
Album = album,
|
|
Artists = [artist],
|
|
Title = title,
|
|
}
|
|
);
|
|
|
|
if (result is QQMusicSearchResult qqResult)
|
|
{
|
|
var response = await Lyricify.Lyrics.Helpers.ProviderHelper.QQMusicApi.GetLyricsAsync(qqResult.Id);
|
|
var original = response?.Lyrics;
|
|
var translated = response?.Trans;
|
|
if (!string.IsNullOrEmpty(translated))
|
|
{
|
|
FileHelper.WriteLyricsCache(
|
|
title,
|
|
artist,
|
|
translated,
|
|
LyricsFormat.Lrc,
|
|
PathHelper.QQTranslationCacheDirectory
|
|
);
|
|
}
|
|
|
|
lyricsSearchResult.Raw = original;
|
|
lyricsSearchResult.Title = qqResult.Title;
|
|
lyricsSearchResult.Artist = qqResult.Artists.Join(" | ");
|
|
}
|
|
else if (result is NeteaseSearchResult neteaseResult)
|
|
{
|
|
var response = await Lyricify.Lyrics.Helpers.ProviderHelper.NeteaseApi.GetLyric(neteaseResult.Id);
|
|
var original = response?.Lrc?.Lyric;
|
|
var translated = response?.Tlyric?.Lyric;
|
|
if (!string.IsNullOrEmpty(translated))
|
|
{
|
|
FileHelper.WriteLyricsCache(
|
|
title,
|
|
artist,
|
|
translated,
|
|
LyricsFormat.Lrc,
|
|
PathHelper.NeteaseTranslationCacheDirectory
|
|
);
|
|
}
|
|
|
|
lyricsSearchResult.Raw = original;
|
|
lyricsSearchResult.Title = neteaseResult.Title;
|
|
lyricsSearchResult.Artist = neteaseResult.Artists.Join(" | ");
|
|
}
|
|
else if (result is KugouSearchResult kugouResult)
|
|
{
|
|
var response = await Lyricify.Lyrics.Helpers.ProviderHelper.KugouApi.GetSearchLyrics(hash: kugouResult.Hash);
|
|
string? original = null;
|
|
if (response?.Candidates.FirstOrDefault() is SearchLyricsResponse.Candidate candidate)
|
|
{
|
|
original = Lyricify.Lyrics.Decrypter.Krc.Helper.GetLyrics(candidate.Id, candidate.AccessKey);
|
|
}
|
|
|
|
lyricsSearchResult.Raw = original;
|
|
lyricsSearchResult.Title = kugouResult.Title;
|
|
lyricsSearchResult.Artist = kugouResult.Artists.Join(" | ");
|
|
}
|
|
|
|
return lyricsSearchResult;
|
|
}
|
|
}
|
|
}
|