mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-11 00:18:38 -05:00
Compare commits
417 Commits
v1.2.8
...
f135db3f60
| Author | SHA1 | Date | |
|---|---|---|---|
|
f135db3f60
|
|||
| 2b76fa9e6f | |||
|
6949f8aed4
|
|||
|
a37f7e0b1d
|
|||
|
2b4cd35cf7
|
|||
|
faa07c2791
|
|||
|
bdd753fd02
|
|||
|
0a07804833
|
|||
|
6c14fc299c
|
|||
|
b03a4b85c9
|
|||
|
565cb46b72
|
|||
|
6357b524da
|
|||
|
aa9f0d0345
|
|||
|
b0e07404c9
|
|||
|
8dbf37f6a3
|
|||
|
baab1e88a5
|
|||
|
972756159d
|
|||
|
f59f265ad4
|
|||
|
bc0467b1ff
|
|||
|
e057f365f4
|
|||
|
e8eb095a23
|
|||
|
591fd5e8e1
|
|||
|
3e840f987b
|
|||
|
56bc9d4ea9
|
|||
|
f1dd01f6d5
|
|||
|
6c06c59f61
|
|||
|
56f2eca867
|
|||
|
248ab804f3
|
|||
|
b1769a35bf
|
|||
|
f741cc5297
|
|||
|
1a0e0216f5
|
|||
|
73bd3bf308
|
|||
|
43bf71c390
|
|||
|
2254616d32
|
|||
|
c0444becad
|
|||
|
b906a5fd6d
|
|||
|
e3bcc93597
|
|||
|
7e6bed51e1
|
|||
|
47b9427c20
|
|||
|
bb46db43b1
|
|||
|
3937e637c6
|
|||
|
2272e8d363
|
|||
|
6169d7a4ac
|
|||
|
da8cb29e08
|
|||
|
d88ed64e37
|
|||
|
210d18220b
|
|||
|
c44e48a425
|
|||
|
e44b46aee1
|
|||
|
a75df9328a
|
|||
|
35c125d042
|
|||
|
b12c971968
|
|||
|
8f051ad413
|
|||
|
6c1a578b35
|
|||
|
8ab2923493
|
|||
|
42b4e0e399
|
|||
|
f03aa0be35
|
|||
|
440ef9850f
|
|||
|
c9b44dea43
|
|||
|
3a9d00dcdb
|
|||
|
2389b80733
|
|||
|
b99a199ef3
|
|||
|
64e2004bdc
|
|||
|
7cee0911b6
|
|||
|
a2b1eace5f
|
|||
|
ac1fbd4b34
|
|||
|
a6ac0dfbd2
|
|||
|
bb3140a247
|
|||
|
791e6a69d9
|
|||
|
3ffa09dcfa
|
|||
|
b366a4b771
|
|||
|
960d15175e
|
|||
|
1d774111e7
|
|||
|
99d701a355
|
|||
|
73509eb80b
|
|||
|
eb8e3196da
|
|||
|
401d0b4008
|
|||
|
6ccc6a4a0d
|
|||
|
c54503f486
|
|||
|
fbac81df64
|
|||
|
3a433e276c
|
|||
|
0c14f4a760
|
|||
|
28c4f8f5df
|
|||
|
a3830c54c4
|
|||
|
4226ead53a
|
|||
|
2155c4a9d5
|
|||
|
a56b2c3ea3
|
|||
|
810247ba8c
|
|||
|
96814aa91b
|
|||
|
d52c0fc938
|
|||
|
64eff088fa
|
|||
|
ff6dfede87
|
|||
|
d8696e254f
|
|||
|
261f20f378
|
|||
|
ad5fea7d8e
|
|||
|
8a3abdcbf7
|
|||
|
f103dac6c8
|
|||
|
7abc26c069
|
|||
|
a2e9021100
|
|||
|
5f22fb0a3b
|
|||
|
a3d1d81810
|
|||
|
2dd7020a61
|
|||
|
e36e685bee
|
|||
|
7ff6dbbe7a
|
|||
|
e0dbd1d4fd
|
|||
|
328a6a0eea
|
|||
|
9abb53de1a
|
|||
|
349fb740a2
|
|||
|
b604d61039
|
|||
|
3b8d83b43e
|
|||
|
8555b67a38
|
|||
|
629e95ac30
|
|||
|
2153a24c86
|
|||
|
1ddb3954f3
|
|||
|
3319c9b21b
|
|||
|
8966fb1fa2
|
|||
|
3b24ef3e78
|
|||
|
dbeb060d52
|
|||
|
2155a287a5
|
|||
|
cb57b406c1
|
|||
|
e91833ebbb
|
|||
|
2e1577eb5a
|
|||
|
7cb722c396
|
|||
|
9dcaddb2db
|
|||
|
5766cf9f62
|
|||
|
a12d5ea3c9
|
|||
|
25bbf45cbb
|
|||
|
3fd13b855d
|
|||
|
d9c0b8bb54
|
|||
|
400ea31477
|
|||
|
b1cab0ddfc
|
|||
|
7cba915c5e
|
|||
|
dfd7d678e7
|
|||
|
4071f6d650
|
|||
|
d045b33afd
|
|||
|
4f74b34b9a
|
|||
|
b7417614b3
|
|||
|
72b1584d51
|
|||
|
4b289e4ddd
|
|||
|
07844cc9c5
|
|||
|
1601b96800
|
|||
|
7db66067f4
|
|||
|
f44d8652b4
|
|||
|
8fad6d8c4e
|
|||
|
d11b656b23
|
|||
|
cf1428d678
|
|||
|
030937b196
|
|||
|
f77281fd3d
|
|||
|
791a8b3fdb
|
|||
|
7311bbc04a
|
|||
|
696a2d56f2
|
|||
|
5680b9c7c9
|
|||
|
1d31784ff8
|
|||
|
10e58eced9
|
|||
|
0937fcf163
|
|||
|
506f39d606
|
|||
|
7bb7c6a40e
|
|||
|
3403f7a8c9
|
|||
|
3e5c57766b
|
|||
|
24c6219189
|
|||
|
ea21d5aa77
|
|||
|
ee84770397
|
|||
|
7ccb660299
|
|||
|
0793c4614b
|
|||
|
bf02dc5a57
|
|||
|
7938871556
|
|||
|
39f6893741
|
|||
|
cd4fd702fc
|
|||
|
038c3a9614
|
|||
|
6e966f9e0d
|
|||
|
b778b3d31e
|
|||
|
526a079368
|
|||
|
7a7b884af2
|
|||
|
6ab5e44112
|
|||
|
7c92515723
|
|||
|
8091d30602
|
|||
|
e7ff330625
|
|||
|
aadda9b873
|
|||
|
8a84237f13
|
|||
|
e3a118e578
|
|||
|
e17eee9bf3
|
|||
|
4229924f61
|
|||
|
a2a48f6ed9
|
|||
|
c7785b6488
|
|||
|
af03a53af5
|
|||
|
c1c2212b53
|
|||
|
17560f0d34
|
|||
|
6ab314f603
|
|||
|
64ac09becf
|
|||
|
a0bbb7cd4c
|
|||
|
4bd478e85c
|
|||
|
f7a88791e8
|
|||
|
9f8b3d65fb
|
|||
|
1a1f9e136f
|
|||
|
48f69b766d
|
|||
|
d619881b8e
|
|||
|
dccdb7b744
|
|||
|
f240423822
|
|||
|
1492778b14
|
|||
|
08af650d6c
|
|||
|
c44be48eb9
|
|||
|
b16d16c9c9
|
|||
|
e51d569d79
|
|||
|
363c9e6f1b
|
|||
|
f813fe9eeb
|
|||
|
ef0ee65160
|
|||
|
b3bfa16b93
|
|||
|
aa9b5c874d
|
|||
|
e3546425eb
|
|||
|
5646aa07ea
|
|||
|
7cdf7e3806
|
|||
|
fe9c1e17be
|
|||
|
63324def62
|
|||
|
ff72ae2395
|
|||
|
1a3134083b
|
|||
|
bd64f437cd
|
|||
|
5606706dc8
|
|||
|
79a9e4063d
|
|||
|
c33c85455f
|
|||
|
5af2bb1113
|
|||
|
2c1297ebec
|
|||
|
df7f11e769
|
|||
|
75c7acb745
|
|||
|
c7f6783fa2
|
|||
|
4c6406ef8f
|
|||
|
3ddf51924b
|
|||
|
3826f29019
|
|||
|
4036c739a3
|
|||
|
b7379e2fd4
|
|||
|
c9895f6d1a
|
|||
|
71c4241a8a
|
|||
|
ffed9a67f3
|
|||
|
a8d04b225b
|
|||
|
6abf0e0717
|
|||
|
8e7fc8b4ef
|
|||
|
b2c28d10f1
|
|||
|
a335997196
|
|||
|
590f8f76cb
|
|||
|
1532d74a20
|
|||
|
f5ce355747
|
|||
|
494b4bbbc2
|
|||
|
375e1894f3
|
|||
|
bbb0d9bb73
|
|||
|
0356f3c54d
|
|||
|
0c25d16e42
|
|||
|
4c3709113f
|
|||
|
12db8370a3
|
|||
|
64be6eddf4
|
|||
|
0980547848
|
|||
|
2bb1ffa581
|
|||
|
51702a544b
|
|||
|
d9375405a5
|
|||
|
83063f594a
|
|||
|
b40349206d
|
|||
|
8dbac23944
|
|||
|
9fb86d3839
|
|||
|
bb2bda1379
|
|||
|
e9f72efb01
|
|||
|
ab36a43892
|
|||
|
2ffb769a6f
|
|||
|
045c810abc
|
|||
|
4c55520ce0
|
|||
|
04079223c2
|
|||
|
1bb902d96a
|
|||
|
b5f3f54c8b
|
|||
|
3bcb60a09a
|
|||
|
ba78ed0883
|
|||
|
d0f26c0182
|
|||
|
91275a2835
|
|||
|
ccbc9cf859
|
|||
|
97975f1e08
|
|||
|
ff48891a5a
|
|||
|
273fac7a0a
|
|||
|
12436c2f9c
|
|||
|
2315d6ab9f
|
|||
|
6eaeee9a67
|
|||
|
9dd49a2f43
|
|||
|
2bc2816191
|
|||
|
1a2e160279
|
|||
|
4111b5228d
|
|||
|
82b480c47e
|
|||
|
0d246a8e74
|
|||
|
fc3a8134ca
|
|||
|
2f91457e52
|
|||
|
77774120bf
|
|||
|
936fa27aa7
|
|||
|
b90ce423d7
|
|||
|
5f038965a2
|
|||
|
229fa0bf65
|
|||
|
1aec76c3dd
|
|||
|
96b06d5d4f
|
|||
|
747d310375
|
|||
|
fc78a095a9
|
|||
|
65ca80f9a0
|
|||
|
8e6eb5cc4a
|
|||
|
1326f1b3ab
|
|||
|
0011538966
|
|||
|
5acdacf132
|
|||
|
cef836da43
|
|||
|
26c9a72def
|
|||
|
f5124bdda2
|
|||
|
f7f57e711c
|
|||
|
76f633afce
|
|||
|
24df910ffa
|
|||
|
eb46692b25
|
|||
|
c54a32ccfc
|
|||
|
c0c7668cc4
|
|||
|
e860bbe0ee
|
|||
|
df3cc51e17
|
|||
|
027aeab969
|
|||
|
449bcc2561
|
|||
|
8da0bef481
|
|||
|
ae8afa20f8
|
|||
|
da1d28d292
|
|||
|
7e0ea501fc
|
|||
|
bb976fed4f
|
|||
|
df77b16640
|
|||
|
74ae85338c
|
|||
|
72b7198f1d
|
|||
|
b24dfb5b6a
|
|||
|
85f8e1cc5f
|
|||
|
74bd64c949
|
|||
|
1afa68064e
|
|||
|
5251c7ef6d
|
|||
|
63ab25ca91
|
|||
|
628f845e77
|
|||
|
8ef5ee7d8f
|
|||
|
fb3ea1b876
|
|||
|
3f3e1b708d
|
|||
|
bc4faead74
|
|||
|
6ffa2a3277
|
|||
|
c3c01b5559
|
|||
|
47d59ec0f5
|
|||
|
e7f72cd87a
|
|||
|
6d15d02f16
|
|||
|
3137cc4657
|
|||
|
18e700d6a4
|
|||
|
2420cd9a23
|
|||
|
65d6eb041a
|
|||
|
103808f079
|
|||
|
cd29e0de6c
|
|||
|
bd480be382
|
|||
|
293f6f5cc4
|
|||
|
e9b893eb3e
|
|||
|
51694a395d
|
|||
|
32166061ef
|
|||
|
a8845a9ef3
|
|||
|
e873cfe3bf
|
|||
|
43718eaefc
|
|||
|
5f9451f5b4
|
|||
|
2c3ef5c360
|
|||
|
4ba2245876
|
|||
|
c117fa41f6
|
|||
|
2b078453b2
|
|||
|
0ee1883ccb
|
|||
|
8912758b5e
|
|||
|
35d5249843
|
|||
|
62bfb367bc
|
|||
|
6f91361966
|
|||
|
d4036095f1
|
|||
|
6620b39357
|
|||
|
dcaa89171a
|
|||
|
1889dc6e19
|
|||
|
615ad58bc6
|
|||
|
6176777d0f
|
|||
|
a339574f05
|
|||
|
67b4fac64c
|
|||
|
ada6653bd1
|
|||
|
df8dbfc5e1
|
|||
|
e8d3fc4d17
|
|||
|
649351f68b
|
|||
|
3487f79b5e
|
|||
|
3a3f572ead
|
|||
|
d7f15fc3ab
|
|||
|
e43f5cd427
|
|||
|
1b79138923
|
|||
|
dda9736f8d
|
|||
|
9493cb48a5
|
|||
|
6c2453896f
|
|||
|
40594dea7e
|
|||
|
a06bf42887
|
|||
|
6713007650
|
|||
|
e7724c2cc0
|
|||
|
3358fe019d
|
|||
|
9efc54857f
|
|||
|
fcdf47984c
|
|||
|
040a5451a1
|
|||
|
8d76e97449
|
|||
|
a86a8013e6
|
|||
|
4c557a0325
|
|||
|
8540a22846
|
|||
|
36a224bd45
|
|||
|
3af3ebb52b
|
|||
|
614adb9892
|
|||
|
f434f13a19
|
|||
|
625a75f8f9
|
|||
|
d600c5e456
|
|||
|
e23e22a736
|
|||
|
ba0fe35e72
|
|||
|
6e9fe0e69e
|
|||
|
cba955c427
|
|||
|
192173ea64
|
|||
|
c33180abd7
|
|||
|
680454e76e
|
|||
|
34bfc20d28
|
|||
|
489159b424
|
|||
|
2bb754b245
|
|||
|
8d8c0892a2
|
|||
|
e12851e9ca
|
|||
|
f8969bea8d
|
|||
|
ceaa17f018
|
|||
|
9aa7ceb138
|
|||
|
72b1ebc2eb
|
|||
|
48a0351862
|
|||
|
4b95f9910c
|
|||
|
80424a867d
|
|||
|
4afd769602
|
|||
|
b47a5f9063
|
@@ -143,6 +143,13 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
|
|||||||
# Enable direct Spotify API access (default: false)
|
# Enable direct Spotify API access (default: false)
|
||||||
SPOTIFY_API_ENABLED=false
|
SPOTIFY_API_ENABLED=false
|
||||||
|
|
||||||
|
# Spotify Client ID from https://developer.spotify.com/dashboard
|
||||||
|
# Create an app in the Spotify Developer Dashboard to get this
|
||||||
|
SPOTIFY_API_CLIENT_ID=
|
||||||
|
|
||||||
|
# Spotify Client Secret (optional - only needed for certain OAuth flows)
|
||||||
|
SPOTIFY_API_CLIENT_SECRET=
|
||||||
|
|
||||||
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
|
||||||
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
|
||||||
# via session cookie because they're not accessible through the official API.
|
# via session cookie because they're not accessible through the official API.
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
using allstarr.Services.Common;
|
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
|
||||||
|
|
||||||
public class FuzzyMatcherTests
|
|
||||||
{
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Mr. Brightside", "Mr. Brightside", 100)]
|
|
||||||
[InlineData("Mr Brightside", "Mr. Brightside", 100)]
|
|
||||||
[InlineData("Mr. Brightside", "Mr Brightside", 100)]
|
|
||||||
[InlineData("The Killers", "Killers", 85)]
|
|
||||||
[InlineData("Dua Lipa", "Dua-Lipa", 100)]
|
|
||||||
public void CalculateSimilarity_ExactAndNearMatches_ReturnsHighScore(string str1, string str2, int expectedMin)
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= expectedMin, $"Expected score >= {expectedMin}, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("Mr. Brightside", "Somebody Told Me", 20)]
|
|
||||||
[InlineData("The Killers", "The Beatles", 40)]
|
|
||||||
[InlineData("Hot Fuss", "Sam's Town", 20)]
|
|
||||||
public void CalculateSimilarity_DifferentStrings_ReturnsLowScore(string str1, string str2, int expectedMax)
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score <= expectedMax, $"Expected score <= {expectedMax}, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_IgnoresPunctuation()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Don't Stop Believin'";
|
|
||||||
var str2 = "Dont Stop Believin";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 95, $"Expected high score for punctuation differences, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_IgnoresCase()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Mr. Brightside";
|
|
||||||
var str2 = "mr. brightside";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(100, score);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_HandlesArticles()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "The Killers";
|
|
||||||
var str2 = "Killers";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 80, $"Expected high score when 'The' is removed, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_HandlesFeaturedArtists()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Song Title (feat. Artist)";
|
|
||||||
var str2 = "Song Title";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 70, $"Expected decent score for featured artist variations, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_HandlesRemixes()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Song Title - Radio Edit";
|
|
||||||
var str2 = "Song Title";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 70, $"Expected decent score for remix/edit variations, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("", "", 0)]
|
|
||||||
[InlineData("Test", "", 0)]
|
|
||||||
[InlineData("", "Test", 0)]
|
|
||||||
public void CalculateSimilarity_EmptyStrings_ReturnsZero(string str1, string str2, int expected)
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(expected, score);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_TokenOrder_DoesNotMatter()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Bright Side Mr";
|
|
||||||
var str2 = "Mr Bright Side";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 90, $"Expected high score regardless of token order, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_PartialTokenMatch_ReturnsModerateScore()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Mr. Brightside";
|
|
||||||
var str2 = "Mr. Brightside (Live)";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 70 && score < 100, $"Expected moderate score for partial match, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CalculateSimilarity_SpecialCharacters_AreNormalized()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var str1 = "Café del Mar";
|
|
||||||
var str2 = "Cafe del Mar";
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var score = FuzzyMatcher.CalculateSimilarity(str1, str2);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.True(score >= 90, $"Expected high score for accented characters, got {score}");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using allstarr.Services.Lyrics;
|
|
||||||
using allstarr.Services.Common;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using allstarr.Models.Settings;
|
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
|
||||||
|
|
||||||
public class LrclibServiceTests
|
|
||||||
{
|
|
||||||
private readonly Mock<ILogger<LrclibService>> _mockLogger;
|
|
||||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
|
||||||
private readonly Mock<RedisCacheService> _mockCache;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
|
|
||||||
public LrclibServiceTests()
|
|
||||||
{
|
|
||||||
_mockLogger = new Mock<ILogger<LrclibService>>();
|
|
||||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
|
||||||
|
|
||||||
// Create mock Redis cache
|
|
||||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
|
||||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
|
||||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
|
||||||
|
|
||||||
_httpClient = new HttpClient
|
|
||||||
{
|
|
||||||
BaseAddress = new Uri("https://lrclib.net")
|
|
||||||
};
|
|
||||||
|
|
||||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(_httpClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_InitializesWithDependencies()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLyricsAsync_RequiresValidParameters()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act & Assert - Should handle empty parameters gracefully
|
|
||||||
var result = service.GetLyricsAsync("", "Artist", "Album", 180);
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLyricsAsync_SupportsMultipleArtists()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
|
||||||
var artists = new[] { "Artist 1", "Artist 2", "Artist 3" };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetLyricsAsync("Track Name", artists, "Album", 180);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLyricsByIdAsync_AcceptsValidId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetLyricsByIdAsync(123456);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetLyricsCachedAsync_UsesCache()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetLyricsCachedAsync("Track", "Artist", "Album", 180);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using allstarr.Services.Common;
|
|
||||||
using allstarr.Models.Settings;
|
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
|
||||||
|
|
||||||
public class RedisCacheServiceTests
|
|
||||||
{
|
|
||||||
private readonly Mock<ILogger<RedisCacheService>> _mockLogger;
|
|
||||||
private readonly IOptions<RedisSettings> _settings;
|
|
||||||
|
|
||||||
public RedisCacheServiceTests()
|
|
||||||
{
|
|
||||||
_mockLogger = new Mock<ILogger<RedisCacheService>>();
|
|
||||||
_settings = Options.Create(new RedisSettings
|
|
||||||
{
|
|
||||||
Enabled = false, // Disabled for unit tests to avoid requiring actual Redis
|
|
||||||
ConnectionString = "localhost:6379"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_InitializesWithSettings()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
Assert.False(service.IsEnabled); // Should be disabled in tests
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_WithEnabledSettings_AttemptsConnection()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var enabledSettings = Options.Create(new RedisSettings
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
ConnectionString = "localhost:6379"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act - Constructor will try to connect but should handle failure gracefully
|
|
||||||
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Assert - Service should be created even if connection fails
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.GetStringAsync("test:key");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Null(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.GetAsync<TestObject>("test:key");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Null(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.SetStringAsync("test:key", "test value");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.SetAsync("test:key", testObj);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.DeleteAsync("test:key");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.ExistsAsync("test:key");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.DeleteByPatternAsync("test:*");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Equal(0, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
var expiry = TimeSpan.FromHours(1);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.SetStringAsync("test:key", "value", expiry);
|
|
||||||
|
|
||||||
// Assert - Should return false when disabled, but not throw
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
|
||||||
var expiry = TimeSpan.FromDays(30);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.SetAsync("test:key", testObj, expiry);
|
|
||||||
|
|
||||||
// Assert - Should return false when disabled, but not throw
|
|
||||||
Assert.False(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void IsEnabled_ReflectsSettings()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
var enabledSettings = Options.Create(new RedisSettings
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
ConnectionString = "localhost:6379"
|
|
||||||
});
|
|
||||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(disabledService.IsEnabled);
|
|
||||||
// enabledService.IsEnabled may be false if connection fails, which is expected
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetAsync_DeserializesComplexObjects()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.Null(result); // Null when disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task SetAsync_SerializesComplexObjects()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
|
||||||
var complexObj = new ComplexTestObject
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
Name = "Test",
|
|
||||||
Items = new System.Collections.Generic.List<string> { "Item1", "Item2" },
|
|
||||||
Metadata = new System.Collections.Generic.Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ "Key1", "Value1" },
|
|
||||||
{ "Key2", "Value2" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1));
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.False(result); // False when disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ConnectionString_IsConfigurable()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var customSettings = Options.Create(new RedisSettings
|
|
||||||
{
|
|
||||||
Enabled = false,
|
|
||||||
ConnectionString = "redis-server:6380,password=secret,ssl=true"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TestObject
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ComplexTestObject
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public System.Collections.Generic.List<string> Items { get; set; } = new();
|
|
||||||
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; } = new();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using allstarr.Services.Spotify;
|
|
||||||
using allstarr.Models.Settings;
|
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
|
||||||
|
|
||||||
public class SpotifyApiClientTests
|
|
||||||
{
|
|
||||||
private readonly Mock<ILogger<SpotifyApiClient>> _mockLogger;
|
|
||||||
private readonly IOptions<SpotifyApiSettings> _settings;
|
|
||||||
|
|
||||||
public SpotifyApiClientTests()
|
|
||||||
{
|
|
||||||
_mockLogger = new Mock<ILogger<SpotifyApiClient>>();
|
|
||||||
_settings = Options.Create(new SpotifyApiSettings
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SessionCookie = "test_session_cookie_value",
|
|
||||||
CacheDurationMinutes = 60,
|
|
||||||
RateLimitDelayMs = 100,
|
|
||||||
PreferIsrcMatching = true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_InitializesWithSettings()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Settings_AreConfiguredCorrectly()
|
|
||||||
{
|
|
||||||
// Arrange & Act
|
|
||||||
var client = new SpotifyApiClient(_mockLogger.Object, _settings);
|
|
||||||
|
|
||||||
// Assert - Constructor should not throw
|
|
||||||
Assert.NotNull(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SessionCookie_IsRequired_ForWebApiAccess()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var settingsWithoutCookie = Options.Create(new SpotifyApiSettings
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SessionCookie = "" // Empty cookie
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var client = new SpotifyApiClient(_mockLogger.Object, settingsWithoutCookie);
|
|
||||||
|
|
||||||
// Assert - Constructor should not throw, but GetWebAccessTokenAsync will return null
|
|
||||||
Assert.NotNull(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void RateLimitSettings_AreRespected()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var customSettings = Options.Create(new SpotifyApiSettings
|
|
||||||
{
|
|
||||||
Enabled = true,
|
|
||||||
SessionCookie = "test_cookie",
|
|
||||||
RateLimitDelayMs = 500
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var client = new SpotifyApiClient(_mockLogger.Object, customSettings);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(client);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
using Xunit;
|
|
||||||
using Moq;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using allstarr.Services.SquidWTF;
|
|
||||||
using allstarr.Services.Common;
|
|
||||||
using allstarr.Models.Settings;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
|
||||||
|
|
||||||
public class SquidWTFMetadataServiceTests
|
|
||||||
{
|
|
||||||
private readonly Mock<ILogger<SquidWTFMetadataService>> _mockLogger;
|
|
||||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
|
||||||
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
|
||||||
private readonly IOptions<SquidWTFSettings> _squidwtfSettings;
|
|
||||||
private readonly Mock<RedisCacheService> _mockCache;
|
|
||||||
private readonly List<string> _apiUrls;
|
|
||||||
|
|
||||||
public SquidWTFMetadataServiceTests()
|
|
||||||
{
|
|
||||||
_mockLogger = new Mock<ILogger<SquidWTFMetadataService>>();
|
|
||||||
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
|
||||||
|
|
||||||
_subsonicSettings = Options.Create(new SubsonicSettings
|
|
||||||
{
|
|
||||||
ExplicitFilter = ExplicitFilter.All
|
|
||||||
});
|
|
||||||
|
|
||||||
_squidwtfSettings = Options.Create(new SquidWTFSettings
|
|
||||||
{
|
|
||||||
Quality = "FLAC"
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create mock Redis cache
|
|
||||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
|
||||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
|
||||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
|
||||||
|
|
||||||
_apiUrls = new List<string>
|
|
||||||
{
|
|
||||||
"https://squid.wtf",
|
|
||||||
"https://mirror1.squid.wtf",
|
|
||||||
"https://mirror2.squid.wtf"
|
|
||||||
};
|
|
||||||
|
|
||||||
var httpClient = new System.Net.Http.HttpClient();
|
|
||||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_InitializesWithDependencies()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Constructor_AcceptsOptionalGenreEnrichment()
|
|
||||||
{
|
|
||||||
// Arrange - GenreEnrichmentService is optional, just pass null
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls,
|
|
||||||
null); // GenreEnrichmentService is optional
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SearchSongsAsync_AcceptsQueryAndLimit()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.SearchSongsAsync("Mr. Brightside", 20);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SearchAlbumsAsync_AcceptsQueryAndLimit()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.SearchAlbumsAsync("Hot Fuss", 20);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SearchArtistsAsync_AcceptsQueryAndLimit()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.SearchArtistsAsync("The Killers", 20);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SearchPlaylistsAsync_AcceptsQueryAndLimit()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.SearchPlaylistsAsync("Rock Classics", 20);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetSongAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetSongAsync("squidwtf", "123456");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetAlbumAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetAlbumAsync("squidwtf", "789012");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetArtistAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetArtistAsync("squidwtf", "345678");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetArtistAlbumsAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetArtistAlbumsAsync("squidwtf", "345678");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetPlaylistAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetPlaylistAsync("squidwtf", "playlist123");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetPlaylistTracksAsync_RequiresProviderAndId()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123");
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SearchAllAsync_CombinesAllSearchTypes()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = service.SearchAllAsync("The Killers", 20, 20, 20);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ExplicitFilter_RespectsSettings()
|
|
||||||
{
|
|
||||||
// Arrange - Test with CleanOnly filter
|
|
||||||
var cleanOnlySettings = Options.Create(new SubsonicSettings
|
|
||||||
{
|
|
||||||
ExplicitFilter = ExplicitFilter.CleanOnly
|
|
||||||
});
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
cleanOnlySettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
_apiUrls);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MultipleApiUrls_EnablesRoundRobinFallback()
|
|
||||||
{
|
|
||||||
// Arrange
|
|
||||||
var multipleUrls = new List<string>
|
|
||||||
{
|
|
||||||
"https://primary.squid.wtf",
|
|
||||||
"https://backup1.squid.wtf",
|
|
||||||
"https://backup2.squid.wtf",
|
|
||||||
"https://backup3.squid.wtf"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var service = new SquidWTFMetadataService(
|
|
||||||
_mockHttpClientFactory.Object,
|
|
||||||
_subsonicSettings,
|
|
||||||
_squidwtfSettings,
|
|
||||||
_mockLogger.Object,
|
|
||||||
_mockCache.Object,
|
|
||||||
multipleUrls);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Assert.NotNull(service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1528,12 +1528,6 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
|
||||||
|
|
||||||
// Invalidate playlist summary cache if playlists were updated
|
|
||||||
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
|
|
||||||
{
|
|
||||||
InvalidatePlaylistSummaryCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = "Configuration updated. Restart container to apply changes.",
|
message = "Configuration updated. Restart container to apply changes.",
|
||||||
@@ -1945,6 +1939,12 @@ public class AdminController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||||
|
if (string.IsNullOrEmpty(token))
|
||||||
|
{
|
||||||
|
return StatusCode(401, new { error = "Failed to authenticate with Spotify. Check your sp_dc cookie." });
|
||||||
|
}
|
||||||
|
|
||||||
// Get list of already-configured Spotify playlist IDs
|
// Get list of already-configured Spotify playlist IDs
|
||||||
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
var configuredPlaylists = await ReadPlaylistsFromEnvFile();
|
||||||
var linkedSpotifyIds = new HashSet<string>(
|
var linkedSpotifyIds = new HashSet<string>(
|
||||||
@@ -1952,24 +1952,82 @@ public class AdminController : ControllerBase
|
|||||||
StringComparer.OrdinalIgnoreCase
|
StringComparer.OrdinalIgnoreCase
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API
|
var playlists = new List<object>();
|
||||||
var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null);
|
var offset = 0;
|
||||||
|
const int limit = 50;
|
||||||
|
|
||||||
if (spotifyPlaylists == null || spotifyPlaylists.Count == 0)
|
while (true)
|
||||||
{
|
{
|
||||||
return Ok(new { playlists = new List<object>() });
|
var url = $"https://api.spotify.com/v1/me/playlists?offset={offset}&limit={limit}";
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var response = await _jellyfinHttpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlists");
|
||||||
|
return StatusCode(429, new { error = "Spotify rate limit exceeded. Please wait a moment and try again." });
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Failed to fetch Spotify playlists: {StatusCode}", response.StatusCode);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
var id = item.TryGetProperty("id", out var itemId) ? itemId.GetString() : null;
|
||||||
|
var name = item.TryGetProperty("name", out var n) ? n.GetString() : null;
|
||||||
|
var trackCount = 0;
|
||||||
|
|
||||||
|
if (item.TryGetProperty("tracks", out var tracks) &&
|
||||||
|
tracks.TryGetProperty("total", out var total))
|
||||||
|
{
|
||||||
|
trackCount = total.GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
var owner = "";
|
||||||
|
if (item.TryGetProperty("owner", out var ownerObj) &&
|
||||||
|
ownerObj.TryGetProperty("display_name", out var displayName))
|
||||||
|
{
|
||||||
|
owner = displayName.GetString() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPublic = item.TryGetProperty("public", out var pub) && pub.GetBoolean();
|
||||||
|
|
||||||
|
// Check if this playlist is already linked
|
||||||
|
var isLinked = !string.IsNullOrEmpty(id) && linkedSpotifyIds.Contains(id);
|
||||||
|
|
||||||
|
playlists.Add(new
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
trackCount,
|
||||||
|
owner,
|
||||||
|
isPublic,
|
||||||
|
isLinked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.GetArrayLength() < limit) break;
|
||||||
|
offset += limit;
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
if (_spotifyApiSettings.RateLimitDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(_spotifyApiSettings.RateLimitDelayMs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlists = spotifyPlaylists.Select(p => new
|
|
||||||
{
|
|
||||||
id = p.SpotifyId,
|
|
||||||
name = p.Name,
|
|
||||||
trackCount = p.TotalTracks,
|
|
||||||
owner = p.OwnerName ?? "",
|
|
||||||
isPublic = p.Public,
|
|
||||||
isLinked = linkedSpotifyIds.Contains(p.SpotifyId)
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return Ok(new { playlists });
|
return Ok(new { playlists });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2050,16 +2108,11 @@ public class AdminController : ControllerBase
|
|||||||
trackStats = await GetPlaylistTrackStats(id!);
|
trackStats = await GetPlaylistTrackStats(id!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use actual track stats for configured playlists, otherwise use Jellyfin's count
|
|
||||||
var actualTrackCount = isConfigured
|
|
||||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
|
||||||
: childCount;
|
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
trackCount = actualTrackCount,
|
trackCount = childCount,
|
||||||
linkedSpotifyId,
|
linkedSpotifyId,
|
||||||
isConfigured,
|
isConfigured,
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly LyricsPlusService? _lyricsPlusService;
|
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
|
||||||
private readonly OdesliService _odesliService;
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
@@ -66,9 +64,7 @@ public class JellyfinController : ControllerBase
|
|||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null,
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
LyricsPlusService? lyricsPlusService = null,
|
LrclibService? lrclibService = null)
|
||||||
LrclibService? lrclibService = null,
|
|
||||||
LyricsOrchestrator? lyricsOrchestrator = null)
|
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
@@ -84,9 +80,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_lyricsPlusService = lyricsPlusService;
|
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
_lyricsOrchestrator = lyricsOrchestrator;
|
|
||||||
_odesliService = odesliService;
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
@@ -285,50 +279,53 @@ public class JellyfinController : ControllerBase
|
|||||||
// Parse Jellyfin results into domain models
|
// Parse Jellyfin results into domain models
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Sort all results by match score (local tracks get +10 boost)
|
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||||
// This ensures best matches appear first regardless of source
|
// Just interleave local and external results based on which source has better overall match
|
||||||
var allSongs = localSongs.Concat(externalResult.Songs)
|
|
||||||
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
|
// Calculate average match score for each source to determine which should come first
|
||||||
.OrderByDescending(x => x.Score)
|
var localSongsAvgScore = localSongs.Any()
|
||||||
.Select(x => x.Song)
|
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
.ToList();
|
: 0.0;
|
||||||
|
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||||
|
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
var allAlbums = localAlbums.Concat(externalResult.Albums)
|
var localAlbumsAvgScore = localAlbums.Any()
|
||||||
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
|
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
.OrderByDescending(x => x.Score)
|
: 0.0;
|
||||||
.Select(x => x.Album)
|
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||||
.ToList();
|
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
var allArtists = localArtists.Concat(externalResult.Artists)
|
var localArtistsAvgScore = localArtists.Any()
|
||||||
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
|
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
.OrderByDescending(x => x.Score)
|
: 0.0;
|
||||||
.Select(x => x.Artist)
|
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||||
.ToList();
|
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
// Log top results for debugging
|
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||||
|
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||||
|
? localSongs.Concat(externalResult.Songs).ToList()
|
||||||
|
: externalResult.Songs.Concat(localSongs).ToList();
|
||||||
|
|
||||||
|
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||||
|
? localAlbums.Concat(externalResult.Albums).ToList()
|
||||||
|
: externalResult.Albums.Concat(localAlbums).ToList();
|
||||||
|
|
||||||
|
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
||||||
|
? localArtists.Concat(externalResult.Artists).ToList()
|
||||||
|
: externalResult.Artists.Concat(localArtists).ToList();
|
||||||
|
|
||||||
|
// Log results for debugging
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
if (allSongs.Any())
|
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
{
|
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||||
var topSong = allSongs.First();
|
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
|
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||||
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
|
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
topSong.Title, topSong.IsLocal, topScore);
|
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||||
}
|
|
||||||
if (allAlbums.Any())
|
|
||||||
{
|
|
||||||
var topAlbum = allAlbums.First();
|
|
||||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
|
|
||||||
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
|
|
||||||
topAlbum.Title, topAlbum.IsLocal, topScore);
|
|
||||||
}
|
|
||||||
if (allArtists.Any())
|
|
||||||
{
|
|
||||||
var topArtist = allArtists.First();
|
|
||||||
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
|
|
||||||
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
|
|
||||||
topArtist.Name, topArtist.IsLocal, topScore);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
@@ -346,7 +343,7 @@ public class JellyfinController : ControllerBase
|
|||||||
mergedAlbums.AddRange(playlistItems);
|
mergedAlbums.AddRange(playlistItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
@@ -1277,53 +1274,50 @@ public class JellyfinController : ControllerBase
|
|||||||
searchArtists.Add(searchArtist);
|
searchArtists.Add(searchArtist);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use orchestrator for clean, modular lyrics fetching
|
|
||||||
LyricsInfo? lyrics = null;
|
LyricsInfo? lyrics = null;
|
||||||
|
|
||||||
if (_lyricsOrchestrator != null)
|
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
||||||
|
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
||||||
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
|
||||||
trackName: searchTitle,
|
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
||||||
artistNames: searchArtists.ToArray(),
|
|
||||||
albumName: searchAlbum,
|
|
||||||
durationSeconds: song.Duration ?? 0,
|
|
||||||
spotifyTrackId: spotifyTrackId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fallback to manual fetching if orchestrator not available
|
|
||||||
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
|
|
||||||
|
|
||||||
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
|
// Spotify track IDs are 22 characters, base62 encoded
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
||||||
{
|
{
|
||||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
|
||||||
|
cleanSpotifyId, searchArtist, searchTitle);
|
||||||
|
|
||||||
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
|
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
||||||
|
|
||||||
|
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||||
{
|
{
|
||||||
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||||
|
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||||
{
|
}
|
||||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
else
|
||||||
}
|
{
|
||||||
|
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
// Fall back to LyricsPlus
|
|
||||||
if (lyrics == null && _lyricsPlusService != null)
|
|
||||||
{
|
{
|
||||||
lyrics = await _lyricsPlusService.GetLyricsAsync(
|
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
|
||||||
searchTitle,
|
|
||||||
searchArtists.ToArray(),
|
|
||||||
searchAlbum,
|
|
||||||
song.Duration ?? 0);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Fall back to LRCLIB
|
|
||||||
if (lyrics == null && _lrclibService != null)
|
// Fall back to LRCLIB if no Spotify lyrics
|
||||||
|
if (lyrics == null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||||
|
string.Join(", ", searchArtists),
|
||||||
|
searchTitle);
|
||||||
|
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||||
|
if (lrclibService != null)
|
||||||
{
|
{
|
||||||
lyrics = await _lrclibService.GetLyricsAsync(
|
lyrics = await lrclibService.GetLyricsAsync(
|
||||||
searchTitle,
|
searchTitle,
|
||||||
searchArtists.ToArray(),
|
searchArtists.ToArray(),
|
||||||
searchAlbum,
|
searchAlbum,
|
||||||
@@ -1504,21 +1498,6 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
|
||||||
|
|
||||||
// Use orchestrator for prefetching
|
|
||||||
if (_lyricsOrchestrator != null)
|
|
||||||
{
|
|
||||||
await _lyricsOrchestrator.PrefetchLyricsAsync(
|
|
||||||
trackName: searchTitle,
|
|
||||||
artistNames: searchArtists.ToArray(),
|
|
||||||
albumName: searchAlbum,
|
|
||||||
durationSeconds: song.Duration ?? 0,
|
|
||||||
spotifyTrackId: spotifyTrackId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to manual prefetching if orchestrator not available
|
|
||||||
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
|
|
||||||
|
|
||||||
// Try Spotify lyrics if we have a valid Spotify track ID
|
// Try Spotify lyrics if we have a valid Spotify track ID
|
||||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
@@ -1537,22 +1516,6 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to LyricsPlus
|
|
||||||
if (_lyricsPlusService != null)
|
|
||||||
{
|
|
||||||
var lyrics = await _lyricsPlusService.GetLyricsAsync(
|
|
||||||
searchTitle,
|
|
||||||
searchArtists.ToArray(),
|
|
||||||
searchAlbum,
|
|
||||||
song.Duration ?? 0);
|
|
||||||
|
|
||||||
if (lyrics != null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
|
|
||||||
return; // Success, lyrics are now cached
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to LRCLIB
|
// Fall back to LRCLIB
|
||||||
if (_lrclibService != null)
|
if (_lrclibService != null)
|
||||||
{
|
{
|
||||||
@@ -3566,17 +3529,8 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass through all requested fields from the original request
|
// Request MediaSources field to get bitrate info
|
||||||
var queryString = Request.QueryString.Value ?? "";
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
|
||||||
|
|
||||||
// Append the original query string (which includes Fields parameter)
|
|
||||||
if (!string.IsNullOrEmpty(queryString))
|
|
||||||
{
|
|
||||||
// Remove the leading ? if present
|
|
||||||
queryString = queryString.TrimStart('?');
|
|
||||||
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ public class SpotifyApiSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool Enabled { get; set; }
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify Client ID from https://developer.spotify.com/dashboard
|
||||||
|
/// Used for OAuth token refresh and API access.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify Client Secret from https://developer.spotify.com/dashboard
|
||||||
|
/// Optional - only needed for certain OAuth flows.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Spotify session cookie (sp_dc).
|
/// Spotify session cookie (sp_dc).
|
||||||
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.
|
||||||
|
|||||||
@@ -473,8 +473,7 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
|
||||||
sp.GetRequiredService<RedisCacheService>(),
|
sp.GetRequiredService<RedisCacheService>(),
|
||||||
squidWtfApiUrls,
|
squidWtfApiUrls));
|
||||||
sp.GetRequiredService<GenreEnrichmentService>()));
|
|
||||||
builder.Services.AddSingleton<IDownloadService>(sp =>
|
builder.Services.AddSingleton<IDownloadService>(sp =>
|
||||||
new SquidWTFDownloadService(
|
new SquidWTFDownloadService(
|
||||||
sp.GetRequiredService<IHttpClientFactory>(),
|
sp.GetRequiredService<IHttpClientFactory>(),
|
||||||
@@ -538,6 +537,18 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
|
||||||
|
if (!string.IsNullOrEmpty(clientId))
|
||||||
|
{
|
||||||
|
options.ClientId = clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
|
||||||
|
if (!string.IsNullOrEmpty(clientSecret))
|
||||||
|
{
|
||||||
|
options.ClientSecret = clientSecret;
|
||||||
|
}
|
||||||
|
|
||||||
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
|
||||||
if (!string.IsNullOrEmpty(sessionCookie))
|
if (!string.IsNullOrEmpty(sessionCookie))
|
||||||
{
|
{
|
||||||
@@ -565,6 +576,7 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
|
|||||||
// Log configuration (mask sensitive values)
|
// Log configuration (mask sensitive values)
|
||||||
Console.WriteLine($"SpotifyApi Configuration:");
|
Console.WriteLine($"SpotifyApi Configuration:");
|
||||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||||
|
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
|
||||||
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
|
||||||
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
|
||||||
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||||
@@ -575,12 +587,6 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
|
|||||||
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
|
||||||
|
|
||||||
// Register LyricsPlus service (multi-source lyrics API)
|
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
|
||||||
|
|
||||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
|
||||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
|
||||||
|
|
||||||
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
|
||||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ public class EndpointBenchmarkService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Benchmarks a list of endpoints by making test requests.
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
/// Returns endpoints sorted by average response time (fastest first).
|
/// Returns endpoints sorted by average response time (fastest first).
|
||||||
///
|
|
||||||
/// IMPORTANT: The testFunc should implement its own timeout to prevent slow endpoints
|
|
||||||
/// from blocking startup. Recommended: 5-10 second timeout per ping.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> BenchmarkEndpointsAsync(
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
List<string> endpoints,
|
List<string> endpoints,
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ public static class FuzzyMatcher
|
|||||||
/// Calculates similarity score following OPTIMAL ORDER:
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
/// 1. Strip decorators (already done by caller)
|
/// 1. Strip decorators (already done by caller)
|
||||||
/// 2. Substring matching (cheap, high-precision)
|
/// 2. Substring matching (cheap, high-precision)
|
||||||
/// 3. Token-based matching (handles word order)
|
/// 3. Levenshtein distance (expensive, fuzzy)
|
||||||
/// 4. Levenshtein distance (expensive, fuzzy)
|
|
||||||
/// Returns score 0-100.
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
@@ -104,71 +103,11 @@ public static class FuzzyMatcher
|
|||||||
return 85;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
||||||
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
// Only use this for candidates that survived substring checks
|
||||||
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
||||||
if (tokens1.Length > 0 && tokens2.Length > 0)
|
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
||||||
{
|
|
||||||
// Calculate how many tokens match (order-independent)
|
|
||||||
var matchedTokens = 0.0; // Use double for partial matches
|
|
||||||
var usedTokens = new HashSet<int>();
|
|
||||||
|
|
||||||
foreach (var token1 in tokens1)
|
|
||||||
{
|
|
||||||
for (int i = 0; i < tokens2.Length; i++)
|
|
||||||
{
|
|
||||||
if (usedTokens.Contains(i)) continue;
|
|
||||||
|
|
||||||
var token2 = tokens2[i];
|
|
||||||
|
|
||||||
// Exact token match
|
|
||||||
if (token1 == token2)
|
|
||||||
{
|
|
||||||
matchedTokens++;
|
|
||||||
usedTokens.Add(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Partial token match (one contains the other)
|
|
||||||
else if (token1.Contains(token2) || token2.Contains(token1))
|
|
||||||
{
|
|
||||||
matchedTokens += 0.8; // Partial credit
|
|
||||||
usedTokens.Add(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate token match percentage
|
|
||||||
var maxTokens = Math.Max(tokens1.Length, tokens2.Length);
|
|
||||||
var tokenMatchScore = (matchedTokens / maxTokens) * 100.0;
|
|
||||||
|
|
||||||
// If token match is very high (90%+), return it
|
|
||||||
if (tokenMatchScore >= 90)
|
|
||||||
{
|
|
||||||
return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If token match is decent (70%+), use it as a floor for Levenshtein
|
|
||||||
if (tokenMatchScore >= 70)
|
|
||||||
{
|
|
||||||
var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm);
|
|
||||||
return (int)Math.Max(tokenMatchScore, levenshteinScore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
|
||||||
return CalculateLevenshteinScore(queryNorm, targetNorm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Calculates similarity score based on Levenshtein distance.
|
|
||||||
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
|
||||||
/// </summary>
|
|
||||||
private static int CalculateLevenshteinScore(string str1, string str2)
|
|
||||||
{
|
|
||||||
var distance = LevenshteinDistance(str1, str2);
|
|
||||||
var maxLength = Math.Max(str1.Length, str2.Length);
|
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
@@ -178,9 +117,8 @@ public static class FuzzyMatcher
|
|||||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
|
|
||||||
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
||||||
// Using 75 instead of 80 to be slightly stricter
|
var score = (int)(normalizedSimilarity * 80);
|
||||||
var score = (int)(normalizedSimilarity * 75);
|
|
||||||
|
|
||||||
return Math.Max(0, score);
|
return Math.Max(0, score);
|
||||||
}
|
}
|
||||||
@@ -216,9 +154,7 @@ public static class FuzzyMatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string for matching by:
|
/// Normalizes a string for matching by:
|
||||||
/// - Converting to lowercase
|
/// - Converting to lowercase
|
||||||
/// - Removing accents/diacritics
|
/// - Normalizing apostrophes (', ', ') to standard '
|
||||||
/// - Converting hyphens/underscores to spaces (for word separation)
|
|
||||||
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
|
|
||||||
/// - Removing extra whitespace
|
/// - Removing extra whitespace
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NormalizeForMatching(string text)
|
private static string NormalizeForMatching(string text)
|
||||||
@@ -230,42 +166,18 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
var normalized = text.ToLowerInvariant().Trim();
|
var normalized = text.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
// Normalize different apostrophe types to standard apostrophe
|
||||||
normalized = RemoveDiacritics(normalized);
|
normalized = normalized
|
||||||
|
.Replace("\u2019", "'") // Right single quotation mark (')
|
||||||
// Replace hyphens and underscores with spaces (for word separation)
|
.Replace("\u2018", "'") // Left single quotation mark (')
|
||||||
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
.Replace("`", "'") // Grave accent
|
||||||
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
.Replace("\u00B4", "'"); // Acute accent (´)
|
||||||
|
|
||||||
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
|
||||||
|
|
||||||
// Normalize whitespace
|
// Normalize whitespace
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes diacritics (accents) from characters.
|
|
||||||
/// Example: é -> e, ñ -> n, ü -> u
|
|
||||||
/// </summary>
|
|
||||||
private static string RemoveDiacritics(string text)
|
|
||||||
{
|
|
||||||
var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD);
|
|
||||||
var stringBuilder = new System.Text.StringBuilder();
|
|
||||||
|
|
||||||
foreach (var c in normalizedString)
|
|
||||||
{
|
|
||||||
var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
|
|
||||||
if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark)
|
|
||||||
{
|
|
||||||
stringBuilder.Append(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates Levenshtein distance between two strings.
|
/// Calculates Levenshtein distance between two strings.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Common;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -16,17 +15,12 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
|
||||||
private const string BaseUrl = "https://api.deezer.com";
|
private const string BaseUrl = "https://api.deezer.com";
|
||||||
|
|
||||||
public DeezerMetadataService(
|
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
IOptions<SubsonicSettings> settings,
|
|
||||||
GenreEnrichmentService? genreEnrichment = null)
|
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_genreEnrichment = genreEnrichment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
@@ -209,23 +203,6 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing
|
|
||||||
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Silently ignore genre enrichment failures
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,11 +263,9 @@ public class JellyfinResponseBuilder
|
|||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
["PlaylistItemId"] = song.Id, // Required for playlist items
|
|
||||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
||||||
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
|
|
||||||
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||||
["ProductionYear"] = song.Year,
|
["ProductionYear"] = song.Year,
|
||||||
["IndexNumber"] = song.Track,
|
["IndexNumber"] = song.Track,
|
||||||
@@ -275,7 +273,6 @@ public class JellyfinResponseBuilder
|
|||||||
["IsFolder"] = false,
|
["IsFolder"] = false,
|
||||||
["Type"] = "Audio",
|
["Type"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
["ChannelId"] = (object?)null,
|
||||||
["ParentId"] = song.AlbumId,
|
|
||||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
? new[] { song.Genre }
|
? new[] { song.Genre }
|
||||||
: new string[0],
|
: new string[0],
|
||||||
@@ -289,9 +286,6 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: new Dictionary<string, object?>[0],
|
: new Dictionary<string, object?>[0],
|
||||||
["Tags"] = new string[0],
|
|
||||||
["People"] = new object[0],
|
|
||||||
["SortName"] = songTitle,
|
|
||||||
["ParentLogoItemId"] = song.AlbumId,
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
["ParentBackdropItemId"] = song.AlbumId,
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
["ParentBackdropImageTags"] = new string[0],
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
|
|||||||
@@ -85,10 +85,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
_logger.LogDebug("Session created for {DeviceId}", deviceId);
|
||||||
|
|
||||||
// Track this session
|
// Track this session
|
||||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
|
||||||
?? headers["X-Real-IP"].FirstOrDefault()
|
|
||||||
?? "Unknown";
|
|
||||||
|
|
||||||
_sessions[deviceId] = new SessionInfo
|
_sessions[deviceId] = new SessionInfo
|
||||||
{
|
{
|
||||||
DeviceId = deviceId,
|
DeviceId = deviceId,
|
||||||
@@ -96,8 +92,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Device = device,
|
Device = device,
|
||||||
Version = version,
|
Version = version,
|
||||||
LastActivity = DateTime.UtcNow,
|
LastActivity = DateTime.UtcNow,
|
||||||
Headers = CloneHeaders(headers),
|
Headers = CloneHeaders(headers)
|
||||||
ClientIp = clientIp
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||||
@@ -227,7 +222,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
Client = s.Client,
|
Client = s.Client,
|
||||||
Device = s.Device,
|
Device = s.Device,
|
||||||
Version = s.Version,
|
Version = s.Version,
|
||||||
ClientIp = s.ClientIp,
|
|
||||||
LastActivity = s.LastActivity,
|
LastActivity = s.LastActivity,
|
||||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||||
HasWebSocket = s.WebSocket != null,
|
HasWebSocket = s.WebSocket != null,
|
||||||
@@ -571,7 +565,6 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public ClientWebSocket? WebSocket { get; set; }
|
public ClientWebSocket? WebSocket { get; set; }
|
||||||
public string? LastPlayingItemId { get; set; }
|
public string? LastPlayingItemId { get; set; }
|
||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
public string? ClientIp { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
using allstarr.Models.Lyrics;
|
|
||||||
using allstarr.Models.Settings;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace allstarr.Services.Lyrics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
|
|
||||||
/// Priority order: Spotify → LyricsPlus → LRCLib
|
|
||||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
|
|
||||||
/// </summary>
|
|
||||||
public class LyricsOrchestrator
|
|
||||||
{
|
|
||||||
private readonly SpotifyLyricsService _spotifyLyrics;
|
|
||||||
private readonly LyricsPlusService _lyricsPlus;
|
|
||||||
private readonly LrclibService _lrclib;
|
|
||||||
private readonly SpotifyApiSettings _spotifySettings;
|
|
||||||
private readonly ILogger<LyricsOrchestrator> _logger;
|
|
||||||
|
|
||||||
public LyricsOrchestrator(
|
|
||||||
SpotifyLyricsService spotifyLyrics,
|
|
||||||
LyricsPlusService lyricsPlus,
|
|
||||||
LrclibService lrclib,
|
|
||||||
IOptions<SpotifyApiSettings> spotifySettings,
|
|
||||||
ILogger<LyricsOrchestrator> logger)
|
|
||||||
{
|
|
||||||
_spotifyLyrics = spotifyLyrics;
|
|
||||||
_lyricsPlus = lyricsPlus;
|
|
||||||
_lrclib = lrclib;
|
|
||||||
_spotifySettings = spotifySettings.Value;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Fetches lyrics with automatic fallback through all available sources.
|
|
||||||
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="trackName">Track title</param>
|
|
||||||
/// <param name="artistNames">Artist names (can be multiple)</param>
|
|
||||||
/// <param name="albumName">Album name</param>
|
|
||||||
/// <param name="durationSeconds">Track duration in seconds</param>
|
|
||||||
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
|
|
||||||
/// <returns>Lyrics info or null if not found</returns>
|
|
||||||
public async Task<LyricsInfo?> GetLyricsAsync(
|
|
||||||
string trackName,
|
|
||||||
string[] artistNames,
|
|
||||||
string? albumName,
|
|
||||||
int durationSeconds,
|
|
||||||
string? spotifyTrackId = null)
|
|
||||||
{
|
|
||||||
var artistName = string.Join(", ", artistNames);
|
|
||||||
|
|
||||||
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
|
||||||
|
|
||||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
|
||||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
|
||||||
if (spotifyLyrics != null)
|
|
||||||
{
|
|
||||||
return spotifyLyrics;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try LyricsPlus
|
|
||||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
|
||||||
if (lyricsPlusLyrics != null)
|
|
||||||
{
|
|
||||||
return lyricsPlusLyrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Try LRCLib
|
|
||||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
|
||||||
if (lrclibLyrics != null)
|
|
||||||
{
|
|
||||||
return lrclibLyrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prefetches lyrics in the background (for cache warming).
|
|
||||||
/// Skips Jellyfin local since we don't have an itemId.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<bool> PrefetchLyricsAsync(
|
|
||||||
string trackName,
|
|
||||||
string[] artistNames,
|
|
||||||
string? albumName,
|
|
||||||
int durationSeconds,
|
|
||||||
string? spotifyTrackId = null)
|
|
||||||
{
|
|
||||||
var artistName = string.Join(", ", artistNames);
|
|
||||||
|
|
||||||
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
|
|
||||||
|
|
||||||
// 1. Try Spotify lyrics (if Spotify ID provided)
|
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
|
||||||
{
|
|
||||||
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
|
|
||||||
if (spotifyLyrics != null)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try LyricsPlus
|
|
||||||
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
|
||||||
if (lyricsPlusLyrics != null)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Try LRCLib
|
|
||||||
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
|
|
||||||
if (lrclibLyrics != null)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Private Helper Methods
|
|
||||||
|
|
||||||
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
|
|
||||||
{
|
|
||||||
if (!_spotifySettings.Enabled)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Spotify API not enabled, skipping Spotify lyrics");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Validate Spotify ID format
|
|
||||||
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
|
|
||||||
|
|
||||||
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
|
|
||||||
|
|
||||||
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
|
|
||||||
|
|
||||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
|
|
||||||
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
|
||||||
|
|
||||||
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
|
|
||||||
string trackName,
|
|
||||||
string[] artistNames,
|
|
||||||
string? albumName,
|
|
||||||
int durationSeconds,
|
|
||||||
string artistName)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
|
|
||||||
|
|
||||||
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
|
|
||||||
|
|
||||||
if (lyrics != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return lyrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<LyricsInfo?> TryLrclibLyrics(
|
|
||||||
string trackName,
|
|
||||||
string[] artistNames,
|
|
||||||
string? albumName,
|
|
||||||
int durationSeconds,
|
|
||||||
string artistName)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
|
|
||||||
|
|
||||||
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
|
|
||||||
|
|
||||||
if (lyrics != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return lyrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using allstarr.Models.Lyrics;
|
|
||||||
using allstarr.Services.Common;
|
|
||||||
|
|
||||||
namespace allstarr.Services.Lyrics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
|
|
||||||
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
|
|
||||||
/// </summary>
|
|
||||||
public class LyricsPlusService
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly RedisCacheService _cache;
|
|
||||||
private readonly ILogger<LyricsPlusService> _logger;
|
|
||||||
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
|
|
||||||
|
|
||||||
public LyricsPlusService(
|
|
||||||
IHttpClientFactory httpClientFactory,
|
|
||||||
RedisCacheService cache,
|
|
||||||
ILogger<LyricsPlusService> logger)
|
|
||||||
{
|
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
|
|
||||||
_cache = cache;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds)
|
|
||||||
{
|
|
||||||
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string? albumName, int durationSeconds)
|
|
||||||
{
|
|
||||||
// Validate input parameters
|
|
||||||
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
|
|
||||||
trackName, artistNames?.Length ?? 0);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var artistName = string.Join(", ", artistNames);
|
|
||||||
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
var cached = await _cache.GetStringAsync(cacheKey);
|
|
||||||
if (!string.IsNullOrEmpty(cached))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to deserialize cached LyricsPlus lyrics");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Build URL with query parameters
|
|
||||||
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(albumName))
|
|
||||||
{
|
|
||||||
url += $"&album={Uri.EscapeDataString(albumName)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (durationSeconds > 0)
|
|
||||||
{
|
|
||||||
url += $"&duration={durationSeconds}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
|
|
||||||
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
|
|
||||||
|
|
||||||
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
|
||||||
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
|
|
||||||
|
|
||||||
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to LyricsInfo format
|
|
||||||
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
|
|
||||||
|
|
||||||
if (result != null)
|
|
||||||
{
|
|
||||||
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
|
|
||||||
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
|
|
||||||
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
|
|
||||||
{
|
|
||||||
if (response.Lyrics == null || response.Lyrics.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string? syncedLyrics = null;
|
|
||||||
string? plainLyrics = null;
|
|
||||||
|
|
||||||
// Convert based on type
|
|
||||||
if (response.Type == "Word")
|
|
||||||
{
|
|
||||||
// Word-level timing - convert to line-level LRC
|
|
||||||
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
|
|
||||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
|
||||||
}
|
|
||||||
else if (response.Type == "Line")
|
|
||||||
{
|
|
||||||
// Line-level timing - convert to LRC
|
|
||||||
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
|
|
||||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Static or unknown type - just plain text
|
|
||||||
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new LyricsInfo
|
|
||||||
{
|
|
||||||
TrackName = trackName,
|
|
||||||
ArtistName = artistName,
|
|
||||||
AlbumName = albumName ?? string.Empty,
|
|
||||||
Duration = durationSeconds,
|
|
||||||
Instrumental = false,
|
|
||||||
PlainLyrics = plainLyrics,
|
|
||||||
SyncedLyrics = syncedLyrics
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
|
|
||||||
{
|
|
||||||
var lrcLines = new List<string>();
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
if (line.Time.HasValue)
|
|
||||||
{
|
|
||||||
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
|
|
||||||
var mm = (int)timestamp.TotalMinutes;
|
|
||||||
var ss = timestamp.Seconds;
|
|
||||||
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
|
|
||||||
|
|
||||||
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// No timing, just add the text
|
|
||||||
lrcLines.Add(line.Text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Join("\n", lrcLines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
|
|
||||||
{
|
|
||||||
// For word-level timing, we use the line start time
|
|
||||||
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
|
|
||||||
return ConvertLineTimingToLrc(lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
|
|
||||||
private class LyricsPlusResponse
|
|
||||||
{
|
|
||||||
[JsonPropertyName("type")]
|
|
||||||
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
|
|
||||||
|
|
||||||
[JsonPropertyName("metadata")]
|
|
||||||
public LyricsPlusMetadata? Metadata { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("lyrics")]
|
|
||||||
public List<LyricsPlusLine> Lyrics { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LyricsPlusMetadata
|
|
||||||
{
|
|
||||||
[JsonPropertyName("source")]
|
|
||||||
public string? Source { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("title")]
|
|
||||||
public string? Title { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("language")]
|
|
||||||
public string? Language { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LyricsPlusLine
|
|
||||||
{
|
|
||||||
[JsonPropertyName("time")]
|
|
||||||
public long? Time { get; set; } // Milliseconds
|
|
||||||
|
|
||||||
[JsonPropertyName("duration")]
|
|
||||||
public long? Duration { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("text")]
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[JsonPropertyName("syllabus")]
|
|
||||||
public List<LyricsPlusSyllable>? Syllabus { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LyricsPlusSyllable
|
|
||||||
{
|
|
||||||
[JsonPropertyName("time")]
|
|
||||||
public long Time { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("duration")]
|
|
||||||
public long Duration { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("text")]
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -167,14 +167,16 @@ public class LyricsStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!_spotifySettings.Enabled)
|
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
|
||||||
{
|
{
|
||||||
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
|
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
|
||||||
|
WriteDetail("Set SpotifyApi__ClientId to enable");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
|
||||||
WriteDetail("Note: Spotify API is used for track matching and lyrics");
|
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
|
||||||
|
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ public class MusicBrainzService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Searches for recordings by title and artist.
|
/// Searches for recordings by title and artist.
|
||||||
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
|
||||||
{
|
{
|
||||||
@@ -108,8 +107,7 @@ public class MusicBrainzService
|
|||||||
// Build Lucene query
|
// Build Lucene query
|
||||||
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
|
||||||
var encodedQuery = Uri.EscapeDataString(query);
|
var encodedQuery = Uri.EscapeDataString(query);
|
||||||
// Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
|
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
|
||||||
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
|
|
||||||
|
|
||||||
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
_logger.LogDebug("MusicBrainz search: {Url}", url);
|
||||||
|
|
||||||
@@ -142,56 +140,9 @@ public class MusicBrainzService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Looks up a recording by MBID to get full details including genres.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
|
|
||||||
{
|
|
||||||
if (!_settings.Enabled)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
await RateLimitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
|
|
||||||
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
|
|
||||||
|
|
||||||
if (recording == null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
|
|
||||||
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
|
|
||||||
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
|
|
||||||
|
|
||||||
return recording;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enriches a song with genre information from MusicBrainz.
|
/// Enriches a song with genre information from MusicBrainz.
|
||||||
/// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
|
/// First tries ISRC lookup, then falls back to title/artist search.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
|
||||||
{
|
{
|
||||||
@@ -202,23 +153,17 @@ public class MusicBrainzService
|
|||||||
|
|
||||||
MusicBrainzRecording? recording = null;
|
MusicBrainzRecording? recording = null;
|
||||||
|
|
||||||
// Try ISRC lookup first (most accurate and includes genres)
|
// Try ISRC lookup first (most accurate)
|
||||||
if (!string.IsNullOrEmpty(isrc))
|
if (!string.IsNullOrEmpty(isrc))
|
||||||
{
|
{
|
||||||
recording = await LookupByIsrcAsync(isrc);
|
recording = await LookupByIsrcAsync(isrc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
|
// Fall back to search if ISRC lookup failed or no ISRC provided
|
||||||
if (recording == null)
|
if (recording == null)
|
||||||
{
|
{
|
||||||
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
|
||||||
var searchResult = recordings.FirstOrDefault();
|
recording = recordings.FirstOrDefault();
|
||||||
|
|
||||||
// If we found a recording from search, do a full lookup by MBID to get genres
|
|
||||||
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
|
|
||||||
{
|
|
||||||
recording = await LookupByMbidAsync(searchResult.Id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recording == null)
|
if (recording == null)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using allstarr.Models.Settings;
|
|||||||
using allstarr.Models.Download;
|
using allstarr.Models.Download;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Common;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
@@ -19,7 +18,6 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly QobuzBundleService _bundleService;
|
private readonly QobuzBundleService _bundleService;
|
||||||
private readonly ILogger<QobuzMetadataService> _logger;
|
private readonly ILogger<QobuzMetadataService> _logger;
|
||||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
|
||||||
private readonly string? _userAuthToken;
|
private readonly string? _userAuthToken;
|
||||||
private readonly string? _userId;
|
private readonly string? _userId;
|
||||||
|
|
||||||
@@ -30,14 +28,12 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
IOptions<SubsonicSettings> settings,
|
IOptions<SubsonicSettings> settings,
|
||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
QobuzBundleService bundleService,
|
QobuzBundleService bundleService,
|
||||||
ILogger<QobuzMetadataService> logger,
|
ILogger<QobuzMetadataService> logger)
|
||||||
GenreEnrichmentService? genreEnrichment = null)
|
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_bundleService = bundleService;
|
_bundleService = bundleService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_genreEnrichment = genreEnrichment;
|
|
||||||
|
|
||||||
var qobuzConfig = qobuzSettings.Value;
|
var qobuzConfig = qobuzSettings.Value;
|
||||||
_userAuthToken = qobuzConfig.UserAuthToken;
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
||||||
@@ -181,26 +177,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
if (track.TryGetProperty("error", out _)) return null;
|
if (track.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
var song = ParseQobuzTrackFull(track);
|
return ParseQobuzTrackFull(track);
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing
|
|
||||||
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return song;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -349,17 +349,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
// Handle 429 rate limiting with exponential backoff
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
|
||||||
{
|
|
||||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
|
||||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds);
|
|
||||||
await Task.Delay(retryAfter, cancellationToken);
|
|
||||||
|
|
||||||
// Retry the request
|
|
||||||
response = await _webApiClient.SendAsync(request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
_logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode);
|
||||||
@@ -746,18 +735,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||||
string searchName,
|
string searchName,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
|
||||||
return await GetUserPlaylistsAsync(searchName, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets all playlists from the user's library, optionally filtered by name.
|
|
||||||
/// Uses GraphQL API which is less rate-limited than REST API.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="searchName">Optional name filter (case-insensitive). If null, returns all playlists.</param>
|
|
||||||
public async Task<List<SpotifyPlaylist>> GetUserPlaylistsAsync(
|
|
||||||
string? searchName = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
@@ -775,33 +752,56 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
// GraphQL query to fetch user playlists - using libraryV3 operation
|
// GraphQL query to fetch user playlists
|
||||||
var queryParams = new Dictionary<string, string>
|
var graphqlQuery = new
|
||||||
{
|
{
|
||||||
{ "operationName", "libraryV3" },
|
operationName = "fetchLibraryPlaylists",
|
||||||
{ "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" },
|
variables = new
|
||||||
{ "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" }
|
{
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
},
|
||||||
|
query = @"
|
||||||
|
query fetchLibraryPlaylists($offset: Int!, $limit: Int!) {
|
||||||
|
me {
|
||||||
|
library {
|
||||||
|
playlists(offset: $offset, limit: $limit) {
|
||||||
|
totalCount
|
||||||
|
items {
|
||||||
|
playlist {
|
||||||
|
uri
|
||||||
|
name
|
||||||
|
description
|
||||||
|
images {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
ownerV2 {
|
||||||
|
data {
|
||||||
|
__typename
|
||||||
|
... on User {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}"
|
||||||
};
|
};
|
||||||
|
|
||||||
var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
|
var request = new HttpRequestMessage(HttpMethod.Post, $"{WebApiBase}/query")
|
||||||
var url = $"{WebApiBase}/query?{queryString}";
|
{
|
||||||
|
Content = new StringContent(
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
JsonSerializer.Serialize(graphqlQuery),
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json")
|
||||||
|
};
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
// Handle 429 rate limiting with exponential backoff
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
|
|
||||||
{
|
|
||||||
var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
|
|
||||||
_logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds);
|
|
||||||
await Task.Delay(retryAfter, cancellationToken);
|
|
||||||
|
|
||||||
// Retry the request
|
|
||||||
response = await _httpClient.SendAsync(request, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
|
||||||
@@ -814,157 +814,56 @@ public class SpotifyApiClient : IDisposable
|
|||||||
|
|
||||||
if (!root.TryGetProperty("data", out var data) ||
|
if (!root.TryGetProperty("data", out var data) ||
|
||||||
!data.TryGetProperty("me", out var me) ||
|
!data.TryGetProperty("me", out var me) ||
|
||||||
!me.TryGetProperty("libraryV3", out var library) ||
|
!me.TryGetProperty("library", out var library) ||
|
||||||
!library.TryGetProperty("items", out var items))
|
!library.TryGetProperty("playlists", out var playlistsData) ||
|
||||||
|
!playlistsData.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get total count
|
|
||||||
if (library.TryGetProperty("totalCount", out var totalCount))
|
|
||||||
{
|
|
||||||
var total = totalCount.GetInt32();
|
|
||||||
if (total == 0) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemCount = 0;
|
var itemCount = 0;
|
||||||
foreach (var item in items.EnumerateArray())
|
foreach (var item in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
itemCount++;
|
itemCount++;
|
||||||
|
|
||||||
if (!item.TryGetProperty("item", out var playlistItem) ||
|
if (!item.TryGetProperty("playlist", out var playlist))
|
||||||
!playlistItem.TryGetProperty("data", out var playlist))
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
// Check __typename to filter out folders and only include playlists
|
|
||||||
if (playlistItem.TryGetProperty("__typename", out var typename))
|
|
||||||
{
|
|
||||||
var typeStr = typename.GetString();
|
|
||||||
// Skip folders - only process Playlist types
|
|
||||||
if (typeStr != null && typeStr.Contains("Folder", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get playlist URI/ID
|
|
||||||
string? uri = null;
|
|
||||||
if (playlistItem.TryGetProperty("uri", out var uriProp))
|
|
||||||
{
|
|
||||||
uri = uriProp.GetString();
|
|
||||||
}
|
|
||||||
else if (playlistItem.TryGetProperty("_uri", out var uriProp2))
|
|
||||||
{
|
|
||||||
uri = uriProp2.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(uri)) continue;
|
|
||||||
|
|
||||||
// Skip if not a playlist URI (e.g., folders have different URI format)
|
|
||||||
if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||||
|
|
||||||
// Check if name matches (case-insensitive) - if searchName is provided
|
// Check if name matches (case-insensitive)
|
||||||
if (!string.IsNullOrEmpty(searchName) &&
|
if (itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||||
!itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
continue;
|
var uri = playlist.TryGetProperty("uri", out var u) ? u.GetString() ?? "" : "";
|
||||||
}
|
var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get track count if available - try multiple possible paths
|
playlists.Add(new SpotifyPlaylist
|
||||||
var trackCount = 0;
|
|
||||||
if (playlist.TryGetProperty("content", out var content))
|
|
||||||
{
|
|
||||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
|
||||||
{
|
{
|
||||||
trackCount = totalTrackCount.GetInt32();
|
SpotifyId = spotifyId,
|
||||||
}
|
Name = itemName,
|
||||||
|
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||||
|
TotalTracks = 0, // GraphQL doesn't return track count in this query
|
||||||
|
SnapshotId = null
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Fallback: try attributes.itemCount
|
|
||||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
|
||||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
|
||||||
{
|
|
||||||
trackCount = itemCountProp.GetInt32();
|
|
||||||
}
|
|
||||||
// Fallback: try totalCount directly
|
|
||||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
|
||||||
{
|
|
||||||
trackCount = directTotalCount.GetInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log if we couldn't find track count for debugging
|
|
||||||
if (trackCount == 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}",
|
|
||||||
itemName, spotifyId, playlist.GetRawText());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get owner name
|
|
||||||
string? ownerName = null;
|
|
||||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
|
||||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
|
||||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
|
||||||
{
|
|
||||||
ownerName = ownerNameProp.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get image URL
|
|
||||||
string? imageUrl = null;
|
|
||||||
if (playlist.TryGetProperty("images", out var images) &&
|
|
||||||
images.TryGetProperty("items", out var imageItems) &&
|
|
||||||
imageItems.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
var firstImage = imageItems[0];
|
|
||||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
|
||||||
sources.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
var firstSource = sources[0];
|
|
||||||
if (firstSource.TryGetProperty("url", out var urlProp))
|
|
||||||
{
|
|
||||||
imageUrl = urlProp.GetString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playlists.Add(new SpotifyPlaylist
|
|
||||||
{
|
|
||||||
SpotifyId = spotifyId,
|
|
||||||
Name = itemName,
|
|
||||||
Description = playlist.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
|
||||||
TotalTracks = trackCount,
|
|
||||||
OwnerName = ownerName,
|
|
||||||
ImageUrl = imageUrl,
|
|
||||||
SnapshotId = null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (itemCount < limit) break;
|
if (itemCount < limit) break;
|
||||||
offset += limit;
|
offset += limit;
|
||||||
|
|
||||||
// Add delay between pages to avoid rate limiting
|
// GraphQL is less rate-limited, but still add a small delay
|
||||||
// Library fetching can be aggressive, so use a longer delay
|
if (_settings.RateLimitDelayMs > 0)
|
||||||
var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages
|
{
|
||||||
_logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs);
|
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||||
await Task.Delay(delayMs, cancellationToken);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
|
_logger.LogInformation("Found {Count} playlists matching '{SearchName}' via GraphQL", playlists.Count, searchName);
|
||||||
playlists.Count,
|
|
||||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL",
|
_logger.LogError(ex, "Error searching user playlists for '{SearchName}' via GraphQL", searchName);
|
||||||
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
|
|
||||||
return new List<SpotifyPlaylist>();
|
return new List<SpotifyPlaylist>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -992,8 +992,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request all fields that clients typically need (not just MediaSources)
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
||||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
|
||||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
if (statusCode != 200 || existingTracksResponse == null)
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
@@ -1302,61 +1301,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (finalItems.Count > 0)
|
if (finalItems.Count > 0)
|
||||||
{
|
{
|
||||||
// Enrich external tracks with genres from MusicBrainz
|
|
||||||
if (externalUsedCount > 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
|
||||||
if (genreEnrichment != null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
|
||||||
|
|
||||||
// Extract external songs from matched tracks
|
|
||||||
var externalSongs = matchedTracks
|
|
||||||
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
|
|
||||||
.Select(t => t.MatchedSong!)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
// Enrich genres in parallel
|
|
||||||
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
|
|
||||||
|
|
||||||
// Update the genres in finalItems
|
|
||||||
foreach (var item in finalItems)
|
|
||||||
{
|
|
||||||
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
|
|
||||||
{
|
|
||||||
// Find the corresponding song
|
|
||||||
var song = externalSongs.FirstOrDefault(s => s.Id == id);
|
|
||||||
if (song != null && !string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
// Update Genres array
|
|
||||||
item["Genres"] = new[] { song.Genre };
|
|
||||||
|
|
||||||
// Update GenreItems array
|
|
||||||
item["GenreItems"] = new[]
|
|
||||||
{
|
|
||||||
new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
["Name"] = song.Genre,
|
|
||||||
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private readonly GenreEnrichmentService? _genreEnrichment;
|
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -64,15 +63,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
IOptions<SquidWTFSettings> squidwtfSettings,
|
IOptions<SquidWTFSettings> squidwtfSettings,
|
||||||
ILogger<SquidWTFMetadataService> logger,
|
ILogger<SquidWTFMetadataService> logger,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
List<string> apiUrls,
|
List<string> apiUrls)
|
||||||
GenreEnrichmentService? genreEnrichment = null)
|
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
_genreEnrichment = genreEnrichment;
|
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
@@ -86,19 +83,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
// Race all endpoints for fastest search results
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
// Use 's' parameter for track search as per hifi-api spec
|
// Use 's' parameter for track search as per hifi-api spec
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
|
||||||
// Check for error in response body
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -132,19 +129,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
// Race all endpoints for fastest search results
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
@@ -169,14 +166,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
// Race all endpoints for fastest search results
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'a' parameter for artist search
|
// Per hifi-api spec: use 'a' parameter for artist search
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -184,7 +181,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
@@ -289,23 +286,6 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
|
||||||
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
|
||||||
{
|
|
||||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||||
|
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 5 second timeout per ping - mark slow endpoints as failed
|
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token);
|
|
||||||
return response.IsSuccessStatusCode;
|
return response.IsSuccessStatusCode;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>allstarr</RootNamespace>
|
<RootNamespace>allstarr</RootNamespace>
|
||||||
<Version>1.2.2</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.2.2.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.2.2.0</FileVersion>
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -61,6 +61,8 @@
|
|||||||
},
|
},
|
||||||
"SpotifyApi": {
|
"SpotifyApi": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
"SessionCookie": "",
|
"SessionCookie": "",
|
||||||
"CacheDurationMinutes": 60,
|
"CacheDurationMinutes": 60,
|
||||||
"RateLimitDelayMs": 100,
|
"RateLimitDelayMs": 100,
|
||||||
|
|||||||
@@ -1174,7 +1174,7 @@
|
|||||||
<div class="modal-content" style="max-width: 600px;">
|
<div class="modal-content" style="max-width: 600px;">
|
||||||
<h3>Map Track to External Provider</h3>
|
<h3>Map Track to External Provider</h3>
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||||
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Jellyfin mapping modal instead.
|
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Track Info -->
|
<!-- Track Info -->
|
||||||
@@ -1216,43 +1216,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Local Jellyfin Track Mapping Modal -->
|
|
||||||
<div class="modal" id="local-map-modal">
|
|
||||||
<div class="modal-content" style="max-width: 700px;">
|
|
||||||
<h3>Map Track to Local Jellyfin Track</h3>
|
|
||||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
|
||||||
Search your Jellyfin library and select a local track to map to this Spotify track.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Track Info -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Spotify Track (Position <span id="local-map-position"></span>)</label>
|
|
||||||
<div style="background: var(--bg-primary); padding: 12px; border-radius: 8px; margin-bottom: 16px;">
|
|
||||||
<strong id="local-map-spotify-title"></strong><br>
|
|
||||||
<span style="color: var(--text-secondary);" id="local-map-spotify-artist"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Section -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Search Jellyfin Library</label>
|
|
||||||
<input type="text" id="local-map-search" placeholder="Search for track name or artist...">
|
|
||||||
<button onclick="searchJellyfinTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search Results -->
|
|
||||||
<div id="local-map-results" style="max-height: 300px; overflow-y: auto; margin-top: 16px;"></div>
|
|
||||||
|
|
||||||
<input type="hidden" id="local-map-playlist-name">
|
|
||||||
<input type="hidden" id="local-map-spotify-id">
|
|
||||||
<input type="hidden" id="local-map-jellyfin-id">
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button onclick="closeModal('local-map-modal')">Cancel</button>
|
|
||||||
<button class="primary" onclick="saveLocalMapping()" id="local-map-save-btn" disabled>Save Mapping</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Link Playlist Modal -->
|
<!-- Link Playlist Modal -->
|
||||||
<div class="modal" id="link-playlist-modal">
|
<div class="modal" id="link-playlist-modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -3034,27 +2997,8 @@
|
|||||||
saveBtn.disabled = !externalId;
|
saveBtn.disabled = !externalId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open local Jellyfin mapping modal
|
// Open manual mapping modal (external only)
|
||||||
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
function openManualMap(playlistName, position, title, artist, spotifyId) {
|
||||||
document.getElementById('local-map-playlist-name').value = playlistName;
|
|
||||||
document.getElementById('local-map-position').textContent = position + 1;
|
|
||||||
document.getElementById('local-map-spotify-title').textContent = title;
|
|
||||||
document.getElementById('local-map-spotify-artist').textContent = artist;
|
|
||||||
document.getElementById('local-map-spotify-id').value = spotifyId;
|
|
||||||
|
|
||||||
// Pre-fill search with track info
|
|
||||||
document.getElementById('local-map-search').value = `${title} ${artist}`;
|
|
||||||
|
|
||||||
// Reset fields
|
|
||||||
document.getElementById('local-map-results').innerHTML = '';
|
|
||||||
document.getElementById('local-map-jellyfin-id').value = '';
|
|
||||||
document.getElementById('local-map-save-btn').disabled = true;
|
|
||||||
|
|
||||||
openModal('local-map-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open external mapping modal
|
|
||||||
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
|
||||||
document.getElementById('map-playlist-name').value = playlistName;
|
document.getElementById('map-playlist-name').value = playlistName;
|
||||||
document.getElementById('map-position').textContent = position + 1;
|
document.getElementById('map-position').textContent = position + 1;
|
||||||
document.getElementById('map-spotify-title').textContent = title;
|
document.getElementById('map-spotify-title').textContent = title;
|
||||||
@@ -3069,123 +3013,12 @@
|
|||||||
openModal('manual-map-modal');
|
openModal('manual-map-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search Jellyfin tracks for local mapping
|
// Alias for backward compatibility
|
||||||
async function searchJellyfinTracks() {
|
function openExternalMap(playlistName, position, title, artist, spotifyId) {
|
||||||
const query = document.getElementById('local-map-search').value.trim();
|
openManualMap(playlistName, position, title, artist, spotifyId);
|
||||||
if (!query) {
|
|
||||||
showToast('Please enter a search query', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('local-map-results');
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;padding:20px;">Searching...</p>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin/jellyfin/search?query=' + encodeURIComponent(query));
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.tracks || data.tracks.length === 0) {
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">No tracks found</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resultsDiv.innerHTML = data.tracks.map(track => `
|
|
||||||
<div style="padding: 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: background 0.2s;"
|
|
||||||
onclick="selectJellyfinTrack('${escapeJs(track.id)}', '${escapeJs(track.name)}', '${escapeJs(track.artist)}')"
|
|
||||||
onmouseover="this.style.background='var(--bg-primary)'"
|
|
||||||
onmouseout="this.style.background='transparent'">
|
|
||||||
<strong>${escapeHtml(track.name)}</strong><br>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 0.9em;">${escapeHtml(track.artist)}</span>
|
|
||||||
${track.album ? '<br><span style="color: var(--text-secondary); font-size: 0.85em;">' + escapeHtml(track.album) + '</span>' : ''}
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search error:', error);
|
|
||||||
resultsDiv.innerHTML = '<p style="text-align:center;color:var(--error);padding:20px;">Search failed</p>';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select a Jellyfin track for mapping
|
// Save manual mapping (external only)
|
||||||
function selectJellyfinTrack(jellyfinId, name, artist) {
|
|
||||||
document.getElementById('local-map-jellyfin-id').value = jellyfinId;
|
|
||||||
document.getElementById('local-map-save-btn').disabled = false;
|
|
||||||
|
|
||||||
// Highlight selected track
|
|
||||||
document.querySelectorAll('#local-map-results > div').forEach(div => {
|
|
||||||
div.style.background = 'transparent';
|
|
||||||
div.style.border = '1px solid var(--border)';
|
|
||||||
});
|
|
||||||
event.target.closest('div').style.background = 'var(--primary)';
|
|
||||||
event.target.closest('div').style.border = '1px solid var(--primary)';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save local Jellyfin mapping
|
|
||||||
async function saveLocalMapping() {
|
|
||||||
const playlistName = document.getElementById('local-map-playlist-name').value;
|
|
||||||
const spotifyId = document.getElementById('local-map-spotify-id').value;
|
|
||||||
const jellyfinId = document.getElementById('local-map-jellyfin-id').value;
|
|
||||||
|
|
||||||
if (!jellyfinId) {
|
|
||||||
showToast('Please select a Jellyfin track', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestBody = {
|
|
||||||
spotifyId,
|
|
||||||
jellyfinId
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const saveBtn = document.getElementById('local-map-save-btn');
|
|
||||||
const originalText = saveBtn.textContent;
|
|
||||||
saveBtn.textContent = 'Saving...';
|
|
||||||
saveBtn.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 30000);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal: controller.signal
|
|
||||||
});
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
showToast('Track mapped successfully!', 'success');
|
|
||||||
closeModal('local-map-modal');
|
|
||||||
|
|
||||||
// Refresh the tracks view if it's open
|
|
||||||
const tracksModal = document.getElementById('tracks-modal');
|
|
||||||
if (tracksModal.style.display === 'flex') {
|
|
||||||
await viewTracks(playlistName);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const data = await res.json();
|
|
||||||
showToast(data.error || 'Failed to save mapping', 'error');
|
|
||||||
saveBtn.textContent = originalText;
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AbortError') {
|
|
||||||
showToast('Request timed out. The mapping may still be processing.', 'warning');
|
|
||||||
} else {
|
|
||||||
showToast('Failed to save mapping', 'error');
|
|
||||||
}
|
|
||||||
saveBtn.textContent = originalText;
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save manual mapping (external only) - kept for backward compatibility
|
|
||||||
async function saveManualMapping() {
|
async function saveManualMapping() {
|
||||||
const playlistName = document.getElementById('map-playlist-name').value;
|
const playlistName = document.getElementById('map-playlist-name').value;
|
||||||
const spotifyId = document.getElementById('map-spotify-id').value;
|
const spotifyId = document.getElementById('map-spotify-id').value;
|
||||||
|
|||||||
@@ -17,11 +17,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- allstarr-network
|
- allstarr-network
|
||||||
|
|
||||||
# Spotify Lyrics API sidecar service
|
|
||||||
# Note: This image only supports AMD64. On ARM64 systems, Docker will use emulation.
|
|
||||||
spotify-lyrics:
|
spotify-lyrics:
|
||||||
image: akashrchandran/spotify-lyrics-api:latest
|
image: akashrchandran/spotify-lyrics-api:latest
|
||||||
platform: linux/amd64
|
|
||||||
container_name: allstarr-spotify-lyrics
|
container_name: allstarr-spotify-lyrics
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -107,6 +104,8 @@ services:
|
|||||||
|
|
||||||
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
|
||||||
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
|
||||||
|
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
|
||||||
|
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
|
||||||
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
|
||||||
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
|
||||||
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
|
||||||
|
|||||||
Reference in New Issue
Block a user