PK-HUÉfeeluown/feeluown.pngPNG  IHDRxbKGD pHYs  tIME /=s IDATxWW.w?X9ERrKVwO'M9c36c/7 Ɔ4fz:j)IQb,+kbWXkz@PU]{W@DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD >Gk-b@&h{O: ^u" Z=IqBA 8q0U0ϧRp8VWte(Oxg®twPR{tJbBJ=2Y=~̷̹> ֔+~Z.>"-)`og'Db#UJ^>Qن !>I)Vu0|RZ,DD'h! [!na'"ڼA@1_RTǛ]R[ _&#m>GDDUf|v0lft@=DD/L)8DD5k MV&"Y;1lrB|+k6 Zncoe""E3QkTmS;YGr6Qc"""b """""""""b """"""b ""M# "Z;!G3ZCךJhSJ;y 1)dry8`i"Q  @DT#|Ȼ.c;p]A)4 !$ C0LX ۶ڨ=}himD(aZ3P[]|h\.d2;qmLO?T5|j^W|uu%߁wp37m2c>PJ!L9\vW.B.4M'yibx^tt24 aHKKq Mͻs{L ){xJk\.۶{Oہ;Q]SUL֓m[H3޽$)X ؏5*ׁ#Ga׮^6\@D1,n ⅛E<\J"y ZkVaxQ__| @DO>%p3R.TץaiǷvt!dxb<HDto@iKo?|y(W) AlfbH9vRrջsvHx{O_dz!>" "᧿.#c+쁖R"?n@C DDP5Ʀ?}7r?eX\?X!hInO7emUOP({wGg:.C1v-~T*`; c|C1*S㝷O#w`8)8871gũ??]n>Lw148)h,8N~^; 1eY9^-+lwwdK| "8'8U淇>O,Lm1!qMo<ǭ[Cl>b , }l6W˰mgpq6@ƃs/O"Ȕn J+@B Q SSx05*x71fŒo |FA mmͨ2 $i clt P`Cg+o< 7%@Dpo`rR ۷/|--0 RJQu} WxDjf<ׇK@D7},v[:|/`~xRJDKK#,]h%CUe|hsfqU㩒oZCkP(:=>e_8)>70TF"buU @Dkdq5 ly>,@ss:GCMm ڟނq?.5ҩ,b$xhӹo?@$8ʪ(؅g?FhJev}ą12(br97m⿴_nܮ'p!D57 |kCi].F /hK8^ضᑹ>1Q[ӗ6uR ъݗp~H)T!O c* 눨(q%,-68q?~BmF"B<(zG@Cl?v[zس?~[PJq᢬14< 9 7!ۇxsV}Gkk'߃4\=[X@ DT^l‡^@2ސF$Ə_&"І[7arrRn̦<Ӕ01}-a`~>6!^~{7d_@,ٳ8NQo| CrO DT>(um-mt㇠E3/blljÖ9**.2&b qܸqSj7[_# ś8w 2_IQUp@De3;`jnC kyMMu6"Cr)x͍,DSs1QYr/8CؽoC!ظ}>~IR _y`#@zQض˗ocrr;E{{3y0lᤔ0MW.[o}X,**#i|_"HE=B `hn/ϥeB8'XZ!vmp gJ/q] 24z{۰gO,*ڱ? "K7O;NIo{,@D1'R3t: =8phk6/evmo}/+oU#MG DT?qFb ҌUilg\* K) 8qa @DK))p=LTlLLT7o+?~[}/E8, @Dۢ @ͥZ! L#L#K. RR4R,<|Rzy4/o7d`;p]]ݭx/g>Ja4.#Kba1V8"K O"Ka)G*jSJ_8DPG MM^E{{8&b ڤJ;ӆ7ɻ;.\Džz;.$cXZc)@q\Z=nQ>iNgX_8wz;QَLӄm[x05Wnb8bRU+7sVu9/tmml J+#+xu>ΝY^MtDҨ⅗Ʊc  C+?1ZD. n+dFŞ(v7^=:c@T~#~6cH0MB\E8ıcq~vB); ILTsn| Nށa,Ð=ǎDKs=,ۂQYry<ΞqWM:XGWA4.c^ ~0 TWw ò,Hi~)!h;wŗF]]5]u~b */XXa(Ξi6lCZ9iAQ45֡G7ڃJd %H$h4Ftumm-DBR>0b *7iu=ܼ1odyXwg}0TtuHis]Y-D DeF0073/H&0L]WPZ#-hmmDgg kaH㡽#j~b *3as=\zgN_D=_+ j84LUQ4MhlCCC ÀP}hSm+z7m#QXVFPWWֶBF4F"2Ww0h3Xq왋{6(ZW|B)hE oE]mkP[SjDboPJA)]@h v-=\T" 0 Ʒ){ ຅*t4 M@8D$F0h0 H!!xsz6%۶L|N?T 7i]Fۄ7P,|䲦Y?Mzh3 s /cܕJ)%Qѐ**"hnCSsք4( ?ϘN7 L=otf-55U@UM%*Q]S*T!;)^ni{"v݁!w13=6-)%^x:naC.z+d9>$"b/޸~oŒ,0?^^;u5ؽL\!b z\sgIbIT(_<.41M!aa0 TUEHf1xUΉ==hikD:ŶD Dcpp XX/><Ƚp񑄔Y. a쀍p(ʪ(+QYY**" u qYeY&ƯNbdx``KWp=ò-Uu5BUUUU*t3 i0M 0VCk8yN_aHLOa0,8㢿 ]7rxchhl骪*ބֶf45ա**+,3nV{aY[0?s|/Zoqʫ'51=fz'qh#zuFOO::[PWWp$P(6 #kl7bl 1.Z;eqЅ,ʘ IDATہv#b zezNxqu6`}u8.**ЄttV4MX )r{ Jo޼x~iqMSdvNf]hlCOO;4O1жgL?X7g1DC;q`.dYu4 ܻ7M !o#Y b *78vN(]=8~ : ;H3rBs])J2ylC@۞1\rDlw2Ղ_9;`Y#˯&q ff5<þ;P__é"OGw0:2r ~eG />`ІP+h#+,\,(! %,$&@+ Qun7qꟈ݁a O²\CCC-y0ۇJ! C4MqNe9gs8-\ y>}i0%,BZCATWkH&3E H;w"RD D4M,.,҅<]@k qcG7^~eE6 ۶aXS]Dl1xIqt]"к_5`Y# qܻ;Kn`rbJ) 6ǦiMu1F{pid28rt?/px}րiJLڵ;q.新2%Uh ێʪ(y#bmOk`vfccbdq.ٟ`0#p8X~]ܹ=j/Ǿk蟈uI-4Kt-F0X#~u'bq!P @gW+ښJ'b 'y~IR P/[sH&xsh-5U nߺֶm1}x,ceqG׺!\y4! FŢFG&pxA0$71<PY-ٵ硧ǏaRJd9NB}mۂyxݏ1x ={@4Zi@D M­`Yf Rm㥗 J}ӟt{ }3 a`bbI ŗFk[rQqʂyF.ݥ?੧ Z5!!y}N0M#\pÍPhp$ph˿ ,d6{BףpC" M|[) !b0$nݸ;`ltCڸiq\i9}mArDB$~))Ėeu$Ѻw uP{`k !RJ]i!̲,'&'g`pYXo@)bdSRiGsswիwpPJ?뱼 400_1>6U6WB140<4V]߾ׁp8| <\a_!?-&&hj޼ˇ4cnn2Y"D D8K$H%%1AmVDx0$ҙ,{#xBso Gh2MEx_2F58{"fK^W0hZ~zfE$8{bK U Zg0nTg`~~i*2155۷% @<4:L)apUU" ²,X yJ) crrfSV! KPQ7hCbq)^`ը@<|bh7oL7꜆B4AeenCk[j _ P,ڔ! CS"?D H,%״ɋF @Wg+<{o`q! => nCOO"Džp\ߋ`. `bbzURB)w>(:B",siY&F'191!W])ʊ(->QJx}!!@`dx7o- aȒ.܀HJͅ@*Y}+ '2 himDGG _i#b 7%]K"5lH~!@J987"FF&rbf{&?5~W33x,מ1P04z)k;yg͛{]wLcffr (Ÿ|+jkGׅ`(:x^q@J);~m K D T4,ӄi(nO=3@$B(XףZk9}fHG:UoӔj}W mMxEoep]n$b(ZòҍTo" k3Mwq D"'][dfWB e~Zkkwp1pgs b2aVi>4O|{Օ\ZkLLL#|4L !P[W={;_?{)*;v5E1P45bĚ#j!c/Obq1񵊸D7oE.Y8~P\.(_an>(jaB"bhht'@iB%.DIA{swB`hp [+ՊfDUUGC.⌡i8atTʦȱ)/t(ֻpI6gi1;4/4inGEE(5E)7㷿yKK/ !8..\QLs]\CCC-݇P(Xԥ) ,`|l!JI)tjJcffnMA4ji]N~z K~mhP(~ E[[S@&d3Y1P)BmMw* @.`a/}uuhh^}/[_׋6u>wd]_GE8*퐦Yh/vG]}MIfyk)=.ڑ;)%y_c T ׯ)JҺi}E9vAcc]Q_s21==Ţ< b čN} v;jEgg뚺}@$FF&[zNQnuNTWWL=}E=/Da >* FEEhdg |? 100L&b *! ;[JӠer9}صJ!g=Xă,(壥=E{\CWW Mfgûw[_FuMUъR P]E]ZòM3Q }l6d5Z E*E!$nܸ ^s5B oyC|6}w  5,B)TҨDcS|cwf !>&gsp~veFCk YB`cEiqv*/hj/(2 sHS$wo?<-I_̚7iʫ  `jTö?[g.e'Nڰ+,G^ R},,ÐH$R?\v_}w{PJ9eA.eؽ-- z8}v}6HQIgF}} ꪋr+`ffng.b_yN>WFۉ@R mw=_| A3aFJ 4Ґ4%BCC E!P`zz3@T7a`Ϟ~ `<Ž{a䩵l~ͅ|<-.}ܻtWNƝsxRh)lXn0Q!PPzx%X, 3s{ܺuB@MM%~on<M:x۶0::əLtv=Lµ>F.E׾~XkoƱ,avt WNE6YShni(C_#JsJ2NWo|x 3v-ɿ::vnmmď_&V Y"H9hՆG}C׾x^G>F'pWD}h7<HhMPA0BOo;,B)J)O$q5a݆o8,˄# aHTVV`]]YÐ_$:h*lKǓ:`|Xicg*j.4-Rk 7 O"1q ͇B!U[)l&.vePR u/-eػ]纮h4/)ܼy HpЅuhE5ع{uGnRblLAkZ he {ko<{#עsO?ۛՆt*d2u h4ʪ(V.Az k_47M=k̝,!H!HR L#4`DèooA6B6F"pU& 6G^_6a"ŊJixڶ@eC/we%MJw {Eq\d9j6 5ld3nB`q1q_ѿFeUA Bbn=tLb _ HU,:cfdK3z,mcb`ytC wGzfua)?q !݆?(J/wϡaD)\Ð;08X쮵FG{3{?@VF~aȦvUB㠦\},'xX|0IރP$q?}Uy4?q,fFeeڻ=̶-.oEiBD:0r;>BAՃ0  r,jsh7^ʆ4t| X^gy&baر - r%>B nɓ0nm brbz݋!(45a/Yx6D)P4/@][ ӄ ;7ﺨmn荻ēu0ؙ!uT )Q&"? 8p`W0Ms]B(*#@6R/ha&vCeeŗv3m VF!ftG]K#,W- !`lO`Mx{+{3 @p_)R"4%Y.U]=U5娚yǙZvQ(ԡ1:4f`Ɗ֠tL*Ul( B2L5T;X)ӧϷlɑy-ER[ 0ep`oX Z~'"ڵxWoaΜnv!II{jcGOE*nJtuu޴VGfM;Eu+/gGbj|LMATQ._!.oI"'δT0Q.$ X$%L(Xp=-t!s'PZ/ x-HA'i4H*U FK`thW/W1zc? e#$wGv8rV ͗ @2D|e#X緒B!~w`hhx*יp M|/QKRg+1KcUrр4IS{^/˃bњ>K%]ZcE%hdp_`6xk;p9\vg6a x}7_3<|ZطSTƪĎ$I3#I4?GN`~wv@E:tw/X ["Yx'jìNDps H!FFZ>V.aa< DaVkXh>|)'LT{_8s—׮G~riTXt֭Ǖqѻx-C{,0oD*…:?I)*#Hj X9po!8qfR޳$Io h 5nۄ V5HJ8t(G#'(R |qK <j ;PZ@6C7J8}̌ Mһ*B3gcPLj V!Dpho/vgN'"Qӧ~ {/jb᎞߽riŸcV% j2*U8^B!1*8y0ʝh=?珝#D8 FGKa6}K[RX)"$IgOL;j^_|?_hxb '>NT+5 T!.@@Jr(RКG@&UzMfwΒA\~$MSl۶;CClQͼj{~">%"T*U|'-$srr(~V!Nv Ŷ6\߾^4[޽$ɤyIb[R[! ܗ03"|k;q%9|bmvSƙ| O׾ Q!MӦ1pJKMRKf ,Xk "ܼKa װo]#Ϛos0pj,^ۇB1q >۳_y_ZUk7HDin\6ڵuhXx>( H@.p _Gw)p_ًM~n[&fthYl,b[ q(122/YXe=b8w1wz-F5^ 4!wqI3_sv =s>lfD G_o(R8{;ݒ?3c_4]_ +E~qO O<";v ׮Dߦ81|,shr,o6Rsu7paJIZ3f@Rw Qvm?z1(5[ ձ*^[) IB)S|ye6ƬΎZ+&)zE^TxM6~KE ]~`c[pe;z}aVOWC @)˃}{?mp  K_S"@a6k0\><=)#);zgO_U+mطPKelڴQf_MۃjКqYpiףr ه9`"H ?'OGGGۤ`3Uc9#"DgݎWѓ-٠7р4šOc8Mע Zߝ5xcReVcT) oZZE`O\|Ra 5ݝXm_zL L$ Lj-o~I,Y2i|7ىt@+vj( xp:'&jƢ+Tf.J)j5|A?ǥKW #"YۏMBQA | *cU\/|O^Ņ [UHjڀ ޵?gqcņ EUkPd^ŋͷ~4IsIj–֣ё H DIR5}wgJw ƬY-̌ZXq!IR΁?AbYga1o^KgHDȆMzVKPVQVQ&Hjo4vٟA hꏱ푍КX03HQ(LyYH[e^8hQ(?y f5(7paT m"]}mH!IL[yLj̺i<][{p͉ȸ̳O` Hq @޿q*Ν'z:8N,Y˗/Ƃho/#Mӆ@{|-7=f=5مoߌ;ȿ ܁5_tLOΜ><?<#6J3#)6lzpn{+W.o( r^{u7=1Sow<6Icl>ٳP*nj##;_ٵ ?e7jwΝTkDiO6=!yA"._w{߇ۼ?VK@l߱?q=F)DQ'b}8a\rl7 ==]bwT,LmO( 8~x}>(j䶍qf!Ȱf,_szf)~>=tq!FIyP>߉bA"?c9r/78}w03 O^E1`p:;7^ۍ JA3<|0{, HbSJ#'_ˊj^mS 5msv㩧`~T*;ު'ܽ[wA Lg8™3/S&pe8py.׷7Ӊ4M K58“O=?oX*t6.]~?={ZkKwRKh(عs+{;w=WJ]|/< ba"?kSJ*ׇӟlY]kKPn:`h@2 /l6 *RXlS||3;2x jM;eعs琦Z f7n c(Z\.OX5Zklyh=6oYr#7N@qsgc붍VD8 ap`a:tr[ˍ?1;0<42VBwl)Z iӅVbmXp\A ׯ7vOLDX܂^~"Bo8 Mhsv֬#6a(bTbA0c( x=z[33:ضm#*ĿR W@J $)֬[7b(jZ-$"R Whk+M7co9]V<| Jش\5k0kV$A"A0̘7ݸtig^D(8FEɛj jI2VZ+aѢ;=]h+b0Cf"A0BSU[[u <ׇpQKRk٫\ Ck,tƼ=7Ǽs),j_ _kF)|_#.D-.E /_#'Њ`pѼ\g٤׿+(FGL辖"IjC'|b"2Jv["ڧ}' "!2іxD';"{R サju_Oꚅ5kP.rIyfq81{,7{l#. A*cc-'ǷTMwZ^ÇCJMl!^RKѿr}9/ K ܣL!DPmF̛r">s ݘ?5fj+6aADFV|$Xl.[4yyjvcc zj͊ V 7lr7X,gp03bΜ,.A L*QnIPIq/pezKg5+P(V'aZH3v%atjEU/"oQZ'vF H%<ІABb½&IR<\pBuqknE^?K.@Ϝ.E(E>2q >>po> %5VZ+?AD3yv~w?z""qi8vǂsՉr[ Iqu8~/ VB X,'\.?AD3"5+U8I E0_m} TZ!ʥ? ![/_sS2|gwNZݷDZo6Ņ4_}ADS6 ߷ ۶msLC<6:oAD3fF[;F_T7NDqyA lRۃIBZtW5ŽlC{{%ADZk\s::ʨUiqaaEA -ӏP,L8ڵxxFJEA ;vlãmFPjx0o @ˎ6<l޼Zk۱vm @ƣZ /|ilyhտHSR[_ضm$7W0]n ]]Wy|re[GcV0B@Ap PP*; zz (mxǑA.>&NDx/'O3CeT\.+_}qW AD”kcv,޽kC "ı½df=Ր IDAT$Izkףؾa$bABt~q&"y'b##c |8 X*ܓ:DZ[܆kWI[n$„]5d} ^ѣ'=qcB0edd ]]䓏au7f#"DZ}I7pxq%KEDQk$ARöG6'EQ, "5GFqa·2xi „ 2gg J"~A @pZ!?w 7n add D۪`6y|5 :tl}d#V^B  @B p=s/ `` ^Q Eʯ@ww'sdB\E O i"DS[ D3221U022ׇ044jfT,s,tuw\B\FGg;pǽ90wD)y2?XKW̡?;wΘs֊=n *ހWsϞ{c 3ٵ(xT'Vy܋ _lqj'Da ;>ZY}k`&{г\?W@.m>`k·Ө59MMr"k3Oڋk\}!)SHY"0SMd|ED^TQR 0eх\ E߃&EuwRD 5Sc{N80gylP;|H܅}NlfPp\o]4 427#ހ;6*(SgWΰ:3s{x@sRh.UH@QSpA ܏߆)μn2g1XΡ54gx7_k8@mQ!uP{v\MZfax+斲*/Kd;w_y p&!w 82^ $ ~d#y\5!"aun}k^w}譳is&t'  Pζ.~OBAVdMy7[] zoU]]'9oKe}ʺ9ߊ SX3G9l] 6Rj) e/ 7zZ05S?4՜f.L5Fl gU.ZǶ-}l@}:l9x>p/xfQWZ@Z_1Ѥb=y(,MP^Tx)K 0F((XJo#PPRRPXQP@'2*.""Lk2l""z*]Νuh5WP}yT9Qu;/0+$LalwL,{:{ְb;"P < r '3 " 2H3]O>ia`D 5@d +I9lP8s 4i߹H2Æ|JĀ @ѯsd>{&gjB8O{mBlGjͻPeӧa@^,og*U{Ơ8M&qɋ,`g@Pvm0DAA # ,h͌7`~ћ: }ܿkfwࠗ  r0ϟr5:hYgS8(FJltpA@ 0nbis}# HQ<̰RUЇ!~H&DBp2eĀ3}\-a=p"DA LI` ^:5;ڄ5#IV@hf~^`.7}^:׶y̔ޖg a@M/* (W)[l`/3k 4G%)*E&1>`EFEZi+ "億,(2ʊqZ@M"( @tg5tb5w"~@HFL^SSF92/M g#:ts{с=Eu!0PS09_(6d+@42<ʟ?RF00=8ToW*HxBE Qd(vVbh&(B =0fS]TPNSFj1i)tjASpj ;6&i#(@2Zgs|e}>(M̌k6=0+Bs7}riRוIzйȀɏׇ)!v>?QSsKxs֝@M ّM37}.Ԃ k2(gR)TF JE&_)BFhܵF ꯺ 90: ß"Mi$HޞB$M}$|{)V=30.A~\2(4RpvG@&+T6('q؟ED`e<{m[ +}m@[1i0G&Et R" _zj~ "utІ&ƸiZ %N19~:km}EPr:[ >Y^5p-Pn gB.{X$U۹o}Yƾ#zuC>W@7)ٔE5:F %PnPC")j[3`jlQ`4[Q#ΉkG82DiDʽe Ah. 4ƾO[C IRԒj$MD@~-xtq"suϜs$ƅ?m4tE \E ls 6RŸlB~.>`Dl8G64AnR unӟRAYqk3CE ̑C٩!|D">iB j1ZbD35# 49|S}`6/B8m{aT{c:aO>ًi atW7&R0״"j@Q6Y#cu9(4ppŋɅlC8E Y5D*?Z @ͰՁPmkc( (>(j-RDJ_&Vv2I @Zes}6l@O! MR$iŀa/"tqi7'\$mbSG3Yj%"nzC |sU_SAVu]`^u?1!u}❜/a37?+~Rtݶ n:bE(2ubM _h}hĝyl3aJf R nʟRvWbP8XAJhb M}k2A20 "aE@~L> m߾1.o8O?= |AM@?ٹan-&ym\@ "z"?H&© =iGv>CjR `P3g6d;i);)n(􅁲)P oz&;fk&8}"VqaS_Td!{%;ְv6t[X;˦ 7$|8?ßUt"PyL8$뷷[o3E f_aА Ce&]S2q0S]eC9$/L\aw7cjBZյ3[ƍo|v+Mo(p7[r-/"37l\+ p&&LI?`_/+ @&f4MZnDPJCbb|YkS f־ ȭ%3Gq2cF` f@ɊI Vn/x"`SLPn1 AOCto t2HvP%Bnlp#^O/zpi&6~W|#(lFwj7ǮU&zu6uc;ٚG?0 ubшSsaaJs],EPhʛAb87M T:К몃]v'ekV }P 3(qd7*PQ P"01^hhF:=8 xaG_qdpUJ ((IC2?X q ]fi;p(K3b;5F+ !0kP Y!ȏ%4_gW6`#?>'Az@Í`楒c19`ʇE UDi|N eIy"!ƿHEGQ&eB}D>M @gn?vREPxwTrRRH@Jpvw[8a 9 n]/Udnэm{6Hmca+ԝq_ʤ:ϟOڇPl1mOrmx`"W@Vgqόco^ .|wwXl3kR>"G*82x,2K"AcO . HTC+b]QT:ve1Zv#MOtふ6l:Y!JTB8)dzHgct0ja&i`RP. @YD!@Ƕ R`fN^zoFvQ`;1WhA&Zߺ! Pr8b Es:/F̆`(@&.L2#sh"ıIıYc\]G?I "abav]ޙeh~˛5H"H2k}F1tD@J?? Ŕ r-flh7&Bn_KY$@qf㵛3 !, ic7L~FM{aJ!낣hB}n Q7"wz~eЅݍwā1fW& "@"W#d-/e$ @&+Z#yl3jZt:HRHG&ٖ@ ڬf3_ kل:vYmv`f?:_f |@]ވW8 ś9N:T縃d$ƅP0,)F~J9(E}T/h3M+7Q'{WHʧ\(R` (D~jj,s(u{쇾 kmN )۵6ock!S΢m d7R 4FesFAFfT "_9w;\5)}k#_h7!0|>DNm``1Օ-tsx !R L.?RRQ7TFhnߪ @Z! P-lQR+>4trLq_ f[÷]pr:"0گ̾ՍapPxǹ}*Dަg-uQ7f׎fܙ""/؅7:Eg #LY T/ p[ PWT{n>`AQdm}2S]! }֌(i=o,UAhm8Wn>pePjhf6v hۥBO&jZ@k' #3M٦hu]tY( =of9t&+8mD>!(S*?9A{^o>L%+ֳ p@Co Z Ԛѓlo"0Y_>C6ଠM 02p|)#HŀRYA2t@i\MOP >=oΧلPg8r+J|/u 'BnRnpЪx$kzA0Eyg 0w-]n==(kve-$K{x|M5pmbhd=c{@gj9# ON .R_m1N1|JP0 ^8b 0.f3G {{yT'M( 7`R`aN<_+|X2fTD3wɯ̋i7h°ݮ }<_T!\}<,̇y+9eŎ6\!p|a\\""uM'Bn_y Lԕ)6 SӐW7A'f.޿ Dpmz[[?&hFWdFM5wl 0v 7L=&*+̀4M"7-`yuvݶ=ǰWՉRYtM 56N%lr9=Aa}`R5BM,gShamsg Sߐ_go؍c7b"49a?nb}/8ؘulo9͛>7Ή⬊>_p<.Ԟڑæ,\R CSCb;M} M0o$f6A߿UصܰϸZ]RUa!@||f= a|Q]]GM_Nu!.,_hG >Gh @hefg9qP7vߙ5"4YC7cׅN3hpob@0J.rdʟYR P3|. &SU@aT E/nVBD?a*zy)L xnߣOM@P?p D~Q 0B Lx|`\Q^JSW[ixƏ|e l :|c{]B)5a[Ո4#DĤ~"B aZ3h݌{V7fˀ"J_ʯp/>TZUw/ >q)/VMپx~BaAaT)YEC3O{@D\j+a;ARggO}v,Aq'|vaFBG*zGFFOfsPA-3鵫BK pAAk .fQY6}_~A=_i/8_w^a{5` e"zWAfx70kz L|ODWJzA+~gP3  03?S0yf`54S ѧ`53C_W?;3E ´o1r5DpR 3d 4DisW")џ E ܿƟ34ykUpKةkf3 ܗ^*Sow;rUDܩ;̼ @Y 5FDxwNWe24/_|r/R?/ p>gVJI\_{b%0!O;oժjd3g"Z"QA7|^Z/}N*WH@K`frdT*ƵZ MG֛@+y(8AR}GhRjE- J5陽BDa+                                                                                                              0&_|e2IENDB`PK-Ht zYYfeeluown/request.pyimport requests from PyQt5.QtCore import QObject, pyqtSignal from requests.exceptions import ConnectionError, HTTPError, Timeout class Request(QObject): connected_signal = pyqtSignal() disconnected_signal = pyqtSignal() slow_signal = pyqtSignal() server_error_signal = pyqtSignal() def __init__(self, app): super().__init__(parent=app) self._app = app def get(self, *args, **kw): try: res = requests.get(*args, **kw) self.connected_signal.emit() return res except ConnectionError: self.disconnected_signal.emit() except HTTPError: self.server_error_signal.emit() except Timeout: self.slow_signal.emit() return None def post(self, *args, **kw): try: res = requests.post(*args, **kw) return res except ConnectionError: self.disconnected_signal.emit() except HTTPError: self.server_error_signal.emit() except Timeout: self.slow_signal.emit() return None PKHkttfeeluown/__main__.py#! /usr/bin/env python3 import asyncio import os import sys sys.path.append(os.path.dirname(sys.path[0])) from PyQt5.QtWidgets import QApplication from quamash import QEventLoop from feeluown.app import App from feeluown.consts import (HOME_DIR, USER_PLUGINS_DIR, PLUGINS_DIR, DATA_DIR, CACHE_DIR, USER_THEMES_DIR) from feeluown.config import config from feeluown import logger_config def parse_args(args): if '-d' in args: config.debug = True logger_config() def ensure_dir(): if not os.path.exists(HOME_DIR): os.mkdir(HOME_DIR) if not os.path.exists(DATA_DIR): os.mkdir(DATA_DIR) if not os.path.exists(USER_PLUGINS_DIR): os.mkdir(USER_PLUGINS_DIR) if not os.path.exists(CACHE_DIR): os.mkdir(CACHE_DIR) if not os.path.exists(USER_THEMES_DIR): os.mkdir(USER_THEMES_DIR) ensure_dir() os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) sys.path.append(PLUGINS_DIR) sys.path.append(USER_PLUGINS_DIR) def main(): parse_args(sys.argv) q_app = QApplication(sys.argv) q_app.setQuitOnLastWindowClosed(True) q_app.setApplicationName('FeelUOwn') app_event_loop = QEventLoop(q_app) asyncio.set_event_loop(app_event_loop) app = App() app.show() app_event_loop.run_forever() sys.exit(0) if __name__ == '__main__': main() PKQHWbke//feeluown/consts.py# -*- coding: utf-8 -*- import os from enum import Enum THEMES_DIR = './feeluown/themes/' PLUGINS_DIR = './feeluown/plugins' APP_ICON = './feeluown/feeluown.png' DEFAULT_THEME_NAME = 'Solarized' HOME_DIR = os.path.expanduser('~') + '/.FeelUOwn' DATA_DIR = HOME_DIR + '/data' USER_PLUGINS_DIR = HOME_DIR + '/plugins' USER_THEMES_DIR = HOME_DIR + '/themes' CACHE_DIR = HOME_DIR + '/cache' LOG_FILE = HOME_DIR + '/run.log' class PlaybackMode(Enum): one_loop = '单曲循环' sequential = '顺序' loop = '全部循环' random = '随机' PK'=H̛feeluown/version.pyimport asyncio import logging from functools import partial logger = logging.getLogger(__name__) class VersionManager(object): current_version = 'v9.1a' def __init__(self, app): self._app = app @asyncio.coroutine def check_release(self): url = 'https://api.github.com/repos/cosven/FeelUOwn/releases' logger.info('正在查找新版本...') try: loop = asyncio.get_event_loop() future = loop.run_in_executor( None, partial(self._app.request.get, url, timeout=5)) res = yield from future if res is None: return if not res.status_code == 200: logger.warning('connect to api.github.com timeout') return releases = res.json() for release in releases: if release['tag_name'] > self.current_version: title = u'发现新版本 %s hoho' % release['tag_name'] logger.info(title) self._app.message(title) break except Exception as e: logger.error(str(e)) PKr;HF49 9 feeluown/install.py#! /usr/bin/env python3 import platform import shutil import os def install_sys_dep(): def _get_gstreamer(gstreamer_version='0.10'): v_package = ['gstreamer{0}-plugins-good'.format(gstreamer_version), 'gstreamer{0}-plugins-bad'.format(gstreamer_version), 'gstreamer{0}-plugins-ugly'.format(gstreamer_version)] return v_package packages = ['python3-pyqt5', 'python3-pyqt5.qtmultimedia', 'libqt5multimedia5-plugins', 'fcitx-frontend-qt5'] linux_distro = platform.linux_distribution() if linux_distro[0] in ('Deepin') or 'ubuntu' in linux_distro[0].lower(): print('Download and install software dependency.') os.system('sudo apt-get install -y {packages} 1>/dev/null' .format(packages=' '.join(packages))) if linux_distro[1] >= '16.04': gstreamer_version = '1.0' else: gstreamer_version = '0.10' gstreamer_packages = _get_gstreamer(gstreamer_version) packages = packages + gstreamer_packages os.system('sudo apt-get install -y {packages} 1>/dev/null' .format(packages=' '.join(packages))) print('\ninstall finished ~') else: print('You should install these packages by yourself, ' 'as your linux may not a debian distribution\n') packages = packages + _get_gstreamer() for i, p in enumerate(packages): print(i, ': ', p) print('\npackage name may be different among different systems') def generate_icon(): print('Generate icon, then you can see app in apps list.') DESKTOP_FILE = 'feeluown.desktop' current_path = os.path.abspath(os.path.dirname(__file__)) icon = current_path + '/feeluown.png' feeluown_home_dir = os.path.expanduser('~') + '/.FeelUOwn' feeluown_icon = feeluown_home_dir + '/feeluown.png' shutil.copy(icon, feeluown_icon) icon_string = '#!/usr/bin/env xdg-open\n'\ '[Desktop Entry]\n'\ 'Type=Application\n'\ 'Name=FeelUOwn\n'\ 'Comment=FeelUOwn Launcher\n'\ 'Exec=python3 -m feeluown\n'\ 'Icon={feeluown_icon}\n'\ 'Categories=AudioVideo;Audio;Player;Qt;\n'\ 'Terminal=false\n'\ 'StartupNotify=true\n'\ .format(feeluown_icon=feeluown_icon) f_path = os.path.expanduser('~') +\ '/.local/share/applications/' + DESKTOP_FILE try: with open(f_path, 'w') as f: f.write(icon_string) os.system('chmod +x %s' % f_path) except PermissionError: print('Please use sudo') en_desktop_path = os.path.expanduser('~') + '/Desktop' cn_desktop_path = os.path.expanduser('~') + '/桌面' if os.path.exists(en_desktop_path): desktop_f = en_desktop_path + '/' + DESKTOP_FILE if os.path.exists(cn_desktop_path): desktop_f = cn_desktop_path + '/' + DESKTOP_FILE shutil.copy(f_path, desktop_f) os.system('chmod +x %s' % desktop_f) print('gen finished') def update(): os.system('sudo -H pip3 install --upgrade feeluown') os.system('feeluown-install-dev') os.system('feeluown-genicon') if __name__ == '__main__': install_sys_dep() generate_icon() PKQHw911feeluown/theme.py# -*- coding: utf-8 -*- import os import logging import configparser from PyQt5.QtWidgets import QWidget from PyQt5.QtGui import QColor from .consts import THEMES_DIR, USER_THEMES_DIR logger = logging.getLogger(__name__) class ThemeManager(object): def __init__(self, app): super().__init__() self._app = app self.current_theme = None self._themes = [] # config file name (theme name) def choose(self, theme_name): ''' :param theme: theme unique name ''' def recursive_update(widget): if hasattr(widget, 'set_theme_style'): widget.set_theme_style() for child in widget.children(): if isinstance(child, QWidget): recursive_update(child) self.set_theme(theme_name) recursive_update(self._app) def scan(self, themes_dir=[THEMES_DIR, USER_THEMES_DIR]): '''themes directory''' self._themes = [] for directory in themes_dir: files = os.listdir(directory) for f in files: f_name, f_ext = f.split('.') if f_ext == 'colorscheme': self._themes.append(f_name) def list(self): '''show themes list :param theme: theme unique name :return: themes name list ''' if not self._themes: self.scan() return self._themes def get_theme(self, theme_name): ''' :param theme: unique theme name :return: `Theme` object ''' pass def set_theme(self, theme_name): '''set current theme''' self.current_theme = Theme(theme_name) class Theme(object): def __init__(self, config_name=None): self._config = configparser.ConfigParser() self.name = config_name self.read(config_name) def read(self, config_file): config_file_path = os.path.abspath(THEMES_DIR + config_file + '.colorscheme') if config_file is not None: config = self._config.read(config_file_path) if config: return True return False @property def background_light(self): color_section = self._config['Background'] return self._parse_color_str(color_section['color']) @property def background(self): color_section = self._config['BackgroundIntense'] return self._parse_color_str(color_section['color']) @property def foreground_light(self): color_section = self._config['Foreground'] return self._parse_color_str(color_section['color']) @property def foreground(self): color_section = self._config['ForegroundIntense'] return self._parse_color_str(color_section['color']) @property def color0_light(self): color_section = self._config['Color0'] return self._parse_color_str(color_section['color']) @property def color0(self): color_section = self._config['Color0Intense'] return self._parse_color_str(color_section['color']) @property def color1_light(self): color_section = self._config['Color1'] return self._parse_color_str(color_section['color']) @property def color1(self): color_section = self._config['Color1Intense'] return self._parse_color_str(color_section['color']) @property def color2_light(self): color_section = self._config['Color2'] return self._parse_color_str(color_section['color']) @property def color2(self): color_section = self._config['Color2Intense'] return self._parse_color_str(color_section['color']) @property def color3_light(self): color_section = self._config['Color3'] return self._parse_color_str(color_section['color']) @property def color3(self): color_section = self._config['Color3Intense'] return self._parse_color_str(color_section['color']) @property def color4_light(self): color_section = self._config['Color4'] return self._parse_color_str(color_section['color']) @property def color4(self): color_section = self._config['Color4Intense'] return self._parse_color_str(color_section['color']) @property def color5_light(self): color_section = self._config['Color5'] return self._parse_color_str(color_section['color']) @property def color5(self): color_section = self._config['Color5Intense'] return self._parse_color_str(color_section['color']) @property def color6_light(self): color_section = self._config['Color6'] return self._parse_color_str(color_section['color']) @property def color6(self): color_section = self._config['Color6Intense'] return self._parse_color_str(color_section['color']) @property def color7_light(self): color_section = self._config['Color7'] return self._parse_color_str(color_section['color']) @property def color7(self): color_section = self._config['Color7Intense'] return self._parse_color_str(color_section['color']) def _parse_color_str(self, color_str): rgb = [int(x) for x in color_str.split(',')] return QColor(rgb[0], rgb[1], rgb[2]) PKQHuhfeeluown/db.py# -*- coding: utf-8 -*- PK-H&feeluown/player_mode.pyclass PlayerModeManager(object): current_mode = None def __init__(self, app): super().__init__() self._app = app def enter_mode(self, mode): self.current_mode = mode self._app.player.change_player_mode_to_other() self._app.message('进入 %s 播放模式' % mode.name) mode.load() def exit_to_normal(self): if self.current_mode is not None: self._app.player.change_player_mode_to_normal() self.current_mode.unload() self.current_mode = None class PlayerModeBase(object): def __init__(self, app): super().__init__() self._app = app self.player = self._app.player self.player.signal_playlist_finished.connect( self.on_playlist_finished) @property def name(self): raise NotImplementedError('This should be implemented by subclass') def on_playlist_finished(self): raise NotImplementedError('This should be implemented by subclass') def load(self): raise NotImplementedError('This should be implemented by subclass') def unload(self): self._app.message('退出 %s 播放模式' % self.name) self.player.signal_playlist_finished.disconnect( self.on_playlist_finished) PKQHNPPPfeeluown/config.py# -*- coding: utf-8 -*- class Config(): debug = False config = Config() PKQH5Byfeeluown/hotkey.pyfrom PyQt5.QtWidgets import QShortcut class Hotkey(object): def __init__(self, app): self._app = app def registe(self, key_sequence, callback): shortcut = QShortcut(key_sequence, self._app) shortcut.activated.connect(callback) PK-HE))feeluown/player.py# -*- coding: utf-8 -*- import asyncio import logging import random from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent from PyQt5.QtCore import QUrl, pyqtSignal, pyqtSlot from .model import SongModel from .consts import PlaybackMode logger = logging.getLogger(__name__) class Player(QMediaPlayer): signal_player_media_changed = pyqtSignal([SongModel]) signal_playlist_is_empty = pyqtSignal() signal_playback_mode_changed = pyqtSignal([PlaybackMode]) signal_playlist_finished = pyqtSignal() signal_song_required = pyqtSignal() finished = pyqtSignal() _music_list = list() # 里面的对象是music_model _current_index = None _tmp_fix_next_song = None playback_mode = PlaybackMode.loop last_playback_mode = PlaybackMode.loop _other_mode = False def __init__(self, app): super().__init__(app) self._app = app self.error.connect(self.on_error_occured) self.mediaChanged.connect(self.on_media_changed) self.mediaStatusChanged.connect(self.on_media_status_changed) self._music_error_times = 0 # latency of retying next operation when error happened self._retry_latency = 3 # when _music_error_times reached _music_error_maximum, play next music self._music_error_maximum = 3 def change_player_mode_to_normal(self): logger.debug('退出特殊的播放模式') self._other_mode = False self._set_playback_mode(self.last_playback_mode) def change_player_mode_to_other(self): # player mode: such as fm mode, different from playback mode logger.debug('进入特殊的播放模式') self._other_mode = True self._set_playback_mode(PlaybackMode.sequential) def _record_playback_mode(self): self.last_playback_mode = self.playback_mode @pyqtSlot(QMediaContent) def on_media_changed(self, media_content): music_model = self._music_list[self._current_index] self.signal_player_media_changed.emit(music_model) @pyqtSlot(QMediaPlayer.MediaStatus) def on_media_status_changed(self, state): if state == QMediaPlayer.EndOfMedia: self.finished.emit() self.stop() if (self._current_index == len(self._music_list) - 1) and\ self._other_mode: self.signal_playlist_finished.emit() logger.debug("播放列表播放完毕") if not self._other_mode: self.play_next() # TODO: at hotkey linux module, when it call # Controller.player.play_next or last may stop the player # add following code to fix the problem. elif state in (QMediaPlayer.LoadedMedia, ): self.play() def insert_to_next(self, model): if not self.is_music_in_list(model): if self._current_index is None: index = 0 else: index = self._current_index + 1 self._music_list.insert(index, model) return True return False def add_music(self, song): self._music_list.append(song) def remove_music(self, mid): for i, music_model in enumerate(self._music_list): if mid == music_model.mid: self._music_list.pop(i) if i == self._current_index: self.play_next() elif i < self._current_index: self._current_index -= 1 return True return False def get_media_content_from_model(self, music_model): url = music_model.url if not url: self._app.message('URL 不存在,不能播放该歌曲') return None if url.startswith('http'): media_content = QMediaContent(QUrl(url)) else: media_content = QMediaContent(QUrl.fromLocalFile(url)) return media_content def set_music_list(self, music_list): self._music_list = [] self._music_list = music_list if len(self._music_list): self.play(self._music_list[0]) def clear_playlist(self): self._music_list = [] self._current_index = 0 self.stop() def is_music_in_list(self, model): for music in self._music_list: if model.mid == music.mid: return True return False def _play(self, music_model): insert_flag = self.insert_to_next(music_model) index = self.get_index_by_model(music_model) if not insert_flag and self._current_index is not None: if music_model.mid == self._music_list[self._current_index].mid\ and self.state() == QMediaPlayer.PlayingState: return True super().stop() media_content = self.get_media_content_from_model(music_model) if media_content is not None: logger.debug('start to play song: %d, %s, %s' % (music_model.mid, music_model.title, music_model.url)) self._current_index = index self.setMedia(media_content) super().play() return True else: self.remove_music(music_model.mid) self.play_next() return False def other_mode_play(self, music_model): self._play(music_model) def play(self, music_model=None): if music_model is None: super().play() return False self._app.player_mode_manager.exit_to_normal() self._play(music_model) def get_index_by_model(self, music_model): for i, music in enumerate(self._music_list): if music_model.mid == music.mid: return i return None def play_or_pause(self): if len(self._music_list) is 0: self.signal_playlist_is_empty.emit() return if self.state() == QMediaPlayer.PlayingState: self.pause() elif self.state() == QMediaPlayer.PausedState: self.play() else: self.play_next() def play_next(self): if self._tmp_fix_next_song is not None: flag = self.play(self._tmp_fix_next_song) self._tmp_fix_next_song = None return flag index = self.get_next_song_index() if index is not None: if index == 0 and self._other_mode: self.signal_playlist_finished.emit() logger.debug("播放列表播放完毕") return music_model = self._music_list[index] self.play(music_model) return True else: self.signal_playlist_is_empty.emit() return False def play_last(self): index = self.get_previous_song_index() if index is not None: music_model = self._music_list[index] self.play(music_model) return True else: self.signal_playlist_is_empty.emit() return False def set_tmp_fixed_next_song(self, song): self._tmp_fix_next_song = song @pyqtSlot(QMediaPlayer.Error) def on_error_occured(self, error): song = self._music_list[self._current_index] self._app.message('cant play song: %s' % (song.title)) self.stop() if error == QMediaPlayer.FormatError: self._app.message('这首歌挂了,也有可能是断网了', error=True) logger.debug('song cant be played, url is %s' % song.url) elif error == QMediaPlayer.NetworkError: self._wait_to_retry() elif error == QMediaPlayer.ResourceError: logger.error('网络出现错误:不能正确解析资源') elif error == QMediaPlayer.ServiceMissingError: self._app.notify('缺少解码器,请向作者求助', error=True) else: self._wait_to_next(2) def _wait_to_retry(self): if self._music_error_times >= self._music_error_maximum: self._music_error_times = 0 self._wait_to_next(self._retry_latency) self._app.message('将要播放下一首歌曲', error=True) else: self._music_error_times += 1 app_event_loop = asyncio.get_event_loop() app_event_loop.call_later(self._retry_latency, self.play) self._app.message('网络连接不佳', error=True) def _wait_to_next(self, second=0): if len(self._music_list) < 2: return app_event_loop = asyncio.get_event_loop() app_event_loop.call_later(second, self.play_next) def get_next_song_index(self): if len(self._music_list) is 0: self._app.message('当前播放列表没有歌曲') return None if self.playback_mode == PlaybackMode.one_loop: return self._current_index elif self.playback_mode == PlaybackMode.loop: if self._current_index >= len(self._music_list) - 1: return 0 else: return self._current_index + 1 elif self.playback_mode == PlaybackMode.sequential: return None else: return random.choice(range(len(self._music_list))) def get_previous_song_index(self): if len(self._music_list) is 0: return None if self.playback_mode == PlaybackMode.one_loop: return self._current_index elif self.playback_mode == PlaybackMode.loop: if self._current_index is 0: return len(self._music_list) - 1 else: return self._current_index - 1 elif self.playback_mode == PlaybackMode.sequential: return None else: return random.choice(range(len(self._music_list))) def _set_playback_mode(self, mode): # item once: 0 # item in loop: 1 # sequential: 2 # loop: 3 # random: 4 if mode == self.playback_mode: return 0 self._record_playback_mode() self.playback_mode = mode self._app.message('设置播放顺序为:%s' % mode.value) self.signal_playback_mode_changed.emit(mode) def next_playback_mode(self): if self.playback_mode == PlaybackMode.one_loop: self._set_playback_mode(PlaybackMode.loop) elif self.playback_mode == PlaybackMode.loop: self._set_playback_mode(PlaybackMode.random) elif self.playback_mode == PlaybackMode.random: self._set_playback_mode(PlaybackMode.one_loop) @property def songs(self): return self._music_list PKQHfeeluown/model.pyclass SongModel(object): def __init__(self): pass @property def mid(self): raise NotImplementedError('This should be implemented by subclass') @property def title(self): raise NotImplementedError('This should be implemented by subclass') @property def artists_name(self): raise NotImplementedError('This should be implemented by subclass') @property def album_name(self): raise NotImplementedError('This should be implemented by subclass') @property def album_img(self): raise NotImplementedError('This should be implemented by subclass') @property def url(self): raise NotImplementedError('This should be implemented by subclass') @property def length(self): raise NotImplementedError('This should be implemented by subclass') class MvModel(object): def __init__(self): pass @property def song_model(self): return None @property def url(self): raise NotImplementedError('This should be implemented by subclass') class ArtistModel(object): def __init__(self): pass @property def name(self): raise NotImplementedError('This should be implemented by subclass') class AlbumModel(object): def __init__(self): pass @property def name(self): raise NotImplementedError('This should be implemented by subclass') @property def artists_name(self): raise NotImplementedError('This should be implemented by subclass') @property def img(self): return '' @property def songs(self): raise NotImplementedError('This should be implemented by subclass') @property def desc(self): return '' class PlaylistModel(object): def __init__(self): pass @property def name(self): raise NotImplementedError('This should be implemented by subclass') @property def songs(self): raise NotImplementedError('This should be implemented by subclass') PK=HI"feeluown/__init__.py# -*- coding: utf-8 -*- import logging from .consts import LOG_FILE from .config import config __version__ = '1.0.3' def logger_config(): if config.debug: logging.basicConfig( level=logging.DEBUG, format="[%(levelname)s] [%(filename)s line:%(lineno)d] : %(message)s", ) else: logging.basicConfig( format="[%(levelname)s] [%(filename)s line:%(lineno)d] : %(message)s", level=logging.DEBUG, filename=LOG_FILE, filemode='w', ) PKQHu[feeluown/plugin.py# -*- coding: utf-8 -*- import os import importlib import logging from .consts import USER_PLUGINS_DIR, PLUGINS_DIR logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) class PluginsManager(object): def __init__(self, app): super().__init__() self._app = app self._plugins = {} def load(self, plugin): plugin.enable(self._app) def unload(self, plugin): plugin.disable(self._app) def scan(self): modules_name = [p for p in os.listdir(PLUGINS_DIR) if os.path.isdir(os.path.join(PLUGINS_DIR, p))] modules_name.extend([p for p in os.listdir(USER_PLUGINS_DIR) if os.path.isdir(os.path.join(USER_PLUGINS_DIR))]) for module_name in modules_name: try: module = importlib.import_module(module_name) plugin_name = module.__alias__ self._plugins[plugin_name] = module module.enable(self._app) logger.info('detect plugin: %s.' % plugin_name) except: logger.exception('detect a bad plugin %s' % module_name) PK-Hrrfeeluown/ui.pyimport asyncio import logging from PyQt5.QtGui import QFontMetrics from PyQt5.QtCore import Qt, QTime, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QMenu, QAction from PyQt5.QtMultimedia import QMediaPlayer from feeluown.libs.widgets.base import FFrame, FButton, FLabel, FScrollArea,\ FComboBox from feeluown.libs.widgets.labels import _BasicLabel from feeluown.libs.widgets.sliders import _BasicSlider from feeluown.libs.widgets.components import LP_GroupHeader, LP_GroupItem, \ MusicTable from .utils import parse_ms from .consts import PlaybackMode logger = logging.getLogger(__name__) class PlayerControlButton(FButton): def __init__(self, app, text=None, parent=None): super().__init__(text, parent) self._app = app self.setObjectName('mc_btn') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; font-size: 13px; color: {1}; outline: none; }} #{0}:hover {{ color: {2}; }} '''.format(self.objectName(), theme.foreground.name(), theme.color4.name()) self.setStyleSheet(style_str) class ProgressSlider(_BasicSlider): def __init__(self, app, parent=None): super().__init__(app, parent) self.setOrientation(Qt.Horizontal) self.setMinimumWidth(400) self.setObjectName('player_progress_slider') self.sliderMoved.connect(self.seek) def set_duration(self, ms): self.setRange(0, ms / 1000) def update_state(self, ms): self.setValue(ms / 1000) def seek(self, second): self._app.player.setPosition(second * 1000) class VolumeSlider(_BasicSlider): def __init__(self, app, parent=None): super().__init__(app, parent) self.setOrientation(Qt.Horizontal) self.setMinimumWidth(100) self.setObjectName('player_volume_slider') self.setRange(0, 100) # player volume range self.setValue(100) self.setToolTip('调教播放器音量') class ProgressLabel(_BasicLabel): def __init__(self, app, text=None, parent=None): super().__init__(app, text, parent) self._app = app self.duration_text = '00:00' self.setObjectName('player_progress_label') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName(), theme.color3.name()) self.setStyleSheet(self._style_str + style_str) def set_duration(self, ms): m, s = parse_ms(ms) duration = QTime(0, m, s) self.duration_text = duration.toString('mm:ss') def update_state(self, ms): m, s = parse_ms(ms) position = QTime(0, m, s) position_text = position.toString('mm:ss') self.setText(position_text + '/' + self.duration_text) class PlayerControlPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self._layout = QHBoxLayout(self) self.previous_btn = PlayerControlButton(self._app, '上一首', self) self.pp_btn = PlayerControlButton(self._app, '播放', self) self.next_btn = PlayerControlButton(self._app, '下一首', self) self.progress_slider = ProgressSlider(self._app, self) self.volume_slider = VolumeSlider(self._app, self) self.progress_label = ProgressLabel(self._app, '00:00/00:00', self) self._btn_container = FFrame(self) self._slider_container = FFrame(self) self._bc_layout = QHBoxLayout(self._btn_container) self._sc_layout = QHBoxLayout(self._slider_container) self.setObjectName('pc_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; color: {1}; }} '''.format(self.objectName(), theme.foreground.name(), theme.color0.name()) self.setStyleSheet(style_str) def setup_ui(self): self._btn_container.setFixedWidth(140) self._slider_container.setMinimumWidth(700) self.progress_label.setFixedWidth(80) self._layout.setSpacing(0) self._layout.setContentsMargins(0, 0, 0, 0) self._bc_layout.setSpacing(0) self._bc_layout.setContentsMargins(0, 0, 0, 0) self._bc_layout.addWidget(self.previous_btn) self._bc_layout.addStretch(1) self._bc_layout.addWidget(self.pp_btn) self._bc_layout.addStretch(1) self._bc_layout.addWidget(self.next_btn) self._sc_layout.addWidget(self.progress_slider) self._sc_layout.addSpacing(2) self._sc_layout.addWidget(self.progress_label) self._sc_layout.addSpacing(5) self._sc_layout.addStretch(0) self._sc_layout.addWidget(self.volume_slider) self._sc_layout.addStretch(1) self._layout.addWidget(self._btn_container) self._layout.addSpacing(10) self._layout.addWidget(self._slider_container) class SongOperationPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.setObjectName('song_operation_panel') self.set_theme_style() def set_theme_style(self): style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName()) self.setStyleSheet(style_str) class TopPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self._layout = QHBoxLayout(self) self.pc_panel = PlayerControlPanel(self._app, self) self.mo_panel = SongOperationPanel(self._app, self) self.setObjectName('top_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; color: {1}; border-bottom: 3px inset {3}; }} '''.format(self.objectName(), theme.foreground.name(), theme.color0_light.name(), theme.color0_light.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self.setFixedHeight(50) self._layout.addSpacing(5) self._layout.addWidget(self.pc_panel) self._layout.addWidget(self.mo_panel) class LP_LibraryPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.header = LP_GroupHeader(self._app, '我的音乐') self.current_playlist_item = LP_GroupItem(self._app, '当前播放列表') self.current_playlist_item.set_img_text('❂') self._layout = QVBoxLayout(self) self.setObjectName('lp_library_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName(), theme.color3.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addSpacing(3) self._layout.addWidget(self.header) self._layout.addWidget(self.current_playlist_item) def add_item(self, item): self._layout.addWidget(item) class LP_PlaylistsPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.header = LP_GroupHeader(self._app, '歌单') self._layout = QVBoxLayout(self) self.setObjectName('lp_playlists_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName(), theme.color5.name()) self.setStyleSheet(style_str) def add_item(self, item): self._layout.addWidget(item) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.header) class LeftPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.library_panel = LP_LibraryPanel(self._app) self.playlists_panel = LP_PlaylistsPanel(self._app) self._layout = QVBoxLayout(self) self.setLayout(self._layout) self.setObjectName('c_left_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName(), theme.color5.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.library_panel) self._layout.addWidget(self.playlists_panel) self._layout.addStretch(1) class LeftPanel_Container(FScrollArea): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.left_panel = LeftPanel(self._app) self._layout = QVBoxLayout(self) # no layout, no children self.setWidget(self.left_panel) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setWidgetResizable(True) self.setObjectName('c_left_panel_container') self.set_theme_style() self.setMinimumWidth(180) self.setMaximumWidth(220) self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; border: 0px; border-right: 3px inset {1}; }} '''.format(self.objectName(), theme.color0_light.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) # self._layout.addWidget(self.left_panel) class RightPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.widget = None self._layout = QHBoxLayout(self) self.setLayout(self._layout) self.setObjectName('right_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName()) self.setStyleSheet(style_str) def set_widget(self, widget): if self.widget and self.widget != widget: self._layout.removeWidget(self.widget) self.widget.hide() widget.show() self._layout.addWidget(widget) else: self._layout.addWidget(widget) self.widget = widget def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) class RightPanel_Container(FScrollArea): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.right_panel = RightPanel(self._app) self._layout = QVBoxLayout(self) self.setWidget(self.right_panel) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setWidgetResizable(True) self.setObjectName('c_left_panel') self.set_theme_style() self.setup_ui() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; border: 0px; }} '''.format(self.objectName(), theme.color5.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) class CentralPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.left_panel_container = LeftPanel_Container(self._app, self) self.right_panel_container = RightPanel_Container(self._app, self) self.left_panel = self.left_panel_container.left_panel self.right_panel = self.right_panel_container.right_panel self._layout = QHBoxLayout(self) self.set_theme_style() self.setup_ui() def set_theme_style(self): style_str = ''' #{0} {{ background: transparent; }} '''.format(self.objectName()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.left_panel_container) self._layout.addWidget(self.right_panel_container) class SongLabel(FLabel): def __init__(self, app, text=None, parent=None): super().__init__(text, parent) self._app = app self.setObjectName('song_label') self.setIndent(5) self.set_theme_style() self.set_song('No song is playing') def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; }} '''.format(self.objectName(), theme.color7.name(), theme.color0.name()) self.setStyleSheet(style_str) def set_song(self, song_text): self.setText('♪ ' + song_text + ' ') class PlaybackModeSwitchBtn(FButton): def __init__(self, app, parent=None): super().__init__(parent=parent) self._app = app self.setObjectName('player_mode_switch_btn') self.set_theme_style() self.set_text('循环') def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; border: 0px; padding: 0px 4px; }} '''.format(self.objectName(), theme.color6.name(), theme.background.name()) self.setStyleSheet(style_str) def set_text(self, text): self.setText('♭ ' + text) def on_playback_mode_changed(self, playback_mode): if playback_mode == PlaybackMode.sequential: self.set_text(self._app.player_mode_manager.current_mode.name) else: self.set_text(playback_mode.value) class ThemeComboBox(FComboBox): clicked = pyqtSignal() signal_change_theme = pyqtSignal([str]) def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.setObjectName('theme_switch_btn') self.setEditable(False) self.maximum_width = 150 self.set_theme_style() self.setFrame(False) self.current_theme = self._app.theme_manager.current_theme.name self.themes = [self.current_theme] self.set_themes(self.themes) self.currentIndexChanged.connect(self.on_index_changed) def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; border: 0px; padding: 0px 4px; border-radius: 0px; }} #{0}::drop-down {{ width: 0px; border: 0px; }} #{0} QAbstractItemView {{ border: 0px; min-width: 200px; }} '''.format(self.objectName(), theme.color4.name(), theme.background.name(), theme.foreground.name()) self.setStyleSheet(style_str) @pyqtSlot(int) def on_index_changed(self, index): if index < 0 or not self.themes: return metrics = QFontMetrics(self.font()) if self.themes[index] == self.current_theme: return self.current_theme = self.themes[index] name = '❀ ' + self.themes[index] width = metrics.width(name) if width < self.maximum_width: self.setFixedWidth(width + 10) self.setItemText(index, name) self.setToolTip(name) else: self.setFixedWidth(self.maximum_width) text = metrics.elidedText(name, Qt.ElideRight, self.width()) self.setItemText(index, text) self.setToolTip(text) self.signal_change_theme.emit(self.current_theme) def add_item(self, text): self.addItem('❀ ' + text) def mousePressEvent(self, event): if event.button() == Qt.LeftButton and \ self.rect().contains(event.pos()): self.clicked.emit() self.showPopup() def set_themes(self, themes): self.clear() if self.current_theme: self.themes = [self.current_theme] self.add_item(self.current_theme) else: self.themes = [] for theme in themes: if theme not in self.themes: self.add_item(theme) self.themes.append(theme) class PlayerStateLabel(FLabel): def __init__(self, app, text=None, parent=None): super().__init__('♫', parent) self._app = app self.setObjectName('player_state_label') self.setToolTip('这里显示的是播放器的状态\n' 'Buffered 代表该音乐已经可以开始播放\n' 'Loading 代表正在加载该音乐\n' 'Failed 代表加载音乐失败') self.set_theme_style() def set_text(self, text): self.setText(('♫ ' + text).upper()) @property def common_style(self): style_str = ''' #{0} {{ padding-left: 3px; padding-right: 5px; }} '''.format(self.objectName()) return style_str def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; }} '''.format(self.objectName(), theme.color6_light.name(), theme.color7.name()) self.setStyleSheet(style_str + self.common_style) def set_error_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; }} '''.format(self.objectName(), theme.color1_light.name(), theme.color7.name()) self.setStyleSheet(style_str + self.common_style) def set_normal_style(self): self.set_theme_style() def update_media_state(self, state): self.set_theme_style() logger.debug('current player media state %d' % state) if state == QMediaPlayer.LoadedMedia: self.set_text('Loaded') elif state == QMediaPlayer.LoadingMedia: self.set_text('Loading') elif state == QMediaPlayer.BufferingMedia: self.set_text('Buffering') elif state == QMediaPlayer.BufferedMedia: self.set_text('Buffered') elif state == QMediaPlayer.InvalidMedia: self.set_text('Failed') def update_state(self, state): return self.set_theme_style() if state == QMediaPlayer.StoppedState: self.set_text('Stopped') elif state == QMediaPlayer.PlayingState: self.set_text('Playing') elif state == QMediaPlayer.PausedState: self.set_text('Paused') def set_error(self, error): self.set_error_style() if error == QMediaPlayer.ResourceError: self.set_text('Decode Failed') elif error == QMediaPlayer.NetworkError: self.set_text('Network Error') elif error == QMediaPlayer.FormatError: self.set_text('Decode Failed') elif error == QMediaPlayer.ServiceMissingError: self.set_text('Gsteamer Missing') else: logger.error('player error %d' % error) class MessageLabel(FLabel): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.setObjectName('message_label') self.queue = [] self.hide() @property def common_style(self): style_str = ''' #{0} {{ padding-left: 3px; padding-right: 5px; }} '''.format(self.objectName()) return style_str def _set_error_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; }} '''.format(self.objectName(), theme.color1_light.name(), theme.color7_light.name()) self.setStyleSheet(style_str + self.common_style) def _set_normal_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; }} '''.format(self.objectName(), theme.color6_light.name(), theme.foreground.name()) self.setStyleSheet(style_str + self.common_style) def show_message(self, text, error=False): if self.isVisible(): self.queue.append({'error': error, 'message': text}) return if error: self._set_error_style() self.setText(text) else: self._set_normal_style() self.setText(text) self.show() app_event_loop = asyncio.get_event_loop() app_event_loop.call_later(3, self.access_message_queue) def access_message_queue(self): self.hide() if self.queue: m = self.queue.pop(0) self.show_message(m['message'], m['error']) class AppStatusLabel(FLabel): clicked = pyqtSignal() def __init__(self, app, text=None, parent=None): super().__init__(text, parent) self._app = app self.setText('♨ Normal'.upper()) self.setToolTip('点击可以切换到其他模式哦 ~\n' '不过暂时还没实现这个功能...敬请期待 ~') self.setObjectName('app_status_label') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {3}; padding-left: 5px; padding-right: 5px; font-size: 14px; }} #{0}:hover {{ color: {2}; }} '''.format(self.objectName(), theme.color4.name(), theme.color2.name(), theme.background.name()) self.setStyleSheet(style_str) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton and \ self.rect().contains(event.pos()): self.clicked.emit() class NetworkStatus(FLabel): def __init__(self, app, text=None, parent=None): super().__init__(text, parent) self._app = app self.setToolTip('这里显示的是当前网络状态') self.setObjectName('network_status_label') self.set_theme_style() self.set_state(1) @property def common_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; color: {2}; padding-left: 5px; padding-right: 5px; font-size: 16px; font-weight: bold; }} '''.format(self.objectName(), theme.color3.name(), theme.background.name()) return style_str def set_theme_style(self): self.setStyleSheet(self.common_style) def _set_error_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; }} '''.format(self.objectName(), theme.color5.name()) self.setStyleSheet(self.common_style + style_str) def _set_normal_style(self): self.setStyleSheet(self.common_style) def set_state(self, state): if state == 0: self._set_error_style() self.setText('✕') elif state == 1: self._set_normal_style() self.setText('✓') class StatusPanel(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self._layout = QHBoxLayout(self) self.player_state_label = PlayerStateLabel(self._app) self.app_status_label = AppStatusLabel(self._app) self.network_status_label = NetworkStatus(self._app) self.message_label = MessageLabel(self._app) self.song_label = SongLabel(self._app, parent=self) self.pms_btn = PlaybackModeSwitchBtn(self._app, self) self.theme_switch_btn = ThemeComboBox(self._app, self) self.setup_ui() self.setObjectName('status_panel') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: {1}; }} '''.format(self.objectName(), theme.color0.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self.setFixedHeight(18) # self.song_label.setMinimumWidth(220) self.song_label.setMaximumWidth(300) self._layout.addWidget(self.player_state_label) self._layout.addWidget(self.app_status_label) self._layout.addWidget(self.network_status_label) self._layout.addStretch(0) self._layout.addWidget(self.message_label) self._layout.addStretch(0) self._layout.addWidget(self.theme_switch_btn) self._layout.addWidget(self.pms_btn) self._layout.addWidget(self.song_label) class CurrentPlaylistTable(MusicTable): remove_signal = pyqtSignal([int]) # song id def __init__(self, app): super().__init__(app) self._app = app self._row = 0 self.menu = QMenu() self.remove = QAction('从当前列表中移除', self) self.menu.addAction(self.remove) self.remove.triggered.connect(self.remove_song) def contextMenuEvent(self, event): point = event.pos() item = self.itemAt(point) if item is not None: row = self.row(item) self._row = row self.menu.exec(event.globalPos()) def remove_song(self): song = self.songs[self._row] self.songs.pop(self._row) self.removeRow(self._row) self.remove_signal.emit(song.mid) class LyricFrame(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app class Ui(object): def __init__(self, app): self._layout = QVBoxLayout(app) self.top_panel = TopPanel(app, app) self.central_panel = CentralPanel(app, app) self.status_panel = StatusPanel(app, app) self.current_playlist_table = CurrentPlaylistTable(app) self.setup() def setup(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.top_panel) self._layout.addWidget(self.central_panel) self._layout.addWidget(self.status_panel) PK-Hق__feeluown/utils.pyimport logging import platform import time from functools import wraps from PyQt5.QtGui import QColor logger = logging.getLogger(__name__) def parse_ms(ms): minute = int(ms / 60000) second = int((ms % 60000) / 1000) return minute, second def lighter(color, degree=1, a=255): r, g, b = color.red(), color.green(), color.blue() r = r + 10 * degree if (r + 10 * degree) < 255 else 255 g = g + 10 * degree if (g + 10 * degree) < 255 else 255 b = b + 10 * degree if (b + 10 * degree) < 255 else 255 return QColor(r, g, b, a) def darker(color, degree=1, a=255): r, g, b = color.red(), color.green(), color.blue() r = r - 10 * degree if (r - 10 * degree) > 0 else 0 g = g - 10 * degree if (g - 10 * degree) > 0 else 0 b = b - 10 * degree if (b - 10 * degree) > 0 else 0 return QColor(r, g, b, a) def measure_time(func): @wraps(func) def wrapper(*args, **kwargs): t = time.process_time() result = func(*args, **kwargs) elapsed_time = time.process_time() - t logger.info('function %s executed time: %f s' % (func.__name__, elapsed_time)) return result return wrapper def is_linux(): if platform.system() == 'Linux': return True return False def is_osx(): if platform.system() == 'Darwin': return True return False PK 10: logger.warning('%s url maybe outdated.' % (self.title)) self._url = None return self.url return self._url data = self._api.weapi_songs_url([self.mid]) if data is not None: if data['code'] == 200: # get when needed as url will be invalid several minute later url = data['data'][0]['url'] if url is None: return self.candidate_url self._start_time = datetime.datetime.now() self._url = url return url if data['code'] == 404: return self.candidate_url return self._url @property def candidate_url(self): if not self._candidate_url: self.get_detail() return self._candidate_url def get_detail(self): data = self._api.song_detail(self.mid) if data is not None: song = data['songs'][0] self._candidate_url = song['mp3Url'] self.album._img = song['album']['picUrl'] @classmethod def mv_available(cls, mvid): if mvid != 0: return True return False @classmethod def get(cls, mid): data = cls._api.song_detail(mid) return cls.create(data) @classmethod def create(cls, data): if data is None or not len(data['songs']): return None song_data = data['songs'][0] return cls.pure_create(song_data) @classmethod def pure_create(cls, song_data): mid = song_data['id'] title = song_data['name'] url = song_data.get('mp3Url', None) length = song_data['duration'] album = NAlbumModel.create_from_brief(song_data['album']) artists = [NArtistModel(x['id'], x['name']) for x in song_data['artists']] mvid = song_data['mvid'] model = cls(mid, title, length, artists, album, mvid, url) return model @classmethod def batch_create(cls, datas): return [cls.pure_create(data) for data in datas] @classmethod def search(cls, text): data = cls._api.search(text) songs = [] if data is not None: if data['result']['songCount']: songs = data['result']['songs'] return cls.batch_create(songs) class NAlbumModel(object): _api = api def __init__(self, bid, name, artists_name, songs=[], img='', desc=''): super().__init__() self.bid = bid self._name = name self._artists_name = artists_name self._songs = songs self._img = img self._desc = desc @property def name(self): return self._name @property def artists_name(self): return self._artists_name @property def img(self): if not self._img: logger.debug('album has no img, so get detail') self.get_detail() return self._img @property def songs(self): if not self._songs: logger.debug('album has no songs, so get detail') self.get_detail() return self._songs @property def desc(self): if not self._desc: logger.debug('album has no desc, so get detail') self.get_detail() return self._desc @classmethod def create_from_brief(cls, data): pid = data['id'] name = data['name'] if 'artists' in data: artists_name = ', '.join([x['name'] for x in data['artists']]) else: artists_name = data['artist'] img = data.get('picUrl', None) return cls(pid, name, artists_name, img=img) def get_detail(self): data = self._api.album_infos(self.bid) if data is not None: data = data['album'] self._songs = NSongModel.batch_create(data['songs']) self._img = data['picUrl'] self._desc = data['briefDesc'] @classmethod def get(cls, bid): data = cls._api.album_infos(bid) return cls.create(data) @classmethod def create(cls, data): if data is None or data['code'] != 200: return None album_data = data['album'] bid = album_data['id'] name = album_data['name'] artists_name = album_data['artist']['name'] songs = NSongModel.batch_create(album_data['songs']) img = album_data['picUrl'] desc = album_data['briefDesc'] return NAlbumModel(bid, name, artists_name, songs, img, desc) class NArtistModel(object): _api = api def __init__(self, aid, name, img='', songs=[]): self.aid = aid self._name = name self._img = img self._songs = songs @property def name(self): return self._name @property def img(self): if not self._img: logger.debug('artist has no img, so get detail') self.get_detail() return self._img @property def songs(self): if not self._songs: logger.debug('artist has no songs, so get detail') self.get_detail() return self._songs def get_detail(self): data = self._api.artist_infos(self.aid) if data is not None: self._img = data['artist']['picUrl'] self._songs = data['hotSongs'] @classmethod def get(cls, aid): data = cls._api.artist_infos(aid) return cls.create(data) @classmethod def create(cls, data): if data is None or data['code'] != 200: return None aid = data['artist']['id'] name = data['artist']['name'] img = data['artist']['picUrl'] songs = NSongModel.batch_create(data['hotSongs']) return cls(aid, name, img, songs) class NUserModel(object): _api = api current_user = None def __init__(self, username, uid, name, img, playlists=[]): super().__init__() self.username = username self.uid = uid self.name = name self.img = img self._playlists = playlists def is_playlist_mine(self, pid): for p in self.playlists: if p.pid == pid: return True return False @classmethod def set_current_user(cls, user): cls.current_user = user @property def playlists(self): if self._playlists: return self._playlists data = self._api.user_playlist(self.uid) if data is None: return [] playlists = data['playlist'] playlists_model = [] for p in playlists: model = NPlaylistModel(p['id'], p['name'], p['specialType'], p['userId']) playlists_model.append(model) self._playlists = playlists_model return playlists_model @classmethod def create(cls, data): user_data = data['data'] username = data['username'] img = user_data['profile']['avatarUrl'] uid = user_data['profile']['userId'] name = user_data['profile']['nickname'] model = NUserModel(username, uid, name, img) return model @classmethod def check(cls, username, pw): data = cls._api.login(username, pw) if data is None: return {'code': 408, 'message': '网络状况不好'} elif data['code'] == 200: return {'code': 200, 'message': '登陆成功', 'data': data, 'username': username} elif data['code'] == 415: captcha_id = data['captchaId'] url = cls._api.get_captcha_url(captcha_id) return {'code': 415, 'message': '本次登陆需要验证码', 'captcha_url': url, 'captchar_id': captcha_id} elif data['code'] == 501: return {'code': 501, 'message': '用户名不存在'} elif data['code'] == 502: return {'code': 502, 'message': '密码错误'} elif data['code'] == 509: return {'code': 509, 'message': '请休息几分钟再尝试'} @classmethod def check_captcha(cls, captcha_id, text): flag, cid = cls._api.confirm_captcha(captcha_id, text) if flag is not True: url = cls._api.get_captcha_url(cid) return {'code': 415, 'message': '验证码错误', 'captcha_url': url, 'captcha_id': cid} return {'code': 200, 'message': '验证码正确'} def save(self): with open(USERS_INFO_FILE, 'w+') as f: data = { self.username: { 'uid': self.uid, 'name': self.name, 'img': self.img, 'cookies': self._api.cookies } } if f.read() != '': data.update(json.load(f)) json.dump(data, f, indent=4) @classmethod def load(cls): if not os.path.exists(USERS_INFO_FILE): return None with open(USERS_INFO_FILE, 'r') as f: text = f.read() if text == '': return None data = json.loads(text) username = next(iter(data.keys())) user_data = data[username] model = cls(username, user_data['uid'], user_data['name'], user_data['img']) model._api.load_cookies(user_data['cookies']) return model @classmethod def get_recommend_songs(cls): if cls.current_user is None: return [] data = cls._api.get_recommend_songs() if data.get('code') == 200: return NSongModel.batch_create(data['recommend']) return [] @classmethod def get_fm_song(cls): if cls.current_user is None: return [] data = cls._api.get_radio_music() if data is None: return [] if data['code'] == 200: songs = data['data'] return NSongModel.batch_create(songs) else: return [] class NPlaylistModel(PlaylistModel): instances = [] _api = api def __init__(self, pid, name, ptype, uid, songs=[]): super().__init__() self.pid = pid self._name = name self.ptype = ptype self.uid = uid self._songs = songs NPlaylistModel.instances.append(self) @property def name(self): return self._name @property def songs(self): if self._songs: return self._songs data = self._api.playlist_detail(self.pid) if data is None: return None songs_model = NSongModel.batch_create(data['result']['tracks']) self._songs = songs_model return self._songs def update_songs(self): self._songs = [] def add_song(self, mid): data = self._api.op_music_to_playlist(mid, self.pid, op='add') if data is None: return False if data['code'] == 502: return False elif data['code'] == 200: self.update_songs() return True def del_song(self, mid): data = self._api.op_music_to_playlist(mid, self.pid, op='del') if data.get('code') == 200: self.update_songs() return True return False @classmethod def del_song_from_playlist(cls, mid, pid): for playlist in cls.instances: if playlist.pid == pid: return playlist.del_song(mid) data = cls._api.op_music_to_playlist(mid, pid, op='del') if data['code'] == 200: return True return False PK=WHhuu)feeluown/plugins/neteasemusic/__init__.py# -*- coding: utf-8 -*- import logging from .nem import Nem __alias__ = '网易云音乐' __version__ = '0.0.1' __desc__ = '网易云音乐' def enable(app): logger = logging.getLogger(__name__) Nem(app) logger.info('neteasemusic plugin enabled') def disable(app): logger = logging.getLogger(__name__) logger.info('neteasemusic plugin disabled') PK-H>vv/feeluown/plugins/neteasemusic/fm_player_mode.pyimport logging from feeluown.player_mode import PlayerModeBase from .model import NUserModel logger = logging.getLogger(__name__) class FM_mode(PlayerModeBase): def __init__(self, app): super().__init__(app) self.player = app.player self._name = 'FM' self._songs = [] @property def name(self): return self._name def on_playlist_finished(self): logger.debug('fm mode: playlist finished') song = self._get_song() self.player.other_mode_play(song) def load(self): self.player.stop() song = self._get_song() if song is not None: self.player.other_mode_play(song) else: self.unload() def _get_song(self): if not self._songs: self._songs = NUserModel.get_fm_song() song = self._songs.pop(0) return song PK-Hԥn$feeluown/plugins/neteasemusic/nem.pyimport json import logging import os from PyQt5.QtCore import QObject from PyQt5.QtGui import QKeySequence from .api import api from .consts import USER_PW_FILE from .fm_player_mode import FM_mode from .model import NUserModel, NSongModel from .ui import Ui, SongsTable, PlaylistItem logger = logging.getLogger(__name__) class Nem(QObject): def __init__(self, app): super().__init__(parent=app) self._app = app api.set_http(self._app.request) self.ui = Ui(self._app) self.user = None self.registe_hotkey() self.init_signal_binding() def init_signal_binding(self): self.ui.login_btn.clicked.connect(self.ready_to_login) self.ui.login_dialog.ok_btn.clicked.connect(self.login) self.ui.songs_table_container.table_control.play_all_btn.clicked\ .connect(self.play_all) self.ui.songs_table_container.table_control.search_box.textChanged\ .connect(self.search_table) self.ui.songs_table_container.table_control.search_box.returnPressed\ .connect(self.search_net) self.ui.fm_item.clicked.connect(self.enter_fm_mode) self.ui.recommend_item.clicked.connect(self.show_recommend_songs) def enter_fm_mode(self): mode = FM_mode(self._app) self._app.player_mode_manager.enter_mode(mode) def registe_hotkey(self): self._app.hotkey_manager.registe( QKeySequence('Ctrl+F'), self.ui.songs_table_container.table_control.search_box.setFocus) def show_recommend_songs(self): songs = NUserModel.get_recommend_songs() songs_table = SongsTable(self._app) self.load_songs(songs, songs_table) def load_user_pw(self): if not os.path.exists(USER_PW_FILE): return with open(USER_PW_FILE, 'r') as f: d = json.load(f) data = d[d['default']] self.ui.login_dialog.username_input.setText(data['username']) self.ui.login_dialog.pw_input.setText(data['password']) self.ui.login_dialog.is_encrypted = True logger.info('load username and password from %s' % USER_PW_FILE) def save_user_pw(self, data): with open(USER_PW_FILE, 'w+') as f: if f.read() == '': d = dict() else: d = json.load(f) d['default'] = data['username'] d[d['default']] = data json.dump(d, f, indent=4) logger.info('save username and password to %s' % USER_PW_FILE) def ready_to_login(self): model = NUserModel.load() if model is None: self.ui.login_dialog.show() self.load_user_pw() else: logger.info('load last user.') self.user = model NUserModel.set_current_user(model) self._on_login_in() def login(self): login_dialog = self.ui.login_dialog if login_dialog.captcha_needed: captcha = str(login_dialog.captcha_input.text()) captcha_id = login_dialog.captcha_id data = NUserModel.check_captcha(captcha_id, captcha) if data['code'] == 200: login_dialog.captcha_input.hide() login_dialog.captcha_label.hide() else: login_dialog.captcha_verify(data) user_data = login_dialog.data self.ui.login_dialog.show_hint('正在登录...') data = NUserModel.check(user_data['username'], user_data['password']) message = data['message'] self.ui.login_dialog.show_hint(message) if data['code'] == 200: # login in self.save_user_pw(user_data) self.user = NUserModel.create(data) self._on_login_in() elif data['code'] == 415: login_dialog.captcha_verify(data) def _on_login_in(self): logger.info('login in... set user infos.') self.ui.on_login_in() self.ui.login_btn.set_avatar(self.user.img) self.user.save() self.load_playlists() def load_playlists(self): playlist_widget = self._app.ui.central_panel.left_panel.playlists_panel for playlist in self.user.playlists: item = PlaylistItem(self._app, playlist) item.load_playlist_signal.connect(self.load_playlist) playlist_widget.add_item(item) def play_song(self, song): self._app.player.play(song) def play_all(self): songs_table = self.ui.songs_table_container.songs_table if songs_table is not None: self._app.player.set_music_list(songs_table.songs) def play_mv(self, mvid): pass def search_table(self, text): songs_table = self.ui.songs_table_container.songs_table songs_table.search(text) def search_net(self): text = self.ui.songs_table_container.table_control.search_box.text() songs = NSongModel.search(text) self._app.message('搜索到 %d 首相关歌曲' % len(songs)) if songs: self.load_songs(songs) def load_songs(self, songs, songs_table=None): if songs_table is None: songs_table = SongsTable(self._app) songs_table.set_songs(songs) songs_table.play_song_signal.connect(self.play_song) songs_table.play_mv_signal.connect(self.play_mv) songs_table.show_artist_signal.connect(self.load_artist) songs_table.show_album_signal.connect(self.load_album) songs_table.add_song_signal.connect(self._app.player.add_music) songs_table.set_to_next_signal.connect( self._app.player.set_tmp_fixed_next_song) self.ui.songs_table_container.set_table(songs_table) self._app.ui.central_panel.right_panel.set_widget( self.ui.songs_table_container) def load_playlist(self, playlist): logger.info('load playlist : %d, %s' % (playlist.pid, playlist.name)) if playlist.songs is None: return songs_table = SongsTable(self._app) songs_table.set_playlist_id(playlist.pid) self.load_songs(playlist.songs, songs_table) def load_artist(self, aid): print('aid:', aid) pass def load_album(self, bid): print('bid:', bid) pass PK-H)wF7F7#feeluown/plugins/neteasemusic/ui.pyimport hashlib import logging from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtWidgets import (QHBoxLayout, QVBoxLayout, QLineEdit, QHeaderView, QMenu, QAction, QAbstractItemView) from feeluown.libs.widgets.base import FLabel, FFrame, FDialog, FLineEdit, \ FButton from feeluown.libs.widgets.components import MusicTable, LP_GroupItem from .model import NPlaylistModel, NSongModel, NUserModel logger = logging.getLogger(__name__) class LineInput(FLineEdit): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.setObjectName('line_input') self.set_theme_style() def set_theme_style(self): pass class LoginDialog(FDialog): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.is_encrypted = False self.captcha_needed = False self.captcha_id = 0 self.username_input = LineInput(self) self.pw_input = LineInput(self) self.pw_input.setEchoMode(QLineEdit.Password) # self.remember_checkbox = FCheckBox(self) self.captcha_label = FLabel(self) self.captcha_label.hide() self.captcha_input = LineInput(self) self.captcha_input.hide() self.hint_label = FLabel(self) self.ok_btn = FButton('登录', self) self._layout = QVBoxLayout(self) self.username_input.setPlaceholderText('网易邮箱或者手机号') self.pw_input.setPlaceholderText('密码') self.setObjectName('login_dialog') self.set_theme_style() self.setup_ui() self.pw_input.textChanged.connect(self.dis_encrypt) def fill(self, data): self.username_input.setText(data['username']) self.pw_input.setText(data['password']) self.is_encrypted = True def set_theme_style(self): pass def setup_ui(self): self.setFixedWidth(200) self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.username_input) self._layout.addWidget(self.pw_input) self._layout.addWidget(self.captcha_label) self._layout.addWidget(self.captcha_input) self._layout.addWidget(self.hint_label) # self._layout.addWidget(self.remember_checkbox) self._layout.addWidget(self.ok_btn) def show_hint(self, text): self.hint_label.setText(text) @property def data(self): username = self.username_input.text() pw = self.pw_input.text() if self.is_encrypted: password = pw else: password = hashlib.md5(pw.encode('utf-8')).hexdigest() d = dict(username=username, password=password) return d def captcha_verify(self, data): self.captcha_needed = True url = data['captcha_url'] self.captcha_id = data['captcha_id'] self.captcha_input.show() self.captcha_label.show() self._app.pixmap_from_url(url, self.captcha_label.setPixmap) def dis_encrypt(self, text): self.is_encrypted = False class LoginButton(FLabel): clicked = pyqtSignal() def __init__(self, app, text=None, parent=None): super().__init__(text, parent) self._app = app self.setText('登录') self.setToolTip('登陆网易云音乐') self.setObjectName('nem_login_btn') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ background: transparent; color: {1}; }} #{0}:hover {{ color: {2}; }} '''.format(self.objectName(), theme.foreground.name(), theme.color4.name()) self.setStyleSheet(style_str) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton and \ self.rect().contains(event.pos()): self.clicked.emit() def set_avatar(self, url): pixmap = self._app.pixmap_from_url(url) if pixmap is not None: self.setPixmap(pixmap.scaled(self.size(), transformMode=Qt.SmoothTransformation)) class PlaylistItem(LP_GroupItem): load_playlist_signal = pyqtSignal(NPlaylistModel) def __init__(self, app, playlist=None, parent=None): super().__init__(app, playlist.name, parent=parent) self._app = app self.model = playlist self.clicked.connect(self.on_clicked) self.setAcceptDrops(True) def on_clicked(self): self.load_playlist_signal.emit(self.model) def dropEvent(self, event): source = event.source() if not isinstance(source, SongsTable): return event.accept() song = source.drag_song if song is not None: user = NUserModel.current_user if user.is_playlist_mine(self.model.pid): self.add_song_to_playlist(song) def add_song_to_playlist(self, song): logger.debug('temp to add "%s" to playlist "%s"' % (song.title, self.model.name)) if self.model.add_song(song.mid): self._app.message('add "%s" to playlist "%s" success' % (song.title, self.model.name)) else: self._app.message('add "%s" to playlist "%s" failed' % (song.title, self.model.name), error=True) def dragEnterEvent(self, event): event.accept() def dragMoveEvent(self, event): event.accept() class SongsTable(MusicTable): play_mv_signal = pyqtSignal([int]) play_song_signal = pyqtSignal([NSongModel]) show_artist_signal = pyqtSignal([int]) show_album_signal = pyqtSignal([int]) add_song_signal = pyqtSignal([NSongModel]) set_to_next_signal = pyqtSignal([NSongModel]) def __init__(self, app, rows=0, columns=6, parent=None): super().__init__(app, rows, columns, parent) self._app = app self.setObjectName('nem_songs_table') self.set_theme_style() self.songs = [] self.setHorizontalHeaderLabels(['', '歌曲名', '歌手', '专辑', '时长', '']) self.setColumnWidth(0, 28) self.setColumnWidth(2, 150) self.setColumnWidth(3, 200) self.setColumnWidth(5, 100) self.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.cellDoubleClicked.connect(self.on_cell_dbclick) self.setDragEnabled(True) self.setDragDropMode(QAbstractItemView.DragOnly) self._context_menu_row = 0 self._drag_row = None self._playlist_id = 0 def add_song_to_current_playlist(self): song = self.songs[self._context_menu_row] self.add_song_signal.emit(song) def set_song_to_next(self): song = self.songs[self._context_menu_row] self.set_to_next_signal.emit(song) def remove_song_from_playlist(self): song = self.songs[self._context_menu_row] if NPlaylistModel.del_song_from_playlist(song.mid, self._playlist_id): self.removeRow(self._context_menu_row) self._app.message('删除 %s 成功' % song.title) else: self._app.message('删除 %s 失败' % song.title, error=True) def set_playlist_id(self, pid): self._playlist_id = pid def is_playlist(self): if self._playlist_id: return True return False def _is_playlist_mine(self): if self.is_playlist(): user = NUserModel.current_user if user.is_playlist_mine(self._playlist_id): return True return False def contextMenuEvent(self, event): menu = QMenu() add_to_current_playlist_action = QAction('添加到当前播放列表', self) set_song_next_to_action = QAction('下一首播放', self) menu.addAction(add_to_current_playlist_action) menu.addAction(set_song_next_to_action) if self._is_playlist_mine(): remove_song_from_playlist_action = QAction('从歌单中删除该歌曲', self) menu.addAction(remove_song_from_playlist_action) remove_song_from_playlist_action.triggered.connect( self.remove_song_from_playlist) add_to_current_playlist_action.triggered.connect( self.add_song_to_current_playlist) set_song_next_to_action.triggered.connect( self.set_song_to_next) point = event.pos() item = self.itemAt(point) if item is not None: row = self.row(item) self._context_menu_row = row menu.exec(event.globalPos()) @property def drag_song(self): if self._drag_row is not None: return self.songs[self._drag_row] return None def mousePressEvent(self, event): super().mousePressEvent(event) point = event.pos() item = self.itemAt(point) if item is not None: self._drag_row = self.row(item) def on_cell_dbclick(self, row, column): song = self.songs[row] if column == 0: if NSongModel.mv_available(song.mvid): self.play_mv_signal.emit(song.mvid) elif column == 1: self.play_song_signal.emit(song) elif column == 2: self.show_artist_signal.emit(song.artists[0].aid) elif column == 3: self.show_album_signal.emit(song.album.bid) class SearchBox(FLineEdit): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.setObjectName('search_box') self.setPlaceholderText('搜索歌曲、歌手') self.setToolTip('输入文字可以从当前歌单内搜索\n' '按下 Enter 将搜索网络') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' #{0} {{ padding-left: 3px; font-size: 14px; background: transparent; border: 0px; border-bottom: 1px solid {1}; color: {2}; outline: none; }} #{0}:focus {{ outline: none; }} '''.format(self.objectName(), theme.color6.name(), theme.foreground.name()) self.setStyleSheet(style_str) class TableControl(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.play_all_btn = FButton('▶') self.search_box = SearchBox(self._app) self._layout = QHBoxLayout(self) self.setup_ui() self.setObjectName('n_table_control') self.set_theme_style() def set_theme_style(self): theme = self._app.theme_manager.current_theme style_str = ''' QPushButton {{ background: transparent; color: {1}; font-size: 16px; outline: none; }} QPushButton:hover {{ color: {2}; }} '''.format(self.objectName(), theme.foreground.name(), theme.color0.name()) self.setStyleSheet(style_str) def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self.setFixedHeight(40) self.play_all_btn.setFixedSize(20, 20) self.search_box.setFixedSize(160, 26) self._layout.addSpacing(20) self._layout.addWidget(self.play_all_btn) self._layout.addStretch(0) self._layout.addWidget(self.search_box) self._layout.addSpacing(60) class SongsTable_Container(FFrame): def __init__(self, app, parent=None): super().__init__(parent) self._app = app self.songs_table = None self.table_control = TableControl(self._app) self._layout = QVBoxLayout(self) self.setup_ui() def setup_ui(self): self._layout.setContentsMargins(0, 0, 0, 0) self._layout.setSpacing(0) self._layout.addWidget(self.table_control) def set_table(self, songs_table): if self.songs_table: self._layout.replaceWidget(self.songs_table, songs_table) self.songs_table.deleteLater() else: self._layout.addWidget(songs_table) self._layout.addSpacing(10) self.songs_table = songs_table class Ui(object): def __init__(self, app): super().__init__() self._app = app self.login_dialog = LoginDialog(self._app, self._app) self.login_btn = LoginButton(self._app) self._lb_container = FFrame() self.songs_table_container = SongsTable_Container(self._app) self.fm_item = LP_GroupItem(self._app, '私人FM') self.fm_item.set_img_text('Ω') self.recommend_item = LP_GroupItem(self._app, '每日推荐') self.recommend_item.set_img_text('✦') self._lbc_layout = QHBoxLayout(self._lb_container) self.setup() def setup(self): self._lbc_layout.setContentsMargins(0, 0, 0, 0) self._lbc_layout.setSpacing(0) self._lbc_layout.addWidget(self.login_btn) self.login_btn.setFixedSize(30, 30) self._lbc_layout.addSpacing(10) tp_layout = self._app.ui.top_panel.layout() tp_layout.addWidget(self._lb_container) def on_login_in(self): self.login_dialog.close() library_panel = self._app.ui.central_panel.left_panel.library_panel library_panel.add_item(self.fm_item) library_panel.add_item(self.recommend_item) PK=WHh *a*a*$feeluown/plugins/neteasemusic/api.py#!/usr/bin/env python # encoding: UTF-8 """ 网易云音乐 Api https://github.com/bluetomlee/NetEase-MusicBox The MIT License (MIT) CopyRight (c) 2014 vellow modified by cosven """ import base64 import binascii import os import json import logging import requests from Crypto.Cipher import AES from Crypto.PublicKey import RSA uri = 'http://music.163.com/api' uri_we = 'http://music.163.com/weapi' uri_v1 = 'http://music.163.com/weapi/v1' logger = logging.getLogger(__name__) class Api(object): def __init__(self): super().__init__() self.headers = { 'Host': 'music.163.com', 'Connection': 'keep-alive', 'Referer': 'http://music.163.com/', "User-Agent": 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_2)' ' AppleWebKit/537.36 (KHTML, like Gecko)' ' Chrome/33.0.1750.152 Safari/537.36' } self._cookies = dict(appver="1.2.1", os="osx") self._http = None @property def cookies(self): return self._cookies def load_cookies(self, cookies): self._cookies.update(cookies) def set_http(self, http): self._http = http @property def http(self): return requests if self._http is None else self._http def request(self, method, action, query=None, timeout=3): logger.info('method=%s url=%s' % (method, action)) try: if method == "GET": res = self.http.get(action, headers=self.headers, cookies=self._cookies, timeout=timeout) elif method == "POST": res = self.http.post(action, data=query, headers=self.headers, cookies=self._cookies, timeout=timeout) elif method == "POST_UPDATE": res = self.http.post(action, data=query, headers=self.headers, cookies=self._cookies, timeout=timeout) self._cookies.update(res.cookies.get_dict()) if res is not None: content = res.content content_str = content.decode('utf-8') content_dict = json.loads(content_str) return content_dict else: return None except Exception as e: logger.error(str(e)) return None def login(self, username, pw_encrypt, phone=False): action = 'http://music.163.com/api/login/' phone_action = 'http://music.163.com/api/login/cellphone/' data = { 'password': pw_encrypt, 'rememberLogin': 'true' } if username.isdigit() and len(username) == 11: phone = True data.update({'phone': username}) else: data.update({'username': username}) if phone: res_data = self.request("POST_UPDATE", phone_action, data) return res_data else: res_data = self.request("POST_UPDATE", action, data) return res_data def check_cookies(self): url = uri + '/push/init' data = self.request("POST_UPDATE", url, {}) if data['code'] == 200: return True return False def confirm_captcha(self, captcha_id, text): action = uri + '/image/captcha/verify/hf?id=' + str(captcha_id) + '&captcha=' + str(text) data = self.request('GET', action) return data def get_captcha_url(self, captcha_id): action = 'http://music.163.com/captcha?id=' + str(captcha_id) return action # 用户歌单 def user_playlist(self, uid, offset=0, limit=200): action = uri + '/user/playlist/?offset=' + str(offset) + '&limit=' + str( limit) + '&uid=' + str(uid) res_data = self.request('GET', action) return res_data # 搜索单曲(1),歌手(100),专辑(10),歌单(1000),用户(1002) *(type)* def search(self, s, stype=1, offset=0, total='true', limit=60): action = uri + '/search/get' data = { 's': s, 'type': stype, 'offset': offset, 'total': total, 'limit': 60 } return self.request('POST', action, data) def playlist_detail(self, playlist_id): action = uri + '/playlist/detail?id=' + str(playlist_id) + '&offset=0&total=true&limit=1001' res_data = self.request('GET', action) return res_data def update_playlist_name(self, pid, name): url = uri + '/playlist/update/name' data = { 'id': pid, 'name': name } res_data = self.request('POST', url, data) return res_data def new_playlist(self, uid, name='default'): url = uri + '/playlist/create' data = { 'uid': uid, 'name': name } res_data = self.request('POST', url, data) return res_data def delete_playlist(self, pid): url = uri + '/playlist/delete' data = { 'id': pid, 'pid': pid } return self.request('POST', url, data) def artist_infos(self, artist_id): """ :param artist_id: artist_id :return: { code: int, artist: {artist}, more: boolean, hotSongs: [songs] } """ action = uri + '/artist/' + str(artist_id) data = self.request('GET', action) return data # album id --> song id set def album_infos(self, album_id): """ :param album_id: :return: { code: int, album: { album } } """ action = uri + '/album/' + str(album_id) data = self.request('GET', action) return data # song id --> song url ( details ) def song_detail(self, music_id): action = uri + '/song/detail/?id=' + str(music_id) + '&ids=[' +\ str(music_id) + ']' data = self.request('GET', action) return data def weapi_songs_url(self, music_ids, bitrate=128000): url = uri_we + '/song/enhance/player/url' data = { 'ids': music_ids, 'br': bitrate, 'csrf_token': self._cookies.get('__csrf') } payload = self.encrypt_request(data) return self.request('POST', url, payload) def songs_detail(self, music_ids): music_ids = [str(music_id) for music_id in music_ids] action = uri + '/api/song/detail?ids=[' +\ ','.join(music_ids) + ']' data = self.request('GET', action) return data def op_music_to_playlist(self, mid, pid, op): """ :param op: add or del 把mid这首音乐加入pid这个歌单列表当中去 1. 如果歌曲已经在列表当中,返回code为502 """ url_add = uri + '/playlist/manipulate/tracks' trackIds = '["' + str(mid) + '"]' data_add = { 'tracks': str(mid), # music id 'pid': str(pid), # playlist id 'trackIds': trackIds, # music id str 'op': op # opation } return self.request('POST', url_add, data_add) def set_music_favorite(self, mid, flag): url = uri + '/song/like' data = { "trackId": mid, "like": str(flag).lower(), "time": 0 } return self.request("POST", url, data) def get_radio_music(self): url = 'http://music.163.com/api/radio/get' return self.request('GET', url) def get_mv_detail(self, mvid): """Get mv detail :param mvid: mv id :return: """ url = uri + '/mv/detail?id=' + str(mvid) return self.request('GET', url) def get_lyric_by_musicid(self, mid): """Get song lyric :param mid: music id :return: { lrc: { version: int, lyric: str }, tlyric: { version: int, lyric: str } sgc: bool, qfy: bool, sfy: bool, transUser: {}, code: int, } """ # tv 表示翻译。-1:表示要翻译,1:不要 url = uri + '/song/lyric?' + 'id=' + str(mid) + '&lv=1&kv=1&tv=-1' return self.request('GET', url) def get_similar_song(self, mid, offset=0, limit=10): url = ("http://music.163.com/api/discovery/simiSong" "?songid=%d&offset=%d&total=true&limit=%d" % (mid, offset, limit)) return self.request('GET', url) def get_recommend_songs(self): url = uri + '/discovery/recommend/songs' return self.request('GET', url) def get_comment(self, comment_id): data = { 'rid': comment_id, 'offset': '0', 'total': 'true', 'limit': '20', 'csrf_token': self._cookies.get('__csrf') } url = uri_v1 + '/resource/comments/' + comment_id payload = self.encrypt_request(data) return self.request('POST', url, payload) def _create_aes_key(self, size): return (''.join([hex(b)[2:] for b in os.urandom(size)]))[0:16] def _aes_encrypt(self, text, key): pad = 16 - len(text) % 16 text = text + pad * chr(pad) encryptor = AES.new(key, 2, '0102030405060708') enc_text = encryptor.encrypt(text) enc_text_encode = base64.b64encode(enc_text) return enc_text_encode def _rsa_encrypt(self, text): e = '010001' n = '00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615'\ 'bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf'\ '695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46'\ 'bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b'\ '8e289dc6935b3ece0462db0a22b8e7' reverse_text = text[::-1] pub_key = RSA.construct([int(n, 16), int(e, 16)]) encrypt_text = pub_key.encrypt(int(binascii.hexlify(reverse_text), 16), None)[0] return format(encrypt_text, 'x').zfill(256) def encrypt_request(self, data): text = json.dumps(data) first_aes_key = '0CoJUm6Qyw8W8jud' second_aes_key = self._create_aes_key(16) enc_text = self._aes_encrypt( self._aes_encrypt(text, first_aes_key).decode('ascii'), second_aes_key).decode('ascii') enc_aes_key = self._rsa_encrypt(second_aes_key.encode('ascii')) payload = { 'params': enc_text, 'encSecKey': enc_aes_key, } return payload api = Api() PK>H^- (feeluown-1.0.3.dist-info/DESCRIPTION.rstUNKNOWN PK>H1)feeluown-1.0.3.dist-info/entry_points.txt[console_scripts] feeluown = feeluown.__main__:main feeluown-genicon = feeluown.install:generate_icon feeluown-install-dev = feeluown.install:install_sys_dep feeluown-update = feeluown.install:update PK>H&feeluown-1.0.3.dist-info/metadata.json{"classifiers": ["Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3 :: Only", "Environment :: X11 Applications :: Qt"], "extensions": {"python.commands": {"wrap_console": {"feeluown": "feeluown.__main__:main", "feeluown-genicon": "feeluown.install:generate_icon", "feeluown-install-dev": "feeluown.install:install_sys_dep", "feeluown-update": "feeluown.install:update"}}, "python.details": {"contacts": [{"email": "cosven.yin@gmail.com", "name": "Cosven", "role": "author"}], "document_names": {"description": "DESCRIPTION.rst"}, "project_urls": {"Home": "https://github.com/cosven/FeelUOwn"}}, "python.exports": {"console_scripts": {"feeluown": "feeluown.__main__:main", "feeluown-genicon": "feeluown.install:generate_icon", "feeluown-install-dev": "feeluown.install:install_sys_dep", "feeluown-update": "feeluown.install:update"}}}, "extras": [], "generator": "bdist_wheel (0.26.0)", "keywords": ["media", "player", "application", "PyQt5", "python3"], "metadata_version": "2.0", "name": "feeluown", "run_requires": [{"requires": ["quamash (>=0.5.5)"]}], "summary": "*nix music player", "version": "1.0.3"}PK>H4 &feeluown-1.0.3.dist-info/top_level.txtfeeluown PK>H}\\feeluown-1.0.3.dist-info/WHEELWheel-Version: 1.0 Generator: bdist_wheel (0.26.0) Root-Is-Purelib: true Tag: py3-none-any PK>H)Ay!feeluown-1.0.3.dist-info/METADATAMetadata-Version: 2.0 Name: feeluown Version: 1.0.3 Summary: *nix music player Home-page: https://github.com/cosven/FeelUOwn Author: Cosven Author-email: cosven.yin@gmail.com License: UNKNOWN Keywords: media,player,application,PyQt5,python3 Platform: UNKNOWN Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Environment :: X11 Applications :: Qt Requires-Dist: quamash (>=0.5.5) UNKNOWN PK>HV$  feeluown-1.0.3.dist-info/RECORDfeeluown/__init__.py,sha256=Xnknc3nGY3LtAEIerRh-HVitprFDbhBVGKcFO-bWLPs,542 feeluown/__main__.py,sha256=tJ1PodxsnjvizhudLH3yrPUYp-3lN0K7IkOL-e_5SUc,1396 feeluown/app.py,sha256=LCbCGQgMfrW7Q9FxwbF_F8fo5HNv7OIUKtRgAUkIp_A,7325 feeluown/config.py,sha256=rxNEd55nZd7IONIu9ylmwyrzfs-WIZionfcH9MTAxvE,80 feeluown/consts.py,sha256=Eld-6BtwLC4r9KKbJwxTkzjH3twk7hdeSEIHpX1xJ68,559 feeluown/daemon.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24 feeluown/db.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24 feeluown/feeluown.png,sha256=VE2pDBqh68YtKPcPkhBYx8wYcxSvTWV2E43SRKJgrNQ,35773 feeluown/hotkey.py,sha256=TkpnUrfE2T1HukrPfWMs7uazA3mMgXd51qcPztCv0zw,262 feeluown/install.py,sha256=P01UW33tBUtbj8El9XhX8FPfBBc6fInf3Idqk_V9FGw,3385 feeluown/model.py,sha256=bMuMuJkat1GQiwa_0zwbiD4_s3Z9CQdnrBq6Lzkh_y4,2072 feeluown/player.py,sha256=i1huylMtq41avtFcDwqa2Yni7HmqxOMsA4etkly1Hig,10736 feeluown/player_mode.py,sha256=uSpRDw8dRSpr-_WlA42IzNkiBfYZLOR6K9RHayrMlr0,1300 feeluown/plugin.py,sha256=B96-EeEUza4YAE3uxNf-a-Mlt1I1qLsIpXV2E2vNY-c,1172 feeluown/request.py,sha256=dyKrjN4lOznZWQOQvsKJMxRMP0sc09b-MPkwBGw1zmM,1113 feeluown/theme.py,sha256=UQc5XLDADlYTMeRSLwKyVluAXJH2blQfspuYcRjGaLw,5425 feeluown/ui.py,sha256=AN30KSXSKCQbp1muyDR8YBANvaGhzfiBl9SHxDBW5H8,29435 feeluown/utils.py,sha256=_Gmx-DahTFiwmaJY1f8izCNmzj0YNE_-_fDg2otKttY,1375 feeluown/version.py,sha256=l5acw5fd7JjNSY_AV2m8dYibv4TsOEKOaok5cCyOSJY,1162 feeluown/libs/widgets/base.py,sha256=A1Ty87D4q1VTAO5SqzIXY22Q3WHLKB268hE7V0Ov2xI,2071 feeluown/libs/widgets/components.py,sha256=xGZVjdo6YhAyrq7vnrdzFjEpnKNc_AA2lJkRYmhZGCg,10206 feeluown/libs/widgets/labels.py,sha256=J2c8h8nFxfNwZFUkgssoTgoKG8CnaabF_qAtMLz_q3E,560 feeluown/libs/widgets/sliders.py,sha256=YwVC8vUmUUAXzEcTVARSKnnL7FmvmiLFshXumGzStKs,1065 feeluown/plugins/neteasemusic/__init__.py,sha256=ZfOOnBQAhOCxhRRu0Htzoc_HxOR6O-pQ_u4AwNH3ESU,373 feeluown/plugins/neteasemusic/api.py,sha256=0Egfw3HRhPz-MXzo8SslG96uDY5sNzjoJhezLcio3vs,10849 feeluown/plugins/neteasemusic/consts.py,sha256=jXTsodjG7Ww7gDwY853qYt3zwcG_slNBOexNh98GgII,182 feeluown/plugins/neteasemusic/fm_player_mode.py,sha256=oN15XMO-0nVCCeo0ZTPeoPRn3B_AEEIVTehaoYZ4O1s,886 feeluown/plugins/neteasemusic/model.py,sha256=D76ljP5aMnkehbeEKRGpSRdQGINsjHx3_UwVH5YyckE,13268 feeluown/plugins/neteasemusic/nem.py,sha256=f-qoj4YPbqlJIxUJsTMoW3Ald0PzhZuCrERsQPtEoHs,6334 feeluown/plugins/neteasemusic/ui.py,sha256=y8T16bmYDzbzH_SBjQ5LVgRnKngFPQyHCYhGTpOSdhQ,14150 feeluown/themes/Molokai.colorscheme,sha256=Jq9tBmkcq1S5gd0SFqYW8LZ8y_Cj2A-Ioq4kSvHJa80,675 feeluown/themes/Solarized.colorscheme,sha256=fFgCQqtZksMMaQt736WE6EzFsikRPJfH5jv_QNWP2zM,692 feeluown/themes/Tomorrow Night.colorscheme,sha256=bTfAMUtDRTJ7EXwa8ZE9W8mc86M21jTqoU5VXUxdjdY,687 feeluown-1.0.3.dist-info/DESCRIPTION.rst,sha256=OCTuuN6LcWulhHS3d5rfjdsQtW22n7HENFRh6jC6ego,10 feeluown-1.0.3.dist-info/METADATA,sha256=TDBtC24753RbvFE_og1FCIOKUw6pZI_V3Eb8tdXj43E,509 feeluown-1.0.3.dist-info/RECORD,, feeluown-1.0.3.dist-info/WHEEL,sha256=zX7PHtH_7K-lEzyK75et0UBa3Bj8egCBMXe1M4gc6SU,92 feeluown-1.0.3.dist-info/entry_points.txt,sha256=N8cihwwsJ6it5eOmxd5iW3EpZCnYxWIj-6zskBCJzW8,201 feeluown-1.0.3.dist-info/metadata.json,sha256=5Drs0Fiq4Bth47lafSydiOtkUKuW9IF5ekgXlQ39MCg,1175 feeluown-1.0.3.dist-info/top_level.txt,sha256=OYjzeGH-M1EteOf-NqynqdD9UKlqhDQGJIQadZk0DVE,9 PK-HUÉfeeluown/feeluown.pngPK-Ht zYYfeeluown/request.pyPKHkttzfeeluown/__main__.pyPKQHWbke// feeluown/consts.pyPK'=H̛feeluown/version.pyPKr;HF49 9 :feeluown/install.pyPKQHw911feeluown/theme.pyPKQHuhfeeluown/db.pyPK-H&Hfeeluown/player_mode.pyPKQHNPPPfeeluown/config.pyPKQH5Byfeeluown/hotkey.pyPK-HE))Gfeeluown/player.pyPKQHgfeeluown/model.pyPK=HI"feeluown/__init__.pyPKQHu[feeluown/plugin.pyPK-Hrrfeeluown/ui.pyPK-Hق__sfeeluown/utils.pyPKvv/o feeluown/plugins/neteasemusic/fm_player_mode.pyPK-Hԥn$2feeluown/plugins/neteasemusic/nem.pyPK-H)wF7F7#2*feeluown/plugins/neteasemusic/ui.pyPK=WHh *a*a*$afeeluown/plugins/neteasemusic/api.pyPK>H^- (\feeluown-1.0.3.dist-info/DESCRIPTION.rstPK>H1)feeluown-1.0.3.dist-info/entry_points.txtPK>H&feeluown-1.0.3.dist-info/metadata.jsonPK>H4 &feeluown-1.0.3.dist-info/top_level.txtPK>H}\\feeluown-1.0.3.dist-info/WHEELPK>H)Ay!|feeluown-1.0.3.dist-info/METADATAPK>HV$  feeluown-1.0.3.dist-info/RECORDPK((