From ae1dd7481d0403d6ed514419a3a4d66ceb6bc24a Mon Sep 17 00:00:00 2001 From: Uyanide Date: Tue, 31 Mar 2026 22:24:08 +0200 Subject: [PATCH] rename to lrx resolve conflicts --- .claude/resume | 1 + .vscode/settings.json | 3 + CLAUDE.md | 3 + lrx/__init__.py | 0 lrx/__main__.py | 4 + lrx/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 152 bytes lrx/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 180 bytes lrx/__pycache__/__main__.cpython-313.pyc | Bin 0 -> 261 bytes lrx/__pycache__/__main__.cpython-314.pyc | Bin 0 -> 267 bytes lrx/__pycache__/cache.cpython-313.pyc | Bin 0 -> 21695 bytes lrx/__pycache__/cache.cpython-314.pyc | Bin 0 -> 25001 bytes lrx/__pycache__/cli.cpython-313.pyc | Bin 0 -> 17492 bytes lrx/__pycache__/cli.cpython-314.pyc | Bin 0 -> 16108 bytes lrx/__pycache__/config.cpython-313.pyc | Bin 0 -> 3294 bytes lrx/__pycache__/core.cpython-313.pyc | Bin 0 -> 7543 bytes lrx/__pycache__/lrc.cpython-313.pyc | Bin 0 -> 8380 bytes lrx/__pycache__/lrc.cpython-314.pyc | Bin 0 -> 5194 bytes lrx/__pycache__/models.cpython-313.pyc | Bin 0 -> 3286 bytes lrx/__pycache__/models.cpython-314.pyc | Bin 0 -> 4289 bytes lrx/__pycache__/mpris.cpython-313.pyc | Bin 0 -> 8751 bytes lrx/cache.py | 441 ++++++++++++++++++ lrx/cli.py | 426 +++++++++++++++++ lrx/config.py | 88 ++++ lrx/core.py | 178 +++++++ lrx/enrichers/__init__.py | 40 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 1712 bytes .../__pycache__/audio_tag.cpython-313.pyc | Bin 0 -> 3503 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 1470 bytes .../__pycache__/file_name.cpython-313.pyc | Bin 0 -> 3356 bytes lrx/enrichers/audio_tag.py | 78 ++++ lrx/enrichers/base.py | 31 ++ lrx/enrichers/file_name.py | 83 ++++ lrx/fetchers/__init__.py | 41 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 1364 bytes lrx/fetchers/__pycache__/base.cpython-313.pyc | Bin 0 -> 1794 bytes .../__pycache__/cache_search.cpython-313.pyc | Bin 0 -> 3644 bytes .../__pycache__/local.cpython-313.pyc | Bin 0 -> 4830 bytes .../__pycache__/lrclib.cpython-313.pyc | Bin 0 -> 5667 bytes .../__pycache__/lrclib_search.cpython-313.pyc | Bin 0 -> 7987 bytes .../__pycache__/musixmatch.cpython-313.pyc | Bin 0 -> 11553 bytes .../__pycache__/netease.cpython-313.pyc | Bin 0 -> 9555 bytes .../__pycache__/qqmusic.cpython-313.pyc | Bin 0 -> 8710 bytes .../__pycache__/spotify.cpython-313.pyc | Bin 0 -> 17939 bytes lrx/fetchers/base.py | 35 ++ lrx/fetchers/cache_search.py | 85 ++++ lrx/fetchers/local.py | 98 ++++ lrx/fetchers/lrclib.py | 111 +++++ lrx/fetchers/lrclib_search.py | 168 +++++++ lrx/fetchers/netease.py | 213 +++++++++ lrx/fetchers/qqmusic.py | 178 +++++++ lrx/fetchers/spotify.py | 373 +++++++++++++++ lrx/lrc.py | 178 +++++++ lrx/models.py | 59 +++ lrx/mpris.py | 188 ++++++++ main.py | 2 +- pyproject.toml | 4 +- 56 files changed, 3106 insertions(+), 3 deletions(-) create mode 100644 .claude/resume create mode 100644 .vscode/settings.json create mode 100644 CLAUDE.md create mode 100644 lrx/__init__.py create mode 100644 lrx/__main__.py create mode 100644 lrx/__pycache__/__init__.cpython-313.pyc create mode 100644 lrx/__pycache__/__init__.cpython-314.pyc create mode 100644 lrx/__pycache__/__main__.cpython-313.pyc create mode 100644 lrx/__pycache__/__main__.cpython-314.pyc create mode 100644 lrx/__pycache__/cache.cpython-313.pyc create mode 100644 lrx/__pycache__/cache.cpython-314.pyc create mode 100644 lrx/__pycache__/cli.cpython-313.pyc create mode 100644 lrx/__pycache__/cli.cpython-314.pyc create mode 100644 lrx/__pycache__/config.cpython-313.pyc create mode 100644 lrx/__pycache__/core.cpython-313.pyc create mode 100644 lrx/__pycache__/lrc.cpython-313.pyc create mode 100644 lrx/__pycache__/lrc.cpython-314.pyc create mode 100644 lrx/__pycache__/models.cpython-313.pyc create mode 100644 lrx/__pycache__/models.cpython-314.pyc create mode 100644 lrx/__pycache__/mpris.cpython-313.pyc create mode 100644 lrx/cache.py create mode 100644 lrx/cli.py create mode 100644 lrx/config.py create mode 100644 lrx/core.py create mode 100644 lrx/enrichers/__init__.py create mode 100644 lrx/enrichers/__pycache__/__init__.cpython-313.pyc create mode 100644 lrx/enrichers/__pycache__/audio_tag.cpython-313.pyc create mode 100644 lrx/enrichers/__pycache__/base.cpython-313.pyc create mode 100644 lrx/enrichers/__pycache__/file_name.cpython-313.pyc create mode 100644 lrx/enrichers/audio_tag.py create mode 100644 lrx/enrichers/base.py create mode 100644 lrx/enrichers/file_name.py create mode 100644 lrx/fetchers/__init__.py create mode 100644 lrx/fetchers/__pycache__/__init__.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/base.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/cache_search.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/local.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/lrclib.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/lrclib_search.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/musixmatch.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/netease.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/qqmusic.cpython-313.pyc create mode 100644 lrx/fetchers/__pycache__/spotify.cpython-313.pyc create mode 100644 lrx/fetchers/base.py create mode 100644 lrx/fetchers/cache_search.py create mode 100644 lrx/fetchers/local.py create mode 100644 lrx/fetchers/lrclib.py create mode 100644 lrx/fetchers/lrclib_search.py create mode 100644 lrx/fetchers/netease.py create mode 100644 lrx/fetchers/qqmusic.py create mode 100644 lrx/fetchers/spotify.py create mode 100644 lrx/lrc.py create mode 100644 lrx/models.py create mode 100644 lrx/mpris.py diff --git a/.claude/resume b/.claude/resume new file mode 100644 index 0000000..87046c6 --- /dev/null +++ b/.claude/resume @@ -0,0 +1 @@ +claude --resume 48d54aac-a89b-48c3-8a76-23e9eb73722d diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..acf6d3b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python-envs.defaultEnvManager": "ms-python.python:system" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f42724e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude.md + +The role of this file is to describe common mistakes and confusion points that agents might encounter as they work in this project. If you ever encounter something in the project that surprises you, please alert the developer working with you and indicate that this is the case in this file to help prevent future agents from having the same issue. diff --git a/lrx/__init__.py b/lrx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lrx/__main__.py b/lrx/__main__.py new file mode 100644 index 0000000..1407b6f --- /dev/null +++ b/lrx/__main__.py @@ -0,0 +1,4 @@ +from lrx.cli import run + +if __name__ == "__main__": + run() diff --git a/lrx/__pycache__/__init__.cpython-313.pyc b/lrx/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a3c3beefd47449605f7278a99143d75518b1f61 GIT binary patch literal 152 zcmey&%ge<81h0;t&IHkqK?DpiLK&Y~fQ+dO=?t2Tek&P@n1H;`AgNpC`WgATsruRZ zIoXND`a!7$`Nf$f`9+zj#rio#$!V!2$r%Vne0*kJW=VX!UP0w84x8Nkl+v73yCPPg TIUswAL5z>gjEsy$%s>_ZrTit> literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/__init__.cpython-314.pyc b/lrx/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4ebce988ceae99d1cbd5178aefd5c75c7791d161 GIT binary patch literal 180 zcmdPqh|o46^zIGb1D84L;E(?jjZ-7XaqzER_HN literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/__main__.cpython-313.pyc b/lrx/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c980ca9036b42c24ae8aa2fa0533b2fcd4baa42 GIT binary patch literal 261 zcmey&%ge<81iV&lIYq;;_lhPbtkwwJQSoiV=v5rGUf-W=2NFy9_c9n7KQuE;CEsWMOZ0{lLt?B2@%* GKLY^wtwBWq literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/__main__.cpython-314.pyc b/lrx/__pycache__/__main__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5908b00cdd5f551f54794d89737644e929b55ea GIT binary patch literal 267 zcmdPqSs9EqSzdy~G#PI(7nSDS;)svWP0Y-TkN4ALzQvPMl$@4YlANKJ zoRe9^3{(b^&r8frjgMc+@EN4PO4oI$Zw25Tr347J}@&fGTvp7dBDuwQFVn`@+J#=o9jnr1{SFz H4xlsuhG;;t literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/cache.cpython-313.pyc b/lrx/__pycache__/cache.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..670e768570159d1eff6a9b4981302143e2236f8f GIT binary patch literal 21695 zcmch9eRLDqm1jw%?=Q=?{0WA_U~Cy%rrU-FW1x*~jIj|QSwP3`D6%Bm>K2kyCG(-~ z&N!Lfw4sw=`eP82nZ-S4KIkO7A-i*S*mK-F(8+FR=Z`s3^ZadzN(VSrZ7ww!>e-lyfrKo#&BL$t9cyLBV zQSVV+ilw|NuX<3$s>oH%s>zjRX>!%D8gkXLTDa1Ky2E-_&tM(Rpy9BQH9}hJ)eV{s zn_2T=3u__w`a$bq8*4jU!j>Givv!6WRe9>AAr!Cml-IBW#({w?dtw=5p-lM`%b4Iv zlhBep-k}!m*or6CG(V+grPnfA<+YAhb!fb{TIv-ITP@2eAvrZ(`>4%ZDwflE%Sdi* zH3e^2UQ2l$g>kH#P&+G<@0hx$6O(balkuGo#3G?EGj;xKAU3gm=k{GMj>pfP4n(4^ zVEnYHH;@Q-GS6>+erN0U_SWZjFwbr8d~R3g_I6Wmmtwvgha!n^>sWvb zhnVPjHWFllf#77AIU7k#GE-r;m5Wca!7#%m;%s0d%miW~W_Wmr37?yaumNnu^edbx zw^PL%j*?abQAq2e@rj8r`z!oNPBpLZJ>WmqJ$#6-==FKJhX;XC3Fz?+9~Af7Nc9Zm4&9vqK15lBpP)BhD(8~Mtn7dLNd_~O>)CZ~Vf zXzSk5khi+;^1|;gFZ{dZg@3=i@ZXmg zzP`M0ad}~OdEvtH!e1{hytTaW&EZ|3syhu>cQ@Q;^2{G;WCZ!a(W;qt

-{?5^{@o*q9>e@d#_By1C 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 literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/cache.cpython-314.pyc b/lrx/__pycache__/cache.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f16fd4e629b9d47b521974e1be9a66171cf8126 GIT binary patch literal 25001 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 ZWwZtKuS+U4{PJTOpFv|;;wWYP{=eyJ&k+Cs literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/cli.cpython-313.pyc b/lrx/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e82c18fc4cbcdf9b065318517e8adc398befb46 GIT binary patch literal 17492 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 literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/cli.cpython-314.pyc b/lrx/__pycache__/cli.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ac351b057f308538d91efe5ab78120633818489 GIT binary patch literal 16108 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 Q>pww32R%pXmMpx;zQmg<0>YwSuK~;T1zf5 zyObrmZCJs8ZJ>bTLty997-$d4&Bq*jEYOP*7O1kV(E{zIHwA9*{b%{GT)77?#F_s! z|NQ^=&-|m;9*>iNkN(3SrC$N{56rYbTZ?#oU?zw!2$nzuYhul7CS)>ZGcv<$S+lHK zk(Isr^U4cS)h$ZqgmYmU`!)NRnVHRq}exeVIA=3ez656}+eWxG*N&ND&eytAg} zj;J?h!+fr17IwxT>dTp!b4~jA$Xq+Gx>$XUUY{cUgUeOQ;@1-=J!-Jlf*` z9!oK&YJ6E!NxqyXWu;gY5vht=wc`92jx{xD(yi;fwl!(iy;W62oWKiPBA1tt-qYj_ ze^>yfE?MF8Twc+{@(!@JrbD3YJ0enHL){@Gp&)9)mTqsQRdX0F1NqIGH2~|1O^nVh zO7&YNLaP7ULP$^}Li!eNLv3P9(<*9&qLhkQ4yYAHlL~tn28yppl%{Np<=R}^My&|4 zB$l7*S*s>Bo9gWHK`=lK5bhf{$8LcX2;Sz4wuODo)ZBDz5{y1kWUZ8@pbBw6OE*}xQe>1V;< zhh#!3S9i(X@EkWc^C5|LBEgw(U}}=QDGJ*PH9Zxag1;cSECImEE@fzI-FOFl-pJYo zvQyw@$qZOpr;uu&%gGEJSBJPOsT!wj*M2)+M50(;xR2`|*?`ZVO!{Lna%Ck_Dn(TF z7xPrpg8Vr-FN-^(yl~%`BIJA+luZ#<3UG}V?w9$J2x5bYv}A>9S-{uvf90}N?%0z4 zj{B0Rs(cY^n$C4=Jhhzu#;n^43M%oM-et&z)@fvTY&r(}T zNu;(Fd7D=$Myx2Rq$x-e)lQ+`5Y3htsO;%(vCMDE(B8z&Y7vp(DGNXKEDUwxpH}C? zcVncvwtECtkFWNsM^G!Un*4;q~V)a5;Z>0YdpfB z>yGI9I+uzjY2AsbXg0HwW^@;(Kc*Qro=)lAOVOnjnv2C5-HOA|t#=ivtlMKZxb2x-uJ2ROC$8MyS=$P)tbT-xEJeW(RGk|_J71R3+md@Nx zGiw~pFc8CFEXzbQ5CE4+Cuk-LPPini_qE$wCcQ?djCRo979}>A_!4W#PPJt$%|IeX zL~k3j^b$j744EAwmk{($3$!Byj|HI*x7RnfX+p3ZPtdxrtvQZSe~Qk~QI_UdI?6y( z!dps=>>PJUEKpb%tIBIOtfr%{jAC?go>-4Qc5v zpxbRwca-48FwVniSc!)5FoK6MJdk)8#{)iX2p>z`F<(;hRasm>E5PD^QPtnTcw{k| zOh1~fChKcAVRHV3aK0jj|3-vg5ocZzBd>@HM>fJeRCgSDhU(5Ayn~-_HN0o*t{-iL zXY6_S#U~B-RK5Gq>3(wY@x>=sA7A}^@5SYY(_goPMUU_4M=!!(f70-U8{X^RE`R5} zQFr}7xE`(ji5Q0Yfd`!T4%FQ+dHd_m!ycdydq?V?!-3%gq7TF);|Ihb%!Ai~KO9UR z5dDXvu><1Nk=4|7^~ge)d`AwV&-cvv)cMT&)cgFim*z%q2t169e|7E4YhTTOIsbL{ zLFi^&$!*~79 zzQyl+i@*0Zd|=-T%a5CcUT!peA2sFPk4=z9@5u8j&&LlgBo4gEy6f+R`Jrpy^(g+r z`aN;}$YJR^i{o}5c?pyKp=aOopy!aVJ#_9nABCPo9!H*Uza09W2>-`sF)bMN`!8!N BYcc=; literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/core.cpython-313.pyc b/lrx/__pycache__/core.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a638a934fed6bdbed667c6847f605aa8a78f478f GIT binary patch literal 7543 zcmcgRTWlNGm6zlU#WyKY59&R!6hwEtHIAsc$v6;5(rYr#~w$ip~$`-I= zd%%Gm0Vj6SG4qrwP><^a4Y(oT#_m8PZlvRusir_PZVt5ImVgI)=$Lh?HPD9JI27}Q zk812zeq;48K33^#tylw$`?X%>H|B=%6P3Q!iZu>1xI4zhJTZOjH5P`C=t&*h7qK0L zUk}=cBK9^Eag6F!9K3(t>2>9P0zJ!_T=sef`?=^cFQ$?Lx3v5&FU}7i9UeP9mw9iI zPo;f{%%WwS&kBC-(D0$7gTsdh4~=poBmUuI{?WshaY0Jp)KWH;5&hh`3>LTyPFxqH zEatNr%>Ci!Pq;)TgOe!{NJ!kAkcC++aqp(G*SQ1_ok9Lx9?<6abb6LgELi?Zxb*52 z(?v>^Pea?7&dkpX_^)ur$g5ZCCsi^*0k}`jEH4R)Gl2zIA+9X|NY=BwB$OdZ_Z(Sm zLYz;Dg4dup#0*~K)2S69p5^ByrJ>T#iPEx|5RytmQpgI4Y@BrCQkKu=Brl^_W@e`2 zkspT6O^hox(vF78Jv-@zF3!X!FGfS-N3YDcq%>-4qptzy5<=Sy9Ca8#X7x= zQ;D!3<@fF zoFHcLGPjgTiCJzAE(txelsM0&i9D+A28{U>BNnnbEQS)~@U>!q2Kj$>8LE%a)t$M~ zGDH-mcgC&~wrPo?a9Iho5%N1)h6Ef!la_h(P#5wt*bF^|r56>~k+!MohB1>%z7BcAtUK$DuOBpEz8jupCp)^iVq)HMONFbrWqhaX-Gc%KXT(NM`OeJ5d8LFhyc zVQURAbYDbYqr{=$1%#}KtCo=M+q6JJ`l!K;F0+z;2uXU_g9D*VGz@iZ`My5rQfW$h zk=-rTsG$e2IrtaZIpj60_{PCH;YBcKT=qJ#yGa2HNiLm|vRr1aVrsJ)4)o2p;);q3 zV&+|uQ}2hLTX9d8jV>n%Z|4NCd4A4o!kxetCTFd%0>&AvFbl%6q}WnYoPP(N1b#Lx zC`Nv1Nf46?n-peq^NLl<=;kCN%y6{eZUW?t z&*oC;WPAs^ed=tqQ9At%~Nu=e|GYdlLhDSmaF-m?XE4~J@m-7@pt*o zQ=6{um0DVFn@f$Jd%-)w^#QqYDDNK5+lHSU=v_6Hn%nLz-dQX*_sY$^Uo`ivPL`bR zTQ@$qv36N@_7t5XvUB9o>qX~S!8ulPdG0yyIMc{M; z5g3VieFUy#lyRf;`V0R6>S>G`!`j0H>}y4=iyAb%^SWdMsF^Xmhr(3B04k{B9*PpK z*Qi#0ue9uNXo{LND*R)hVG6@)wdHD~HWF^uT9se_6)=BrjbAETL$uBTe&u09#V|xo zL{Y&AzHD7jWj(D$*L`i*y5RHH7BDL6y6moX*#wxis7Yjl|N08M?x>{-t5#$}RJf)b z(I1Bq{>UqHXWUx?VXlTSbU}u7huMk@&+CF-gf8~K_>Lq&HdQC|B1xI5CH0Cp=4u?^ zgjlqia>X679Mg;Ds9C#lNwA-lPuq%f5ZUSlq)S)K91OqF&yD=FY1-M^?%nld$r`E=CZ!MY~AnB zwUqi;5s5|aY)hak2|FZ(Tn9E~=)>#3|7>Z%c?#=sxm zzssp%4VPM66p|?jUDM0o#u9~jKAU<+;MCxg>)i=1xgjpJE3)JWO=%&uRAzUF*kVeO zz&Yf!sFngI!12>O!~wDmvAhqmBTOSwi{;E5_?H}kR3#vBBv8*nOp_cavoBE*%tE|h z#)5z@Cnhbu<^7(jEPILzWgznQSyr0pVN?Zta2|dxr0A}$v|TIn0fO^$yM(sl-8Hwf zopR9Fo5+aSl$Zmu-WB^U&=t!7mCO>%t+=!))#VbBD~)6%4F^82NyO*#d3Bg5%u4&U z@=oE@3IANLm?>}7`w)@}*=!mdR#-bJt+=mo^O15gKrN+c2@ks*lg4#JP-O zOQxiyG`~z8cuZ^wCMF*IdI)hg9N zUN_Nm4%K(AMGLO|tL&D^am)IlHQ(I7F;N^iB@dh`44lsQpUF3#-87wh;%+Ir56bR? z8*IUSV%79}XG_W5vTa1ZL#x(5wREqZFEw=(n|kG@-qp!(jHr>@s4Mm!hrfL9@ltE& zgSPu^54!Gm0eb7v+s5C!TT3mS_io&|@wv6wdsgl}TWC4E<>`80{n)zBZy49#%MZPo z?+I*rrb^wfe{3(cc0cI6-}!lev2RlDn=G_W5@6fMwo+I3x^eBj{K4<%J1#vp=v&)> zr;dYiyRXzYyf*ba4_8Lr^6W2q24&A6ZH&mCk!{w{-~0?2+L|F80{Rtd>o*=<{>8PQ zUn`DYkVh}%kG%PBZTXHFO8qM%YJKg$)V;!X%i-JF+w0BirhM;MzVq0o%m3Vn?Cx9B zA5O20tX*Eaknb7Gw;tQH`2p?TwL90=M{i%tH;u1O{@LAHL*QJ#b9~b^p}0J-jmGw( z`vClvnmg{jb?2>O^QBLxKD+qI#YdCB2>v|y+mnT!OScUrSK~d)9m_q(9miU3WA3&? zc1;4VtC<9apIVEYU*`M;r=Kijy=z_LRdM;3W6Q%G-lxW7Tt%0@0heMix?Pj>D5bfI|QtbE{X!F9IO*j;Qq z0Dph#@|M~U6x#>o_Q9?8!HtEY@3ibYU1&eEGhFiQf8f0Dd}?O98*W>l+lch0KAb8w zj6CWu9vYVqjps)uwvld9*AI35z;lDa?$~x9S9{*n_M9z)=i7W6x#G|ndFV`G=v>}6 zp7%^_+9sbk+W-phgIiBkbMHh`7d*QEKk@3-orF(xCkZ9$uM)MU<*Hq4Rp$4?iReih z6FeG{n0m#SGYa1sq6WZYiQoHDPspfwSsa=-c zB)-GsD+4A0ylQkwh)zvEo`4qQL@UW1YMI}JQHgkK#C)o$9J|T_Bsa;f6i-zmXV(nG zJ4m_#6(Z<>Nz^YTULVv;CIk1IsPP2aemhwd)(kUyiRjWYZbVHaCv^hv^W# z%3i&?M+n~F69aCApAaU=SFb)a>1`mXT9Vs>RIMc*U(6(PY0|dE<8Q+OmS>#t_*@E0 zS>im38JMn%$CH^vJdQaagh|c67I^jCx z?ZGqfs~EG(#6zPJqF;P+I7eL+H6cl4rl#QS#J2Zt%7CG~WumqGm+v-$xBzE!E}@jZfdnp9^$86h-I_7H^(sevWt$#sdkI{k0sOw8~UPkA?M4H)4Fz&Halge6OL}%%#P@9T32Uo_piOTVYq)I-!LHC2cIEU Rr~k$k((4X?g-A`g{U2dbN7VoT literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/lrc.cpython-313.pyc b/lrx/__pycache__/lrc.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d398addf208af1c02718bc5851f069ac6c3bcc2 GIT binary patch literal 8380 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 literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/lrc.cpython-314.pyc b/lrx/__pycache__/lrc.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6864851c703ebd1dc6a9a1bd3bd1485ff59b2abe GIT binary patch literal 5194 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 aUz^}RiLVDJ$P&J`LzH>A51S|XivI%)vL&7X literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/models.cpython-313.pyc b/lrx/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af046de67b827ce0912d2a41f760d75e599bc113 GIT binary patch literal 3286 zcmb7GO>Er86&~(#x!fQ9Se9Z(itJG=p=MBPRap`eDXE=G@}J~Yv6SNcY`{`nN@C0< zH$xKkE^=6)Kr5Z39E>Wb#;3-o^q8D-Es%=}1+_{bg^HehQy>?<_C1ojS}97=4!}1v z-+MEhdB1bm)|MvFK7aA8(Uv6SUpQ$rAsQUkVep8^gb|sOV{_b{7>iLH$LHdAc*X}~ zelBrGU;-uQ$*V+8^b%PpCX2CgKHPgtnpbYyhm%E;9#qvLdB zY;xp{$*U9T8QsyCQE?61nxr76(z0FBO(*>f4uc_-RFfD|;r6vx@Sur6Q)bq@t8;O;K114$06@SOy86+3!&l2d-dfit4(|SoU1q0ns+#9l}Ta@!XnS)^qo5 z^S~_{b~FgBFkGoJa6%Fg{EO2Io`OorKP~Vmknzb9LYr~YA?Gg7_>xJm|doKe_gmOQ=8GU zYJugcVO2bLIPWI|VWZ>=rf#jeYkopCm%XwtsLVAS*H5^HYwCX7W2P@K-SwEIHT)lr zKu~L__QML0N92PhZ;2G5PzOe{y~Sb7U{m`Dq2zR_0`7XE$=rCfccUL9Qmi(*$UL1s zSko=)uIbU$4F|Mf+8W$*#V~bx8R=v$N0$oo(r_&qO{KWcFWa`sF2KM1GeD@f55Q+IX0=a>o`@DMIR{|L2s7PL6`=^UuA_i^tRi(kI~n%AWd9h zIwY56h##g$7KgNNTXY4|h-t5`Lbe&M#pxAVOI`M?`>5AAl)9r2wn z+%G?Q^2yWOJ72lqJpB404E{0&a9CrotVunr9Xi&5q!S4khOjOq!KKiie%x`H52aMK zEF0^iZDCo&(D361RP!J{g3t(pB*-~ph1nIIxm7lRn_&Pj2}usgStNLX@F?)6=)&#u3|HNElHE`RaA z@?P$*Pp_Zfy0p>1%lGdm+74o|#M|8V;32{OFiwQ_5IPW};Z1IP5xF;yxHq{2F`2l> zZEG(G_TlOA;2M0*)(E-7-vD%4u`VWxf*cP@D$kNdv6#ZL09aU}d6sU$!V=B13}D$v zE-cX^Yg^^<^)-(VtAg%$ru%FRcB)Ng1L)N>#Ks!sSm zgILgPRvPvNd<1z;9ttP!>i}STOoLMCx@KTCsr{$j24k9yB6$M|IuQE>l1U^}NUkC| z%>^cqfdnt%i~~UznBC8w+vU%lFz<0kI5O|w`2Nr=xKCcn1kUX9I=q1y8papgY6apu zIGLYmzJqm#p>o5s2A2pNGC0z4PxZw&!{_EN*)>?f!RNdST?aJ*RNAOzkFcmj9f07NeYoF8H^3a;}X D*H6kn literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/models.cpython-314.pyc b/lrx/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14d49aa2c2738bbe4f298994db62dff00dfd3c01 GIT binary patch literal 4289 zcmbVPUrZdw8K1q~`^Vh@hQoG@2}`hDpA+Bp4^>^0*u;Q=8UtA{R?_0tvbVt2d%Ks} zxjLNkaB8F0d9ZAr8b39WDm6T%kI7S{_N9*?TQXiLs--?ueS?DPJofu$_qYXNoOY!B zX7>ALXJ_X3{l59;_2x_yfwK9|U(E|~LjHj-^%4sj+rNTFjVOeXTSO7YgwY6#jEYR` z#`nmWG#X`5-WJDVqj47RCLN@QDAF;aMCTH7(LTu^i6yx{2KxA1q)!aAO{JZadj zl%P&bmYM0;nxzMe;gt})Y@dThjm!}y!0IBm5=>MCCMgjXRYVq3Bo1!?e=TgS&%hZM?+DzmZ_=BhRx`5-fNz|F*rCpJ*`f^J25ys&EwapUNu1Mey8810QHU0K=gK5TVi=wg}I*4B}0pMrO&a$c2vxDT4MS zc}G|h6k%HUIVlLeWHs_H?C-^>!79{-F{OFOv4AWwU{6V7UPe`QOLN^K{0O`Od%sqr zS^Nk*OS5eU90dLB=a|Z z)W|RD3xkDB;qUjAe&Kt8rU?H!NxEOqZwklVR}`Ty7`j;N+M0hxZu#O0g zsX#|w@POd3mZ9--SOILw|1c~;TR&(*YS3aJ*;n#X2vnik*8o+s5JST%x-vE1ub335 zHoq*7D}&=>S7ePEa+w+~gynoL>WR?Xi&=)f$d4GQ1doYXS$)z@Njo zrT|0TDU~gQ8D7U8XzOYjKRFAcMiAY0K=<0pHNf@6XD2?N`Qp9R_rA=2-T77LTKn*$ zmaA)ttBno)|FMN5PK4>c9Np76yIs=8qc;Ivln-^kZcGL`@&d^E=nla2Y9vp}0-zEY z!_{80&I(ib<-T(8mvuBb!4L3_?>O=SUxr3*1d}!lCW)VH^43PqGgOxoaSfb zAhH)tw`^(4Jf8?h_OPBi$s&wUBhOmf?nXCKE%*EH_uuW`NFTXB{_*%H**|nX?0lR) zjfRW&FW$Y_Fr0XJ;&J-a-%sW4R_<3nu6}a*QH#8mnEjl6@!{%+PYSbZh1oCJ*B^ZK zf#3dHKNuTMS7!i1iSD!g7Z3*@bp?V)9X!Gt7~O)>A5jD(ZD5P+vqeI-{ojd)*rY>j z(L-!8v;pjKib0=VKye;L9tDEQLG)1*DIR1L(-TiLZT50Dw?bF3i4LC3!1_iZn@QR|fCBwJvpUWl+%^>d}IV)=-ZlsAwZi zZ7aR^y4Izxf475zgT)iYT$W~V!p$gJP~d#%5fliXm*#mP1We4x4_~~OC{w3wP_|4l zcG4f9xQK%s3FRCM6&n*;ATZbOrZ2i-S*p4!_{SPL(ExXMdK8C2!FQ?z4cMs~WTZcP z+TQi)%;WZRwee?NJ++aIlXC6qMrU_zIMjNjt2X#7nt2|<Bd+Ja?v!;MdMy- z9Pb{A;Z`hG?e}Z{ZsK1`_^nBXwBIO|*&DKExsF_M;h@x9|MmhQNq1;5KSS}L*qejy z60-`X!-yFoBIcwoBH}@|IuX$(?jtZKA`mLd359twUculL$2CLkIu)uLp2(P$llUPG-6HX(e}TP3(fg6B z?oUB%ih>|KCEfod=e{8w-;mCoSX4Omp!WgWA<)=qJt^E29wc`N);pKt!mAH*{~=KE Gq5lgebZzwj literal 0 HcmV?d00001 diff --git a/lrx/__pycache__/mpris.cpython-313.pyc b/lrx/__pycache__/mpris.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5fb6c2f04d62df508123fc9a753ed73ce6f514b6 GIT binary patch literal 8751 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 literal 0 HcmV?d00001 diff --git a/lrx/cache.py b/lrx/cache.py new file mode 100644 index 0000000..7e16fc3 --- /dev/null +++ b/lrx/cache.py @@ -0,0 +1,441 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 10:18:03 +Description: SQLite-based lyric cache with per-source storage and TTL expiration +""" + +import re +import sqlite3 +import hashlib +import time +import unicodedata +from typing import Optional +from loguru import logger + +from .config import DB_PATH, DURATION_TOLERANCE_MS +from .models import TrackMeta, LyricResult, CacheStatus + +# Punctuation to strip for fuzzy matching (ASCII + fullwidth + CJK brackets/symbols) +_PUNCT_RE = re.compile( + r"[~!@#$%^&*()_+\-=\[\]{}|;:'\",.<>?/\\`" + r"~!@#$%^&*()_+-=【】{}|;:'",。<>?/\`" + r"「」『』《》〈〉〔〕·•‥…—–]" +) +_SPACE_RE = re.compile(r"\s+") +# feat./ft./featuring and everything after (case-insensitive, word boundary) +_FEAT_RE = re.compile(r"\s*(?:\bfeat\.?\b|\bft\.?\b|\bfeaturing\b).*", re.IGNORECASE) +# Multi-artist separators: /, &, ×, x (surrounded by spaces), ;, 、, vs. +_ARTIST_SEP_RE = re.compile(r"\s*(?:[/&;×、]|\bvs\.?\b|\bx\b)\s*", re.IGNORECASE) + + +def _normalize_for_match(s: str) -> str: + """Normalize a string for fuzzy comparison. + + Lowercases, NFKC-normalizes (fullwidth → halfwidth), strips punctuation, + and collapses whitespace. + """ + s = unicodedata.normalize("NFKC", s).lower() + s = _FEAT_RE.sub("", s) + s = _PUNCT_RE.sub(" ", s) + s = _SPACE_RE.sub(" ", s).strip() + return s + + +def _normalize_artist(s: str) -> str: + """Normalize an artist string: split by separators, normalize each, sort. + + Splits first (on /, &, ;, ×, 、, vs., x), then strips feat./ft./featuring + from each part individually, so 'A feat. C / B' → ['a', 'b'] not just ['a']. + """ + s = unicodedata.normalize("NFKC", s).lower() + parts = _ARTIST_SEP_RE.split(s) + normed = sorted( + {_normalize_for_match(p) for p in parts if _FEAT_RE.sub("", p).strip()} + ) + return "\0".join(normed) if normed else _normalize_for_match(s) + + +def _generate_key(track: TrackMeta, source: str) -> str: + """Generate a unique cache key from track metadata and source. + + The key is scoped by source so that different fetchers can cache + independently for the same track (e.g. Spotify synced vs Netease unsynced). + """ + # Spotify tracks always use their track ID as the primary identifier + if track.trackid and source == "spotify": + return f"spotify:{track.trackid}" + + parts = [] + if track.artist: + parts.append(track.artist) + if track.title: + parts.append(track.title) + if track.album: + parts.append(track.album) + if track.length: + parts.append(str(track.length)) + + # Fall back to URL for local files + if not parts and track.url: + return f"{source}:url:{track.url}" + + if not parts: + raise ValueError("Insufficient metadata to generate cache key") + + raw = "|".join(parts) + digest = hashlib.sha256(raw.encode()).hexdigest() + return f"{source}:{digest}" + + +class CacheEngine: + def __init__(self, db_path: str = DB_PATH): + self.db_path = db_path + self._init_db() + + def _init_db(self) -> None: + """Create or migrate the cache table.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + source TEXT NOT NULL, + status TEXT NOT NULL, + lyrics TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + artist TEXT, + title TEXT, + album TEXT, + length INTEGER + ) + """) + # Migration: add length column if missing + cols = {r[1] for r in conn.execute("PRAGMA table_info(cache)").fetchall()} + if "length" not in cols: + conn.execute("ALTER TABLE cache ADD COLUMN length INTEGER") + conn.commit() + + # Read + + def get(self, track: TrackMeta, source: str) -> Optional[LyricResult]: + """Look up a cached result for *track* from *source*. + + Returns None on cache miss or expiration. + """ + try: + key = _generate_key(track, source) + except ValueError: + return None + + with sqlite3.connect(self.db_path) as conn: + row = conn.execute( + "SELECT status, lyrics, source, expires_at, length FROM cache WHERE key = ?", + (key,), + ).fetchone() + + if not row: + logger.debug(f"Cache miss: {source} / {track.display_name()}") + return None + + status_str, lyrics, src, expires_at, cached_length = row + + # Check TTL expiration + if expires_at and expires_at < int(time.time()): + logger.debug(f"Cache expired: {source} / {track.display_name()}") + conn.execute("DELETE FROM cache WHERE key = ?", (key,)) + conn.commit() + return None + + # Backfill length if the cached row is missing it + if cached_length is None and track.length is not None: + conn.execute( + "UPDATE cache SET length = ? WHERE key = ?", + (track.length, key), + ) + conn.commit() + + remaining = expires_at - int(time.time()) if expires_at else None + logger.debug( + f"Cache hit: {source} / {track.display_name()} " + f"[{status_str}, ttl={remaining}s]" + ) + return LyricResult( + status=CacheStatus(status_str), + lyrics=lyrics, + source=src, + ttl=remaining, + ) + + def get_best(self, track: TrackMeta, sources: list[str]) -> Optional[LyricResult]: + """Return the best cached result across *sources* (synced > unsynced). + + Skips negative statuses (NOT_FOUND, NETWORK_ERROR) — those are only + consulted per-source to avoid redundant fetches. + """ + best: Optional[LyricResult] = None + for src in sources: + cached = self.get(track, src) + if not cached: + continue + if cached.status == CacheStatus.SUCCESS_SYNCED: + return cached # Can't do better + if cached.status == CacheStatus.SUCCESS_UNSYNCED and best is None: + best = cached + return best + + # Write + + def set( + self, + track: TrackMeta, + source: str, + result: LyricResult, + ttl_seconds: Optional[int] = None, + ) -> None: + """Store a lyric result in the cache.""" + try: + key = _generate_key(track, source) + except ValueError: + logger.warning("Cannot cache: insufficient track metadata.") + return + + now = int(time.time()) + expires_at = now + ttl_seconds if ttl_seconds else None + + with sqlite3.connect(self.db_path) as conn: + conn.execute( + """INSERT OR REPLACE INTO cache + (key, source, status, lyrics, created_at, expires_at, + artist, title, album, length) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + key, + source, + result.status.value, + result.lyrics, + now, + expires_at, + track.artist, + track.title, + track.album, + track.length, + ), + ) + conn.commit() + logger.debug( + f"Cached: {source} / {track.display_name()} " + f"[{result.status.value}, ttl={ttl_seconds}s]" + ) + + # Delete + + def clear_all(self) -> None: + """Remove every entry from the cache.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute("DELETE FROM cache") + conn.commit() + logger.info("Cache cleared.") + + def clear_track(self, track: TrackMeta) -> None: + """Remove all cached entries (every source) for a single track.""" + conditions, params = self._track_where(track) + if not conditions: + logger.info(f"No cache entries found for {track.display_name()}.") + return + where = " AND ".join(conditions) + with sqlite3.connect(self.db_path) as conn: + cur = conn.execute(f"DELETE FROM cache WHERE {where}", params) + conn.commit() + if cur.rowcount: + logger.info( + f"Cleared {cur.rowcount} cache entries for {track.display_name()}." + ) + else: + logger.info(f"No cache entries found for {track.display_name()}.") + + def prune(self) -> int: + """Remove all expired entries. Returns the number of rows deleted.""" + with sqlite3.connect(self.db_path) as conn: + cur = conn.execute( + "DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?", + (int(time.time()),), + ) + conn.commit() + count = cur.rowcount + logger.info(f"Pruned {count} expired cache entries.") + return count + + @staticmethod + def _track_where(track: TrackMeta) -> tuple[list[str], list[str]]: + """Build WHERE conditions to match a track across all sources.""" + conditions: list[str] = [] + params: list[str] = [] + if track.artist: + conditions.append("artist = ?") + params.append(track.artist) + if track.title: + conditions.append("title = ?") + params.append(track.title) + if track.album: + conditions.append("album = ?") + params.append(track.album) + return conditions, params + + # Exact cross-source search + + def find_best_positive(self, track: TrackMeta) -> Optional[LyricResult]: + """Find the best positive (synced/unsynced) cache entry for *track*. + + Uses exact metadata match (artist + title + album) across all sources. + Returns synced if available, otherwise unsynced, or None. + """ + conditions, params = self._track_where(track) + if not conditions: + return None + + now = int(time.time()) + conditions.append("status IN (?, ?)") + params.extend( + [CacheStatus.SUCCESS_SYNCED.value, CacheStatus.SUCCESS_UNSYNCED.value] + ) + conditions.append("(expires_at IS NULL OR expires_at > ?)") + params.append(str(now)) + + where = " AND ".join(conditions) + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + f"SELECT status, lyrics, source FROM cache WHERE {where} " + "ORDER BY CASE status WHEN ? THEN 0 ELSE 1 END LIMIT 1", + params + [CacheStatus.SUCCESS_SYNCED.value], + ).fetchall() + + if not rows: + return None + + row = dict(rows[0]) + return LyricResult( + status=CacheStatus(row["status"]), + lyrics=row["lyrics"], + source="cache-search", + ) + + # Fuzzy search + + def search_by_meta( + self, + artist: Optional[str], + title: Optional[str], + length: Optional[int] = None, + ) -> list[dict]: + """Search cache for lyrics matching artist/title with fuzzy normalization. + + Ignores album and source. Only returns positive results (synced/unsynced) + that have not expired. When *length* is provided, filters by duration + tolerance and sorts by closest match. + """ + if not title: + return [] + + now = int(time.time()) + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """SELECT * FROM cache + WHERE status IN (?, ?) + AND (expires_at IS NULL OR expires_at > ?)""", + ( + CacheStatus.SUCCESS_SYNCED.value, + CacheStatus.SUCCESS_UNSYNCED.value, + now, + ), + ).fetchall() + + norm_title = _normalize_for_match(title) + norm_artist = _normalize_artist(artist) if artist else None + + matches: list[dict] = [] + for row in rows: + row_dict = dict(row) + # Title must match + row_title = row_dict.get("title") or "" + if _normalize_for_match(row_title) != norm_title: + continue + # Artist must match if provided + if norm_artist: + row_artist = row_dict.get("artist") or "" + if _normalize_artist(row_artist) != norm_artist: + continue + matches.append(row_dict) + + # Duration filtering + if length is not None and matches: + scored = [] + for m in matches: + row_len = m.get("length") + if row_len is not None: + diff = abs(row_len - length) + if diff <= DURATION_TOLERANCE_MS: + scored.append((diff, m)) + else: + # No duration info in cache — still a candidate but lower priority + scored.append((DURATION_TOLERANCE_MS, m)) + scored.sort( + key=lambda x: ( + x[0], + x[1].get("status") != CacheStatus.SUCCESS_SYNCED.value, + ) + ) + matches = [m for _, m in scored] + + return matches + + # Query / inspect + + def query_track(self, track: TrackMeta) -> list[dict]: + """Return all cached rows for a given track (across all sources).""" + conditions, params = self._track_where(track) + if not conditions: + return [] + where = " AND ".join(conditions) + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + return [ + dict(r) + for r in conn.execute( + f"SELECT * FROM cache WHERE {where}", params + ).fetchall() + ] + + def query_all(self) -> list[dict]: + """Return every row in the cache table.""" + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + return [dict(r) for r in conn.execute("SELECT * FROM cache").fetchall()] + + def stats(self) -> dict: + """Return aggregate cache statistics.""" + now = int(time.time()) + with sqlite3.connect(self.db_path) as conn: + total = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0] + expired = conn.execute( + "SELECT COUNT(*) FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?", + (now,), + ).fetchone()[0] + by_status = dict( + conn.execute( + "SELECT status, COUNT(*) FROM cache GROUP BY status" + ).fetchall() + ) + by_source = dict( + conn.execute( + "SELECT source, COUNT(*) FROM cache GROUP BY source" + ).fetchall() + ) + return { + "total": total, + "expired": expired, + "active": total - expired, + "by_status": by_status, + "by_source": by_source, + } diff --git a/lrx/cli.py b/lrx/cli.py new file mode 100644 index 0000000..48d7640 --- /dev/null +++ b/lrx/cli.py @@ -0,0 +1,426 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-26 02:04:39 +Description: CLI interface +""" + +import sys +import time +import os +from pathlib import Path +from typing import Annotated +from urllib.parse import quote +import cyclopts +from loguru import logger + +from .config import enable_debug +from .models import TrackMeta, CacheStatus +from .mpris import get_current_track +from .core import LrcManager +from .fetchers import FetcherMethodType +from .lrc import get_sidecar_path + + +app = cyclopts.App( + help="LRCFetch — Fetch line-synced lyrics for your music player.", +) +app.register_install_completion_command() + +cache_app = cyclopts.App(name="cache", help="Manage the local SQLite cache.") +app.command(cache_app) + +manager = LrcManager() + +# Global state set by the meta launcher +_player: str | None = None + + +@app.meta.default +def launcher( + *tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)], + debug: Annotated[ + bool, + cyclopts.Parameter( + name=["--debug", "-d"], negative="", help="Enable debug logging." + ), + ] = False, + player: Annotated[ + str | None, + cyclopts.Parameter( + name=["--player", "-p"], + help="Target a specific MPRIS player using its DBus name or a portion thereof.", + ), + ] = None, +): + global _player + if debug: + enable_debug() + _player = player + app(tokens) + + +# fetch + + +@app.command +def fetch( + *, + method: Annotated[ + FetcherMethodType | None, + cyclopts.Parameter(help="Force a specific source."), + ] = None, + no_cache: Annotated[ + bool, + cyclopts.Parameter( + name="--no-cache", negative="", help="Bypass the cache for this request." + ), + ] = False, + only_synced: Annotated[ + bool, + cyclopts.Parameter( + name="--only-synced", negative="", help="Only accept synced (timed) lyrics." + ), + ] = False, +): + """Fetch and print lyrics for the currently playing track.""" + track = get_current_track(_player) + + if not track: + logger.error("No active playing track found.") + sys.exit(1) + + logger.info(f"Track: {track.display_name()}") + + result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache) + + if not result or not result.lyrics: + logger.error("No lyrics found.") + sys.exit(1) + + if only_synced and result.status != CacheStatus.SUCCESS_SYNCED: + logger.error("Only unsynced lyrics available (--only-synced requested).") + sys.exit(1) + + print(result.lyrics) + + +# search + + +@app.command +def search( + *, + title: Annotated[ + str | None, cyclopts.Parameter(name=["--title", "-t"], help="Track title.") + ] = None, + artist: Annotated[ + str | None, cyclopts.Parameter(name=["--artist", "-a"], help="Artist name.") + ] = None, + album: Annotated[str | None, cyclopts.Parameter(help="Album name.")] = None, + trackid: Annotated[str | None, cyclopts.Parameter(help="Spotify track ID.")] = None, + length: Annotated[ + int | None, + cyclopts.Parameter( + name=["--length", "-l"], help="Track duration in milliseconds." + ), + ] = None, + url: Annotated[ + str | None, + cyclopts.Parameter( + help="Local file URL (file:///...). Mutually exclusive with --path." + ), + ] = None, + path: Annotated[ + str | None, + cyclopts.Parameter( + name=["--path"], + help="Local audio file path. Mutually exclusive with --url.", + ), + ] = None, + method: Annotated[ + FetcherMethodType | None, cyclopts.Parameter(help="Force a specific source.") + ] = None, + no_cache: Annotated[ + bool, + cyclopts.Parameter( + name="--no-cache", negative="", help="Bypass the cache for this request." + ), + ] = False, + only_synced: Annotated[ + bool, + cyclopts.Parameter( + name="--only-synced", negative="", help="Only accept synced (timed) lyrics." + ), + ] = False, +): + """Search for lyrics by metadata (bypasses MPRIS).""" + if url and path: + logger.error("--url and --path are mutually exclusive.") + sys.exit(1) + + if path: + resolved = str(Path(path).resolve()) + url = "file://" + quote(resolved, safe="/") + + track = TrackMeta( + title=title, + artist=artist, + album=album, + trackid=trackid, + length=length, + url=url, + ) + + logger.info(f"Track: {track.display_name()}") + + result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache) + + if not result or not result.lyrics: + logger.error("No lyrics found.") + sys.exit(1) + + if only_synced and result.status != CacheStatus.SUCCESS_SYNCED: + logger.error("Only unsynced lyrics available (--only-synced requested).") + sys.exit(1) + + print(result.lyrics) + + +# export + + +@app.command +def export( + *, + output: Annotated[ + str | None, + cyclopts.Parameter( + name=["--output", "-o"], + help="Output file path (default: same directory as audio file with .lrc extension, or current directory if not available).", + ), + ] = None, + method: Annotated[ + FetcherMethodType | None, cyclopts.Parameter(help="Force a specific source.") + ] = None, + no_cache: Annotated[ + bool, cyclopts.Parameter(name="--no-cache", negative="", help="Bypass cache.") + ] = False, + overwrite: Annotated[ + bool, + cyclopts.Parameter( + name=["--overwrite", "-f"], negative="", help="Overwrite existing file." + ), + ] = False, +): + """Export lyrics of the current track to a .lrc file.""" + track = get_current_track(_player) + if not track: + logger.error("No active playing track found.") + sys.exit(1) + + result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache) + if not result or not result.lyrics: + logger.error("No lyrics available to export.") + sys.exit(1) + + # Build default output path + if not output: + if track.url: + lrc_path = get_sidecar_path(track.url, ensure_exists=False) + if lrc_path: + output = str(lrc_path) + logger.info(f"Exporting to sidecar path: {output}") + + # Fallback to current directory with sanitized filename + if not output: + filename = ( + f"{track.artist} - {track.title}.lrc" + if track.artist and track.title + else "lyrics.lrc" + ) + # Sanitize filename + filename = "".join( + c for c in filename if c.isalpha() or c.isdigit() or c in " -_." + ).rstrip() + output = os.path.join(os.getcwd(), filename) + + if os.path.exists(output) and not overwrite: + logger.error(f"File exists: {output} (use -f to overwrite)") + sys.exit(1) + + try: + with open(output, "w", encoding="utf-8") as f: + f.write(result.lyrics) + logger.info(f"Exported lyrics to {output}") + except Exception as e: + logger.error(f"Failed to write file: {e}") + sys.exit(1) + + +# cache subcommands + + +@cache_app.command +def query( + *, + all: Annotated[ + bool, + cyclopts.Parameter(name="--all", negative="", help="Dump all cache entries."), + ] = False, +): + """Show cached entries for the current track.""" + if all: + rows = manager.cache.query_all() + if not rows: + print("Cache is empty.") + return + for row in rows: + _print_cache_row(row) + print() + return + + track = get_current_track(_player) + if not track: + logger.error("No active playing track found.") + sys.exit(1) + _print_track_cache(track) + + +@cache_app.command +def clear( + *, + all: Annotated[ + bool, + cyclopts.Parameter(name="--all", negative="", help="Clear the entire cache."), + ] = False, +): + """Clear cached entries for the current track.""" + if all: + manager.cache.clear_all() + return + + track = get_current_track(_player) + if not track: + logger.error("No active playing track found.") + sys.exit(1) + manager.cache.clear_track(track) + + +@cache_app.command +def prune(): + """Remove expired cache entries.""" + manager.cache.prune() + + +@cache_app.command +def stats(): + """Show cache statistics.""" + s = manager.cache.stats() + print("=== Cache Statistics ===") + print(f"Total entries : {s['total']}") + print(f"Active : {s['active']}") + print(f"Expired : {s['expired']}") + if s["by_status"]: + print("\nBy status:") + for status, count in s["by_status"].items(): + print(f" {status}: {count}") + if s["by_source"]: + print("\nBy source:") + for source, count in s["by_source"].items(): + print(f" {source}: {count}") + + +@cache_app.command +def insert( + *, + path: Annotated[ + str | None, + cyclopts.Parameter( + name=["--path"], + help="Path to a local .lrc file to insert instead of reading from stdin.", + ), + ] = None, +): + """Manually insert lyrics into the cache for the current track.""" + track = get_current_track(_player) + if not track: + logger.error("No active playing track found.") + sys.exit(1) + + if path: + try: + with open(path, "r", encoding="utf-8") as f: + lyrics = f.read() + except Exception as e: + logger.error(f"Failed to read file: {e}") + sys.exit(1) + else: + logger.info("Reading lyrics from stdin (Ctrl+D to finish)...") + lyrics = sys.stdin.read() + + manager.manual_insert(track, lyrics) + + +# helpers + + +def _print_track_cache(track: TrackMeta) -> None: + """Print all cached entries for a given track.""" + print(f"Track: {track.display_name()}") + if track.album: + print(f"Album: {track.album}") + if track.length: + secs = track.length / 1000.0 + print(f"Duration: {int(secs // 60)}:{secs % 60:05.2f}") + print() + + rows = manager.cache.query_track(track) + if not rows: + print(" (no cache entries)") + return + + for row in rows: + _print_cache_row(row, indent=" ") + + +def _print_cache_row(row: dict, indent: str = "") -> None: + """Pretty-print a single cache row.""" + now = int(time.time()) + source = row.get("source", "?") + status = row.get("status", "?") + artist = row.get("artist", "") + title = row.get("title", "") + album = row.get("album", "") + created = row.get("created_at", 0) + expires = row.get("expires_at") + lyrics = row.get("lyrics", "") + + name = f"{artist} - {title}" if artist and title else row.get("key", "?") + print(f"{indent}[{source}] {name}") + if album: + print(f"{indent} Album : {album}") + print(f"{indent} Status : {status}") + if created: + age = now - created + print(f"{indent} Cached : {age // 3600}h {(age % 3600) // 60}m ago") + if expires: + remaining = expires - now + if remaining > 0: + print( + f"{indent} Expires : in {remaining // 3600}h {(remaining % 3600) // 60}m" + ) + else: + print(f"{indent} Expires : EXPIRED") + else: + print(f"{indent} Expires : never") + if lyrics: + line_count = len(lyrics.splitlines()) + print(f"{indent} Lyrics : {line_count} lines") + + +def run(): + app.meta() + + +if __name__ == "__main__": + run() diff --git a/lrx/config.py b/lrx/config.py new file mode 100644 index 0000000..5f0f5b8 --- /dev/null +++ b/lrx/config.py @@ -0,0 +1,88 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 10:17:56 +Description: Global configuration constants and logger setup +""" + +import os +import sys +from pathlib import Path +from platformdirs import user_cache_dir, user_config_dir +from dotenv import load_dotenv +from loguru import logger +from importlib.metadata import version + +# Application +APP_NAME = "lrcfetch" +APP_AUTHOR = "Uyanide" +APP_VERSION = version(APP_NAME) + +# Paths +CACHE_DIR = user_cache_dir(APP_NAME, APP_AUTHOR) +DB_PATH = os.path.join(CACHE_DIR, "cache.db") + +# .env loading +_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env" +load_dotenv(_config_env) # ~/.config/lrcfetch/.env +load_dotenv() # .env in cwd (does NOT override existing vars) + +# HTTP +HTTP_TIMEOUT = 10.0 + +# Cache TTLs (seconds) +TTL_SYNCED = None # never expires +TTL_UNSYNCED = 86400 # 1 day +TTL_NOT_FOUND = 86400 * 3 # 3 days +TTL_NETWORK_ERROR = 3600 # 1 hour + +# Search +DURATION_TOLERANCE_MS = 3000 # max duration mismatch for search matching + +# Spotify related +SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token" +SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/" +SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time" +SPOTIFY_SECRET_URL = ( + "https://raw.githubusercontent.com/xyloflake/spot-secrets-go" + "/refs/heads/main/secrets/secrets.json" +) +SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "") +SPOTIFY_TOKEN_CACHE_FILE = os.path.join(CACHE_DIR, "spotify_token.json") +SPOTIFY_APP_VERSION = "1.2.87.284.g3ff41c13" + +# Netease api +NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc" +NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric" + +# LRCLIB api +LRCLIB_API_URL = "https://lrclib.net/api/get" +LRCLIB_SEARCH_URL = "https://lrclib.net/api/search" + +# QQ Music API (self-hosted proxy) +QQ_MUSIC_API_URL = os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/") + +# Player preference (used when multiple MPRIS players are active) +PREFERRED_PLAYER = os.environ.get("LRCFETCH_PLAYER", "spotify") + +# User-Agents +UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" +UA_LRCFETCH = f"LRCFetch {APP_VERSION} (https://github.com/Uyanide/lrcfetch)" + +os.makedirs(CACHE_DIR, exist_ok=True) + +# Logger +_LOG_FORMAT = ( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level: <8} | " + "{name}:{function}:{line} - " + "{message}" +) + +logger.remove() +logger.add(sys.stderr, format=_LOG_FORMAT, level="INFO") + + +def enable_debug() -> None: + """Switch logger to DEBUG level.""" + logger.remove() + logger.add(sys.stderr, format=_LOG_FORMAT, level="DEBUG") diff --git a/lrx/core.py b/lrx/core.py new file mode 100644 index 0000000..04ba9ea --- /dev/null +++ b/lrx/core.py @@ -0,0 +1,178 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 11:09:53 +Description: Core orchestrator — coordinates fetchers with cache-aware fallback +""" + +""" +Fetch pipeline: + 1. Check cache for each source in the fallback sequence + 2. For sources without a valid cache hit, call the fetcher + 3. Cache every result (success, not-found, or error) per source + 4. Return the best result (synced > unsynced > None) +""" + +from typing import Optional +from loguru import logger + +from .fetchers import FetcherMethodType, create_fetchers +from .fetchers.base import BaseFetcher +from .cache import CacheEngine +from .lrc import normalize_tags, normalize_unsynced, detect_sync_status +from .config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR +from .models import TrackMeta, LyricResult, CacheStatus +from .enrichers import enrich_track + + +# Maps CacheStatus to the default TTL used when storing results +_STATUS_TTL: dict[CacheStatus, Optional[int]] = { + CacheStatus.SUCCESS_SYNCED: TTL_SYNCED, + CacheStatus.SUCCESS_UNSYNCED: TTL_UNSYNCED, + CacheStatus.NOT_FOUND: TTL_NOT_FOUND, + CacheStatus.NETWORK_ERROR: TTL_NETWORK_ERROR, +} + + +class LrcManager: + """Main entry point for fetching lyrics with caching.""" + + def __init__(self) -> None: + self.cache = CacheEngine() + self.fetchers = create_fetchers(self.cache) + + def _build_sequence( + self, track: TrackMeta, force_method: Optional[FetcherMethodType] = None + ) -> list[BaseFetcher]: + """Determine the ordered list of fetchers to try.""" + if force_method: + if force_method not in self.fetchers: + logger.error(f"Unknown method: {force_method}") + return [] + return [self.fetchers[force_method]] + + sequence: list[BaseFetcher] = [] + for method in self.fetchers.keys(): + if self.fetchers[method].is_available(track): + sequence.append(self.fetchers[method]) + + logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}") + return sequence + + def fetch_for_track( + self, + track: TrackMeta, + force_method: Optional[FetcherMethodType] = None, + bypass_cache: bool = False, + ) -> Optional[LyricResult]: + """Fetch lyrics for *track* using the fallback pipeline. + + Each source is checked against the cache independently: + - Cache hit with synced lyrics → return immediately + - Cache hit with negative status (NOT_FOUND / NETWORK_ERROR) → skip source + - Cache miss or unsynced → call fetcher, then cache the result + + After all sources are tried, returns the best result found + (synced > unsynced > None). + """ + track = enrich_track(track) + logger.info(f"Fetching lyrics for: {track.display_name()}") + + sequence = self._build_sequence(track, force_method) + if not sequence: + return None + + # Best result seen so far (synced wins over unsynced) + best_result: Optional[LyricResult] = None + + for fetcher in sequence: + source = fetcher.source_name + + # Cache check (skip for fetchers that handle their own caching) + if not bypass_cache and not fetcher.self_cached: + cached = self.cache.get(track, source) + if cached: + if cached.status == CacheStatus.SUCCESS_SYNCED: + logger.info(f"[{source}] cache hit: synced lyrics") + return cached + elif cached.status == CacheStatus.SUCCESS_UNSYNCED: + logger.debug( + f"[{source}] cache hit: unsynced lyrics (continuing)" + ) + if best_result is None: + best_result = cached + continue # Try next source for synced + elif cached.status in ( + CacheStatus.NOT_FOUND, + CacheStatus.NETWORK_ERROR, + ): + logger.debug( + f"[{source}] cache hit: {cached.status.value}, skipping" + ) + continue + elif not fetcher.self_cached: + logger.debug(f"[{source}] cache bypassed") + + # Fetch + logger.debug(f"[{source}] calling fetcher...") + result = fetcher.fetch(track, bypass_cache=bypass_cache) + + if not result: + logger.debug(f"[{source}] returned None (no result)") + continue + + # Cache the result (skip for self-cached fetchers) + if not fetcher.self_cached: + ttl = result.ttl or _STATUS_TTL.get(result.status, TTL_NOT_FOUND) + self.cache.set(track, source, result, ttl_seconds=ttl) + + # Evaluate result + if result.status == CacheStatus.SUCCESS_SYNCED: + logger.info(f"[{source}] got synced lyrics") + return result + + if result.status == CacheStatus.SUCCESS_UNSYNCED: + logger.debug(f"[{source}] got unsynced lyrics (continuing)") + if best_result is None: + best_result = result + + # NOT_FOUND / NETWORK_ERROR: already cached, try next + + # Return best available + if best_result: + # Normalize unsynced lyrics: set all timestamps to [00:00.00] + if ( + best_result.status == CacheStatus.SUCCESS_UNSYNCED + and best_result.lyrics + ): + best_result = LyricResult( + status=best_result.status, + lyrics=normalize_unsynced(best_result.lyrics), + source=best_result.source, + ttl=best_result.ttl, + ) + logger.info( + f"Returning unsynced lyrics from {best_result.source} " + f"(no synced source found)" + ) + else: + logger.info(f"No lyrics found for {track.display_name()}") + + return best_result + + def manual_insert( + self, + track: TrackMeta, + lyrics: str, + ) -> None: + """Manually insert lyrics into the cache for a track.""" + track = enrich_track(track) + logger.info(f"Manually inserting lyrics for: {track.display_name()}") + lyrics = normalize_tags(lyrics) + result = LyricResult( + status=detect_sync_status(lyrics), + lyrics=normalize_tags(lyrics), + source="manual", + ttl=None, + ) + self.cache.set(track, "manual", result, ttl_seconds=None) + logger.info("Lyrics inserted into cache.") diff --git a/lrx/enrichers/__init__.py b/lrx/enrichers/__init__.py new file mode 100644 index 0000000..9be0a80 --- /dev/null +++ b/lrx/enrichers/__init__.py @@ -0,0 +1,40 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-31 06:09:11 +Description: Metadata enrichment pipeline +""" + +from loguru import logger + +from .base import BaseEnricher +from .audio_tag import AudioTagEnricher +from .file_name import FileNameEnricher +from ..models import TrackMeta + +# Enrichers run in order; earlier ones have higher priority. +_ENRICHERS: list[BaseEnricher] = [ + AudioTagEnricher(), + FileNameEnricher(), +] + + +def enrich_track(track: TrackMeta) -> TrackMeta: + """Run all enrichers and return a track with missing fields filled in. + + Each enricher sees the cumulative state (earlier enrichers' results + are already applied). A field is only set if it is currently None. + """ + for enricher in _ENRICHERS: + try: + result = enricher.enrich(track) + except Exception as e: + logger.warning(f"Enricher {enricher.name} failed: {e}") + continue + if not result: + continue + # Only apply fields that are still None + updates = {k: v for k, v in result.items() if getattr(track, k, None) is None} + if updates: + for k, v in updates.items(): + setattr(track, k, v) + return track diff --git a/lrx/enrichers/__pycache__/__init__.cpython-313.pyc b/lrx/enrichers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd522c1c3bef933a233e58481e96829a8003b1ff GIT binary patch literal 1712 zcmZ8iU2IfE6h8NFe{OGE+U_DCl(DJ8DzsZDRT?#J=_0grP4==C>?Svt-Pzr-cjvA% zcY!t`;lYOnA4ClS--?ldZ$9|YS9vlqCLkojjYJdU18<8fYN8L$-R)9kvUkpzGv{Y! z&Uel>ySsG|U5q#K;%tmK)rX$FQhTy=u(|lxuAC*~G-pH$(*vogD(OrU?dR{lX z^7^=H8r$GqmGzXn6Ftk0o~nBlmrbJFl1)hwquSCLo0gLk$yeSexo4|3aYhPmRiCy! zJc;%t_f70g?%%tA3?(NflLsfq#`I~-3yjn};?hYphdsM!dp5$9kwT@4sfTK$h8;q& z{xCReLh?h7TP|bvP--uYDVyVTOCK|_y(v{M68E@W{;%vXad5`2KGAz9w~eF6nO#^6 z!uqWm;kiujtIkY0j?jvNNy2mzqdAmpTMpz+km z!GU!@RL;q}*LREJAajENIXTb%C4Byd^8*$hw@cD3|EZ^!0@YAOfT zqn-+U#sE&FEqQJWKo=mOA*xa>7y4h^cSKys_H}%0&|Jt=4@wl~7TeZ27>4Iyp#wn( zgFPnhsw{*$HrYsr+n_@Np5iT9klv66To0IXPU(UDYUfQ5>=Xc|N=li#Cg9fMN>T}4 zQ+7g0;sV}Wp&C_7kkFRD%hoBf9cLXTm~mv&B5G#~*{GF0bcT2pR3)4fT1F*;ogx>+ zac~h4I-=`oyHI)J0db5u@+ue=>eaeqd*n1m+!L@xyRpq2g4q*YzDtDSb;skn&9FEK z!*+2A*|nNbD<(z|O10x5!jVgzCE?sdq=bl=xd_0R0JackT#85Z)fvjyC$7#3vQ51+9a6_@l?Dj@D_#KTqYhsy%hp2vK> z>K3tM72MiVBFwr1i%8oOW&y;5oA?s;rA1#l?T6bjIS&g()E<6nwBlCr=%VW^+I%#N zYc40A%LwM94l9(fSEy_-9p8M^vIr%fWsTI9e4{P4TDf66g!8!g_zf|ghd&L7?_7yA zWHlPU7vFK&`p8<%-HE@na`=x3Y}$O^i2XeL;`L)!kA0rG+duzV=F{mbsjtdw_#5@k z#M~$27soyxznWPI-|nCPsjGkG<$JNMKg5P_$A)jbc`G)2D>i;1{pcAHp|J&wzVpZa z`h5U4ZEb=ajsIx$f0((DxpH##?Dcb3&#k&&o?Lrxt#Iqvsk_FZ2S)7gh9(7v zQ=(O|ps)H-JLpoDXO~$}Lw-~s6I((4Sn%+EsFgI%qGHj`zUmO}`Ms89Q|gM$iiXK8 ztM!s!KvSpz+a6d(UKOTw2fxmOS`S(de@jdaMUtf7WK~ih8z61_6$T%`uGVkxQ3SO3 t9d+}4H74eUrVPpF^_A(1+ix3#O;9AcVf0IVSE3CNWR*M)2n+rd{spWhv*`c; literal 0 HcmV?d00001 diff --git a/lrx/enrichers/__pycache__/audio_tag.cpython-313.pyc b/lrx/enrichers/__pycache__/audio_tag.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c65cccab768d9d616b6fd7775181af37d68bfb13 GIT binary patch literal 3503 zcmai0U2Igx6`uRM|3Cf%{{>%a2iOMxf$ca;p)rjC|6nR#Zx!m5uC8}y*B9^JyPbPC zu`QD3rIASOV8ZhYl1__ zXm{q^nKS3iIdjf;W+xH}B4{6v{MC3VfY9IBhu3^nV&`2Tt|Ao?q;e`h#*OlXR~Rjf z38NwrU0NKIMr9(8`iKv{B~>2tj|NBpXrJmI3yy|J$fW~g;n4_*D5w#A1F6Agq=wRg zexag^MAdNGr$*8-HJawrF$t#oWHr`|E{PI6sq z7hGX_0cg2k<@1;%cq*QOE!5BYsf7}L#8sHXr*#{9S|9P1$-orRGjr!bFNlcdv7@F89K&{CEX-T00O-Bpz568ywsz_qv&TzixGRJ3s+XERQPp6;Ht zinx2uD$MD2_arV^w&7UBz;<_mWOCTa%swG-^+N6LiVHztS80KYre4IfVXv88Y`PA# z*vv8Spa-$$9Uu^unB8+3c0XQY3E>_)4gFP=;wHV7FN{a3v|fW4#~1fkS3Hg^7?(%W z-i>|*H%qx!c#}`@PwzW`ro9A3DZanzUQ;<-yI#ktbQsp3lId3iYH(MkFXi)gRAtug5eT0ihbyzJ`oAPIq=wbV zu1tT*@9n6{tp6IBKT<wKt2y+uU~7xF{%l?~+F!lj-7_AAT`xR; z*R#&0#yTMmc5g9|3aIhq&k)j?=dY?!*CJ_GOAlyqftzWX414;Xi~y}Q({0{&N}4?A z(N9JX9Xzd3bbL3C6qJ%qa-O~{SP9^bI?zEh?e`@3kbVdvN&MQA2q`{_R52xj2GP@| zpf-?bibG<)I_9_>Iw`cF9G9q_m;wlhVAC!Wtl@dU9DA|J%iW}s1CYmA#j#w#Ru=iB z0tw3?ii%SsQ^(3Iss4Nua+}H($b$Lr4)<2)hbE_}NjkyU}f|I70RSQLa>D}?KChvpTTZjAS~&ql*KdU zJPm3&gV>G+utb^}b1Ul(uug%FiiVl9sBc!cb;lu8FieMv1#kcr=kS7^2ojc5WIy8# zRuGOA39yF&IOQ<)l}k{(u)Rk-4OC3iinh&SN7-$J;UASN*3tc*yETfv1)&8}^HwM;U*^HlDlJ1A%s}0v$uC;8nb*!A-2)8~2 zhT*lr8{b>MxLNbm&e~5ztb}CTj$o;ZT@!e?e;rOXFd#{y;ryYf7=qU{MTj(^d~Mfd;I2>hL@7Ccy`i#QNe>10}uh0D5hn0;$q2JP~d_MGq1d%7s{|`mEnD+ z7cH1!%z5JtY_d13!jPnE!o|0QOhuTosQ|A|8Z>Ofgdi|8@PviqkU{9E#Ke{oBH6tu zDu4(ofF7HH8-tQcbiBwmv2Fy|HFxwLFvF;~^i z=s}nXMX#LrC|v(A)UcBIW#8S9vJq15x3;acujoHHv^KT$olis2rQ`SN_N~}I9sK#J zAD`N+YyVrg{j(Yruivgk;kX-36Nwr!02-<6ER?_vl>lcC@-it`KsZu;yLUmd6tD}_ zgxxF^XW&&%SZ$*+WEd{kZjMj^9+(wScSDuA!!>VDR#5-9DtUQ|oC6jJR%DMrw=HlS z_Zcs8;^$$+wSIz{K1E0Vfew7ae#&FHi)&lC{20NoJ;Oz~_{!`yf?>^eha2;^gqv@D J&SqQ#{||<_`+fib literal 0 HcmV?d00001 diff --git a/lrx/enrichers/__pycache__/base.cpython-313.pyc b/lrx/enrichers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a88af1da5af2abf191fbd96622b6ed2a1078655 GIT binary patch literal 1470 zcmZ8g&2Jk;6rcUHy*Ms3RDwo|2Iv7x)Nz|Msw}8TX%S6CDs}12(q^(A$3xb;+c)DD z2O;$W=UyW5C(s-J2S+Z|N=OliLoO(lL0oyyYdfWLc<;@=k9qI+dplWL3K*W>@BN}~ zIgI^5m-+E##^iG}j#(YVhLA6;sdK7Gp%pijfe>fJY>PeoDpNRCJz1hC`X+QgBC{)slvte*Y7}??1 z+t)X*w%4z&U*qk~PWxu(+GcP^>KIgRRF-!5ZJ{NP6QMQlXTXQjh@LQl%M?^Rkf4K8 zdcA2E&erYEiiX(N21INyaFF$yR#Ck_tt66Do8qjZ_7(aMzr;c@WzLU3GbUX$j#-z1 zg^<{x1x{##+x5DB=yWR^cIaMaU%NME;d2dD^n2YJ#su9r2-^*pAUcnPdRT!a0;#Ce zRCi|+oGxMT(O+A}$YE|YHyQ7%B;iA)wMq~0B9orxS;_@pwB8DWyCNPeVBjj%xr`}& zAUPp$_z@RrkApNLNHym6RBU+GFYEDC45be6t;Zr6$r4Viw^h`nghugi3DTzB^P;~DA{WTg?OM@40y-GlfK3&4%nrM5)~$9E~Xg5oJVtFu}iE}}gxx7%zK)o40M z6cs@f4YS@Tk!aVW=n>LNBjW8s6!jHolc-dt8GfilQ7?a+M)cXi@0|7{YD6L{CVSfYeHrjM!xb9s=wQ;!3yfa5&JRF zR+aM^AroEBGVO{wI`6coep~`}3qC*xEsuT=#hGJS)=S&9+<)uLy7C)a U`ID{w?O(L&&({B8sHP?U1B)|_xBvhE literal 0 HcmV?d00001 diff --git a/lrx/enrichers/__pycache__/file_name.cpython-313.pyc b/lrx/enrichers/__pycache__/file_name.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e514b4fc787cbbd668ed1b89c15ddcc6ae713b7f GIT binary patch literal 3356 zcmb6b-)|JhdG2oS*V^U`XB&J5G6hHR0lqU}oA_MP7;L~kFzGEzt<*oDFIZQJ(+Uvkk4Q*)aO70&W53zGwOvR^ zJ6i30Gv7Dgulat=MpF}yV0=CLXL+Cjp}(<(JN$KE;}!t7k$@-?Jc2jvp&pohY44bi z`Y_w$(%hJz`T_O}fplOjNP_?eg-|*)7N%hv4yXCC2HJp8&Xd{WirAVDAc61mIBVLp z2f?}_hJ?mku+LZD-BI+$n`UqEL#8pU(G^0k|E(Ks+9mkuw)iw4Nu7m$dim|{uxthJnpeV7i~t80&qalibX=7F*{({cuLZV z%m10z*$iKzQvS*X5C9ZAio_6Y)*|CN+x&}M;rh`d$A2}E(-XPwcMs>8730^vb3`@U>~8VnZtGKufTxO7Fov zNRtXl@ccPqQaCQ>b=)PD%Zf}2*wAdZ>INxcZOVojGZnOmr+AFJ@qr;nXg9vZgdV{7 zueUzNbybcynmB|#M}gcW!Z2(?M^#N#W^k86q=Kv#vB7+gRkJh+7p@x+1Z7f)J6yIi zU`(Fp!t$(G&NM&xV;Q-Av#YEGjvjC{sG4#MY_gv{oX?E1Gge(vuld)^r)vlZl>G zXnu+q`ROfy)H6j-GKL~!m?+O!90RP@Gtb3P!nP2k=q&1PqNkDA1^~$N+cxA`I=CYU zfUWk|@V||+o{WpxHh0xwX(wV7+t1fAE=2*1E20TEUA~8!)v^Jv>dkup`&bjXQmwz^4S6D`wY5ER&f7b8PXCg1+-85GH z*}zNjfvn%PLplc91vXcQZGSbt$lIkTK(xFq2!Ypt1U$Rz2PpGGkWpTIcbkmX|Lf$N zKj@Q9AHLM)U^eL5ApLI|=#K7S<;(nW6(J#z^$kKCNmsx!Z-y1b=Y|@Z*zIm*LvHU9 zLa9H(skh)%NC>kK{H|`Nh5^fcFP6{pn|86=;|1Q<?v>p{*IZapS~~+;!HMIq1S$vm2?dCw8FY zUKhqF>pSUj$rmlcwAQPJ{}V);y}P?oq*2%-5{ zejOT;VKs@DGDD}+;&}Fgm>IErx;Y6b)*)y+lgXTI1)VakTR!Mil;sKC$9}BZTK)o= zG>aCefDIORh0N&j2Fjw0LTDn3-)i$#&@992g6I@845yGO!}3$PIBi%Bww+o7bcAIj36sjV4%dy?s?RsORUb7Ppq7#U83SoT{)gmnmA1r)O z$*#V$`c5sn`|qs>DC^vI;*QZ?Zz7-9JUq;zhBf z6-;QTqSYvhS4~M_ofB3OQJjK{HWXPUssXHC@N z3Uc0{M;X{Ein<}e%S)6Dh~-HWf=(1|EgXGV)KC^m_K&Hk55Vsh`oRX{7I}Utvy< K{)u3+ng0iCbph)D literal 0 HcmV?d00001 diff --git a/lrx/enrichers/audio_tag.py b/lrx/enrichers/audio_tag.py new file mode 100644 index 0000000..4e9f604 --- /dev/null +++ b/lrx/enrichers/audio_tag.py @@ -0,0 +1,78 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-31 06:11:27 +Description: Enricher that reads metadata from audio file tags (mutagen) +""" + +from typing import Optional +from loguru import logger +from mutagen._file import File, FileType + +from .base import BaseEnricher +from ..models import TrackMeta +from ..lrc import get_audio_path + + +class AudioTagEnricher(BaseEnricher): + """Extract title, artist, album, and duration from audio file tags.""" + + @property + def name(self) -> str: + return "audio-tag" + + def enrich(self, track: TrackMeta) -> Optional[dict]: + if not track.is_local or not track.url: + return None + + audio_path = get_audio_path(track.url, ensure_exists=True) + if not audio_path: + return None + + try: + audio = File(audio_path) + except Exception as e: + logger.debug(f"AudioTag: failed to read {audio_path}: {e}") + return None + + if audio is None: + return None + + updates: dict = {} + + # Try common tag names (vorbis comments, ID3, MP4) + title = _first_tag(audio, "title", "TIT2", "\xa9nam") + if title and not track.title: + updates["title"] = title + + artist = _first_tag(audio, "artist", "TPE1", "\xa9ART") + if artist and not track.artist: + updates["artist"] = artist + + album = _first_tag(audio, "album", "TALB", "\xa9alb") + if album and not track.album: + updates["album"] = album + + if not track.length and audio.info and hasattr(audio.info, "length"): + length_ms = int(audio.info.length * 1000) + if length_ms > 0: + updates["length"] = length_ms + + if updates: + logger.debug(f"AudioTag: enriched fields: {list(updates.keys())}") + return updates or None + + +def _first_tag(audio: FileType, *keys: str) -> Optional[str]: + """Return the first non-empty string value found among the given tag keys.""" + if not audio.tags: + return None + for key in keys: + val = audio.tags.get(key) + if val is None: + continue + # mutagen returns lists for vorbis, single values for ID3 + if isinstance(val, list): + val = val[0] if val else None + if val: + return str(val).strip() + return None diff --git a/lrx/enrichers/base.py b/lrx/enrichers/base.py new file mode 100644 index 0000000..f0a09da --- /dev/null +++ b/lrx/enrichers/base.py @@ -0,0 +1,31 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-31 06:08:16 +Description: Base class for metadata enrichers +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from ..models import TrackMeta + + +class BaseEnricher(ABC): + """Attempts to fill missing fields on a TrackMeta. + + Each enricher inspects the track, and returns a dict of field names + to values for any fields it can provide. Only fields that are + currently ``None`` on the track will actually be applied. + """ + + @property + @abstractmethod + def name(self) -> str: ... + + @abstractmethod + def enrich(self, track: TrackMeta) -> Optional[dict]: + """Return a dict of {field_name: value} for fields this enricher can fill. + + Return None or an empty dict if nothing can be contributed. + """ + ... diff --git a/lrx/enrichers/file_name.py b/lrx/enrichers/file_name.py new file mode 100644 index 0000000..150cebd --- /dev/null +++ b/lrx/enrichers/file_name.py @@ -0,0 +1,83 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-31 06:08:44 +Description: Enricher that parses metadata from the audio file path +""" + +import re +from typing import Optional +from loguru import logger + +from .base import BaseEnricher +from ..models import TrackMeta +from ..lrc import get_audio_path + + +# Common track-number prefixes: "01 - ", "01. ", "1 - ", etc. +_TRACK_NUM_RE = re.compile(r"^\d{1,3}[\s.\-]+") + + +class FileNameEnricher(BaseEnricher): + """Derive artist / title from the file path when tags are unavailable. + + Heuristics (applied to the stem of the filename): + - "Artist - Title" → artist, title + - "01 - Title" → title only (leading track number stripped) + - "Title" → title only + + If artist is still missing after parsing the filename, the parent + directory name is used as a guess (common layout: ``Artist/Album/track``). + """ + + @property + def name(self) -> str: + return "file-name" + + def enrich(self, track: TrackMeta) -> Optional[dict]: + if not track.is_local or not track.url: + return None + + audio_path = get_audio_path(track.url, ensure_exists=False) + if not audio_path: + return None + + updates: dict = {} + stem = audio_path.stem + + # Try "Artist - Title" split + if " - " in stem: + left, right = stem.split(" - ", 1) + left = _TRACK_NUM_RE.sub("", left).strip() + right = right.strip() + + if left and right: + # Both sides non-empty after stripping track number + if not track.artist: + updates["artist"] = left + if not track.title: + updates["title"] = right + elif right: + # Left was only a track number → right is the title + if not track.title: + updates["title"] = right + else: + # No separator: strip track number, remainder is title + title_guess = _TRACK_NUM_RE.sub("", stem).strip() + if title_guess and not track.title: + updates["title"] = title_guess + + # Use parent directory as artist fallback + # Typical layout: /Music/Artist/Album/01 - Track.flac + if not track.artist and "artist" not in updates: + parents = audio_path.parents + if len(parents) >= 2: + album_dir = parents[0].name + artist_dir = parents[1].name + if artist_dir and artist_dir not in (".", "/"): + updates["artist"] = artist_dir + if not track.album and album_dir and album_dir != artist_dir: + updates["album"] = album_dir + + if updates: + logger.debug(f"FileName: enriched fields: {list(updates.keys())}") + return updates or None diff --git a/lrx/fetchers/__init__.py b/lrx/fetchers/__init__.py new file mode 100644 index 0000000..75377d6 --- /dev/null +++ b/lrx/fetchers/__init__.py @@ -0,0 +1,41 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 02:33:26 +Description: Fetcher pipeline — registry and types +""" + +from typing import Literal + +from .base import BaseFetcher +from .local import LocalFetcher +from .cache_search import CacheSearchFetcher +from .spotify import SpotifyFetcher +from .lrclib import LrclibFetcher +from .lrclib_search import LrclibSearchFetcher +from .netease import NeteaseFetcher +from .qqmusic import QQMusicFetcher +from ..cache import CacheEngine + +FetcherMethodType = Literal[ + "local", + "cache-search", + "spotify", + "lrclib", + "lrclib-search", + "netease", + "qqmusic", +] + + +def create_fetchers(cache: CacheEngine) -> dict[FetcherMethodType, BaseFetcher]: + """Instantiate all fetchers. Returns a dict keyed by source name.""" + fetchers: dict[FetcherMethodType, BaseFetcher] = { + "local": LocalFetcher(), + "cache-search": CacheSearchFetcher(cache), + "spotify": SpotifyFetcher(), + "lrclib": LrclibFetcher(), + "lrclib-search": LrclibSearchFetcher(), + "netease": NeteaseFetcher(), + "qqmusic": QQMusicFetcher(), + } + return fetchers diff --git a/lrx/fetchers/__pycache__/__init__.cpython-313.pyc b/lrx/fetchers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..645408d34ae8728f11e0263feafe3d407323bf60 GIT binary patch literal 1364 zcmZWoOK;mo5MEN0DC=oGoZ2yJZ;%|SHkMTRfkFv_q)mf{bR0OWfdUpl(9~Mqnj)1U z1y?@#2lSQ(=r1Uef6;p{E_@>_plH#9Z!+B6yStPuv|eC$zx`%rXLe@xDw|CqHa^+@ zjUFio{lyQHkr;!kUl@3c3Wz(+`szw!9oHMp)zxNWBlVQ{Hqc%`J-3OUk-$D6fP1t@9O@GM z=gChPNQe3X46x<4aWLo+KXt)eqly%&n=~L`ITuoNPJe0nWPAw|XEr_Ca;E3vR~Eb8 zAQspMQ#NPxynyZxrc8DdY=`bmiBd$|u-qa6`H#ok_V$auPwlbkg*=W+IR4u0us6L| z!??raI?mWUoE2Zh6RLg`MwoDf_v37|U(4OdS*X5#-Q_;QxUdfsK!QHFHqXIiBpHaY zdG#Y(-lE-W+4@*xmrKRiLF;4bE-$he*IMcgX66QyzQJVw&lplwb)_BY9;(R4kDj}J zV7UQhJ%KI9!TV9w{VIMXUgBd5x2YZALoy(3yf?tU*9V(m*Xoig z0*?#$7Xuy-WY|)XVUBHKa^k?}<-j<6Ts!c(q;}{zhn8P^MS7l30}m+iYb@tNdkQAT zHPfUn4NSAz8-#fq2n*kw2;nxnd5KN`AzMz+KM7P^db4ys|LME+v-!`@OLxvnch8H< zZ))cY_s literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/base.cpython-313.pyc b/lrx/fetchers/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14e0222d26a7331b1d93792e841a0b4976a861d9 GIT binary patch literal 1794 zcmah}&1)M+6rcT)Ry#tDWhtpZ*bdJrt5)kxlGwX3{Y zB~ea2^_)Xpa_lv?9{WG^)LU361bQgG$tb1QzL}LQ(FQt)H*e;>dGGz+$4tuQBEj|T z<*&gn1|fgQ!)#eoW>QDyoVWyttGW7pt*(P!H^89sY}_~N7Ffueu5~|G&qKa$gH6dM z`G~l=E#l^z`T^RO3kqG^DLi{xyfaMBVyMw?Ml1^2oDN1$Sk&D+-23c{PW-gbg0SMp z{o*~A@EWb|RS$Re_IIiWbgx?5->+25|OR zah9TsGHRze;K>joUz-LcH_6LVO-v-`q^mjlv+E7k=QQrn#^seiBiiVGq$8q8AM4 zfWuDac6yB>zvonrK}G;8NHWl{hAhU8S@4vB_P=7E3)bbMY3w<&x&iVcwPYLieA#rf z^|ng5cVz$>hIuIv4SS3O!&)I;#-Qrd4IcA|Dzo$kuXHQ)j0p<-(J%nsrr7bU%@XER z8VZb~!zy})O(a5wL1_l66bSFidb+t-kwwY-^4%4zLOFv~YK;asi&;+m=@)>SZ|}1s z)txkyV}=F#FOUj7Q5`F2BaT!lhmiuE#92LOV2n8!_&`P`UDFvPOLbkkwunVtZA;ZD zh$8lt0-@5=aI|A*HMKp}n4XswJ+B|PhapG4;tb=@kza3=(}i9vGfk}Qu+JmTmehw_aVr&7L=iR6V8 zt*fU$Ib-?sXLD{r-7AgP2enJXL_ZtbW3^YRYJXi=dw%n`!uA^rg_&0!xh$PB)`n9| z^Pbm^eaw%+T0Y1EW%Fe2Qk|;D`^A kj15iGE_G8g|1J^jli$hpKgrEExocYa`Nw||+*CmS0P5+$F8}}l literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/cache_search.cpython-313.pyc b/lrx/fetchers/__pycache__/cache_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80369567eaf65ef7113442e6a293b2da773faa57 GIT binary patch literal 3644 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 literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/lrclib_search.cpython-313.pyc b/lrx/fetchers/__pycache__/lrclib_search.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c01e5b0de2368401e88c4fd4d98199db3bf7ccfd GIT binary patch literal 7987 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 literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/musixmatch.cpython-313.pyc b/lrx/fetchers/__pycache__/musixmatch.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c09c742e575f80f1335a016c65d9c4b2fba4ef2 GIT binary patch literal 11553 zcmbtaYj9h~bzZ!mBme>=!1v`tBB2LFilQh!B!(g>iQr4d<+WskE(QcIDOlhIwHLG` z*zUOfkx|^th;-r+P0}G;SOJDm_x8Y4WFM z@5Kc`8Zzs4f!W==?>&2V&-u>oladl60%!W+--NrH5c(DQkewzQdGc$Byo+dr5v`z= zqYA7LLnT&numLz7~u??(cWBQ43+tear|Aa}>`8F?7yTGqM-CTpNkb&$HOA5)4x~7`i^r#)5)rjLg`_@=KAJU>XdqnB z;{)?77ZbFD(dhLs8+T3nr)OV3`DTykPkTsktmDn;SK?*AH|=k0>uGD7_OxZwLK0U= zzApRm485B1AS2Y@SkDU4>p%WA(I#R!(bADQOM)?#x!fI!M(|Ysr3uJgr#P zK|&G;$D%mQ@+TrVB<{_8 zB)e7l6AQWR>sh=l=mUW;7mfu2Le*F1=8^LbL3g|jyth#1ko%7MyQWMl1V7i;J%L2D zXVzqB;;hvWc^AzqU}l0b-{}VkS3!<61zZDB!NfR{pv6ZvfZRAS+uv% zXO?PQ53xPHYav1G{Zk<3yT}iplJm)tg0K58B-C8j-(LPNS9G3MJx{jM7II zW}oxJs3hli$T3>gt>jdU%IB0*@}W!z3PFBlK3$H<`J9>-f1O!cQRs@gSpT%94asGS z=4%hg*U@^pKBr+c@;c1I*b6917-^paiW>41omXGIoZn@P!cX{3bjQJm9hh75f>w*K zlQG{aqiIGB=ore43V41_Nuhc~!EfU2v#NSDr)WU_x@>1ihk{KAaoU1jijUpIXiHIt zGT&D{qSc&sKsyg~C*ho84oU61&GF_*Nm_yg!z>6)DoBNh{FyC~o}M_B-%&lHgk$+w zn470K_NF9Y;aJhd!V7G4DF!l2ca06svY4L`bPFuc2j^KqJrkY1Ef|SN&Yn#ue!Q?% zNlwEdwHW1K7z<&ZCq3jV#LEkd&qrfY&+*2>WVw|aOE5xeE*OrmvptmCDyYfmunVY( z33Fny4+YIEJF_$|Xs*R#i?=`uMZzGBuz_T$=Gho7Au)a6((u60u#Xn3I2h*H04M_i zUKG!Q`c*#42^tpTD8^P&+Aw@8#ERnHt;4Y8p(QSQQ_zOQwgokrprD=&g4EYYXS9Ol ziS(i*Cmm zFTN7rZan=+zZ_3EN0!wYt0Qeal(Zh&wzg!P)%R|%-2UF{%j#!3RCa3DjBMri^eg(+ z%C++;Ys-gQ%!^m3s1Sw(E z*+SK4H-npWqHbPe^RkJ`J3JgSe63^?KAk@6_%)67M$-A5;CrP&wS9`bmoh;;=A8GsC^mM2JWN*%8Jd-%ox63kK3y!6@4la2_g( zNd|dm2Q6?)6fME1Xk`mD2f4X3MD7aZPKIp#A|;@{iq!Y#WQs2*De~>WX{LrA$k_Ke z`9$7(T6`l3q7fyCnkjmM=OqZEDv%-CZWTF?50D|6uazNMT1S5J`3^EfTU18q50DXt z1M-ded``#ciuPg3mnoWGFa}P~=!=#yAJ9gLe45jxJy$_n_!V$X@s~XYi8) zN_31NFV|$M<~aC6glH>cAp0Ts=g_l(wi5k`Gctyvv$4@-avPlKIc4m$BUi=@{F#$; z(s+nJ^8)l`&W*wgoZcYkGG@B`I1HkA&Eud0$rR&Cm=b@ZT(9T*FbB{@58N{qjHwjS zPNoEWSH3=cRd(p)wkb5PJFulH#t5~l#rA4CH2J!Wu^Bywr)u-7RFuMnJXOk+7A^A) zdFrAfco^teysd+{s_sNiSK}-NTxIb$%aoT_JGY+y3B6fzU%v((({WZmInYE$*UOaR zY>dT@F0BE_l@+vI_B9*}x+_ywlqv=MKdER#oSm^3EoaU3?qD49C}-gm9>4HBuA3Q! zoZ_t&a(g*bF1Hs1EKokxTQs=j+QC zRTGMw#w-(8)|e{_#&IOeTm=Eh$3~`N@905e)^QaB74sltNLZjTH^sk2i&mqr*o=s| zY9ScHQBU4R1X|oN(3c6Yr$nU!OjI7hK$KcZ`P1D9i0Oj+Mg7#Eeg6R8=mF3gg;$wP(;Py>lAP|!;2A|eLt zA#^g&hWW)v@V01ez$Xa(h|yF84!E7f2*$>+%@FP&-YM_|V;BGp5FSjN006=vSPqGx zf+{$}V*=I%wKzS^TqGKd;nSolQPSK_5lM)PBxQWFaX zi2~7?SFxAm!r6rII4J^s&&>%X(tHDeN5JTaFN>9D05ri02nI1CmQ#mg?1G>Gn#UkR z4FutE9~9<^2_TDE_6HJ%(FL+ASFi#(03&6Mk%qnxoCKzr#^dWtiH6QQ+KkCcz)OZY z^uFn#>CT1a?xeYvB=+6y%QPH*zvf{LB=#ipP{!H5aVULqD0y-yb@D=@{bHj0(zbqN zSN($GIpAi;;odm>sC8F`T#dU(=_*@R?`i?Z6h%2Ef^r-Opqy6#{McFu%5m*lk*(&Q zWyO-9IyQVM>lq0Ly|kuG*_w7WsKl|WM^@+E*T3`n>X|iP%F>#)97|e`t-q48^ltJg z%lS-A(}U6bqwCrYWvZqlUDK7U>Dtt7m8EKimJK`h+STxfj|VYYkeEd$)?kr<$qoCE zo@wvg&~C8XeJ><>eF@*xu1eAHqT;DaRa*xWYi|AAQFU+Zov}pIx3*kc#>b71Uw%B6 zxcX9};pJ_IKU3YbJPE}l3aypG46Pme2~d2~kr?Y=c{b)AbIhyPoT^)LG>Hej)E9*1cHSLKP=v76guKodg zpI!5&>Q1bxKCN&3=bGkBHT9tHeqXY>Bh%FUe&@r^^}0=>d3U6S{uoD+W++8mm1W3iV*7P!+t42g_=8HU7B0o^M2WY z8jkKZBUgRWc_ibiePF(CPP&e57`D&)QfupW0y3ofngx7gL=hiBsN0<>6z$OecV5+hL6YUI^s7Cord7=tBC&(wie=d8z=R;PgHt{e_}FD zo>6^LUOCyJ`lLexaq$@11E)>UKJ0z;Bm#>39z$ypuuIIGjAE~t2B>;~is9Eo`=ZMl zJBjg<^t;dbbEZ;1z;($nIkPIGq*WfJpV)UqN2>wC63}axQ}Uq(vP_PgYM;m%>f=c%ysCoa-mN=bXY7+cn z<_lOaBct29UVb7oMCT`lIMMlv7%}sTJ#Fe%0!S)8)l{7p>iF7o7t#bauyN}q>yz#NB^HG^tEFGpq~T4J^;V_9g{P6 z1Ee>~GYCSY0FK$J5M#9G<|%Vjt~6XB6s)S8T+kI~nzR;_APVa~$C(N!c5GiJd;(9E zF(x0;!S*=-*mgoo+Tkbqm*~LoK$Ne2PF^Q@{7|R75YXpW;OdXzLWXE=`y5@-t@=9S z{YAyZzvsXR(cwe~=6lewIdj1biT<|F!3>>@`G2u%h{m_i`H5O4IX)==YGlY0cNeP4;yob2B-x940!?xXw}?^PN=fS+ zfbrlV@Ukf7BKf{Vyb5phD8<_VmTs0mRZmKX%K2RIx!MZwY$v9-_T z#f8lKN6~BOT@8iC;kY2kdmeSE<`izlgj)$0vo8<(CdGUFz|_FSfNxmPh^jl}DLrj? z23qHd7^Emkn>O^pO*vfS;&Vb=np_u11oZSpq5uH-bDms{zax#26W|N<7D`l) zd_1&yW1DJEQ(Z}_Yg3V;deSvL$(o*zhyI%<6!^myw<2!M_tHa=+m?8F(e(zj)*9DQ zkub;dZnp}Dp#eZP;u8*7HSt%r)dqLmqF{=K)qxNJZqbpyFeAAFje+0YO+t zat*nc5ZFi(8Iq`hD&a-J8USA3LvP(&n41Np4DgG)EtJyC;NUP#2k4jJiNug#mA{#Z zY>FB{XH?L@Ly{#{Pyr4SjDP|GB9fWoCemRJu?Zy;g|9F$5l`+C3B`$Utd|tu3*ZFR z0zWUP2`0x8GIAB%zysn6&hsKsCD=u-r1s#g?ESZRC6uLl0lY54ARj;rd^_+KTp4BW zf}EnNo+uyP){pI~j~cEjirxtUY}#o(x_;>|O16fR)!v<&W&k_{L8(OqWqcljvJeDS zhTsaSb)|K+du=9F=DstSv6S7t`JJ1~x7Ny1mgcmjHEC&GuT5FHHak+5a~WsVy_+jH zS8uJCrJTpo&bFkpZKF2j>|a*PH%RptwqjdziQ4gP>%@+;@x!C(BmK!E{f}NrI_ZR! zCWy{)Pq(65)dKXgHc2SsW$4Y;EX4yb5}TWVwyLLQfN9+mrV8&DwPLSh9O8 z-93@)p2&50@v(j@o~ZS0Tj?SlepXhWE<2PgJCrJGxik1wiyQzzjwbC#*9TMfwmUHX4_t2O0tC+tVoTL6UFIsk)}ls?rXNB!E|mLomz zLK6OVU&8ErHm;D>|I3Q3`VWgNqlUiC7d8hImEYPn5A4|Met127xIcNge|z}Fr2Wdz z%~!-bwX2HX@)T|FtQz0Jt2iMP^{y)*YF zE++Dcv=a>>5dRjV4ZyB^=}1`ial14q^{V zSsU(XKD9W$vc(eSE1$vP*tgQRrb*e4-_d_+ab^qkCJ*;Ms!iG_6Xwa!EcUdeK5406 z)2A$L34NRRb_o+u1^QMw1QTu>d5uNPw(uIu7zivxXO|)*ZVm))ECnOk97`ZD7sk9O zy0~Z{fNP<$pkKt%MKDa?#!K)iNi?G9Nc1M#lo(!P5|u^r69VJ!kc{$x_#`46f+uDm zZ^8TvugwCukrW`vN~AL;cvq1W#0Q!mhSf z<66D6M&BQQiXggs&aZ%kmv#{e*Lf-2xV5GF%Qv5jX}fXd3C&f-+TboC;f6{IH}zZ9 z$urOhN!*=LST*gd-Ma{ab=Stxr2E`65|>6x2I{U6bb-KZGz8>RVtA`a22YH=f;M)0 z5!7Eyh$BAeT*6BtqY4)J(VPb;&qjDmsFr6{p(2+rG8R0M@yx*UPOK*_4~oEv zi+Btk$Pb0$OQlAkeo~4Q#$O=QFOd0{sQQ389AN5#FKcX|?a-6?xd0$DTg?S03as6Ul*bS9APsrG_O(Y|W>FGNC- G(EkH8%~_TJ literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/netease.cpython-313.pyc b/lrx/fetchers/__pycache__/netease.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9fd6d5389c2b4497fe7bb7bc31994c0901dbab0c GIT binary patch literal 9555 zcmd5iTTmNUmi^FMLJ~+kgm^Rk@ye)??TkDt3#kPPLL#??ZIo2T zyB}Mg`~+t)Nr{etbn($|qMkZ}_#^(1k}_X-{I^hen_vk< z)DbMnQX`~?LKK6`^azb;$Q2_Bj}j@NPRXi9R30@_Ls`vgMl>ER(t31A=g}j*Sf?E^ zcuG)-$B2yLw{FDbF(b356qS0)P?^VqEFLSeddg9`rvgmu`^bLnu0*j3(kPW}HiML7rmru|{6Z`#=m&x@FPjV|7kDQnl!amKP%^+T#zO&KtQCy$1ey!RqA4zr3{LY- zwO|;YoP04bd2VcI!Z#_DOiqpl#wR8NXC{2(gF>nJIW&1;!aEWe@_Hw{LiwQ2>z+I} zF&>zl7#;Gu#|MT2W2{g%J~TPxW`_doklQ;j9PoKZg;J?*^d;}P0kKNwa|cd)CoZr< z-c-d*GC9w?Iy&YsRoc6bbz{PIIH{D*$8+egdzyUd6g?qeZQ@LVjy<9>{As$(WG$92pmVzWWM z!^_Pl_-HbLq8#55Lm^ST3WdD1@g4cu+UFMqJ)c-WAubRP&T&HZvq-iVwK-rp9`F7- z@z7ND7z%`-Ok(K^VOf+C#$3d{4GM1)etBm;(p%gw-~VX7y$SX;4uYzfN+e=1Z!{U> zoD`~oZm0nOkH<0$s)bB2MEOAQ3Y@;+RE!hsdvGppJcNgq0=Q1>m@G>}w?=M`Y?_#~ zhCvLTNg0dIMUx?nu#^l?5I680HQoTAEwvQoC&`xlR~QfjokJQ4mfC-d`}OUny^=00 znpKj}IPIh5`GG*wv|lB4^GfnARX`3JOyQ#e1Er4wsOnZqWMx3V`bB+=yL<88_M}F_ zZurPn6)DCE*! z83Nkvg?gzZwZ~N~jem~jg@+;hSO&y&LFI=VCYPjknQn&(qKImY)US{O)eQav(zj0~ z;qGmc>I%7$=$7}!06%;xFFp@@5?dn85my(m^r^oFOQ2m5mizP8D9tLatloEJlhh}! zDOy?Mm&ifvrBBcVE92uKCZ9$c&zfBq2_pDBc$G?HFkaudi?aPz18KE%(}j891Yy0g zC!Hmy0Mq^3;#!uD>--(k5Y{Tq7T5bU7Gn5k1Q9nB(ZujI+yEIHtA>>f`_i~vp1I^J zdQ`BL5}o75ujo@H&tfXV%H;2qb|B3lu`XQy95Q0_l>jZPeMW%x;`2ZDS1`I;n)~Aa z90qM5V_^P9t`*N)<1@kRwLUYzx^AsB3!GrHPwxWlfOX;6KLP#BK>L!oZft`f^q?Jm zx+y2r>isazPZDk!{~#Jj~>59ws@%F;g6$JUkZ!8#o%D zX7~hvbA#H5CjV57TSZ7~5_VVR+oj?dAGoGr- zn;0&J7q^3vjU*7`NNF8Ri)cff6iDZNQcx$Ob6jE}DUe+P+38dWBrhnEi}M`9_l2NJ zL?S$wyiW=087>&+5VXP;f~f<0R>%_x@Qf$ohr`iO(#2qF5gYfZuDzWdxdsOliE^

uDTAi>gUV%^9zOqkBUqpFaPb1q&!d z`;j~)P~l|Cz!ds0DGKOR>2SeLWY)AX{A_f7o(rc8&>%7FWJ){7w=tM)Ck1MbPn8#8 z5d;4Pvn_lwrDj^hJ}G*RccyCPPOyM16cTe&#yOs8!E%pG4Tq0KO!_ zKST<#9OkALrUfPTg{}#zfmjrr9#n#x6kygT5jM&MO<;J)JviiL1sbXZGYUp|E&$Rf z`E`nyf!BgA3dn({2f_~`2J#~)IfN1jb>$`z?fpPNpr^T{pojrN0u2WZd`|JS3Cc(; z5rn!Z_+4~xibrNVPCIlBA0IeePD{Z!&&js(l?4th3aXHZx1a!u%pFQ=TF zpbq4CD^%nenRmU~^Y!g8m_G}){&iy4Kv-?J`fv7cTbfsnq{|Mj1Xq>s>+kEgTl&^e z+Tlu@Pi&jr8MAxSJa9w#dy_S5sr*c1eADoTAzj(Ard++c7Tr*;U)>0%%ZIl#=XMq2 zq_%3OwBlCHTQ&D$zb)-vyOOP}z4hwNS9h!jcWRol^-Zgxj|MiBA9~VWU&eNRS4~tM z*d-KIWlM@(9Z_1oZEnt(o43r4tgZI0?Y8ZXeM$AyM3nVBu@ct0<;cD8w&hU9a%jg~ zam(pZ-q-l=I=X}&YMr2S(}!_wf6y?G`4-r%;qD`W5a_bMh^U3a(TcFRi7 zo%W@HtiAqj@9o}|Gphre_RgijZ2f_|*KS{1Ror=fX&8^3`6<0^@5$JEcB<;`8h&iZ z*0rv7uQsouwWhU-^zrd@%>)p`R{cmt*cx{1b<5W>^+z`C-LS5;`qt=MquJ_%EBp$# zdSo@cdVZH6dz!LMZ7Z(Tm)4SNk@eHNG--Q*d_vRZ6}uXs=W_ISm5yvv%d$4x*!*Ml zqh_MEakquA+B24et9@I&W1EM@@-wFo^sZN~Yd0z%*dFN9?D=%Vg)QsFti5q*{4{6tqB3of!9=tib^!=5_72mrrz4OxQl{J2|`9!*@f87nsl%9fRN{q0K)wXQC zc_MA7d&*KEYX4T+{pTm=DZ*@fOi&=t5|g{v+-u!w+wm|aOG^*HPiu8! zZR$@Q8eHzsjn~kh_SwcO>3^+MLRnB_)d1?^nV18(GC7yM0xTzpIlyNw?H6$X6NdDG zyI$3iu$a8Et1Z=F2iq^ZW-KK-$&`=s>I*%jl3#YQ3vRVvcBXx#WDJ5!t9a%| z*{)?(;4Xvf@5NeePm`lK{Cica;bkSQ{WXkY72jD#Bak z#}})Zq;`oXLEtk48f4d2>Q~^l)HD1%J2d`Z?a+@+2r(6cCa@>j$t7tvjI{-n`j z;L7Avdu1o2&;pK)8RN^A`un6>pv4KPT*xKKm!ty5ly$46FOYv(`YvAiI$HV&Z+~G{ zsl;0P=&yCA$nk%WovH6gqrjOu4bGINX!rY{D66=~a-RlHjC`VsasDXiAPSN~fV)&JS7u}a2GabDGj#l10lKrn#vuyc_5o2uS77fd*;FAbTJ{V)c z7=-AtlsdHtGfSXC^8%H;B2Y2-L-02Xe~VWHdJ!t=MRWyWSs5={Mw}hn;PZ(%ypVvS z7f)b=9lJf?EUEHO7IZQh12Ywcid-OUtwW82Nld*AE&_Nwg?X@#CSeP)Uc7UJRZ5Wy z#Ldko7x&v;szfHjG~kl5?%RoCTEHENMdKXrbkb-ZZ(o(86<{q!){8uj+%`p;<~;(D z4-$$g3LFGRbOw+cWIy|sNZ<8BEo4oEBo4nF~Yw_mdk6v9;JUxJIaxnesmc#c3w=K?$#VOn5 zCq5e7uzu*-?w`o?Poz)CHu)2<$q($<59VyvE!W5+nyM<>C9t`!Az<>En={J`KT6y< zi|y~}d&zA}TgK8R&wKWPdSg*C@WsjAJG9+;D${!EKu>5-ZusDxs$69x+mCN*9<89++Rm-N;nYOg9 zIs6h#2B(%6&0R8m4D9_yn$ zNxF!iHU8jQ#cF0xDj34}wMjN(K-CqkuGupPK4Ep$w^_YldKX@=yz=W+VSaywRE9S& zYD;l0FHL2EoY!lxyIG8EMG{i__PQyo&eE!a87jGTZ3X3dYAm1$cC>Zl! z*Z&4CE`A{riH300hna&HeH<7ZFA8t%cNeE>%rz(phHw={mXpG#XkTN=EmN*+xV7YBCkYF zWDcJAxHe;%NE;>|nk;Yr;D6uAwL5$8L%wj-7Z!o0Kit^159yZ$BdD54ELBhceo_r%GttB>?2G z#t1uYf;tcgCqh6c6-3X$0L{?>8${y2#VnwOT+mc81^*O-RAN1lo{RBFq+&sWMS}yI zF`_{O@ySFy5}nRjRH9&uW{??oz>a#(z_Q?yXl026#Oy8*RnYY3xcnXTCNzMaCwR;& zxZ6nb5v3p%y9O1h_`*z(HUB{v{*$o%H&OcsqBleI{(bmBMAwiKXOa3nioQrGzFPTjAzyJUM literal 0 HcmV?d00001 diff --git a/lrx/fetchers/__pycache__/qqmusic.cpython-313.pyc b/lrx/fetchers/__pycache__/qqmusic.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a8b62e0e10206d4fc7cd677bf1df911162f6e12f GIT binary patch literal 8710 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 JBtlm3{{on+7w7;0 literal 0 HcmV?d00001 diff --git a/lrx/fetchers/base.py b/lrx/fetchers/base.py new file mode 100644 index 0000000..2bf70af --- /dev/null +++ b/lrx/fetchers/base.py @@ -0,0 +1,35 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 02:33:26 +Description: Base fetcher class and common interfaces +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from ..models import TrackMeta, LyricResult + + +class BaseFetcher(ABC): + @property + @abstractmethod + def source_name(self) -> str: + """Name of the fetcher source.""" + pass + + @property + def self_cached(self) -> bool: + """True if this fetcher manages its own cache (skip per-source cache check).""" + return False + + @abstractmethod + def is_available(self, track: TrackMeta) -> bool: + """Check if the fetcher is available for the given track (e.g. has required metadata).""" + pass + + @abstractmethod + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Fetch lyrics for the given track. Returns None if unable to fetch.""" + pass diff --git a/lrx/fetchers/cache_search.py b/lrx/fetchers/cache_search.py new file mode 100644 index 0000000..af973c5 --- /dev/null +++ b/lrx/fetchers/cache_search.py @@ -0,0 +1,85 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-28 05:57:46 +Description: Cache-search fetcher — cross-album fuzzy lookup in the local cache +""" + +""" +Searches existing cache entries by artist + title with fuzzy normalization, +ignoring album and source. Useful when the same track appears on different +albums or is played from different players. +""" + +from typing import Optional +from loguru import logger + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..cache import CacheEngine + + +class CacheSearchFetcher(BaseFetcher): + def __init__(self, cache: CacheEngine) -> None: + self._cache = cache + + @property + def source_name(self) -> str: + return "cache-search" + + @property + def self_cached(self) -> bool: + return True + + def is_available(self, track: TrackMeta) -> bool: + return bool(track.title) + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + if bypass_cache: + logger.debug("Cache-search: bypassed by caller") + return None + + if not track.title: + logger.debug("Cache-search: skipped — no title") + return None + + # Fast path: exact metadata match (artist+title+album), single SQL query + exact = self._cache.find_best_positive(track) + if exact: + logger.info(f"Cache-search: exact hit ({exact.status.value})") + return exact + + # Slow path: fuzzy cross-album search + matches = self._cache.search_by_meta( + artist=track.artist, + title=track.title, + length=track.length, + ) + + if not matches: + logger.debug(f"Cache-search: no match for {track.display_name()}") + return None + + # Pick best: prefer synced, then first available + best = None + for m in matches: + if m.get("status") == CacheStatus.SUCCESS_SYNCED.value: + best = m + break + if best is None: + best = m + + if not best or not best.get("lyrics"): + return None + + status = CacheStatus(best["status"]) + logger.info( + f"Cache-search: fuzzy hit from [{best.get('source')}] " + f"album={best.get('album')!r} ({status.value})" + ) + return LyricResult( + status=status, + lyrics=best["lyrics"], + source=self.source_name, + ) diff --git a/lrx/fetchers/local.py b/lrx/fetchers/local.py new file mode 100644 index 0000000..82e3ecd --- /dev/null +++ b/lrx/fetchers/local.py @@ -0,0 +1,98 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-26 02:08:41 +Description: Local fetcher — reads lyrics from .lrc sidecar files or embedded audio metadata +""" + +""" +Priority: + 1. Same-directory .lrc file (e.g. /path/to/track.lrc) + 2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags) +""" + +from typing import Optional +from loguru import logger +from mutagen._file import File +from mutagen.flac import FLAC + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult +from ..lrc import detect_sync_status, normalize_tags, get_audio_path, get_sidecar_path + + +class LocalFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "local" + + def is_available(self, track: TrackMeta) -> bool: + return track.is_local + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Attempt to read lyrics from local filesystem.""" + if not track.is_local or not track.url: + return None + + audio_path = get_audio_path(track.url, ensure_exists=False) + if not audio_path: + logger.debug(f"Local: audio URL is not a valid file path: {track.url}") + return None + + lrc_path = get_sidecar_path( + track.url, ensure_audio_exists=False, ensure_exists=True + ) + if lrc_path: + try: + with open(lrc_path, "r", encoding="utf-8") as f: + content = f.read().strip() + if content: + content = normalize_tags(content) + status = detect_sync_status(content) + logger.info(f"Local: found .lrc sidecar ({status.value})") + return LyricResult( + status=status, lyrics=content, source=self.source_name + ) + except Exception as e: + logger.error(f"Local: error reading {lrc_path}: {e}") + else: + logger.debug(f"Local: no .lrc sidecar found for {audio_path}") + + # Embedded metadata + if not audio_path.exists(): + logger.debug(f"Local: audio file does not exist: {audio_path}") + return None + try: + audio = File(audio_path) + if audio is not None: + lyrics = None + + if isinstance(audio, FLAC): + # FLAC stores lyrics in vorbis comment tags + lyrics = ( + audio.get("lyrics") or audio.get("unsynclyrics") or [None] + )[0] + elif hasattr(audio, "tags") and audio.tags: + # MP3 / other: look for USLT or SYLT ID3 frames + for key in audio.tags.keys(): + if key.startswith("USLT") or key.startswith("SYLT"): + lyrics = str(audio.tags[key]) + break + + if lyrics: + lyrics = normalize_tags(lyrics.strip()) + status = detect_sync_status(lyrics) + logger.info(f"Local: found embedded lyrics ({status.value})") + return LyricResult( + status=status, + lyrics=lyrics, + source=f"{self.source_name} (embedded)", + ) + else: + logger.debug("Local: no embedded lyrics found") + except Exception as e: + logger.error(f"Local: error reading metadata for {audio_path}: {e}") + + logger.debug(f"Local: no lyrics found for {audio_path}") + return None diff --git a/lrx/fetchers/lrclib.py b/lrx/fetchers/lrclib.py new file mode 100644 index 0000000..94928d1 --- /dev/null +++ b/lrx/fetchers/lrclib.py @@ -0,0 +1,111 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 05:23:38 +Description: LRCLIB fetcher — queries lrclib.net for synced/plain lyrics +""" + +""" +Requires complete track metadata (artist, title, album, duration). +""" + +from typing import Optional +import httpx +from loguru import logger +from urllib.parse import urlencode + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import normalize_tags +from ..config import ( + HTTP_TIMEOUT, + TTL_UNSYNCED, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + LRCLIB_API_URL, + UA_LRCFETCH, +) + + +class LrclibFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "lrclib" + + def is_available(self, track: TrackMeta) -> bool: + return track.is_complete + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Fetch lyrics from LRCLIB. Requires complete metadata.""" + if not track.is_complete: + logger.debug("LRCLIB: skipped — incomplete metadata") + return None + + params = { + "track_name": track.title, + "artist_name": track.artist, + "album_name": track.album, + "duration": track.length / 1000.0 if track.length else 0, + } + + url = f"{LRCLIB_API_URL}?{urlencode(params)}" + logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get(url, headers={"User-Agent": UA_LRCFETCH}) + + if resp.status_code == 404: + logger.debug(f"LRCLIB: not found for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + if resp.status_code != 200: + logger.error(f"LRCLIB: API returned {resp.status_code}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + data = resp.json() + + # Validate response + if not isinstance(data, dict): + logger.error(f"LRCLIB: unexpected response type: {type(data).__name__}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + synced = data.get("syncedLyrics") + unsynced = data.get("plainLyrics") + + if isinstance(synced, str) and synced.strip(): + lyrics = normalize_tags(synced.strip()) + logger.info( + f"LRCLIB: got synced lyrics ({len(lyrics.splitlines())} lines)" + ) + return LyricResult( + status=CacheStatus.SUCCESS_SYNCED, + lyrics=lyrics, + source=self.source_name, + ) + elif isinstance(unsynced, str) and unsynced.strip(): + lyrics = normalize_tags(unsynced.strip()) + logger.info( + f"LRCLIB: got unsynced lyrics ({len(lyrics.splitlines())} lines)" + ) + return LyricResult( + status=CacheStatus.SUCCESS_UNSYNCED, + lyrics=lyrics, + source=self.source_name, + ttl=TTL_UNSYNCED, + ) + else: + logger.debug(f"LRCLIB: empty response for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + except httpx.HTTPError as e: + logger.error(f"LRCLIB: HTTP error: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + except Exception as e: + logger.error(f"LRCLIB: unexpected error: {e}") + return None diff --git a/lrx/fetchers/lrclib_search.py b/lrx/fetchers/lrclib_search.py new file mode 100644 index 0000000..95f87c2 --- /dev/null +++ b/lrx/fetchers/lrclib_search.py @@ -0,0 +1,168 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 05:30:50 +Description: LRCLIB search fetcher — fuzzy search via lrclib.net /api/search +""" + +""" +Used when metadata is incomplete (no album or duration) but title is available. +Selects the best match by duration when track length is known. +""" + +import httpx +from typing import Optional +from loguru import logger +from urllib.parse import urlencode + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import normalize_tags +from ..config import ( + HTTP_TIMEOUT, + TTL_UNSYNCED, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + DURATION_TOLERANCE_MS, + LRCLIB_SEARCH_URL, + UA_LRCFETCH, +) + + +class LrclibSearchFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "lrclib-search" + + def is_available(self, track: TrackMeta) -> bool: + return bool(track.title) + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Search LRCLIB for lyrics. Requires at least a title.""" + if not track.title: + logger.debug("LRCLIB-search: skipped — no title") + return None + + params: dict[str, str] = {"track_name": track.title} + if track.artist: + params["artist_name"] = track.artist + if track.album: + params["album_name"] = track.album + + url = f"{LRCLIB_SEARCH_URL}?{urlencode(params)}" + logger.info(f"LRCLIB-search: searching for {track.display_name()}") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get(url, headers={"User-Agent": UA_LRCFETCH}) + + if resp.status_code != 200: + logger.error(f"LRCLIB-search: API returned {resp.status_code}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + data = resp.json() + + if not isinstance(data, list) or len(data) == 0: + logger.debug(f"LRCLIB-search: no results for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + logger.debug(f"LRCLIB-search: got {len(data)} candidates") + + # Select best match by duration + best = self._select_best(data, track) + if best is None: + logger.debug("LRCLIB-search: no valid candidate found") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + # Extract lyrics + synced = best.get("syncedLyrics") + unsynced = best.get("plainLyrics") + + if isinstance(synced, str) and synced.strip(): + lyrics = normalize_tags(synced.strip()) + logger.info( + f"LRCLIB-search: got synced lyrics ({len(lyrics.splitlines())} lines)" + ) + return LyricResult( + status=CacheStatus.SUCCESS_SYNCED, + lyrics=lyrics, + source=self.source_name, + ) + elif isinstance(unsynced, str) and unsynced.strip(): + lyrics = normalize_tags(unsynced.strip()) + logger.info( + f"LRCLIB-search: got unsynced lyrics ({len(lyrics.splitlines())} lines)" + ) + return LyricResult( + status=CacheStatus.SUCCESS_UNSYNCED, + lyrics=lyrics, + source=self.source_name, + ttl=TTL_UNSYNCED, + ) + else: + logger.debug("LRCLIB-search: best candidate has empty lyrics") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + except httpx.HTTPError as e: + logger.error(f"LRCLIB-search: HTTP error: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + except Exception as e: + logger.error(f"LRCLIB-search: unexpected error: {e}") + return None + + @staticmethod + def _select_best(candidates: list[dict], track: TrackMeta) -> Optional[dict]: + """Pick the best candidate, preferring synced lyrics and closest duration.""" + if track.length is not None: + track_s = track.length / 1000.0 + best: Optional[dict] = None + best_diff = float("inf") + + for item in candidates: + if not isinstance(item, dict): + continue + duration = item.get("duration") + if not isinstance(duration, (int, float)): + continue + diff = abs(duration - track_s) * 1000 # compare in ms + if diff > DURATION_TOLERANCE_MS: + continue + # Prefer synced over unsynced at similar duration + has_synced = ( + isinstance(item.get("syncedLyrics"), str) + and item["syncedLyrics"].strip() + ) + best_synced = ( + best is not None + and isinstance(best.get("syncedLyrics"), str) + and best["syncedLyrics"].strip() + ) + if diff < best_diff or ( + diff == best_diff and has_synced and not best_synced + ): + best_diff = diff + best = item + + if best is not None: + logger.debug( + f"LRCLIB-search: selected id={best.get('id')} (diff={best_diff:.0f}ms)" + ) + return best + + logger.debug( + f"LRCLIB-search: no candidate within {DURATION_TOLERANCE_MS}ms" + ) + return None + + # No duration — pick first with synced lyrics, or just first + for item in candidates: + if ( + isinstance(item, dict) + and isinstance(item.get("syncedLyrics"), str) + and item["syncedLyrics"].strip() + ): + return item + return candidates[0] if isinstance(candidates[0], dict) else None diff --git a/lrx/fetchers/netease.py b/lrx/fetchers/netease.py new file mode 100644 index 0000000..eecc8d7 --- /dev/null +++ b/lrx/fetchers/netease.py @@ -0,0 +1,213 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 11:04:51 +Description: Netease Cloud Music fetcher +""" + +""" +Uses the public cloudsearch API for searching and the song/lyric API for +retrieving lyrics. No authentication required. + +Search results are filtered by duration when the track has a known length +to avoid returning lyrics for the wrong version of a song. +""" + +from typing import Optional +import httpx +from loguru import logger + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import detect_sync_status, normalize_tags +from ..config import ( + HTTP_TIMEOUT, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + DURATION_TOLERANCE_MS, + NETEASE_SEARCH_URL, + NETEASE_LYRIC_URL, + UA_BROWSER, +) + +_HEADERS = { + "User-Agent": UA_BROWSER, + "Referer": "https://music.163.com/", +} + + +class NeteaseFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "netease" + + def is_available(self, track: TrackMeta) -> bool: + return bool(track.title) + + def _search(self, track: TrackMeta, limit: int = 10) -> Optional[int]: + """Search Netease and return the best-matching song ID. + + When ``track.length`` is available, candidates are ranked by duration + difference and only accepted if within ``DURATION_TOLERANCE_MS``. + """ + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + return None + + logger.debug(f"Netease: searching for '{query}' (limit={limit})") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.post( + NETEASE_SEARCH_URL, + headers=_HEADERS, + data={"s": query, "type": "1", "limit": str(limit), "offset": "0"}, + ) + resp.raise_for_status() + result = resp.json() + + # Validate response + if not isinstance(result, dict): + logger.error( + f"Netease: search returned non-dict: {type(result).__name__}" + ) + return None + + result_body = result.get("result") + if not isinstance(result_body, dict): + logger.debug("Netease: search 'result' field missing or invalid") + return None + + songs = result_body.get("songs") + if not isinstance(songs, list) or len(songs) == 0: + logger.debug("Netease: search returned 0 results") + return None + + logger.debug(f"Netease: search returned {len(songs)} candidates") + + # Duration-based best-match selection + if track.length is not None: + track_ms = track.length + best_id: Optional[int] = None + best_diff = float("inf") + + for song in songs: + if not isinstance(song, dict): + continue + sid = song.get("id") + name = song.get("name", "?") + duration = song.get("dt") # milliseconds + if not isinstance(duration, int): + logger.debug( + f" candidate {sid} '{name}': no duration, skipped" + ) + continue + diff = abs(duration - track_ms) + logger.debug( + f" candidate {sid} '{name}': " + f"duration={duration}ms, diff={diff}ms" + ) + if diff < best_diff: + best_diff = diff + best_id = sid + + if best_id is not None and best_diff <= DURATION_TOLERANCE_MS: + logger.debug(f"Netease: selected id={best_id} (diff={best_diff}ms)") + return best_id + + logger.debug( + f"Netease: no candidate within {DURATION_TOLERANCE_MS}ms " + f"(best diff={best_diff}ms)" + ) + return None + + # No duration info — take the first result + first = songs[0] + if not isinstance(first, dict) or "id" not in first: + logger.error("Netease: first search result has no 'id'") + return None + logger.debug( + f"Netease: no duration available, using first result " + f"id={first['id']} '{first.get('name', '?')}'" + ) + return first["id"] + + except Exception as e: + logger.error(f"Netease: search failed: {e}") + return None + + def _get_lyric(self, song_id: int) -> Optional[LyricResult]: + """Fetch lyrics for a given Netease song ID.""" + logger.debug(f"Netease: fetching lyrics for song_id={song_id}") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.post( + NETEASE_LYRIC_URL, + headers=_HEADERS, + data={ + "id": str(song_id), + "cp": "false", + "tv": "0", + "lv": "0", + "rv": "0", + "kv": "0", + "yv": "0", + "ytv": "0", + "yrv": "0", + }, + ) + resp.raise_for_status() + data = resp.json() + + # Validate response + if not isinstance(data, dict): + logger.error( + f"Netease: lyric response is not dict: {type(data).__name__}" + ) + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + lrc_obj = data.get("lrc") + if not isinstance(lrc_obj, dict): + logger.debug( + f"Netease: no 'lrc' object in response for song_id={song_id}" + ) + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + lrc: str = lrc_obj.get("lyric", "") + if not isinstance(lrc, str) or not lrc.strip(): + logger.debug(f"Netease: empty lyrics for song_id={song_id}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + # Determine sync status + lrc = normalize_tags(lrc) + status = detect_sync_status(lrc) + logger.info( + f"Netease: got {status.value} lyrics for song_id={song_id} " + f"({len(lrc.splitlines())} lines)" + ) + return LyricResult( + status=status, lyrics=lrc.strip(), source=self.source_name + ) + + except Exception as e: + logger.error(f"Netease: lyric fetch failed for song_id={song_id}: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Search for the track and fetch its lyrics.""" + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + logger.debug("Netease: skipped — insufficient metadata") + return None + + logger.info(f"Netease: fetching lyrics for {track.display_name()}") + song_id = self._search(track) + if not song_id: + logger.debug(f"Netease: no match found for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + return self._get_lyric(song_id) diff --git a/lrx/fetchers/qqmusic.py b/lrx/fetchers/qqmusic.py new file mode 100644 index 0000000..a5d0b63 --- /dev/null +++ b/lrx/fetchers/qqmusic.py @@ -0,0 +1,178 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-31 01:54:02 +Description: QQ Music fetcher via self-hosted API proxy +""" + +""" +Requires a running qq-music-api instance. +The base URL is read from the QQ_MUSIC_API_URL environment variable. + +Search → pick best match by duration → fetch LRC lyrics. +""" + +from typing import Optional +import httpx +from loguru import logger + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import detect_sync_status, normalize_tags +from ..config import ( + HTTP_TIMEOUT, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + DURATION_TOLERANCE_MS, + QQ_MUSIC_API_URL, +) + + +class QQMusicFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "qqmusic" + + def is_available(self, track: TrackMeta) -> bool: + return bool(track.title) and bool(QQ_MUSIC_API_URL) + + def _search(self, track: TrackMeta, limit: int = 10) -> Optional[str]: + """Search QQ Music and return the best-matching song MID.""" + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + return None + + logger.debug(f"QQMusic: searching for '{query}' (limit={limit})") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get( + f"{QQ_MUSIC_API_URL}/api/search", + params={"keyword": query, "type": "song", "num": limit}, + ) + resp.raise_for_status() + data = resp.json() + + if data.get("code") != 0: + logger.error(f"QQMusic: search API error: {data}") + return None + + songs = data.get("data", {}).get("list", []) + if not songs: + logger.debug("QQMusic: search returned 0 results") + return None + + logger.debug(f"QQMusic: search returned {len(songs)} candidates") + + # Duration-based best-match selection + if track.length is not None: + track_ms = track.length + best_mid: Optional[str] = None + best_diff = float("inf") + + for song in songs: + if not isinstance(song, dict): + continue + mid = song.get("mid") + name = song.get("name", "?") + # interval is in seconds + interval = song.get("interval") + if not isinstance(interval, int): + logger.debug( + f" candidate {mid} '{name}': no duration, skipped" + ) + continue + duration_ms = interval * 1000 + diff = abs(duration_ms - track_ms) + logger.debug( + f" candidate {mid} '{name}': " + f"duration={duration_ms}ms, diff={diff}ms" + ) + if diff < best_diff: + best_diff = diff + best_mid = mid + + if best_mid is not None and best_diff <= DURATION_TOLERANCE_MS: + logger.debug( + f"QQMusic: selected mid={best_mid} (diff={best_diff}ms)" + ) + return best_mid + + logger.debug( + f"QQMusic: no candidate within {DURATION_TOLERANCE_MS}ms " + f"(best diff={best_diff}ms)" + ) + return None + + # No duration info — take the first result + first = songs[0] + if not isinstance(first, dict) or "mid" not in first: + logger.error("QQMusic: first search result has no 'mid'") + return None + logger.debug( + f"QQMusic: no duration available, using first result " + f"mid={first['mid']} '{first.get('name', '?')}'" + ) + return first["mid"] + + except Exception as e: + logger.error(f"QQMusic: search failed: {e}") + return None + + def _get_lyric(self, mid: str) -> Optional[LyricResult]: + """Fetch lyrics for a given QQ Music song MID.""" + logger.debug(f"QQMusic: fetching lyrics for mid={mid}") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get( + f"{QQ_MUSIC_API_URL}/api/lyric", + params={"mid": mid}, + ) + resp.raise_for_status() + data = resp.json() + + if data.get("code") != 0: + logger.error(f"QQMusic: lyric API error: {data}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + lrc = data.get("data", {}).get("lyric", "") + if not isinstance(lrc, str) or not lrc.strip(): + logger.debug(f"QQMusic: empty lyrics for mid={mid}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + lrc = normalize_tags(lrc) + status = detect_sync_status(lrc) + logger.info( + f"QQMusic: got {status.value} lyrics for mid={mid} " + f"({len(lrc.splitlines())} lines)" + ) + return LyricResult( + status=status, lyrics=lrc.strip(), source=self.source_name + ) + + except Exception as e: + logger.error(f"QQMusic: lyric fetch failed for mid={mid}: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Search for the track and fetch its lyrics.""" + if not QQ_MUSIC_API_URL: + logger.debug("QQMusic: skipped — QQ_MUSIC_API_URL not configured") + return None + + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + logger.debug("QQMusic: skipped — insufficient metadata") + return None + + logger.info(f"QQMusic: fetching lyrics for {track.display_name()}") + mid = self._search(track) + if not mid: + logger.debug(f"QQMusic: no match found for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + return self._get_lyric(mid) diff --git a/lrx/fetchers/spotify.py b/lrx/fetchers/spotify.py new file mode 100644 index 0000000..fcd568c --- /dev/null +++ b/lrx/fetchers/spotify.py @@ -0,0 +1,373 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 10:43:21 +Description: Spotify fetcher — obtains synced lyrics via Spotify's internal color-lyrics API. +""" + +""" +Authentication flow: + 1. Fetch server time from Spotify + 2. Fetch TOTP secret + 3. Generate a TOTP code and exchange it (with SP_DC cookie) for an access token + 4. Request lyrics using the access token + +The secret and token are cached on the instance to avoid redundant network +calls within the same session. + +Requires SPOTIFY_SP_DC environment variable to be set. +""" + +import httpx +import json +import time +import struct +import hmac +import hashlib +from typing import Optional, Tuple +from loguru import logger + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import normalize_tags +from ..config import ( + HTTP_TIMEOUT, + SPOTIFY_APP_VERSION, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + SPOTIFY_TOKEN_URL, + SPOTIFY_LYRICS_URL, + SPOTIFY_SERVER_TIME_URL, + SPOTIFY_SECRET_URL, + SPOTIFY_SP_DC, + SPOTIFY_TOKEN_CACHE_FILE, + UA_BROWSER, +) + + +class SpotifyFetcher(BaseFetcher): + def __init__(self) -> None: + # Session-level caches to avoid refetching within the same run + self._cached_secret: Optional[Tuple[str, int]] = None + self._cached_token: Optional[str] = None + self._token_expires_at: float = 0.0 + + @property + def source_name(self) -> str: + return "spotify" + + def is_available(self, track: TrackMeta) -> bool: + return bool(track.trackid) and bool(SPOTIFY_SP_DC) + + # ─── Auth helpers ──────────────────────────────────────────────── + + def _get_server_time(self, client: httpx.Client) -> Optional[int]: + """Fetch Spotify's server timestamp (seconds since epoch).""" + try: + res = client.get(SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT) + res.raise_for_status() + data = res.json() + if not isinstance(data, dict) or "serverTime" not in data: + logger.error(f"Spotify: unexpected server-time response: {data}") + return None + server_time = data["serverTime"] + logger.debug(f"Spotify: server time = {server_time}") + return server_time + except Exception as e: + logger.error(f"Spotify: failed to fetch server time: {e}") + return None + + def _get_secret(self, client: httpx.Client) -> Optional[Tuple[str, int]]: + """Fetch and decode the TOTP secret. Cached after first success. + + Response format: [{version: int, secret: str}, ...] + Each character in *secret* is XOR-decoded with ``(index % 33) + 9``. + """ + if self._cached_secret is not None: + logger.debug("Spotify: using cached TOTP secret") + return self._cached_secret + + try: + res = client.get(SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT) + res.raise_for_status() + data = res.json() + + if not isinstance(data, list) or len(data) == 0: + logger.error( + f"Spotify: unexpected secrets response (type={type(data).__name__}, len={len(data) if isinstance(data, list) else '?'})" + ) + return None + + last = data[-1] + if "secret" not in last or "version" not in last: + logger.error(f"Spotify: malformed secret entry: {list(last.keys())}") + return None + + secret_raw = last["secret"] + version = last["version"] + + # XOR decode + parts = [] + for i, char in enumerate(secret_raw): + parts.append(str(ord(char) ^ ((i % 33) + 9))) + secret = "".join(parts) + + logger.debug(f"Spotify: decoded secret v{version} (len={len(secret)})") + self._cached_secret = (secret, version) + return self._cached_secret + + except Exception as e: + logger.error(f"Spotify: failed to fetch secret: {e}") + return None + + @staticmethod + def _generate_totp(server_time_s: int, secret: str) -> str: + """Generate a 6-digit TOTP code compatible with Spotify's auth. + + Uses HMAC-SHA1 with a 30-second period, matching the Go reference. + """ + counter = server_time_s // 30 + counter_bytes = struct.pack(">Q", counter) + + mac = hmac.new(secret.encode(), counter_bytes, hashlib.sha1).digest() + + offset = mac[-1] & 0x0F + binary_code = ( + (mac[offset] & 0x7F) << 24 + | (mac[offset + 1] & 0xFF) << 16 + | (mac[offset + 2] & 0xFF) << 8 + | (mac[offset + 3] & 0xFF) + ) + + code = binary_code % (10**6) + return str(code).zfill(6) + + def _load_cached_token(self) -> Optional[str]: + """Try to load a valid token from the persistent cache file.""" + try: + with open(SPOTIFY_TOKEN_CACHE_FILE, "r") as f: + data = json.load(f) + expires_ms = data.get("accessTokenExpirationTimestampMs", 0) + if expires_ms <= int(time.time() * 1000): + logger.debug("Spotify: persisted token expired") + return None + token = data.get("accessToken", "") + if not token: + return None + self._cached_token = token + self._token_expires_at = expires_ms / 1000.0 + logger.debug("Spotify: loaded token from cache file") + return token + except (FileNotFoundError, json.JSONDecodeError, KeyError): + return None + + def _save_token(self, body: dict) -> None: + """Persist the token response to disk.""" + try: + with open(SPOTIFY_TOKEN_CACHE_FILE, "w") as f: + json.dump(body, f) + logger.debug("Spotify: token saved to cache file") + except Exception as e: + logger.warning(f"Spotify: failed to write token cache: {e}") + + def _get_token(self) -> Optional[str]: + """Obtain a Spotify access token. Cached in memory and on disk. + + Requires SP_DC cookie (set via SPOTIFY_SP_DC env var). + """ + # 1. Memory cache + if self._cached_token and time.time() < self._token_expires_at - 30: + logger.debug("Spotify: using in-memory cached token") + return self._cached_token + + # 2. Disk cache + disk_token = self._load_cached_token() + if disk_token and time.time() < self._token_expires_at - 30: + return disk_token + + # 3. Fetch new token + if not SPOTIFY_SP_DC: + logger.error( + "Spotify: SPOTIFY_SP_DC env var not set — " + "cannot authenticate with Spotify" + ) + return None + + headers = { + "User-Agent": UA_BROWSER, + "Accept": "*/*", + "Referer": "https://open.spotify.com/", + "Cookie": f"sp_dc={SPOTIFY_SP_DC}", + } + + with httpx.Client(headers=headers) as client: + server_time = self._get_server_time(client) + if server_time is None: + return None + + secret_data = self._get_secret(client) + if secret_data is None: + return None + + secret, version = secret_data + totp = self._generate_totp(server_time, secret) + logger.debug(f"Spotify: generated TOTP v{version}: {totp}") + + params = { + "reason": "init", + "productType": "web-player", + "totp": totp, + "totpVer": str(version), + "totpServer": totp, + } + + try: + res = client.get(SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT) + if res.status_code != 200: + logger.error(f"Spotify: token request returned {res.status_code}") + return None + + body = res.json() + + if not isinstance(body, dict) or "accessToken" not in body: + logger.error( + f"Spotify: unexpected token response keys: {list(body.keys()) if isinstance(body, dict) else type(body).__name__}" + ) + return None + + token = body["accessToken"] + is_anonymous = body.get("isAnonymous", False) + if is_anonymous: + logger.warning( + "Spotify: received anonymous token — SP_DC may be invalid" + ) + + expires_ms = body.get("accessTokenExpirationTimestampMs", 0) + if expires_ms and expires_ms > int(time.time() * 1000): + self._token_expires_at = expires_ms / 1000.0 + else: + logger.warning("Spotify: token expiry missing or invalid") + self._token_expires_at = time.time() + 3600 + + self._cached_token = token + # Persist to disk (including anonymous tokens, same as Go ref) + self._save_token(body) + logger.debug("Spotify: obtained access token") + return token + + except Exception as e: + logger.error(f"Spotify: token request failed: {e}") + return None + + # ─── Lyrics ────────────────────────────────────────────────────── + + @staticmethod + def _format_lrc_line(start_ms: int, words: str) -> str: + """Format a single lyric line as LRC ``[mm:ss.cc]text``.""" + minutes = start_ms // 60000 + seconds = (start_ms // 1000) % 60 + centiseconds = round((start_ms % 1000) / 10.0) + return f"[{minutes:02d}:{seconds:02d}.{centiseconds:02.0f}]{words}" + + @staticmethod + def _is_truly_synced(lines: list[dict]) -> bool: + """Check if lyrics are actually synced (not all timestamps zero).""" + for line in lines: + try: + ms = int(line.get("startTimeMs", "0")) + if ms > 0: + return True + except (ValueError, TypeError): + continue + return False + + def fetch( + self, track: TrackMeta, bypass_cache: bool = False + ) -> Optional[LyricResult]: + """Fetch lyrics for a Spotify track by its track ID.""" + if not track.trackid: + logger.debug("Spotify: skipped — no trackid in metadata") + return None + + logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}") + + token = self._get_token() + if not token: + logger.error("Spotify: cannot fetch lyrics without a token") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + + url = f"{SPOTIFY_LYRICS_URL}{track.trackid}?format=json&vocalRemoval=false&market=from_token" + headers = { + "User-Agent": UA_BROWSER, + "Accept": "application/json", + "Authorization": f"Bearer {token}", + "Referer": "https://open.spotify.com/", + "App-Platform": "WebPlayer", + "Spotify-App-Version": SPOTIFY_APP_VERSION, + "Origin": "https://open.spotify.com", + } + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + res = client.get(url, headers=headers) + + if res.status_code == 404: + logger.debug(f"Spotify: 404 for trackid={track.trackid}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + if res.status_code != 200: + logger.error(f"Spotify: lyrics API returned {res.status_code}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + data = res.json() + + # Validate response structure + if not isinstance(data, dict) or "lyrics" not in data: + logger.error("Spotify: unexpected lyrics response structure") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + lyrics_data = data["lyrics"] + sync_type = lyrics_data.get("syncType", "") + lines = lyrics_data.get("lines", []) + + if not isinstance(lines, list) or len(lines) == 0: + logger.debug("Spotify: response contained no lyric lines") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + # Determine sync status + # syncType == "LINE_SYNCED" AND at least one non-zero timestamp + is_synced = sync_type == "LINE_SYNCED" and self._is_truly_synced(lines) + + # Convert to LRC + lrc_lines: list[str] = [] + for line in lines: + words = line.get("words", "") + if not isinstance(words, str): + continue + try: + ms = int(line.get("startTimeMs", "0")) + except (ValueError, TypeError): + ms = 0 + + if is_synced: + lrc_lines.append(self._format_lrc_line(ms, words)) + else: + # Unsynced: emit with zero timestamps + lrc_lines.append(f"[00:00.00]{words}") + + content = normalize_tags("\n".join(lrc_lines)) + status = ( + CacheStatus.SUCCESS_SYNCED + if is_synced + else CacheStatus.SUCCESS_UNSYNCED + ) + + logger.info(f"Spotify: got {status.value} lyrics ({len(lrc_lines)} lines)") + return LyricResult(status=status, lyrics=content, source=self.source_name) + + except Exception as e: + logger.error(f"Spotify: lyrics fetch failed: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) diff --git a/lrx/lrc.py b/lrx/lrc.py new file mode 100644 index 0000000..6913512 --- /dev/null +++ b/lrx/lrc.py @@ -0,0 +1,178 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 21:54:01 +Description: Shared LRC time-tag utilities (definitely overengineered) +""" + +import re +from pathlib import Path +from typing import Optional +from urllib.parse import unquote + +from .models import CacheStatus + +# Parses any time tag input format: +# [mm:ss], [mm:ss.c], [mm:ss.cc], [mm:ss.ccc], [mm:ss:cc], … +_RAW_TAG_RE = re.compile(r"\[(\d{2,}):(\d{2})(?:[.:](\d{1,3}))?\]") + +# Standard format after normalization: [mm:ss.cc] +_STD_TAG_RE = re.compile(r"\[\d{2,}:\d{2}\.\d{2}\]") + +# Standard format with capture groups +_STD_TAG_CAPTURE_RE = re.compile(r"\[(\d{2,}):(\d{2})\.(\d{2})\]") + +# Matches a standard time tag at the start of a line +_LRC_LINE_RE = re.compile(r"^\[\d{2,}:\d{2}\.\d{2}\]", re.MULTILINE) + +# [offset:+/-xxx] tag — value in milliseconds +_OFFSET_RE = re.compile(r"^\[offset:\s*([+-]?\d+)\]\s*$", re.MULTILINE | re.IGNORECASE) + + +def _raw_tag_to_cs(mm: str, ss: str, frac: Optional[str]) -> str: + """Convert parsed time tag components to standard [mm:ss.cc] string.""" + if frac is None: + ms = 0 + else: + # cc in [mm:ss:cc] is also treated as centiseconds, per LRC spec + # ^ + # why does this format even exist, idk + n = len(frac) + if n == 1: + ms = int(frac) * 100 + elif n == 2: + ms = int(frac) * 10 + else: + ms = int(frac) + cs = min(round(ms / 10), 99) + return f"[{mm}:{ss}.{cs:02d}]" + + +def _reformat(text: str) -> str: + """Parse each line and reformat to standard [mm:ss.cc]...content form. + + Handles any mix of time tag formats on input. Lines with no time tags + are stripped of leading/trailing whitespace and passed through unchanged. + """ + out: list[str] = [] + for line in text.splitlines(): + line = line.strip() + pos = 0 + tags: list[str] = [] + while True: + while pos < len(line) and line[pos] == " ": + pos += 1 + m = _RAW_TAG_RE.match(line, pos) + # Non-time tags are passed through as-is, except for leading/trailing whitespace which is stripped. + if not m: + # No more tags on this line + break + tags.append(_raw_tag_to_cs(m.group(1), m.group(2), m.group(3))) + pos = m.end() + if tags: + # This could break lyric lines of some kind of word-synced LRC format, + # but such format were not planned to be supported in the first place, so… + out.append("".join(tags) + line[pos:].lstrip()) + else: + out.append(line) + # Empty lines with no tags are also preserved + return "\n".join(out) + + +def _apply_offset(text: str) -> str: + """Parse [offset:±ms] and shift all standard [mm:ss.cc] tags accordingly. + + Per LRC spec, positive offset = lyrics appear sooner (subtract from timestamps). + """ + m = _OFFSET_RE.search(text) + if not m: + return text + offset_ms = int(m.group(1)) + text = _OFFSET_RE.sub("", text).strip("\n") + if offset_ms == 0: + return text + + def _shift(match: re.Match) -> str: + total_ms = max( + 0, + (int(match.group(1)) * 60 + int(match.group(2))) * 1000 + + int(match.group(3)) * 10 + - offset_ms, + ) + new_mm = total_ms // 60000 + new_ss = (total_ms % 60000) // 1000 + new_cs = min(round((total_ms % 1000) / 10), 99) + return f"[{new_mm:02d}:{new_ss:02d}.{new_cs:02d}]" + + return _STD_TAG_CAPTURE_RE.sub(_shift, text) + + +def normalize_tags(text: str) -> str: + """Normalize LRC to standard form: reformat all tags to [mm:ss.cc], then apply offset.""" + return _apply_offset(_reformat(text)) + + +def is_synced(text: str) -> bool: + """Check whether text contains non-zero LRC time tags. + + Assumes text has been normalized by normalize_tags (standard [mm:ss.cc] format). + """ + tags = _STD_TAG_RE.findall(text) + return bool(tags) and any(tag != "[00:00.00]" for tag in tags) + + +def detect_sync_status(text: str) -> CacheStatus: + """Determine whether lyrics contain meaningful LRC time tags. + + Assumes text has been normalized by normalize_tags. + """ + return ( + CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED + ) + + +def normalize_unsynced(lyrics: str) -> str: + """Normalize unsynced lyrics so every line has a [00:00.00] tag. + + - Lines that already have time tags: replace with [00:00.00] + - Lines without time tags: prepend [00:00.00] + - Blank lines are converted to [00:00.00] + """ + out: list[str] = [] + for line in lyrics.splitlines(): + stripped = line.strip() + if not stripped: + out.append("[00:00.00]") + continue + cleaned = _LRC_LINE_RE.sub("", stripped) + while _LRC_LINE_RE.match(cleaned): + cleaned = _LRC_LINE_RE.sub("", cleaned) + out.append(f"[00:00.00]{cleaned}") + return "\n".join(out) + + +def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]: + """Convert file:// URL to Path, return None if invalid or (if ensure_exists) file doesn't exist.""" + if not audio_url.startswith("file://"): + return None + file_path = unquote(audio_url.replace("file://", "", 1)) + path = Path(file_path) + if ensure_exists and not path.exists(): + return None + return path + + +def get_sidecar_path( + audio_url: str, ensure_audio_exists: bool = False, ensure_exists: bool = False +) -> Optional[Path]: + """Given a file:// URL, return the corresponding .lrc sidecar path. + + If ensure_audio_exists is True, return None if the audio file does not exist. + If ensure_exists is True, return None if the .lrc file does not exist. + """ + audio_path = get_audio_path(audio_url, ensure_exists=ensure_audio_exists) + if not audio_path: + return None + lrc_path = audio_path.with_suffix(".lrc") + if ensure_exists and not lrc_path.exists(): + return None + return lrc_path diff --git a/lrx/models.py b/lrx/models.py new file mode 100644 index 0000000..775922a --- /dev/null +++ b/lrx/models.py @@ -0,0 +1,59 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 04:09:36 +Description: Data models +""" + +from enum import Enum +from typing import Optional +from dataclasses import dataclass + + +class CacheStatus(str, Enum): + """Status of a cached lyric entry.""" + + SUCCESS_SYNCED = "SUCCESS_SYNCED" + SUCCESS_UNSYNCED = "SUCCESS_UNSYNCED" + NOT_FOUND = "NOT_FOUND" + NETWORK_ERROR = "NETWORK_ERROR" + + +@dataclass +class TrackMeta: + """Metadata describing a track obtained from MPRIS or manual input.""" + + trackid: Optional[str] = None # Spotify track ID (without "spotify:track:" prefix) + length: Optional[int] = None # Duration in milliseconds + album: Optional[str] = None + artist: Optional[str] = None + title: Optional[str] = None + url: Optional[str] = None # Playback URL (file:// for local files) + + @property + def is_local(self) -> bool: + """True when the track is a local file (file:// URL).""" + return bool(self.url and self.url.startswith("file://")) + + @property + def is_complete(self) -> bool: + """True when all fields required by LRCLIB are present.""" + return all([self.length, self.album, self.title, self.artist]) + + def display_name(self) -> str: + """Human-readable representation for logging.""" + parts = [] + if self.artist: + parts.append(self.artist) + if self.title: + parts.append(self.title) + return " - ".join(parts) if parts else self.trackid or self.url or "(unknown)" + + +@dataclass +class LyricResult: + """Result of a lyric fetch attempt, also used as cache record.""" + + status: CacheStatus + lyrics: Optional[str] = None + source: Optional[str] = None # Which fetcher produced this result + ttl: Optional[int] = None # Hint for cache TTL (seconds) diff --git a/lrx/mpris.py b/lrx/mpris.py new file mode 100644 index 0000000..c8a2c17 --- /dev/null +++ b/lrx/mpris.py @@ -0,0 +1,188 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-03-25 04:44:15 +Description: MPRIS integration for fetching track metadata +""" + +import asyncio +from dbus_next.aio.message_bus import MessageBus +from dbus_next.constants import BusType +from dbus_next.message import Message +from lrx.models import TrackMeta +from lrx.config import PREFERRED_PLAYER +from loguru import logger +from typing import Optional, List, Any + + +async def _list_mpris_players(bus: MessageBus) -> List[str]: + """List all MPRIS player bus names.""" + try: + reply = await bus.call( + Message( + destination="org.freedesktop.DBus", + path="/org/freedesktop/DBus", + interface="org.freedesktop.DBus", + member="ListNames", + ) + ) + if not reply or not reply.body: + return [] + return [ + name for name in reply.body[0] if name.startswith("org.mpris.MediaPlayer2.") + ] + except Exception as e: + logger.error(f"Failed to list DBus names: {e}") + return [] + + +async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[str]: + """Get PlaybackStatus ('Playing', 'Paused', 'Stopped') for a player.""" + try: + introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2") + proxy = bus.get_proxy_object( + player_name, "/org/mpris/MediaPlayer2", introspection + ) + props = proxy.get_interface("org.freedesktop.DBus.Properties") + status_var = await getattr(props, "call_get")( + "org.mpris.MediaPlayer2.Player", "PlaybackStatus" + ) + return status_var.value if status_var else None + except Exception as e: + logger.debug(f"Could not get playback status for {player_name}: {e}") + return None + + +async def _select_player( + bus: MessageBus, specific_player: Optional[str] = None +) -> Optional[str]: + """Select the best MPRIS player. + + When specific_player is given, filter by name match. + Otherwise: prefer the currently playing player. If multiple are playing, + prefer the one matching LRCFETCH_PLAYER env var (default: spotify). + """ + players = await _list_mpris_players(bus) + if not players: + return None + + if specific_player: + players = [p for p in players if specific_player.lower() in p.lower()] + return players[0] if players else None + + # Check playback status for each player + playing = [] + for p in players: + status = await _get_playback_status(bus, p) + logger.debug(f"Player {p}: {status}") + if status == "Playing": + playing.append(p) + + candidates = playing if playing else players + + if len(candidates) == 1: + return candidates[0] + + # Multiple candidates: prefer LRCFETCH_PLAYER + preferred = PREFERRED_PLAYER.lower() + if preferred: + for p in candidates: + if preferred in p.lower(): + return p + return candidates[0] + + +async def _fetch_metadata_dbus( + specific_player: Optional[str] = None, +) -> Optional[TrackMeta]: + bus = None + try: + bus = await MessageBus(bus_type=BusType.SESSION).connect() + except Exception as e: + logger.error(f"Failed to connect to DBus: {e}") + return None + + try: + player_name = await _select_player(bus, specific_player) + if not player_name: + logger.debug( + f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}." + ) + return None + + logger.debug(f"Using player: {player_name}") + + introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2") + proxy = bus.get_proxy_object( + player_name, "/org/mpris/MediaPlayer2", introspection + ) + + props_iface = proxy.get_interface("org.freedesktop.DBus.Properties") + if not props_iface: + logger.error(f"Player {player_name} doesn't support Properties interface.") + return None + + try: + metadata_var: Any = await getattr(props_iface, "call_get")( + "org.mpris.MediaPlayer2.Player", "Metadata" + ) + if not metadata_var: + logger.error("Empty metadata received.") + return None + + metadata = metadata_var.value + + # Extract trackid — MPRIS returns either "spotify:track:ID" + # or a DBus object path like "/com/spotify/track/ID" + trackid = metadata.get("mpris:trackid", None) + if trackid: + trackid = trackid.value + if isinstance(trackid, str): + if trackid.startswith("spotify:track:"): + trackid = trackid.removeprefix("spotify:track:") + elif trackid.startswith("/com/spotify/track/"): + trackid = trackid.removeprefix("/com/spotify/track/") + + # Extract length (usually microseconds) + length = metadata.get("mpris:length", None) + if length: + length = length.value // 1000 if isinstance(length.value, int) else None + + album = metadata.get("xesam:album", None) + album = album.value if album else None + + artist = metadata.get("xesam:artist", None) + artist = ( + artist.value[0] + if artist and isinstance(artist.value, list) and artist.value + else None + ) + + title = metadata.get("xesam:title", None) + title = title.value if title else None + + url = metadata.get("xesam:url", None) + url = url.value if url else None + + return TrackMeta( + trackid=trackid, + length=length, + album=album, + artist=artist, + title=title, + url=url, + ) + except Exception as e: + logger.error(f"Failed to get properties from {player_name}: {e}") + return None + + finally: + if bus: + bus.disconnect() + + +def get_current_track(player_name: Optional[str] = None) -> Optional[TrackMeta]: + try: + return asyncio.run(_fetch_metadata_dbus(player_name)) + except Exception as e: + logger.error(f"DBus async loop failed: {e}") + return None diff --git a/main.py b/main.py index a2fdeab..1407b6f 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from lrcfetch.cli import run +from lrx.cli import run if __name__ == "__main__": run() diff --git a/pyproject.toml b/pyproject.toml index bb8238d..f61f550 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "lrcfetch" +name = "lrx-cli" version = "0.1.7" description = "Fetch line-synced lyrics for your music player." readme = "README.md" @@ -19,7 +19,7 @@ dependencies = [ ] [project.scripts] -lrcfetch = "lrcfetch.cli:run" +lrx = "lrx.cli:run" [tool.ruff.lint] ignore = ["E402"]