From fb38a5a814a91acd3864cb0568f52c669fe73ceb Mon Sep 17 00:00:00 2001 From: Alex Resnick Date: Sun, 5 Oct 2025 14:22:00 -0500 Subject: [PATCH] Create Basketball Base Class and Consolidate Basketball Managers (#97) * Create basketball Base class * Migrate NBA * Add NCAA Mens Basketball * Add NCAA Women's Basketball * Add WNBA --------- Co-authored-by: Alex Resnick --- assets/sports/wnba_logos/.keep | 0 assets/sports/wnba_logos/LV.png | Bin 0 -> 38699 bytes assets/sports/wnba_logos/PHX.png | Bin 0 -> 60679 bytes config/config.template.json | 60 ++ src/base_classes/basketball.py | 309 +++++++ src/base_classes/sports.py | 1 + src/display_controller.py | 177 ++-- src/logo_downloader.py | 1 + src/nba_managers.py | 990 +++-------------------- src/ncaam_basketball_managers.py | 1288 ++++++------------------------ src/ncaaw_basketball_managers.py | 274 +++++++ src/nfl_managers.py | 5 +- src/wnba_managers.py | 304 +++++++ 13 files changed, 1416 insertions(+), 1993 deletions(-) create mode 100644 assets/sports/wnba_logos/.keep create mode 100644 assets/sports/wnba_logos/LV.png create mode 100644 assets/sports/wnba_logos/PHX.png create mode 100644 src/base_classes/basketball.py create mode 100644 src/ncaaw_basketball_managers.py create mode 100644 src/wnba_managers.py diff --git a/assets/sports/wnba_logos/.keep b/assets/sports/wnba_logos/.keep new file mode 100644 index 00000000..e69de29b diff --git a/assets/sports/wnba_logos/LV.png b/assets/sports/wnba_logos/LV.png new file mode 100644 index 0000000000000000000000000000000000000000..888e5ada63558e30a8060b1b4076739ec9ccfc8a GIT binary patch literal 38699 zcmb?@^;=b4)GdvmB1%aMC?(P$4I&}Z9a0Js(%mH~DS}9Yf*>s--6ElMd8E5by5Y|K ze&2nb`v=?~e2~LAd+)W@Tyu^w=2+*AijwSgdn-Po&h)(9rjge^+qemCip? z;b>?HJ91KwG~83yC)}Jh7XCENOzqvxjwSRg8TfoSSxbEL?j2_O)pGA}1BrFd_xHoG z9}#0^%f(I#2Tgiiy%#U>B;fn6_^p^EJ8GcC7+`%@0mpd@CGq1v5J^YEC;RpSVx3AfekD;se z1rth!iAdnr5K3N&#eMSsp6LMGRxIe%X?KEgf;LcCg5_%+`VUp3?4WcUsI5wG#q(%_Z6TyJ!?e61h z{3tidQQ}{s59*MDr|uY|KyS#qY_xG@gje+cdnMW9?6S>Y+x6c6eWkX}!6wYeDIr0xxexn1K#)Yq}jq%;M_~Zm-aY zf6v0_bdlNdQ0G*kmB=0R@D)X~ct?$g_rFh`6C#0*7TOY^tHP^gW_1MNrKM>BO|8aP|F>=j>C|vlxBLo^En;69;b54m+XI* zq{Hhs=6iH10%6%0nuV?ro}WK|X1XcXQ~HyRBxMpxdLg6rTE(37tWOULa#u7Uzkf35k^4z{LbpFRz*{qg}V|K+m;$t&IejV13o@!;D2T}DR7z2*MzMpwkW z64p>9wW$96jtwixc^2e(o>5@n)Eg>0JM%Irrz@NW2L)j%rHcfNSiAb=UC*qQy+$X+ zeiz#ytnRa@f4{~2(66}h8W)}ovQ#Ce_Gp^c8T+b64RYqVeHMH=?m%RQq6ez@Yd@ma z1f7?s4LAfG=da*xZ*N*2N~rY~T%~gqN5)6pjpi5FETN!q4aQcxhJEuU*_$e7yujw^ z3$?XE1(hqY9uWAkZoIgCl-HzWWE{6=TWbxC%SHs;|DiFej|zF-*H2prBCJkD?ubL1 z$>QH{Jlzr7V%a{t>Adq3Pfe|GbI$6s!$u@5jUzHnN>)sgadAQK^Uvh3_P#|3*`_7*#~`RE))kq7q`Qri-E zND-AP?iLMGDgG~DqSnoebT3Duv%CCk9 z(MfIIgQ*-Zxk;Ekxbj3!u6pTsS6Es~itwgG|8BIiU<0ERJJWyf5U#S-7WO(=7mX>H zU=b8dQqNVYmR&|uL~HDlSG^KDg50O}cy4tn+R$!#{@2`HCZ^oAW&>e2&x!z_Yt=rA zVfu;?DAmudXHxlhC;YfLKOH=uUKz}-@;$FvTCD0B&1DMeU_}N#F^cI~-`o^M`A}fd z!C?x!n`onx2jlmUXfpu2`TCvm_w9~^a20A+QH1>@nz57v4#FC~rk%=@cLZ^kW zv$nRb>B*Q{87;S>5_i2;CTNpWpPPV(PxmbvR@SkE9StWdb}(0&h@3oW28|#z@Np=? zr-!%4Ka>yQJbjgtLr8xgxv3i~HZK|{dDQx3xw=DUAnpx&9`+uCzQ9;U9?AKq$RN{% z(0yUsRaFT@(~9Q^^d<3_hlPdx6KVJ(6M+>>s4I_5mO7KYOX$@b;_~k9RkIRGxpBw< zxj$1Itz8NF(gVx;`M>2Q{^9bN0w)lb-qpj^TivubeFFAa)w5<&w9&jCwg3AzXROzI zo}zEwyrB|ue&W(pX5M2z^8>rLQxCaF=YMmxf?vFHu)Eabu&H0Q>aSJso+0#xS8-M) z4u%FUsiY}6GE?lq0i2s=seVomtt)-!k)< zB_U7BOsSmukA#v$$kmR2&k0F}G#d}?tskGSkC&!BtF*1joe1?8yrPLL(C$7) z-8CG{($fC*(Y*7`1K~6=pYVVG?0Y9Sm0zBu;L~ZuzGj!|F=H$@NkQ^rp1OXGGt1!A z&`QvQXBF;BwGXjbO;0GV(h#tzQIIvZ{CJq;med?J(AtE5?;$Bi}sYKHcd~=9{ZO zoK)XiZ>aYQt0P^Rq{_vVB*DHVX)1&Sk$xar8VqH&^(sWHgz8%YYN5VrC-b`cdY&_D z$kn(9TkRGKDVAH;uvni;U5U*@=81;{cl5QD+i9^u?UN@@y6g$rD z$=ueTB`e@}kqU{bIUECO?|URva+=Z1%8*RxxVUF2{Rr+7_lGLSyf;1k#oc@NN}ZMn zdKq~h_&bKbhEGHxpGf!26Nd_7JyAmvIv6{1$I#FaDj~zh5(%qDrdfA#_^r`z9y|~2 za>`#{M@v^BesU$&^1sF3!&H#TinWT3UH?my!~A5T#wAa${MBf0eSQ7Pcy-w)9Wr+P zX}D5ix7p!`^ycAKr7N+I{~OpguIFnjp78+8(`tvgkWUh#o_qg99ur7>U^fuS?cp&U z`%@&4rsUSeD#0Ti0N@5G=YsMNGb5F9TWl-KEC+Fc~Pc6aH-ChG;+3AIZ+=%fe$jfSO~Truun+lT7#)g|Ry+0wCq&;7MX;n_Tpq&2#1Pe_Rt;_5xF2VZJ?}g5AjNv@>(5LTNCk|0A zLe%wMhg!sAW@V)gb2x`rsP)^|C|HpvjYpO*#8pBtO(_)0*C?MAF|OxoUg(zQ+GM?O zLqkLO?AG7vzyj?O_ss;_Pt0!#CDoCw?JG2mZEvF|?6r6;G=@rzm{~yJC}42IHb(T| zw#(Wd%{`h@E-ve_itgPX3APfJ(#=7H$;d`ND7eatT5(>!jsyEBl5ZrRS*0`K0gK-E z<;O<3gDbZ=jr~6zqW%5uz#u30W|L>-k^eVAZ@M5eWQ6B9=w=9n(ukjp&}k~KJ?}B& zJ{-(bXL|kmHF16KNSQgnmOG^KqrO#3{*hC?tU-N|FuZTt=$Rb;tuLN0^-xP0MraU! zG5z)KyWN!7L{QuhV!__wQ?%Ypw=~>!^p7Ti;oA0?$FVD&b`b$(b9-Rh~L=G%*hcNn9_6v3E z@k1HrP}vwm`I-?jQ4}wa1dWW0TFnR0D?)7;gib8BdwRpJQE>3}a)2@Lb=fvh*3t(yg>D znw6jya?W&K8OVQ!cm2BN**cO<9Ou6-G5at(1}TQIArPxjJ90?1@bt?l>@Q+mN&)-K z)u0;Z6-mgaB%*8f0y<@$d}m%4qBaMQuEe?^Q@)>$Bk3$eEhi^eXprUYY*_wE6LP|5 zp^vYzud+5UQ5(ZL>I=B_IJKX4=Ep%&4MP?=ftDs^1~Tc-QWIGc+_k~nnAzIyEnfG3 z#;qxHw{G2nkjnC^6qdMpXKrjWm@phE1y=iLtgFsKR0Ozde~MJSHM+;fo(+#4rmg-d zlHV9DClPP-Z4vtPskk;BVBqj|bE5XXExr>6 zRLaefPp8X5cDalvz;6Eo47aT%t$9<5NETq*JMNtXlpf_-X-#w73G)vX#H3S+h$q zG=e_@FmXlPx*D;tUcF5woJB_#>zEbuyxB#=X6%oRl#HP!RiWSI1u>hBO2PQ1<#ON0 z@C+BBv_hRyD%jf>YL=NXIF@*HGzei#vb&vUV`w)~)4D^_NsPO5aA5Xtx;gott2h!= zFv#$Xsao?Jve8uA`tp9%|4?`M=zjVjso&{6HV$TDH)(~<*zaSfC1PBwi5fn>S3`Zw z<)>vjZ28e&KWAruHQQ(H<OBsRX?fvsh)8 zUjB~%sGLza#ccRd61&D(RqRWH#~R~(E%tT`-cL?Z18bNY-+wD#+2%l2KAOy*UMP*A zcB1D~YlOYfdxIYs#WLK-yGtnc+G|I>eivD*$NOu4Zr;2Z(9ic2g;_#3)smbkJZdY#zCDE6fHM{v5L7B?F&Z~Q0eh*uvBaLBLW3}@|ir|{qM*k_f=!qczp zssAi(D;eeoJ5cgjKvlH)^HLoFdt-56FxpgIV`C{x8GeRYL-A_^pRYmyYoDC~Wzwm2 zEm~r(r_S{L%Mnx+0h{SN9~SeCsy{uK$GD*_#R57dhV|dFW4l@s6&MXYSMF(YX%*== zms@Fzq6Vg98gTJGk{`F<4RV!ylJ@jqXJ>Xg0CQbD)7#mWJL%hZ_}Je1NL)LOm%{cg zDyz86cR~p8;IqunIC*$W*pE7F6eW zoF~6qstdWSX6rf{`JMAI$VLyb83tfryKlFWX}13T>P!8Bo}M#h(DsEF5vTa{O}d{5 zLX%7z$ewiDTv%xBNfZ9t=$Dc2&ma@|Zyc}qHw0r6zYWFiQt#5)@ow@@tlJ{)U-PxT zJ2*J78C3jqBK>reFdsrhBp))&#~xt`UjQe3f=SYsY#Jnm9=nUqTayMUUH*CMxsGdp z9zX?aEor>S*ByV+8lGqCUiNC?DbSYS)_44zewhDMQ&I?HSYcGm+SlE2^sSbjRoW~Z zZO`RnH0=L*mb_sd6B#)VA*XbD3|Dz7B^AV2if-t8{PJ~RAc=TJYHHWF&+>gM0}auc?iDR@ct2mhcJ_kB@{sPqNW1PZ>Dk~1e)4@5A7y@ zLS;na5bfKNo*jfncRmBRI=N0L)_v(?DNz2v->C`Z4#5y1Do}UGRlb7*^7Y)3Ay)qc-tKaIBVPg z>GJG0V_fk20xkRfRZUIwDW5}wZkeUh($YxEhno$JVh*!Xin1%94rsctPt4eVwV%Ow z7gw~r?`iKc;U!ye7UZshK}?j^7requE~J=j1B328Mtr_YHqNM9aP zMls>x1J3b=nG)#36LSPKA`&`(wR%-gj58~K4+Xhr|92rA3>UOWMxskL{U_Gek+a4C z#ttXFl*^o!U|zkam&Z3Mr+f#U+fT1@)JeR#bx)&JhtrP+{^s1PafG&oE?AIMPbioO zyJCRjwsvf@HNVrz5DR~yQ7)@7h>I2@-(ZhdD`beJ5iu!=?78S|x!)ilSWI&p>V6j* z8d~*&NF1?V47?$xlA)Ooh@RVZ z{jYPQ@9fVv$`ISnnNgwYnwrXRoAS{vo1dMXCAM)M^o*Zu^z&OCF34qV zTh+WT=(PCVT=f#olAqz{W5~s`Gs)=6=sI(wOrYTW!f|KVGYr&~P-r^m-j86-Zpt1@7(_E!|u)B{_vS(pw8OsAYT z#AMCuRt&2{6zLoMSQgmDiq`TU*t90%7lyTsG<|+{4#Hkd{#d>%+}hr5ZoxCr0+d$l zbe%^t%F&vilE=LJ4hvU5H51{HR2Fe?M;xr0gC8AK3xoC{r3|zFS?PR?ga^+OJ{bAx z!thY>#}GvYLOpvcgM#+c{y5~-Lj~F~eJU=f6k1`=c&PWDhT{Xc7A5=->2{E0GCnmX z!E)&N@9fwwE-6bXqq4Da=1G^@`N5bSa7bO3RKV4cBaJNH_BT#?EKv_PqOf%bl%2T?S}^`4-4@Beo$|W*pR! zg-lkZp#+(IRF8IvVe1>BcF0aUznC<)&JLVGEN4_sFVq#OB)PBk)%)17dzOS*#Xm;u zX2q+1R48R_kQv8ti84l96`DP*p9PCCg?^W zysN3v18UN&zqtu1`0m}iQYq{Hd{%$Hx^KVeo}K93o@*!B5Al1;(Rik|&sA|xp415u zqOLn;{i{NUTAl=1OCJZ*rio9w&ao9AHp*vKW6*@jpTkh`|1+$-fP%s6xg86G1I?z2OZ)Ebq^cJSFEn0iF8)fOvb(om2%0 z+^=9AWy_y(yk6EjE-g#scX|HsS%vkle{fOa>C$#q^P_4jRPi>nPD378HZ#1&?l4){ykd2KZYkX>I!7_(RUCEkn(BL+ zOZ;osvK<##!_(7*Tv%(}wzT%v>yM6)Egk>(EOwDgNlQQHyggxbf`3JYS|>~&_P_g3 zIYv76SDm`Q4L-t@4{cRQzCMYi_dc2l)tgUPg#A17x6~v&{efYf03a~!J!?rx3|gPP z*wuQh^pm}n$3ST{TdO?xS)G=;+N_nv1dZ_eHeY+tInuE#&#ErsMq*wA1u8Mo;v!k> zdhTEz>#{7Zk5vCBX+Al^X<$L#yWJ1NGdR?9*aD6*e|_M{__PF^zYBm1N#kX`_sL4` z(dlVHc&Xz8{=vprD5_Dr@PbxF*2Y$HUQ4a7HEW$ZQ>f}s9a-09%2hIsFZh3JT&zN_ zk$;l5wG=y^CjzXHv#blD0;TH;K5J$^_8Q#(Cuk8Q;(ocTn)#eBeqPb!T6Yy5%vHQ6 zCN}BAKY`&LvldxH7(<1OKoi#;vuSprwK))1vr=RqkZlAd zJB_s2^s}aCB`6a`C9}5g;&Mt#S|rX#|7M0tjEv&2nq3rZhQ6qRD9946`tilRGfa%H zdT}0gS~i-;k<`KofJO4cXMXke#*{Ri{GP}#DD}HI4vk|{a^Lj4L~~cL@s*tCMd#4nPK;K63RTbQlyb8ko#Q|s4l+C>pKO(n zlHqd(jlKrE-M$b`pMZMizqot%C(lN2-rq~n6dM0E%#<2se93ukYZE(5we zkKN={exUF9J_-vPJ1=_9b*}AtTO>J;k1C8V-4#ofcBwsDC1b|$|# zog*d^y_qe;5{x<}s5z{QRH5MIWed$mW0iKjfHq2CzAnyom4_?yhKruX{3#kUE}yKo z5^sI=St04$-sWz0{$Z8t2E|t6WkddLw|uQ4imm#?^i=`B?!>mw@;Coej2qBdzYwrV z5dcLM!N0(CMnh?w0`wUvHL1<_2XPaERBNzP8#ZCva(|X)E3@AB+mAD7LzGGy?P~u( zHLG#mP->Bh(x^wlMCg9Eh=p5GaJ|4%Oy*T3VGt5ZX7M{C==q*^_@j!Vr{hpKHusm2 zeJn~7P3*Owom$E<>e}$5DZfh}c)Wa}=v*$7wlB$T1o-%i3m+~YfBX?Fx2+@G7}qi* zQG(hYkmpb@GgEBcT5=ZhSW0sXi;B{uno|NPzSN}Mb5l#(^z?x9Pm$3Yc`gIze|yJN ztxwv}4BZ#&^?0{@&bi?G#Kha0k5c$;ey7+qBZ-#ckhYTCDv9)oLrtyQC_3jVY~4C%`Q*=(3Yy!bwh1${XCL_FB~*Jh({u# z?hN%@G)wg@Y!er%k(Arn2|VNb=XoxokGY5bMpvJWRBZI(Cc@{u3~sz=YUgYJud20be18?vZhOl#HE zWEnru``%~D`gbsvL6$hp_%wt_dTFy&zTv>z3 z^VySE)7e3F;^5!__R}M4B2xwxx3!h(4kKg8!%6qY{h{Hf+q=6aXGhydM@L`6%iOjM z6WDblE8X;3l0vf6eF^DHAoJ|JGuB^jo5%K?0CU~kgd-)+(( zl;39bOL#1(26l0bj`uGTP9Mk69O0?><`xnPf>0f+eGMJwb%D+XbVEPO50$H6AFURY zxUZMCYqW3kzWhzR*qc#esZgnTU;7)NL^je+A0rdc#~jWCi2TytR&*N311T{38Fn+d zjWNK!M)w5#F+(*i|Ys??#%dha&e&P-}8epT77_+ZdM^z_Uiq=eDVj zWMV}Vtvt10Kot5esh{OZiAXDkomT6`9o=dW`&7huPh^4aldpy)vZ2v=uuZ=G=9}4B z=mhvQW$`XE_a=i3Avt-ThraUm_I6hqPl{KXaj6blp;2Qxtjo(+1ZZ0gr|bZ&hIkkI zK0aQVYHZBE?OK~Q#dO&Qz)qyL!zW}d@FQV$0*q?R2ZI+|`(f2Rd9t#Xoo1To(9lKF zYzSqcNEfVolCWEG4%e1B2pv>H?*A(C+yG&L_2!bTD|O{U*r3Mw4a(4KBb}CQ=m^*v z(%8+v4Z6+T;ZV0%-s$z%{&M~qqh~=K?2ut?53v3*R)rq}4?31#@Nn|4ob-$UEDpF~ z<>wosI^cWBZQg{rM1Kl(fqC1PkeKkN;Eh#8-PbNQ)ujQvK8G^$8|F2GR&65Z^Vh3U z;li%g=D%j8(`h|d(T(!EmBZGd&p5Aj&tXG_|MoQ;+sV3LB}9yG6Qd*KXtFg6v>-n| zjl-G%YUIA~UONnA6W+}hko_Y%KX3qG2E+lzh;J_oYDTW!6FZv51n)($WfutKJCG&w zqj!<`_M8t}@;r%<5JwUY4l*v&pPF3}u=y1ji9pv2jAK&%f=_Yl){hwRi!U{PM`^w% zD@Fe8Dc~cI8|j*?_u}QqUnzH9QEQ8#g?#}DhEXXM2!OOkD+n0Z)N{`Q znvKDC76>Y)oqt8fdQb;a6>SG^#ylJP(VIR~DMzGfSLeA=j#?h8EFnGq46!{MOlEk8 zon2|LirisuSrw(xIQVPkaZ+R1`6D1y-EddFDBT8c4__dA-+eHi7U`CSklau3>gnOd zY*0zbAk>wE(I`Hh8GU2TdHWj7{Z1!`xD(H2PJU(-1>eBD_ih@%$rd04d@(6ED{Uv| zA&mg*ymj;A#?5cnmK@heIqLV`t@;qStwNg>BEJ}bi_$r)L9`d?J}P$KIW_`#WOeXE zPIBg)!4=MwE`<#KC==P3e0;sH#*cu4uX|XAGD673u`K<4FW_}>jf<;azt;6ZtvVeW zTO`8AcdJ0FMZyPAF+3uGs+(kFq5T%le#c%CV=lMpxZl8DdKQVL7g$if77|6NQ=?&E zrCo2>TjemvYqvo`PW}tXL?Vlg=6i%EexPU1+f2=U?=aU^`f8X}(Ztis%j$c&F9L$n zYtoA`ub}5(9P>U=4^sg9)jWXJQ`jRx^YL8w@eNA8wcQf?a z*X*8i-5gI+7C+Z;Nfp0H>;n5h=~->M7?r!Bi!mK{EX+q7HkuzA-Da_IUuizV#K1`8eu*y~K{|Y=)$blG z6TrZ{bis~m&^P+4ruI!Z%F7dyyrAQJSPmC7@N&8QL;q%d-}` z=3O(M!R~k$%37``cFbw_4d{h_w_Gir3lU7Gc=r>9=&^byLgb)ZA$UJ6#t-WkLneh?>G%O zsPjb>J;U*F?sk4J#|?7sAa;Wq3s0Xs!xHnBkn0E^NpAHXvi|!u0A%moleLlpTf#XO z0f9vLl?VDsnoI;{7kq&OpPvq9sxsV~okJJGq>ZalRS!#*%-tUj@fhjSb^0b8Ec*!C z__~T@$wM@PhO=2{okhN9H=qb?&Rp4k0Qp=>3JbcZa4X^!XQ8JFY;O!pQ0q0C&@1S- zxGN%(O8j1)AmDg+w_nzTS@#<;%qN zC5OQnI^)G2%PTp$t&In$5~q?9& zI5!Do;#K|WMwLA4{DHk&_V7NPTAF}EDwL(`X-Z3zcb8(UL=E_Vy`g#pxr1dABOP6v zOB`IAaf}!FBYOpedOD4^)K?dK+h3G|)e}1rLud0co>2EOyc@3uJp&yGO|i5L_eH1! z2uY?+dXnQCC5<^{K3=!Qjt6R*UC}O5qFC_IA#043kQN(`$kb2z55HPyr)?*?c{6sx z1J6Mj$|-I8Wz&i}lc=4U7dC3cTGEsf0!EmmmCt~Dc{J8~x;Ib#Z>92>a9MDl9++mu zqb~#g{?+M?4$MGL`^NQPI}9ub-y*ORuV~=@+u3=sJuA&-P;=L->=u0*Y=`b3JaLoC z5wHTGiB&0CYIIL_{mgnAUG!$1n?O5|0D$)2Y7<M<+VBiX;j ztn>AbbU3lxS~m?XZ7*za&GNO03YYuA_@uK*&5xhB3fK#!1^cf^cuCX}$Vg^`-!_(d z659|B6@36%X1nFa95IPtU~>~B5~)C(>mbFNHu|xk=A)n9j%ew>g#Xm*QK+jGJ3%dg8xDx`pXgPTV`|6_lox4G{6P|D1 z65dcD((XN+^sf-L33f+YA^w0qz@kK?jLw5r|I3sfXtJ`{#gpxhZVTRTK!YeA+A4s} z4TMk)s?Kg}{xXkUGpLVlW2g-89l6Dxw9_VYFPH~JOVH{VPgPnHjWC;P@JZrfn)53O zCw=f6#4bF1{2w#+eIS$r<8bIj73){O1EPTbZ!zV+7R+}e5RJ$|Fzo8{$`E#o2Ki!Y ze;?YIoHnDpG+yhaV-3C~Q>XAUxtO;A*v#CQPkk*IXWd(&H(;87;Uf!a_t*OQg$A#ZBYipf|-j}ag0O3%-*ng;L7HR zLkSoXx>6p-yt^llQGD8C-ksbEVFo~x{_fo%oa+>v_R|tjCEmK)9hX6LHLdL8ld%4S z8)6WGkzg3nWVl6kyLiq3O#?1HZSe4gh=n;L0%Z$Y%{t|;NWktr1JCX5<)!{f3J(&t z95}!74C{qJuCKGBxe*s1|1)N;(R{w6Kb~E^O%!tngOI>GPv9#dIXE#)-+!x;Sw!EY z83P{FRBRwzCR{Rxg^SDMIR69{ zMTYe!0qTc0xE<#^zQ1U2S<^Z>*ziL2w?K;p(NGx`lYd523m~U#O#VAYHDHSM%6lPNf00fCO&-JD#P|N0gKEEi_G3A$;G( z#UXCEQR=1Pd`*VCcd_ghU6`1dh;H9bJZSy_E=BOpdG!=JFZTt!>`eGLxbnB!i3!vW z26$Eo=>7_uXVcA{cdnAMWr8x{Z`F1ODh2erb0+El-wA>~%%qfR47($FU+jxPF1uZW zAV%59x^IblCR4F|XnZQ$0J`9=gz96ZCGuKBQjyLb^#43)s^eFe5@a;I-&O^Y=YJ;et$6n|{c)yaN3? zKTgw*w_^=jb?y$3*d77X0W0=xFdaP|bFO^Ria%Ou=Qrj4&~`i%>|kyWOhEC0X_tZ! zexjg|4ZGa(!(Kd;KvAy)LPEkOfLxXk)}rP#um&yg%$5^1`;YznP$fnnF-|_;ng6wN z;^dB~!8~WJAHZv}+4$I@i7TFL&wW~Jwbu@zAXES>kyn=y_*vX$wvi@t@>6F?mtkdhZ{>!&RV90 zj3QwoU2CZ!cHh!HMxZZreQWQxK_S$I90APyqeh+@ILHWd2jBcM{e+m6_dOwBbFpd{ zE5I}yfo*1aq%3##z5!w$(>C&Hg+Rw?f)-~t>;`ZRJLz#vRoGC%-t)F7#Ka}|YSI7c zwD{xN{)v}uraV-hv33=hU3@b3)QTS8m4SOeV+Rba%0>Xt(BHXpX9EQncG2#ldmNIz zVFI&i3Pv_d=@Hw?xWvRK5)x?OpId}B^unOBc>Vo0u$@|tRXjj)EhKc}`&w)^#~-1p zg4nGJdF~aeSP{%JM^Kh@YMk>JbMJ!cCTDDX2UGGfP|>y(=M&6*OD>Jrdf}EbA*uzCPen}+_@3i7uAHHnS|po@Gx6`0bDiJPBY#++lgZh%>@gB zl8sOUzyha2+z9xD4U$U3V7HrI=?iQaN$py?4J|D#Xff?T95zp_VFA%wEm;qz@>&n(|3!041d_aJ22o$()&<8AQEA|x1H+`;r3sQ8ZQh| z_MvwRrwaU*b2`D!(_dz;j0QQo6=27}e!~iv_fdrN%4k|Vu}dy$&(miVm*P-H=I};n z%Nsb&)63OldIv}Qx%d?-jOg5pEf-BZv7pGlvXIC=+6VjoTK+vg4SIQdq+<3(^)iO_ zzc1T-3oN-B<1#X6!GJUkMm9@^HDSO^U_f>pdopOv4-A3!^LaX=k~%u>82E|tr$m8q zTJ^hyD!Ak&GQ@n2pzCAnrP%0uegriS=n*;qi+N~|z?msHLm>%`A{Nz8SUx9va$^z| zRw(vXMXPc6!_m~`Mf=`Lv5&TTjsl(E#TgpZ7`h4J6-r)$ zkMFhsJR2VXu}b>#oX zaxMnu%v7||Q_$g!-TwBAnr0S*=%}PLo15V~|L^1D@To1|NErt3E+Ja;iZ5bb&`yEuDmq&*_Ol2co|5+PHCcu>Z1JK~sY3e(t<>^;XoWT7u*6;@$ zguVa^oudk4U>PhdmJJ#YAxo#i1weTdaNDGUUJxBU{cQT>18|H%`oH>~RTCo<;@W!j zJIe+iwA@%|z5g}a^ulQ0CMJe`_&|1iQU4$cJmnz-)X4`QnICc{DL@`a-QeJ;tFObm za_x=)bK=|A*m!t&A0+}Xpg%shwWSQ(mND*^!MNS2L+BIztfPe{z3D5(d>*Z)L6DDw zE|!_)R`n{7(sHuT_N;-|WOos5S*_N3;-{IP`y;n`CH*UI1+G1iMl0NY5+i1AS4=I)Y+0C{qT`YA<6hwAvX8e57CJ4m7VBt~YSu zy}3U{&&nDO&y@l>pA=N>>E^(ZIuEB&D;QD)?C_1T%3R33SU5O2(Plnqs0>|DIiS1( zd-AQaGf7Wny>~A-K0e-S^U=Tgxw*&o_MDIMf*ZiY4zws?diu`U+1cO)C3IP;a(97| z_PmzZ9U=_%SFGZ%w3U77K$avcvrPWBQ+C-7-DvGU>o|k>nLo6YU%Dxc0N|gqb<7It zx2Rhx^Y!)B_@WY^_W3FJThUO=U=`Yhe0$f1%-wKdV?z=8$w4Ll4Si!x7dca4wElIXvz^U2ugv#IqZOd}<9> zZScna{eeTa`E@W9u^+~q<0a-7=*C=}L?|KenvN@?R!<)F`s?fW)d!?WX#Q`P=7W)U z@yI7o{qN?hZ6wh1?LkpVU6LU%|tZdzM~1*P=%M^%jbIQe4CoRY$y6NT&#dH+9=s+>^ZtG&f(f&~*`u3G{j?>VX`ZGE(w$j{I3_lFXR z2+{|6s%4Ci9zBA_8XQaoV!m%*?lOt({o3j$`?mn%y}A;bK2m+FNXkDY>ON$ZgtPs( z;5<&cXe|`xhX4Vy0KtE^7zqbzgRTVqa0vrrI?k)N@lw0m;%Vj7`gR*;;J_{KEfSJe zfR$y!v~UoY^ybY+Qa`=53U!oPLZX)c<`I#wYW=V+t9}?J7QB{v>K8!k!1w*8u7sS? z^H?w9_VTu1U}Iq2)35jBOHsciIgbrPDSTF$pvoX_`gzg`49I7x&iqvDVj~+voA`6I+qNpftk}&$*{UYs6P|Y=H_OY zh5d@j18|K3`yryHR)p3IbXVfCf<5A4lR{Yn+XYbHh|0=CoB9jdyMW$-?E-8ETziL( zF5t@-X4}cGi$AMiY3xa>eZhIVbmU#Li&siyPgx=?`HJ}TtGh%u9b&;JBf7pXNhdBY z4mQgUu(m0I>I24L0`M-~fr5*Lg*ARiffBA}ud8kaj0P3Y2(8o3f`2aklQIKc8nfKBtiuDcI^}Vf}^7s(%XHtLFd*PU( zI`!WFWImO?`{=a+-jkDJXJz(caZHy7U(GiQKtBPoLfGcIP82A4@zBf9kB*}ja@*{J zw#Sq9XlgDBr^#$B7#Tuh;dls~WRcvq3;~>93XV7ha?QoNaC#Yz|G=?;7n%-G$2$Ja zw0Hns0AgPyGx9PDO!Ua1q}kR8T`-3~fBrmt77i$gQ3)o4;#GU-qmT9qvoeZ4JJbC7 z$U=JM8SUF%V@^Uho&?Q;A-;DWU@zliSwsUC)8!KUNi|D`&8Q*uo_sUAJKd}epVKJNN`qMQir({EjDEomhr;*jvWWX=!7cCQx}{p)wz{>|%hM~8rD==k zm#{r7NjZ2HdNVSRUJxir5W;UKR!pD3@*STaPT^g>+WBv^q1$W@rXwj{x2F`0qVOFH zB%%d_D@Wkk04vQ+5Y~nS$_l~lLN4xG_sQHvX!&m`k!O8Ry|V*);+L1c^d^v;s9Vte z;0QFh`k)km{U02uQ1tZTevU)rB$&un+agd7V!rNVwIER;Csh3HWB76f=Fu5%aK0#X zFq-RDtBrKMhfX8Z%}6T2TlHdg-fQ~bOz{v`*`D=q{&1dVZN<{^nXIXEdeKhX)KgU| z?I~9_QV!_fb*`xA665lubR&Y2-a_P3NWt7>f!@hz))^ z0T+O~E+LvQP~PzLa~Q4%bZvQim`}mGhIqi-O^i{t69D=)MJdV97AReV7-6K>KT3o_ z7vhzo8Jw@lf6Zq*p7@`0G6^@Z`7J{uou`Kfq7NXZ-lbL5>SxgZ0J5jKXKl!_4Bucd zgGiaa#gM`C|0Fwo0xhQuK)rOY7OZVMZHcs_t?W z%^jyRM^zcn@h?uNE?IpWtSW5*%BCveN)Z3ENOkL)LGIRzanD5vb&u_S0*AIsMFn6Y z=go25+V#!prKNVz0k|Ld%D|^U*pi0Bir<7ux}k);2en#P^IA%Gjw-*PytQ@Vr+qkh zeX0hkwL%B;DciFOh3$dIzKQ_i51JNWW8 zTEL_rl^~ds!t6Z{(xL8u_X8x*0!tcTiGX{NLP-P!p$xR`h$xqJqN_;4e(0cPUb2K&~FRBRm&FV(zAAd zRq_9PLnVRlxk-Xmcg>jp85|(x6n5LtAmQZY-K&`FO9J`o`5DC7rt-0FJ{VRaX+-sQ ztNQ!>`*CH>-ybcUj6(@I)-jlK9q!xNXZ>nyl_*2igW7@(B?L#G7A;<(64W`}xR58{e|`@41-vfhQV2O5(P!aM zr*5OK==5q@U!g7!G-?##Fz}{VV9JE8cDd_hwG&xgUA>%w0#+6J85|0ML*RSkYl8aS zrf5tN7rJ4OTbIW{3q}sNrF0E^gmeo>3k=RuizDOwr?D!>o_YO?s8TbN_e{eK89n`eYf%e|&%h5M~a>z|Z-KQ=PC?5OLhHFB2Wi3EtB^`>-a6_2kvb zMYRFoL>P1qz=WWX5Z0P7sR)xuIH{PV*R|yg1}CJ0Pr+~90Rr=2L$Ry5SEcO)v|}AI zgd4G$t+<%r%Lw+yEwAKdIpq%i`Xqg6tX;}8@}iW}d0C~%QN9w0{*>#2iCKYGQF4y# z&ZO6tDAI0&N8*j};DGHi3yR)nsAyej!iNCtz~j1_w&9U9{7O?_rM#*xA7~ zkwOl$SjwXN#L#g7>)khSL-1Sw&1BZ9-dV8d%lx=hy}Q!Ub3i2vaUDl{bfZdkN%n7< zS7pAxvdFeng?9@&;+xS>w#-3UmDGn+R#vVG0R6spo6{efUTT4b16#oJM2_dD< zgaZz55cwVl7RDa~b6y_Iy@68Ss@CR-Vx?i@=2nXn0Iks&YCY1bB5&Z8y$bc$WJ&2| zidpCT`?7&Jl|^aFxI9t8a|nj zDC^)PUU-Ib3FK-kZlOzX(;P!Y@^w4`kKBhH_>zTxptqLVO*O15G4(xCnla@5e}};a6nE+U z`pN%O+j~Fs__zPVC5mVuLMgmSp-6ixL{wDTQ+w~dvy}!R?P#e~w09X1sWe`ty|?z( zx*w9kThJ7pgk1ahOJ&VKTbp=_!i0onpKt zeeMtUO;*+7mUpc+ryAsvD?dEBQb~wD#?u3~toqUu2*pI(_u*J?|Gk5o6Ih{j_>+(e z`p4wAPhcx}3>JtyBS-@{_AL_C2K03`&!7I4*j&4aE#X$>NN2GCO0!1x_^34O@7OOM zVt3+j-vC3y2QW8Dr8wGal? z(3m_LqG!*r6wS8 zuvvxy;y;I$1MlE-4%SqnCr)@=-uGUlV|-Myz`v;KD2hN&8}gO^#X9_qU0Tw<&l}(6 z+E1g*sp>M>;Z_$Yg?6hSrX|JDLxgkz$78C#E8IfI+1X(%yZErl%Xg34XIyg>&lbdA z)#Rx9Uj-X6B!$Ue)p}4}vfy5^^H6ug))ZJ3M|;GPdDj^eOi^yFHQy zh1x}YAzA6c#_%)J#f5e1i95``%E7e&O?)70>vj-~eav#HIz_?|1}o2wF)bvvK#gCqAl&{VUg zx4Zkl1rHN1N^}E7E_N1h=hNR$#=b4PZ2kGK6C{Q-cR!M4)R6AxR4Kgu4>$Hac{~}= z`j!k`gI)^h%FzYzDJg#+dZNB@?9?p-*+3Q4*JuDd9hk-y;qA%k+?5);qQwR zq!L)N8nlW;{rNIlvyL4bfcV<&%FiEP+1lk?z!_ZK5=EvdZ0G_V3+$5j{)l0gc6({oxnWXmmAf z1(_jjeMQJxpa!^EXm3RD$T?<>h^^6cP7X^#nJpV5sy-7F0fC0?41&If+s$7_-8??L zcADP$EE!(~UuT)^$1Q!Dc#aq(B%H`6^Hh6ZsI?aI*jRH*k`4a^zmQo&!VXB12BDcS?kaI(mhcwUbbw7L zt&i3fyP?6iU^nkqsXGm=Y&7hc%+@2w)qnlXrGLMd{WgE3kk?-ZR_|%QVYw>@jrk*i z!t?>bz zH#qMEanll^x8OBvxVPuTHPhLt5pN(dZbRWF%P>)%U;C5zXge~upvFOCd^V1EiGAc4 z0YnM?pg4QS;^J=rV81_j!GnauJ0eGMqB%WstXi(g6h#1mX4Enp@f;^&$AH?U+K&LD zLcN98ha$Caz{z})iazA|^Gj-K2LLBw7iU2@5R1>69d7Iuw6ms=JsD@K&n-=m(inBU z7WVN+a$c;fL}>yb7B5rrU8Bo-*+V=09iQ^eM;S2C$Nkg$Hw?HpA)(|ck%1BUC#w~H zsC_psPIi?>HYSrgS<%ydBk;p`e%;3g#4lzok%llX^-rXL7{{uAoTd=dn*+MsJz6==RgY=@a|Mu-0@?mANAvhvr_;UIjVl$548sa2F z4euRO{FSXrKYbJ%QQ@>LVFeN%!Q+})sEOp!h40CSfzRDeIGQlGh4Pf+9ust1>eTZ( zc91G%YqQtUOt3|YpHs9*_`A(*KoS_4x&=gc(8<`J#U|qGYir9R@xcsY#diJD#&t32GAv+h z2qq6*Z8_LD^F4C}p^KN<061mIT&;+US3eCct?{oTg-rcAVBK6H8&>W3JD3_D!~%p~{yXF_WlL4nD$q1whucwSP*5A)4_O2{ zh~EWvcT;^~c17p^)r&$ghLmrkTNyn?RQVY?rKvJ3F#Mu3rbnrc(;JeDy^l+p%Df~$@_Ro${RK1#E9jO1aQ1_SEVh!q_!uN+@5H*z z-W(t#1R4JU!HQ*Q+boMG^8vRL9b77r8+v-|t?9V~=MLEvPs>DVyG!jBdKlyNm26|u zz#QhA*|q0sRdJGcvz)91(trpN{sF*$=RFx0a2mOxUcigsZ@h9V_15F>p(hD}#yOu2 zPu%Ppq>b`)Y`>D+_*BBjn?N-HfQ%qK2Mpu&mTV-5=%b(W1R8VAduPOs?b~lU^tSGmvT>P8zOtO2d?2^GN#7N$BFE`THo zC2Y|wP$U45n?=OI&4jDFNi(27K!|HSh@D$l0AEoz=h0$B`o6s~VZnmZ^!MM~|K1-s z2T1I_Wvin1ihAeQmmrRy*Gd@~k+ELD#yN|>f7;y{YI_20J!CSVpR0kY^&A?;L6icK2@dW58-@-n@C<{WCdl zA&q2SW9zVo4<)E^_R8yQMTj+e!Dp+?vp|PI+3me*RisNGXpn_SVQ(c6Rn$X>bdhoL z6F9KzR;ErgdXjpS;)i%m<4(mSBgW=*+g=bEX5yQD*Plqo7r%Ac_OfF|7-tap_?QK2 zktAoNs0`I9Ee3vV1Ol>fb)SQ6=hOx~RP3kph|3amE1J-VVI%%Xz?jWeOv#XqG)VtJ za5ZGk`S}9a%CpOcq0=*h!kDmo`5UJXgm@g7Z;Z^=?^*r zw+N!lk60u)qj>?JK%}zg@Zo6Z^$`$Y1#WX}c;MGoSLvCV2@x;>34#bAvgRPdGWl^? z5&)~>ycYfmh^fP7XCC>k|u)Lh@2c{qz)8(#2IfV z$gFPv;X&hQ0v-gEM5=aAK)TLi^Thi;Ww_jmSW#H=KW7em zL#681zst+?3=9c&Dut?ObClDAL9j!gu!EMCklihLMXyrm0$tx%9;OI+-%ZbiY|s1D z(^FIx*}nWtxBhL5DrQ=oI_MJ#o%GcjKW%XgDLM&%I>^F!qHsSe}xQ5 zRAnA3_}B$*O0A<2KZ2FN&e%vwki8X2`tR0L?Md}K#q71QMqRI$vsg{y9FuB*5dIM2EE*uE03R47yD>B?B6-9TY(J@ z46D(irBUQf(NeIR{zXY{SPd`OO8Z~S?BjD)h$6qOAyji1wVbZw8<-?Ag05@%-hBrmT?uEs$T=QCJE zgD_O3>gQUu#DJk$`n$r}!^&yTFoyEdGv4n`MxZUn8lM5B%)viLoRi5ZD2ZrUWp2ph zKjBGcoO*4{f7tc3DrMI?4kEf#&ZiK!^#qxhp)L`=(4?PTzV<572$-&wAM|!FT*uUdJ%Z z|0EqeusuUR+o^g#kd^dPdU*c`vGlEzi|9J+@eYvea^`V@ycG}aM!*~q-4=M>K0Q4c z`4M=OGe3Fi-eY?@GjVV62mp{ioJp*N))KuEYF`~vtS*Xphf5O6KSQ1>(c>L?0~U!W zFrb1IY?(|UoQvpfz&C|M*GO0mf zM5_Sh!Fk*(XokP;y&)tJ}}1f}l_%^x)I`ICZ2-F75R~hL&f6&eFdG{i zp}K((v=#Dhf^vbHAvM^qD_AGfV16VR!k;9M^6PtU(z%XE?;RbC;X_$}fs)#tM(=b8 zG)uOBMik)JOd0!n@~H@lOhqKF5pOBDuI<~m$API@I>QFZJQ@eQUif|>=MXSJfJo>e zj{$IRofe_LMi>&H2Sx;^G}P8mJ{^T<6-y2RmFxI_poM_VpxZg>mnpCQ2Q}+}7g14B zdGA6I0uI^;UV?sXBE%8P!cvRBOt?~^Vk4ZG*S&)QjQw*IUAt!QGqQT{MBQIq{;TiJ{aH2TO5e`t$D`+e*wI^=}6*RuMefGc7_ z4+GQ%QLYoO_gd=s$z&(nDC&~fK(ogWL+)hINa&p@;4i&&1*WR{ND}LC6Lv{aZ2K(?%+DnPP!0IimF7j-!_-A##jLjv2c$v*(0pU3j~1 zzTc*ijq|b6gt)Z+!12a3namakqz@vugs;8s3H8xS?#N0DwC*fc%GB|yaEcLE7R=yS z?C=Vn{!f~^NH~*>=40+|>#lAGAur+nd@Wo;rGC_rC@WRc+i#Y8Q@|Z}RbGCvV~&Th zrE1M`(TL+YzPOf=qKjuljj?>RC@3xa6YuGdnas}30lkQTX!ikjn!dq_i;9YGiqDEv zDD>>@$^28T`1a^F?SmB4H2->gS=j*;6VJNZr5-U?*43dwuZFPlqIvUMnWt|qYcujD zCP0?DxIQL4eR%pm`cSzPYMQH(s>bXY4#Q|ffac0q`oDOqm3T_H$GyqvT{FYHaP*s= zdhYrd5m}zA5BVznN#>Wo7h|8qLL5t8d~)U2sFoF%nE1lZqgjuFZ-}FaOF^g(DNQ+E zYlYg3@iTM~@Un|0H=o_Y;}`44)A}+8QO)yDPVeyiv+=mbI3Dk=sKC6NW6CzIi4}*I z{vVlFQKKJjM1x_Tkx)tA>7snDUkcq2NQ zsGdE9BJ@Th9g}qEW4JT2-CsDnb;%?**1x`Z1zJaY^H4e?R)#41^EaphX{Gk0qk<=rs$r3JCzU}D_>{?G)T6PEBiP3cwjusjVvWu_3q!%~X zd)Ctzxo+ZXmzUxjZ~sagKV`{#2!TKcfoTy`lk%B-=+x>rN6DXassIEX-rmEM+H=($4O+uNGI`5m6I+hkB$teDhCZPTEz{?9x)Q^Mo)qo)G=(lF5}+rw`)|3)RoXnhGqB9XD4#DUXJU zoSYn@rM2}nsKwcioC`tSfynLcv|Ja!L_%?gnO=TtylTxBpI zKELagkcIq3z4Nw9gUwvDN64mqDlAyOJWd;rQdhX+_5@B{oSHWz4t`EiYKvlF*G}3M zf5qosY;3u4JU@1wLWkjH)A=gdiB8@4p?-*!JdoXA6C@2Sj9pI!x%j%FY~B-nCt=r_ zL{(E{G7UfFc){%idAj$y(;Z{6qwpDImM#i&YUZ+ui?cjzOsN-rJWk1vfixfWDh$t) zXV`2N#{UHc*0|APo!@UOXuV3l6t%D9vBmK+3f0}+m-)Mb*p^h%^ytc{#kD7RC-(dlQ%M-l`{*+ZSM)llAxf<&>S!FwJ@In z0p%%b%nLyz^{38s$ZT{n&dl5q!o z;kITQpEvmGDA#9nsahr7b%E3(Hr1+vl$=Ee<+yqFjmUdEfPgzT5kLGM<7e5e%qvnLH;q&^s&tdY*a zug|-_IZ3cwQ(a;aUu1*vCh#nWA4LFGjelWSn1IL9pW9>4IU>GLm^QuTMlExF_1Dvg zan799pCMC0v6|#dw;t18y}IsZKekzZC(Si>n|#8?W_1C7L1)tu)~5jorW7JwjT8O_ z*FlVQAk&;-ck(MSizK#>n;RZnP{P6TloSD@NaN$rw@*6u@Gwi*MLh~&LM<&X_e7DS>E%hQfA8Smf>3 zMO*(M$=X#;JK)9_r?fsht#7I%>W_SIVXDXMAxcWiAl0!W!fi3colA>Z&tVD=OEYlBFUC$UZ zrW1Sj?0Gd^UH9bB`fk8x5%0XA+F=TA~8TmJ=IwBwFxb|K~tC%7) zjfgGpm6emvo`s$}l)GXX2_U&s*lD!6^dZ~KuGzRct0WGl1858Wh^}2 z$1V~$n&B`xc~!o_nAI05HGO^_k7ij2CEVeQqI8X)JNvh1#g-h&|!`d=;(#8yxbi6`#jXbe_@WoCkr59jb(_ea!^SqY%DYCM<$}_)moA{T!qxD$-*sNig-XhmW?l?8G75xYsH-}a1 zC$=8Gnyw4~lDh{e%X`eP%YY_Fl?Gt`f$4rv=;{!zf5^s$@8Ql3qx^PkXG5(f5BtW) z(`0_GhKpISq3^9fz(j`64*V6@>o8EbiFT6RQuT|iqz zxxTuv^e5GsrA1bu){Ot?7cnBUqq4Zd*mi>5raj|uw6IJ4ndK=T*&&FlUe&bakl9Yv zPPJr~<7S_P6Jf_*@1xLDG#cI2$Q}Dt`Y^AW68vnERl9S9)g6qKQcG9yjy@-u9q0); zv)*#R+1q()9VY9cw%Y%l5(s|Gd;_&Kx>YdNlS_2ZpgZj0&{yetft z^WA=8%R(r3`Iu|ZUtb7l&Y`d#DttYoq!p-suWE+K`x!Vsy#+H*CYJyHJ#YEjc5Q8) zZ-@6l)ox@vP=tO@kh$$>GXSyOc5*-RLx!$CM6N`h<74CTQ7G-;M~m^YX5SxB#QzDE z?5|&b45x0XWm`LfguZwG{s-HSo_MzNW1W$dG)!)_N6wIyHT|Laf3sb($(FZhHMR|& zzNI(fP-n5<6Db75d71sm6ro%2R8U-utv}v%7oom^5a^h_^iWjX0|`a)jo7l(K8Pj! z_H$~OH*f!jEZ|Yat)V`VZ(EDpO8?R*Mt)0}D3PGubE-H| z^CL6&*!-g9PvYmRaQ5?R-hiPyL3x~?*QQG<)O9Hot=kt}pA-E2(v1uA8J$H6%DqAB zfcIybCYXNS0%{F12r%OwfLlt3>EaMyxQl5~O2bm?a4QP#5nU5!7e!GE{|oMzMUm{v>U-#b5}0h-aGJs>kiDSMRg797i` zrqZFbqaV0kx0;Ax;i*3RlMPQoq_Ayjsn(?jGEz81&T6R#=9xTnUl`6<2QfdBXO2ktgzDu;c-CxKy|JaX=)QwbHM zCT!)K+~Fuf<(&VBFw-Jj``!cEA`35_&HNZPKS;bPuzB%z8ma&f_`Bqz z5$Z~3Khh%!V(U%oHZG#%$xs~3$le6W1j6M(gmy_^gn9QzRQ|k=O+Ki(ZmclEaN&>{ zDdHP;RcwR(k~Mn7ffJ7liaaWXm}%$TDeA-po`YpD-Gd{=2!pLIHu8-kp;EC}{;*u1 zSQX@XthPelK57k`eXYxIq_(COO7p z)qvb;|Iqy^jMWGzF=jnSt#vsWgrz8t>mzA76=qo^+K-@W33qo{aBtarNcd{@YUk8R z4(_KMO5V^Qnx9{=&TmIRE$eFvZKbL8zhZLO(Q&Qu1@|PmRi*5dMF{5_pi)Jvtmzlb zyMyZzE!xC^%l&p%j>ovUpC-vgG~{V}$ZgRru#G|CjiF+ED z=?e0foVux_g@4g`&NNhSzP=qVeMmmngOK*|!<8;m5*MdON$0j7Lk6uU-1jci#nt#Z zAGP0({pD;{f7^&-m(%kadDNFkfsm2Ald1M0Dq6(ZRM@Mt@VY9rrpd&T6!G8(iPdz* zCAm>%Zio|7`3SA&`sc%uNYRIg8s1`8qnV$Ovmml~t9AjfB6Nx}@?{g(nsoo&Fid0_Fxn}zJ`|0@DO%WxxA?^biY7v8 zkj>Nz>gKI-F|Ad*S>xcOB;ot;H@eQ(-jTcydKRQnW7f?Jwi%qHUn?Z*T&G90bh@1ev1G_|#!Qm3~*oUH}(BbGT5eAhRP`MD;G1L}5@j2vU zow?$@bd}REft);gR5EnbLUSz8vebP^;qdrvB6JQ_3fcMf#r%0_GS6rg_+r3Y#21WL zp;=(!ofmzxcrvb&&q`|G0uiUHw=X3>3k14Cy8FVz#s6+(LN5(q37%3d zbg@w8ocLW99mvFQxY|@)*;ff@QyoPBZo(5dV~Q9S-|x>?8hd#*A_%mNZ`?Ob0VXljIY~EG zl|rkvo=ERSzU6;{g1^N`lNC;Gb^Ayk?^nTv5OC%4v5f3*J5YV#X<&sezE<^1x+3K% z%9B*w?4A3nOdgfH#=@Z-t9BKqcMl8qAN<}Sl#OG%_58x5(Tx6}{~+|>YN4(K20!!7 z!i_&)3t^^KtnIAghM*V^3X`YTL{b-feoIymC2OtgEx zM!Ms8^IdEgW_w(l-<3b|FUt5npIh@1aUS80Huh4EEKCc z8e62}odvRT%}Ejd`*K$*W{$uFWsKxA^*qU1{MbbMXT(_tu+6mPS>3ox87RtBCA;;d z`5vuDS{S$H%5cB8aJ*E54_ilM$`U#VjOz53qmJ*i{)Q4hImZ1|qevAw)PMp2OvAnT zJK=l+DU@U8W8QE(1F5O=@dU>GveU^s>b0_=ror!#54fp1u7;`4|8ha`jV0#MM+Z} zWJ|YJw`rw&-*-KZ);PH6hHoRNVr+PDZxT|4u{gL;*4CG_c2VAC8!kq#Inq&hdfU!@ zig?8_XGA?uw%DVwlb{G;ojZKHo%eAIPI_V|6(iARq%5XIY-K#gNMwII)dLmP*yMsG zkwS{fe2?oy1PSx+wr5k`uQ$%#4fYr#clfIQ^zP^U`w#{>i)P7My>qru{lGSK_pg=h zO#sqReDw=8Y*M|-kTm_5`~LoZq`|B6TGgJrTfY@k|xq z3n9coeK2{}D{%a6#Q%oj6ljr&Z&AG%rkHrW@nU@TsDp7GG-J}Ca>n?{rWDm+91|8H zt#?~3Ik411;oeo|!DsU;AFesU#iimT53oGRBlx6fz>0GZ6|s!rxy87F9Tmd1`)VNs zR74;SN%E|A(U+{m>%6UoL$-n#v5LO<^mXg2$YtIR@%F8#;&Jn)iQP4NCsI z@ZXUm>7^Fb8SxLtGR^k>8(ICO{7;2QLRZyLe`ql3NbUnotYVrl&=1$%w@|zX#^S9; zy0Ba%pVHyeVjD3$VT8oY^?@XWBhU1W7T>A=w&bAm={c&AV~PzRP*mWP92tKa^0Qe` zCp>DOIPOF(ASCpdFq462yG2zBa}<=gNZ&LgZm{jI#OnejrMZQj6OvrIg+KlGRdXA? zKk*Klx%NEE-U`3n7L5(_vOVZvWJGsvg>(roviR(TR5=8-~cp56LN#DvzlSUXgq2jzxny}|&o{~pdG}7%;L7{7KUFP;=R4ZNdZBSKbG)f*3G3r9(yjE>b+H%K3qMqkH zT|cRp32E?vByo*$ii)#6!e3R=oUI~wtx|=Nreo5Y?9wh?vMP!$hQN) ziXl|2&(##5W<0f4(J~3oxtND@x65<9H{0pxOgbY-{&RM7U?Wtgy37P>$%lQ;*M;@h%ly+-0Kv8cI)-tS^0AEIQx%EC>5fKI<(oRrB!`CLsGk|egIA8mYTe1!#-*74;%MYd#^C;wBL%mX;qwWB&0y5?> z7`!$~&P5=S5R3@fzv9&7HxdjM*~g*yyjHvDSyYtD&lZYM;tSCs_3+DQxzF);wXjRD z!`nL+2lqvvB8Ek|Bmv36yol>WTHfnn;^hfHpP}#`r(8>2uukc!=zJl@zBoX%o%8ZN z=*Z$ugm2A#&>#ss9c{^EMdI6JknQ&hcEkUo=;??N;19DY{_{JI`2!a&TqyGIA_hgj z$4o1^qHpLvpferqNVF)5omp6b*C%80&5jH9ihF<$CW8{9^ISwZYT1>;VfEl6f+U+; zFk)koX?l9OKi9OWV28}6^}R^)x2%ghpMJynam*{PNBD;{Kr*>V{&-+esdqEfvm+dO z+j8#ZYNQkW_ow@f0@o1v^&ha-V}sqsoEbVZY&*roGW}Q(x7t5re0CE7q3UkAo zUhNMlPC(~}WAdvhZ;`;K%M=E;wqZsbv4PAp(R@Jz4A$9eRXovMNmEPf3~pkBVOuAn zl^|UccJW>=oQL8CN>-mbmT?t}^CXL=I1m(cPj(-4d*Ni!(!)w$0b!`1=SWCjM`r}D z*|mj3e4;i=OaT@y#q@pf8DKh7Z-q;bMs|jZcH~Y)$TzC6zWK~{L;|Gq{c>q1}2($$(l@z zu`Ii>=Bi#z`8D>O^29v|#lDn6jY8tfvlALY4R=SHkS^VI_wHScy85r#=gLPH0>=69 zoMd}U2d|R(*NgAoA&V7vKpMe0w04iRVRk# zVsLw-;qw@rmvj3$CyQTpr8t-%y6R4iv4>^3h5FPNf_kJsC_wX@91|Z2YZx)_g8CJ>WoW(ligQk|vp<4!vva`$4AL8;>gJgE zp4BXk^jq)LIId4>Pc@`7A+2GO?jXOG$tG(|0{t;!-tT#?Z7r0AIn8O>Mb1ix9fyM^ zQsNyKYK)&XG&k6=Cgxjo!$3kU>gY(DkgEl8^OdBoVjTeL8u`}aD@P4>zvpl4y7@?y z7!vj44AuTS7Nv#S#fuC|t(IS5rZb$;k9r$fMroT6)F9%r;=(C*FfC_!&Q4hW^C>l1NzsP2hgLS zw`^qYJPv0KUYnfLhI$hUm1y z`nDfEPYi_qF|j4-Zkj8<>8}vjS5v4>5v);cOygdv1N$ysc8Mb)Y;uvkgM*jb^Cl+U zCf9C*;zWVUz{$&!SRC|qp;Di}Sb$VX)@}NiO-fV>OZ9baenMCHBu?v-v47Hv=#e`X zK0Tcs;TW9Vs%TnYvh9Y%;cI?A0?|gVKKI6LR{emvGWK+gnlNiA^Lo%+JM|nW9;BpZ&F>1w zqAEyz3*W`P(Bw?kuk7ogLcKck2)p+^_wFtAC@jGyqReJ(qgua>=DrGLF0cA^P$?12 z4;=)VVKLPSRd9zn6zp*7aP4BI?^P5yuJ^FDNR@6^)cnK-A_IvHW6c;kre|-z=d>!< zH@%zPVzf6qm$xaNwf^u**+&gAdMWvzggV`#FHH(F5(XA2)BYgoGO>I>^{3I+hG9oXjds!2)~8x_GLMYqX9}GWjZQw zvhYE6Xu|tafKyvpETJU z{~HF^7I9~soeAX~%9Xys#0MGF>>p_(f_D94dq|K%lTxmr2_ZoP0n=IkgW7?C3X4 zT*{p>;cJ+Dwa7nJHQnG8ZRA-s({3=GqhaQ|jW^@?X_hcC$!d`f^#>4$e4v zEFO4)H!1cs4V}EV97cR^m(V7MQ8SUQ@g{|R(=e35Zh!82O9Q5#5-~VxWwOf5$gM=e zmizFCXWozbd-N_S&@K(VqGX0p9YU%i=kW~p=Vch8R8v%z_z?IsfR8Al_>59_YRH{qKRCj(bSaLIav+M3azg5z8Qc|m>_8#8Zi!@nm8!uw7!w{H0Qq5Nj$`7A)axnCn=B#B)ei#{$0|z(>u*#X%dYUTWC1y zc_gZe6@U8PR!AQXJfA+?VvjA90s6cLM}Mb;rt(b}{8j_Hdz;s|E2sFoVc(~fIar%6 zD@XmM8BR#bj%3R7`1tuV4-C zY~#aitqFdwer7-6(R^__Ot}(!3x#xieW|nezGbCwklo(D_Cr!93VM~<#C-@9^-4&YF5r7&}Szb zRjZ`lv7+1A@7}~$6ZHPzE_aw$C)`TS9_%}-bLZ~@lWRuw=8mYwV-iQd+$Em#_@Ax& z{VeWvzA@Ulw&uBys@Q#j8WAYXC{i%Z5@C^xf4b67$%7O}D`UH4b>owhCEng`MB$O^ zP0oy779aOl$^9aQzYl1}`C9RD54B=Y_=gYN{ss`KH6)lR)j13YkU6CfL6k&1rgyoN zzbC=pwNK=jlMV$`z7fQr%x{7i$d#&4B%e$SR9H9_oSJJ%@`qfc=*lpyUP-F2)T3R* z%+6-}E&XIjPWl&HRO;?e^r4tIpE|}SfTkJwIYKU6T6Vk6p0$DAb=2+h7@J;NAMwk? z?C@qy>L^>?^tL295#?<{XDpex>$&|!h%z(Js`HIGj~0OYsgh$0S1J~XH{sB~-zkN7Jr=-`yucd*rZ0Tb^l6Ye1&}_IYw?1 z&&ETR;tg;Vo8-5d4Bu$AFP)GhBz6ht|8MCb^I&Y(en3|ro6-~9(iVoq-C$2 z`%cMXIbwVB>HYiu*u5%!Vl9e4l+m`3M;B_=XbiF7Y1%x4D{2oil=%+H;muVh85iK- z-^0N0r>isO&bYDA(M$ZqX0vEV=HolQT@wPbw@RujyYrB8VE=bM#WD8|kXqQ1Dkrsz z8OmcJ{{|CLVSK&4wT$_9ykH!?)IJC?mBY07IiIBP`9 zDxR?HMFc{SPAQ8`iEdI{E!*5H_2}on$?z^LYGi0%kqfZnXRNN>`}bDuEX(8QBICaW z!GMsyEQSK`etvMUha(HkTDssXda1p{{XOVS1;uFemTgj|P4A!fU7(`(BKuV9kP|n32621-_gl^h8x^CD4LRyOl>%=sU%BEiKl0Q=91`9_hTR|Q zF$MmlMzXv%^?t5DUAu-a@PFR%^gm(+n!N8dr@C&R^#X!VKre2UADt%=AU-#U(Qp)0 zbRn-o;@C5EGLu313T_4-rnFRvdP^_GLwryn+?|hzLrhwjV2m-W!pnuhf@4<2otVpj zX2s){))&PxscpFH_ZGTG7sIR4&7}sm|NG4hvbm2Wp$wG?jjfFAaqKrykpP0KwA@&t z0GH z+8a8SWzy;}v>svi;J#Sh+=WlP6cNHu<}p)#`zg zh%K__*>^)VZSBF1!Zwj?7Im!zOv&CevO6Y~jc*_Edq<}|*v}RZfBSZjHTJDp9562X z-e{MqLHnGX9AfrxSGrxHSm<|KO!8Rp8s7cfBd?`wNlvlz;E3X!8}st1T^I9QoId1~ zXw6+PsVloozjeS~s5aIo62r(|C)v>+K4h%lXS>ifC!r{=EvFcI?pNZ_+`++{CRR=_ z5)H09Iy*P>{whZ3B4SX_hwnNasb($V-M3X+Tl+=2ZTC%YZ?2Tb2SyYf{_drxl&skB zBj3-+XmMyDlBpYBXjTXO7VKnqBfgs2*bM)!H+Ug^o~(|7wT1Up!f)c&glS4xFYo5$ zFzd&eoxKgG zq-NuVDeaP~y}_HMLsH%gJu(^&g+IR0%@_%$a1h_${eT+H=a9)s>z84{Exc9O3)I!s zQ%2ijVpyo}Z%J%-8QzRr+oi1-%8hTmz%@<%oX%2RN8J0$=@t)0lGcUSH<8<2Hg;ES z3dJZr_g#jG z^l&5yUGKw)@0?k4U8uGV-eN&>`a**jak0g1)4MLDFuZ?ndoL&`=z*-LM0rI44o1dcm3;Sg z|5;}~8M&Vq!k+c+KC=B&h?s|acyx52)f+9YZ@A36P91BX{RMm4?>!s)MEsC$#W#fm z&#J8qyl)@Pdpx@$O0l}Mw?oHz)50=TCY6ao``QD|cV|5^GcvLY3a+}ixSZL0SY7m* z$CWEan=6dJ$CR&a#m@v?@l`zVV19o7t9=-D&)SX-E!>)9zj3G7tN#|QeH3{YzEE?s zO3;}m=Y+Ad2U%EdkNmP-aLz4n^4cTw0V{NXU11?_b^hnBVh{H*;g2(WPnud-rF*VL zE$GZXi-`5(I#F$K;lNh%($Z4d8*(jHJ&-|hi}5J6rcw?XpKlrC_e)U2`pAmX-MqpU znzC}0RE)c3v|KyrtXX<(hko4k@Ad(KU6Uuie*FslfQdGxj)umY^z`&u=hd)^0H=d$ ziWjkZXGJLoFAxI?JiR>|DTeHYhK7bYUeM{2yHa;shwZ7vT^km)O<6N|keFDrX5_Da>HVDJ;&@BvQL%JkXIjEmEEPj{ z>Lc5?EG{lsja?;S1vE4?1P5PRH=jFk&HOlSXi{TE^6djz>7~*4@Bha~$Lnc2@U42n zC+&*gvqRPR1fy)zH`oQjIT)z1VKnjn`lI#w@7tzKzpyct#DoX9z<~RqAJvmUW_h^n zmdmKB4A1}k`BOz8)Qw`|%s@ccC^+*R}S?E>qm zq+4?>mc+CR&Xr$ai%juc+g?C_{r2;rjcRvTP1FwEHQk=*|lYULAFIDLC1G`iA0v#PjQn^L!}BaG6k@}8-7n42S={ai|^^*C2p<^#{K zL`PThW$|4Xi(LNbA@n6%x1-R4@$={Zwo0mbSxsj%Uj6shJgoCmCI58J(EE0;N%a(%y)`OEk5BDzLR?ittSh1uZf8#gL4uq zpDHTIU|)IL<>OuSblWxmV0@!*F7Iq~Pwx-FGI>_hwW8~3?jDhmarniBa`GrM*B8(9 zZ^vC9o#lNV8&5|=Q{U|~JZcg8Fxk|sRZx3k&vfk9Fm?`FJVLI_E#1-zmsC|58yk6; zd`8B`^gZ?O_MbKN*gVv&H@66u8!2B)6~RjqoBZ1IuZEA0#NE4t z{O?yhV`Po{3!3)zjJ>>dl4CzUjQHsa61%^tg@u8>{uCHHbNvn0ST}89UD?j3M z3{%_E9?9;yYHV-+3^(}4+}xgV?ZM-T4{<*l=g2B^`EZgBwUn=LR_cG4&0q2h&U<%W z=7VYH*p}m!eQrmn?;L4tZ@-S_exZHB#5Yn?H6lUK^&x)w#1EIg7x`^zr5)4e$17`b ztx=I5rJa5vQtOn{9V!gRIdt&(bX>-_F0omwhAMu?PmCLR|9!W1;z&ug%ZU?F_y{e@ z6`IPi0!39a*I4m&GebmT!*pMl3~+JW$Gh37v+d48tZvd;R#XDRd8`%@3N6jemGJa4 zmlR8ge=kcc@#5Ut^}A2wcx4s-{*+M9`SC#G@u$wanC;|{dA8@P rh@;Fi#fl4I$2qp``oH;KdwkwZ5ByAuJ;hFj|HxdHzmy@Rcjx~Bk08fu literal 0 HcmV?d00001 diff --git a/assets/sports/wnba_logos/PHX.png b/assets/sports/wnba_logos/PHX.png new file mode 100644 index 0000000000000000000000000000000000000000..b0fa0720e4cbdb513818c63bdebb35f840e28e4c GIT binary patch literal 60679 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 literal 0 HcmV?d00001 diff --git a/config/config.template.json b/config/config.template.json index 9696e944..18fccb47 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -267,6 +267,7 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, + "show_all_live": false, "favorite_teams": [ "DAL" ], @@ -285,6 +286,40 @@ "nba_upcoming": true } }, + "wnba_scoreboard": { + "enabled": false, + "live_priority": true, + "live_game_duration": 20, + "show_odds": true, + "test_mode": false, + "update_interval_seconds": 3600, + "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, + "recent_update_interval": 3600, + "upcoming_update_interval": 3600, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "show_all_live": false, + "favorite_teams": [ + "CHI" + ], + "logo_dir": "assets/sports/wnba_logos", + "show_records": true, + "background_service": { + "enabled": true, + "max_workers": 3, + "request_timeout": 30, + "max_retries": 3, + "priority": 2 + }, + "display_modes": { + "wnba_live": true, + "wnba_recent": true, + "wnba_upcoming": true + } + }, "nfl_scoreboard": { "enabled": false, "live_priority": true, @@ -388,6 +423,7 @@ "recent_games_to_show": 1, "upcoming_games_to_show": 1, "show_favorite_teams_only": true, + "show_all_live": false, "favorite_teams": [ "UGA", "AUB" @@ -400,6 +436,30 @@ "ncaam_basketball_upcoming": true } }, + "ncaaw_basketball_scoreboard": { + "enabled": false, + "live_priority": true, + "live_game_duration": 20, + "show_odds": true, + "test_mode": false, + "update_interval_seconds": 3600, + "live_update_interval": 30, + "recent_games_to_show": 1, + "upcoming_games_to_show": 1, + "show_favorite_teams_only": true, + "show_all_live": false, + "favorite_teams": [ + "UGA", + "AUB" + ], + "logo_dir": "assets/sports/ncaa_logos", + "show_records": true, + "display_modes": { + "ncaaw_basketball_live": true, + "ncaaw_basketball_recent": true, + "ncaaw_basketball_upcoming": true + } + }, "ncaam_hockey_scoreboard": { "enabled": false, "live_priority": true, diff --git a/src/base_classes/basketball.py b/src/base_classes/basketball.py new file mode 100644 index 00000000..e4647703 --- /dev/null +++ b/src/base_classes/basketball.py @@ -0,0 +1,309 @@ +import logging +import time +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from PIL import Image, ImageDraw, ImageFont + +from src.base_classes.data_sources import ESPNDataSource +from src.base_classes.sports import SportsCore, SportsLive +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager + + +class Basketball(SportsCore): + """Base class for basketball sports with common functionality.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + self.data_source = ESPNDataSource(logger) + self.sport = "basketball" + + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: + """Extract relevant game details from ESPN NCAA FB API response.""" + # --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES --- + details, home_team, away_team, status, situation = ( + self._extract_game_details_common(game_event) + ) + if details is None or home_team is None or away_team is None or status is None: + return + try: + # Format period/quarter + period = status.get("period", 0) + period_text = "" + if status["type"]["state"] == "in": + if period == 0: + period_text = "Start" # Before kickoff + elif period >= 1 and period <= 4: + period_text = f"Q{period}" # OT starts after Q4 + elif period > 4: + period_text = f"OT{period - 4}" # OT starts after Q4 + elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state + period_text = "HALF" + elif status["type"]["state"] == "post": + if period > 4 : period_text = "Final/OT" + else: period_text = "Final" + elif status["type"]["state"] == "pre": + period_text = details.get("game_time", "") # Show time for upcoming + + details.update({ + "period": period, + "period_text": period_text, # Formatted quarter/status + "clock": status.get("displayClock", "0:00"), + }) + + # Basic validation (can be expanded) + if not details["home_abbr"] or not details["away_abbr"]: + self.logger.warning( + f"Missing team abbreviation in event: {details['id']}" + ) + return None + + self.logger.debug( + f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}" + ) + + return details + except Exception as e: + # Log the problematic event structure if possible + self.logger.error( + f"Error extracting game details: {e} from event: {game_event.get('id')}", + exc_info=True, + ) + return None + + +class BasketballLive(Basketball, SportsLive): + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + logger: logging.Logger, + sport_key: str, + ): + super().__init__(config, display_manager, cache_manager, logger, sport_key) + + def _test_mode_update(self): + if self.current_game and self.current_game["is_live"]: + # For testing, we'll just update the clock to show it's working + minutes = int(self.current_game["clock"].split(":")[0]) + seconds = int(self.current_game["clock"].split(":")[1]) + seconds -= 1 + if seconds < 0: + seconds = 59 + minutes -= 1 + if minutes < 0: + minutes = 19 + if self.current_game["period"] < 3: + self.current_game["period"] += 1 + else: + self.current_game["period"] = 1 + self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" + # Always update display in test mode + + def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: + """Draw the detailed scorebug layout for a live Basketball game.""" # Updated docstring + try: + main_img = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 255) + ) + overlay = Image.new( + "RGBA", (self.display_width, self.display_height), (0, 0, 0, 0) + ) + draw_overlay = ImageDraw.Draw( + overlay + ) # Draw text elements on overlay first + home_logo = self._load_and_resize_logo( + game["home_id"], + game["home_abbr"], + game["home_logo_path"], + game.get("home_logo_url"), + ) + away_logo = self._load_and_resize_logo( + game["away_id"], + game["away_abbr"], + game["away_logo_path"], + game.get("away_logo_url"), + ) + + if not home_logo or not away_logo: + self.logger.error( + f"Failed to load logos for live game: {game.get('id')}" + ) # Changed log prefix + # Draw placeholder text if logos fail + draw_final = ImageDraw.Draw(main_img.convert("RGB")) + self._draw_text_with_outline( + draw_final, "Logo Error", (5, 5), self.fonts["status"] + ) + self.display_manager.image.paste(main_img.convert("RGB"), (0, 0)) + self.display_manager.update_display() + return + + center_y = self.display_height // 2 + + # Draw logos (shifted slightly more inward than NHL perhaps) + home_x = ( + self.display_width - home_logo.width + 10 + ) # adjusted from 18 # Adjust position as needed + home_y = center_y - (home_logo.height // 2) + main_img.paste(home_logo, (home_x, home_y), home_logo) + + away_x = -10 # adjusted from 18 # Adjust position as needed + away_y = center_y - (away_logo.height // 2) + main_img.paste(away_logo, (away_x, away_y), away_logo) + + # --- Draw Text Elements on Overlay --- + # Note: Rankings are now handled in the records/rankings section below + + # Period/Quarter and Clock (Top center) + period_clock_text = ( + f"{game.get('period_text', '')} {game.get('clock', '')}".strip() + ) + + status_width = draw_overlay.textlength( + period_clock_text, font=self.fonts["time"] + ) + status_x = (self.display_width - status_width) // 2 + status_y = 1 # Position at top + self._draw_text_with_outline( + draw_overlay, + period_clock_text, + (status_x, status_y), + self.fonts["time"], + ) + + # Scores (centered, slightly above bottom) + home_score = str(game.get("home_score", "0")) + away_score = str(game.get("away_score", "0")) + score_text = f"{away_score}-{home_score}" + score_width = draw_overlay.textlength(score_text, font=self.fonts["score"]) + score_x = (self.display_width - score_width) // 2 + score_y = ( + self.display_height // 2 + ) - 3 # centered #from 14 # Position score higher + self._draw_text_with_outline( + draw_overlay, score_text, (score_x, score_y), self.fonts["score"] + ) + + # Draw odds if available + if "odds" in game and game["odds"]: + self._draw_dynamic_odds( + draw_overlay, game["odds"], self.display_width, self.display_height + ) + + # Draw records or rankings if enabled + if self.show_records or self.show_ranking: + try: + record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + self.logger.debug(f"Loaded 6px record font successfully") + except IOError: + record_font = ImageFont.load_default() + self.logger.warning( + f"Failed to load 6px font, using default font (size: {record_font.size})" + ) + + # Get team abbreviations + away_abbr = game.get("away_abbr", "") + home_abbr = game.get("home_abbr", "") + + record_bbox = draw_overlay.textbbox((0, 0), "0-0", font=record_font) + record_height = record_bbox[3] - record_bbox[1] + record_y = self.display_height - record_height - 1 + self.logger.debug( + f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}" + ) + + # Display away team info + if away_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + away_text = "" + elif self.show_ranking: + # Show ranking only if available + away_rank = self._team_rankings_cache.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + else: + away_text = "" + elif self.show_records: + # Show record only when rankings are disabled + away_text = game.get("away_record", "") + else: + away_text = "" + + if away_text: + away_record_x = 3 + self.logger.debug( + f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + away_text, + (away_record_x, record_y), + record_font, + ) + + # Display home team info + if home_abbr: + if self.show_ranking and self.show_records: + # When both rankings and records are enabled, rankings replace records completely + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + # Show nothing for unranked teams when rankings are prioritized + home_text = "" + elif self.show_ranking: + # Show ranking only if available + home_rank = self._team_rankings_cache.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + else: + home_text = "" + elif self.show_records: + # Show record only when rankings are disabled + home_text = game.get("home_record", "") + else: + home_text = "" + + if home_text: + home_record_bbox = draw_overlay.textbbox( + (0, 0), home_text, font=record_font + ) + home_record_width = home_record_bbox[2] - home_record_bbox[0] + home_record_x = self.display_width - home_record_width - 3 + self.logger.debug( + f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}" + ) + self._draw_text_with_outline( + draw_overlay, + home_text, + (home_record_x, record_y), + record_font, + ) + + # Composite the text overlay onto the main image + main_img = Image.alpha_composite(main_img, overlay) + main_img = main_img.convert("RGB") # Convert for display + + # Display the final image + self.display_manager.image.paste(main_img, (0, 0)) + self.display_manager.update_display() # Update display here for live + + except Exception as e: + self.logger.error( + f"Error displaying live Hockey game: {e}", exc_info=True + ) # Changed log prefix diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 3c0d0585..24e502e8 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -419,6 +419,7 @@ class SportsCore(ABC): if not home_team or not away_team: self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}") return None, None, None, None, None + try: home_abbr = home_team["team"]["abbreviation"] except KeyError: diff --git a/src/display_controller.py b/src/display_controller.py index b7777a88..04abe282 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -23,6 +23,7 @@ from src.odds_ticker_manager import OddsTickerManager from src.leaderboard_manager import LeaderboardManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager +from src.wnba_managers import WNBALiveManager, WNBARecentManager, WNBAUpcomingManager from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager from src.milb_manager import MiLBLiveManager, MiLBRecentManager, MiLBUpcomingManager from src.soccer_managers import SoccerLiveManager, SoccerRecentManager, SoccerUpcomingManager @@ -30,6 +31,7 @@ from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManage from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBUpcomingManager from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager +from src.ncaaw_basketball_managers import NCAAWBasketballLiveManager, NCAAWBasketballRecentManager, NCAAWBasketballUpcomingManager from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager from src.ncaaw_hockey_managers import NCAAWHockeyLiveManager, NCAAWHockeyRecentManager, NCAAWHockeyUpcomingManager from src.youtube_display import YouTubeDisplay @@ -134,6 +136,21 @@ class DisplayController: self.nba_recent = None self.nba_upcoming = None logger.info("NBA managers initialized in %.3f seconds", time.time() - nba_time) + + # Initialize WNBA managers if enabled + wnba_time = time.time() + wnba_enabled = self.config.get('wnba_scoreboard', {}).get('enabled', False) + wnba_display_modes = self.config.get('wnba_scoreboard', {}).get('display_modes', {}) + + if wnba_enabled: + self.wnba_live = WNBALiveManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_live', True) else None + self.wnba_recent = WNBARecentManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_recent', True) else None + self.wnba_upcoming = WNBAUpcomingManager(self.config, self.display_manager, self.cache_manager) if wnba_display_modes.get('wnba_upcoming', True) else None + else: + self.wnba_live = None + self.wnba_recent = None + self.wnba_upcoming = None + logger.info("WNBA managers initialized in %.3f seconds", time.time() - wnba_time) # Initialize MLB managers if enabled mlb_time = time.time() @@ -242,6 +259,21 @@ class DisplayController: self.ncaam_basketball_upcoming = None logger.info("NCAA Men's Basketball managers initialized in %.3f seconds", time.time() - ncaam_basketball_time) + # Initialize NCAA Womens's Basketball managers if enabled + ncaaw_basketball_time = time.time() + ncaaw_basketball_enabled = self.config.get('ncaaw_basketball_scoreboard', {}).get('enabled', False) + ncaaw_basketball_display_modes = self.config.get('ncaaw_basketball_scoreboard', {}).get('display_modes', {}) + + if ncaaw_basketball_enabled: + self.ncaaw_basketball_live = NCAAWBasketballLiveManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_live', True) else None + self.ncaaw_basketball_recent = NCAAWBasketballRecentManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_recent', True) else None + self.ncaaw_basketball_upcoming = NCAAWBasketballUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaaw_basketball_display_modes.get('ncaaw_basketball_upcoming', True) else None + else: + self.ncaaw_basketball_live = None + self.ncaaw_basketball_recent = None + self.ncaaw_basketball_upcoming = None + logger.info("NCAA Womens's Basketball managers initialized in %.3f seconds", time.time() - ncaaw_basketball_time) + # Initialize NCAA Men's Hockey managers if enabled ncaam_hockey_time = time.time() ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False) @@ -281,6 +313,7 @@ class DisplayController: # Read live_priority flags for all sports self.nhl_live_priority = self.config.get('nhl_scoreboard', {}).get('live_priority', True) self.nba_live_priority = self.config.get('nba_scoreboard', {}).get('live_priority', True) + self.wnba_live_priority = self.config.get('wnba_scoreboard', {}).get('live_priority', True) self.mlb_live_priority = self.config.get('mlb_scoreboard', {}).get('live_priority', True) self.milb_live_priority = self.config.get('milb_scoreboard', {}).get('live_priority', True) self.soccer_live_priority = self.config.get('soccer_scoreboard', {}).get('live_priority', True) @@ -288,6 +321,7 @@ class DisplayController: self.ncaa_fb_live_priority = self.config.get('ncaa_fb_scoreboard', {}).get('live_priority', True) self.ncaa_baseball_live_priority = self.config.get('ncaa_baseball_scoreboard', {}).get('live_priority', True) self.ncaam_basketball_live_priority = self.config.get('ncaam_basketball_scoreboard', {}).get('live_priority', True) + self.ncaaw_basketball_live_priority = self.config.get('ncaaw_basketball_scoreboard', {}).get('live_priority', True) self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True) self.ncaaw_hockey_live_priority = self.config.get('ncaaw_hockey_scoreboard', {}).get('live_priority', True) @@ -315,6 +349,9 @@ class DisplayController: if nba_enabled: if self.nba_recent: self.available_modes.append('nba_recent') if self.nba_upcoming: self.available_modes.append('nba_upcoming') + if wnba_enabled: + if self.wnba_recent: self.available_modes.append('wnba_recent') + if self.wnba_upcoming: self.available_modes.append('wnba_upcoming') if mlb_enabled: if self.mlb_recent: self.available_modes.append('mlb_recent') if self.mlb_upcoming: self.available_modes.append('mlb_upcoming') @@ -336,6 +373,9 @@ class DisplayController: if ncaam_basketball_enabled: if self.ncaam_basketball_recent: self.available_modes.append('ncaam_basketball_recent') if self.ncaam_basketball_upcoming: self.available_modes.append('ncaam_basketball_upcoming') + if ncaaw_basketball_enabled: + if self.ncaaw_basketball_recent: self.available_modes.append('ncaaw_basketball_recent') + if self.ncaaw_basketball_upcoming: self.available_modes.append('ncaaw_basketball_upcoming') if ncaam_hockey_enabled: if self.ncaam_hockey_recent: self.available_modes.append('ncaam_hockey_recent') if self.ncaam_hockey_upcoming: self.available_modes.append('ncaam_hockey_upcoming') @@ -366,6 +406,11 @@ class DisplayController: self.nba_favorite_teams = self.config.get('nba_scoreboard', {}).get('favorite_teams', []) self.in_nba_rotation = False + self.wnba_current_team_index = 0 + self.wnba_showing_recent = True + self.wnba_favorite_teams = self.config.get('wnba_scoreboard', {}).get('favorite_teams', []) + self.in_wnba_rotation = False + self.soccer_current_team_index = 0 # Soccer rotation state self.soccer_showing_recent = True self.soccer_favorite_teams = self.config.get('soccer_scoreboard', {}).get('favorite_teams', []) @@ -394,6 +439,12 @@ class DisplayController: self.ncaam_basketball_showing_recent = True self.ncaam_basketball_favorite_teams = self.config.get('ncaam_basketball_scoreboard', {}).get('favorite_teams', []) self.in_ncaam_basketball_rotation = False + + # Add NCAA Womens's Basketball rotation state + self.ncaaw_basketball_current_team_index = 0 + self.ncaaw_basketball_showing_recent = True + self.ncaaw_basketball_favorite_teams = self.config.get('ncaaw_basketball_scoreboard', {}).get('favorite_teams', []) + self.in_ncaaw_basketball_rotation = False # Update display durations to include all modes self.display_durations = self.config['display'].get('display_durations', {}) @@ -423,6 +474,9 @@ class DisplayController: 'nba_live': 30, 'nba_recent': 20, 'nba_upcoming': 20, + 'wnba_live': 30, + 'wnba_recent': 20, + 'wnba_upcoming': 20, 'mlb_live': 30, 'mlb_recent': 20, 'mlb_upcoming': 20, @@ -445,6 +499,9 @@ class DisplayController: 'ncaam_basketball_live': 30, # Added NCAA Men's Basketball durations 'ncaam_basketball_recent': 15, 'ncaam_basketball_upcoming': 15, + 'ncaaw_basketball_live': 30, # Added NCAA Womens's Basketball durations + 'ncaaw_basketball_recent': 15, + 'ncaaw_basketball_upcoming': 15, 'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations 'ncaam_hockey_recent': 15, 'ncaam_hockey_upcoming': 15, @@ -462,6 +519,8 @@ class DisplayController: logger.info(f"NHL Favorite teams: {self.nhl_favorite_teams}") if nba_enabled: logger.info(f"NBA Favorite teams: {self.nba_favorite_teams}") + if wnba_enabled: + logger.info(f"WNBA Favorite teams: {self.wnba_favorite_teams}") if mlb_enabled: logger.info(f"MLB Favorite teams: {self.mlb_favorite_teams}") if milb_enabled: @@ -476,6 +535,8 @@ class DisplayController: logger.info(f"NCAA Baseball Favorite teams: {self.ncaa_baseball_favorite_teams}") if ncaam_basketball_enabled: # Check if NCAA Men's Basketball is enabled logger.info(f"NCAA Men's Basketball Favorite teams: {self.ncaam_basketball_favorite_teams}") + if ncaaw_basketball_enabled: # Check if NCAA Womens's Basketball is enabled + logger.info(f"NCAA Womens's Basketball Favorite teams: {self.ncaaw_basketball_favorite_teams}") logger.info(f"Available display modes: {self.available_modes}") logger.info(f"Initial display mode: {self.current_display_mode}") @@ -674,6 +735,10 @@ class DisplayController: if self.nba_live: self.nba_live.update() if self.nba_recent: self.nba_recent.update() if self.nba_upcoming: self.nba_upcoming.update() + elif current_sport == 'wnba': + if self.wnba_live: self.wnba_live.update() + if self.wnba_recent: self.wnba_recent.update() + if self.wnba_upcoming: self.wnba_upcoming.update() elif current_sport == 'mlb': if self.mlb_live: self.mlb_live.update() if self.mlb_recent: self.mlb_recent.update() @@ -702,6 +767,10 @@ class DisplayController: if self.ncaam_basketball_live: self.ncaam_basketball_live.update() if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update() if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update() + elif current_sport == 'ncaaw_basketball': + if self.ncaaw_basketball_live: self.ncaaw_basketball_live.update() + if self.ncaaw_basketball_recent: self.ncaaw_basketball_recent.update() + if self.ncaaw_basketball_upcoming: self.ncaaw_basketball_upcoming.update() elif current_sport == 'ncaam_hockey': if self.ncaam_hockey_live: self.ncaam_hockey_live.update() if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update() @@ -721,6 +790,10 @@ class DisplayController: if self.nba_recent: self.nba_recent.update() if self.nba_upcoming: self.nba_upcoming.update() + if self.wnba_live: self.wnba_live.update() + if self.wnba_recent: self.wnba_recent.update() + if self.wnba_upcoming: self.wnba_upcoming.update() + if self.mlb_live: self.mlb_live.update() if self.mlb_recent: self.mlb_recent.update() if self.mlb_upcoming: self.mlb_upcoming.update() @@ -749,6 +822,10 @@ class DisplayController: if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update() if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update() + if self.ncaaw_basketball_live: self.ncaaw_basketball_live.update() + if self.ncaaw_basketball_recent: self.ncaaw_basketball_recent.update() + if self.ncaaw_basketball_upcoming: self.ncaaw_basketball_upcoming.update() + if self.ncaam_hockey_live: self.ncaam_hockey_live.update() if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update() if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update() @@ -770,6 +847,8 @@ class DisplayController: live_checks['nhl'] = self.nhl_live and self.nhl_live.live_games if 'nba_scoreboard' in self.config and self.config['nba_scoreboard'].get('enabled', False): live_checks['nba'] = self.nba_live and self.nba_live.live_games + if 'wnba_scoreboard' in self.config and self.config['wnba_scoreboard'].get('enabled', False): + live_checks['wnba'] = self.wnba_live and self.wnba_live.live_games if 'mlb' in self.config and self.config['mlb'].get('enabled', False): live_checks['mlb'] = self.mlb_live and self.mlb_live.live_games if 'milb' in self.config and self.config['milb'].get('enabled', False): @@ -784,6 +863,8 @@ class DisplayController: live_checks['ncaa_baseball'] = self.ncaa_baseball_live and self.ncaa_baseball_live.live_games if 'ncaam_basketball_scoreboard' in self.config and self.config['ncaam_basketball_scoreboard'].get('enabled', False): live_checks['ncaam_basketball'] = self.ncaam_basketball_live and self.ncaam_basketball_live.live_games + if 'ncaaw_basketball_scoreboard' in self.config and self.config['ncaaw_basketball_scoreboard'].get('enabled', False): + live_checks['ncaaw_basketball'] = self.ncaaw_basketball_live and self.ncaaw_basketball_live.live_games if 'ncaam_hockey_scoreboard' in self.config and self.config['ncaam_hockey_scoreboard'].get('enabled', False): live_checks['ncaam_hockey'] = self.ncaam_hockey_live and self.ncaam_hockey_live.live_games if 'ncaaw_hockey_scoreboard' in self.config and self.config['ncaaw_hockey_scoreboard'].get('enabled', False): @@ -818,6 +899,9 @@ class DisplayController: elif sport == 'nba': manager_recent = self.nba_recent manager_upcoming = self.nba_upcoming + elif sport == 'wnba': + manager_recent = self.wnba_recent + manager_upcoming = self.wnba_upcoming elif sport == 'mlb': manager_recent = self.mlb_recent manager_upcoming = self.mlb_upcoming @@ -872,6 +956,10 @@ class DisplayController: favorite_teams = self.nba_favorite_teams manager_recent = self.nba_recent manager_upcoming = self.nba_upcoming + elif sport == 'wnba': + favorite_teams = self.wnba_favorite_teams + manager_recent = self.wnba_recent + manager_upcoming = self.wnba_upcoming elif sport == 'mlb': favorite_teams = self.mlb_favorite_teams manager_recent = self.mlb_recent @@ -895,73 +983,6 @@ class DisplayController: return bool(favorite_teams and (manager_recent or manager_upcoming)) - def _rotate_team_games(self, sport: str = 'nhl') -> None: - """Rotate through games for favorite teams. (No longer used directly in loop)""" - # This logic is now mostly handled within each manager's display/update - # Keeping the structure in case direct rotation is needed later. - if not self._has_team_games(sport): - return - - if sport == 'nhl': - if not self.nhl_favorite_teams: return - current_team = self.nhl_favorite_teams[self.nhl_current_team_index] - # ... (rest of NHL rotation logic - now less relevant) - elif sport == 'nba': - if not self.nba_favorite_teams: return - current_team = self.nba_favorite_teams[self.nba_current_team_index] - # ... (rest of NBA rotation logic) - elif sport == 'mlb': - if not self.mlb_favorite_teams: return - current_team = self.mlb_favorite_teams[self.mlb_current_team_index] - # ... (rest of MLB rotation logic) - elif sport == 'milb': - if not self.config.get('milb_scoreboard', {}).get('favorite_teams', []): return - current_team = self.config['milb_scoreboard']['favorite_teams'][self.milb_current_team_index] - # ... (rest of MiLB rotation logic) - elif sport == 'soccer': - if not self.soccer_favorite_teams: return - current_team = self.soccer_favorite_teams[self.soccer_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'soccer', self.soccer_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.soccer_showing_recent = not self.soccer_showing_recent - found_games = self._get_team_games(current_team, 'soccer', self.soccer_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.soccer_current_team_index = (self.soccer_current_team_index + 1) % len(self.soccer_favorite_teams) - self.soccer_showing_recent = True # Reset to recent for the new team - # Maybe try finding game for the *new* team immediately? Optional. - elif sport == 'nfl': - if not self.nfl_favorite_teams: return - current_team = self.nfl_favorite_teams[self.nfl_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'nfl', self.nfl_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.nfl_showing_recent = not self.nfl_showing_recent - found_games = self._get_team_games(current_team, 'nfl', self.nfl_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.nfl_current_team_index = (self.nfl_current_team_index + 1) % len(self.nfl_favorite_teams) - self.nfl_showing_recent = True # Reset to recent for the new team - elif sport == 'ncaa_fb': # Add NCAA FB case - if not self.ncaa_fb_favorite_teams: return - current_team = self.ncaa_fb_favorite_teams[self.ncaa_fb_current_team_index] - # Try to find games for current team (recent first) - found_games = self._get_team_games(current_team, 'ncaa_fb', self.ncaa_fb_showing_recent) - if not found_games: - # Try opposite type (upcoming/recent) - self.ncaa_fb_showing_recent = not self.ncaa_fb_showing_recent - found_games = self._get_team_games(current_team, 'ncaa_fb', self.ncaa_fb_showing_recent) - - if not found_games: - # Move to next team if no games found for current one - self.ncaa_fb_current_team_index = (self.ncaa_fb_current_team_index + 1) % len(self.ncaa_fb_favorite_teams) - self.ncaa_fb_showing_recent = True # Reset to recent for the new team - # --- SCHEDULING METHODS --- def _load_schedule_config(self): """Load schedule configuration once at startup.""" @@ -1031,6 +1052,7 @@ class DisplayController: # Check if each sport is enabled before processing nhl_enabled = self.config.get('nhl_scoreboard', {}).get('enabled', False) nba_enabled = self.config.get('nba_scoreboard', {}).get('enabled', False) + wnba_enabled = self.config.get('wnba_scoreboard', {}).get('enabled', False) mlb_enabled = self.config.get('mlb_scoreboard', {}).get('enabled', False) milb_enabled = self.config.get('milb_scoreboard', {}).get('enabled', False) soccer_enabled = self.config.get('soccer_scoreboard', {}).get('enabled', False) @@ -1038,11 +1060,13 @@ class DisplayController: ncaa_fb_enabled = self.config.get('ncaa_fb_scoreboard', {}).get('enabled', False) ncaa_baseball_enabled = self.config.get('ncaa_baseball_scoreboard', {}).get('enabled', False) ncaam_basketball_enabled = self.config.get('ncaam_basketball_scoreboard', {}).get('enabled', False) + ncaaw_basketball_enabled = self.config.get('ncaaw_basketball_scoreboard', {}).get('enabled', False) ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False) ncaaw_hockey_enabled = self.config.get('ncaaw_hockey_scoreboard', {}).get('enabled', False) update_mode('nhl_live', getattr(self, 'nhl_live', None), self.nhl_live_priority, nhl_enabled) update_mode('nba_live', getattr(self, 'nba_live', None), self.nba_live_priority, nba_enabled) + update_mode('wnba_live', getattr(self, 'wnba_live', None), self.wnba_live_priority, wnba_enabled) update_mode('mlb_live', getattr(self, 'mlb_live', None), self.mlb_live_priority, mlb_enabled) update_mode('milb_live', getattr(self, 'milb_live', None), self.milb_live_priority, milb_enabled) update_mode('soccer_live', getattr(self, 'soccer_live', None), self.soccer_live_priority, soccer_enabled) @@ -1050,6 +1074,7 @@ class DisplayController: update_mode('ncaa_fb_live', getattr(self, 'ncaa_fb_live', None), self.ncaa_fb_live_priority, ncaa_fb_enabled) update_mode('ncaa_baseball_live', getattr(self, 'ncaa_baseball_live', None), self.ncaa_baseball_live_priority, ncaa_baseball_enabled) update_mode('ncaam_basketball_live', getattr(self, 'ncaam_basketball_live', None), self.ncaam_basketball_live_priority, ncaam_basketball_enabled) + update_mode('ncaaw_basketball_live', getattr(self, 'ncaaw_basketball_live', None), self.ncaaw_basketball_live_priority, ncaaw_basketball_enabled) update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled) update_mode('ncaaw_hockey_live', getattr(self, 'ncaaw_hockey_live', None), self.ncaaw_hockey_live_priority, ncaaw_hockey_enabled) @@ -1094,6 +1119,7 @@ class DisplayController: for sport, attr, priority in [ ('nhl', 'nhl_live', self.nhl_live_priority), ('nba', 'nba_live', self.nba_live_priority), + ('wnba', 'wnba_live', self.wnba_live_priority), ('mlb', 'mlb_live', self.mlb_live_priority), ('milb', 'milb_live', self.milb_live_priority), ('soccer', 'soccer_live', self.soccer_live_priority), @@ -1101,6 +1127,7 @@ class DisplayController: ('ncaa_fb', 'ncaa_fb_live', self.ncaa_fb_live_priority), ('ncaa_baseball', 'ncaa_baseball_live', self.ncaa_baseball_live_priority), ('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority), + ('ncaaw_basketball', 'ncaaw_basketball_live', self.ncaaw_basketball_live_priority), ('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority), ('ncaaw_hockey', 'ncaaw_hockey_live', self.ncaaw_hockey_live_priority) ]: @@ -1264,6 +1291,10 @@ class DisplayController: manager_to_display = self.nba_recent elif self.current_display_mode == 'nba_upcoming' and self.nba_upcoming: manager_to_display = self.nba_upcoming + elif self.current_display_mode == 'wnba_recent' and self.wnba_recent: + manager_to_display = self.wnba_recent + elif self.current_display_mode == 'wnba_upcoming' and self.wnba_upcoming: + manager_to_display = self.wnba_upcoming elif self.current_display_mode == 'nfl_recent' and self.nfl_recent: manager_to_display = self.nfl_recent elif self.current_display_mode == 'nfl_upcoming' and self.nfl_upcoming: @@ -1280,6 +1311,10 @@ class DisplayController: manager_to_display = self.ncaam_basketball_recent elif self.current_display_mode == 'ncaam_basketball_upcoming' and self.ncaam_basketball_upcoming: manager_to_display = self.ncaam_basketball_upcoming + elif self.current_display_mode == 'ncaaw_basketball_recent' and self.ncaaw_basketball_recent: + manager_to_display = self.ncaaw_basketball_recent + elif self.current_display_mode == 'ncaaw_basketball_upcoming' and self.ncaaw_basketball_upcoming: + manager_to_display = self.ncaaw_basketball_upcoming elif self.current_display_mode == 'mlb_recent' and self.mlb_recent: manager_to_display = self.mlb_recent elif self.current_display_mode == 'mlb_upcoming' and self.mlb_upcoming: @@ -1298,6 +1333,8 @@ class DisplayController: manager_to_display = self.nhl_live elif self.current_display_mode == 'nba_live' and self.nba_live: manager_to_display = self.nba_live + elif self.current_display_mode == 'wnba_live' and self.wnba_live: + manager_to_display = self.wnba_live elif self.current_display_mode == 'nfl_live' and self.nfl_live: manager_to_display = self.nfl_live elif self.current_display_mode == 'ncaa_fb_live' and self.ncaa_fb_live: @@ -1306,6 +1343,8 @@ class DisplayController: manager_to_display = self.ncaa_baseball_live elif self.current_display_mode == 'ncaam_basketball_live' and self.ncaam_basketball_live: manager_to_display = self.ncaam_basketball_live + elif self.current_display_mode == 'ncaaw_basketball_live' and self.ncaaw_basketball_live: + manager_to_display = self.ncaaw_basketball_live elif self.current_display_mode == 'ncaam_hockey_live' and self.ncaam_hockey_live: manager_to_display = self.ncaam_hockey_live elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent: @@ -1383,6 +1422,10 @@ class DisplayController: self.ncaam_basketball_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaam_basketball_upcoming' and self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_basketball_recent' and self.ncaaw_basketball_recent: + self.ncaaw_basketball_recent.display(force_clear=self.force_clear) + elif self.current_display_mode == 'ncaaw_basketball_upcoming' and self.ncaaw_basketball_upcoming: + self.ncaaw_basketball_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaa_baseball_recent' and self.ncaa_baseball_recent: self.ncaa_baseball_recent.display(force_clear=self.force_clear) elif self.current_display_mode == 'ncaa_baseball_upcoming' and self.ncaa_baseball_upcoming: diff --git a/src/logo_downloader.py b/src/logo_downloader.py index 218b3057..7dfd0c6d 100644 --- a/src/logo_downloader.py +++ b/src/logo_downloader.py @@ -49,6 +49,7 @@ class LogoDownloader: LOGO_DIRECTORIES = { 'nfl': 'assets/sports/nfl_logos', 'nba': 'assets/sports/nba_logos', + 'wnba': 'assets/sports/wnba_logos', 'mlb': 'assets/sports/mlb_logos', 'nhl': 'assets/sports/nhl_logos', # NCAA sports use same directory diff --git a/src/nba_managers.py b/src/nba_managers.py index bc70823a..a4ca4c66 100644 --- a/src/nba_managers.py +++ b/src/nba_managers.py @@ -1,18 +1,13 @@ -import os import time import logging import requests -import json -from typing import Dict, Any, Optional, List -from PIL import Image, ImageDraw, ImageFont +from typing import Dict, Any, Optional from pathlib import Path -from datetime import datetime, timedelta, timezone +from datetime import datetime from src.display_manager import DisplayManager from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from src.odds_manager import OddsManager -from src.background_data_service import get_background_service -from src.background_cache_mixin import BackgroundCacheMixin +from src.base_classes.basketball import Basketball, BasketballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming import pytz # Import the API counter function from web interface @@ -26,14 +21,7 @@ except ImportError: # Constants ESPN_NBA_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard" -# Configure logging to match main configuration -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) - -class BaseNBAManager(BackgroundCacheMixin): +class BaseNBAManager(Basketball): """Base class for NBA managers with common functionality.""" # Class variables for warning tracking _no_data_warning_logged = False @@ -42,883 +30,169 @@ class BaseNBAManager(BackgroundCacheMixin): _last_log_times = {} _shared_data = None _last_shared_update = 0 - + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): - self.display_manager = display_manager - # Store reference to config instead of creating new ConfigManager - self.config_manager = None # Not used in this class - self.config = config - self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, None) - self.logger = logging.getLogger(__name__) - self.nba_config = config.get("nba_scoreboard", {}) - self.is_enabled = self.nba_config.get("enabled", False) - self.show_odds = self.nba_config.get("show_odds", False) - self.test_mode = self.nba_config.get("test_mode", False) - self.logo_dir = self.nba_config.get("logo_dir", "assets/sports/nba_logos") - self.show_records = self.nba_config.get('show_records', False) - self.update_interval = self.nba_config.get("update_interval_seconds", 300) - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - self.favorite_teams = self.nba_config.get("favorite_teams", []) - - # Set logging level to INFO to reduce noise - self.logger.setLevel(logging.INFO) - - # Get display dimensions from matrix - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - # Cache for loaded logos - self._logo_cache = {} - - # Initialize background data service - background_config = self.nba_config.get("background_service", {}) - if background_config.get("enabled", True): # Default to enabled - max_workers = background_config.get("max_workers", 3) - self.background_service = get_background_service(self.cache_manager, max_workers) - self.background_fetch_requests = {} # Track background fetch requests - self.background_enabled = True - self.logger.info(f"[NBA] Background service enabled with {max_workers} workers") - else: - self.background_service = None - self.background_fetch_requests = {} - self.background_enabled = False - self.logger.info("[NBA] Background service disabled") + self.logger = logging.getLogger('NBA') # Changed logger name + super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nba") + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("nba_recent", False) + self.upcoming_enabled = display_modes.get("nba_upcoming", False) + self.live_enabled = display_modes.get("nba_live", False) self.logger.info(f"Initialized NBA manager with display dimensions: {self.display_width}x{self.display_height}") self.logger.info(f"Logo directory: {self.logo_dir}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, message_type: str, cooldown: int = 300) -> bool: - """Check if a message should be logged based on cooldown period.""" - current_time = time.time() - last_time = self._last_log_times.get(message_type, 0) - - if current_time - last_time >= cooldown: - self._last_log_times[message_type] = current_time - return True - return False - - def _load_test_data(self) -> Dict: - """Load test data for development and testing.""" - self.logger.info("[NBA] Loading test data") - - # Create test data with current time - now = datetime.now(timezone.utc) - - # Create test events for different scenarios - events = [] - - # Live game - live_game = { - "date": now.isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "in", - "shortDetail": "Q3 5:23" - }, - "period": 3, - "displayClock": "5:23" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "LAL"}, - "score": "85" - }, - { - "homeAway": "away", - "team": {"abbreviation": "GSW"}, - "score": "82" - } - ] - }] - } - events.append(live_game) - - # Recent game (yesterday) - recent_game = { - "date": (now - timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "post", - "shortDetail": "Final" - }, - "period": 4, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "BOS"}, - "score": "112" - }, - { - "homeAway": "away", - "team": {"abbreviation": "MIA"}, - "score": "108" - } - ] - }] - } - events.append(recent_game) - - # Upcoming game (tomorrow) - upcoming_game = { - "date": (now + timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "pre", - "shortDetail": "7:30 PM ET" - }, - "period": 0, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "PHX"}, - "score": "0" - }, - { - "homeAway": "away", - "team": {"abbreviation": "DEN"}, - "score": "0" - } - ] - }] - } - events.append(upcoming_game) - - return {"events": events} - - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - # Try to load the Press Start 2P font first - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - logging.info("[NBA] Successfully loaded Press Start 2P font for all text elements") - except IOError: - logging.warning("[NBA] Press Start 2P font not found, trying 4x6 font.") - try: - # Try to load the 4x6 font as a fallback - fonts['score'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) - fonts['time'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 9) - logging.info("[NBA] Successfully loaded 4x6 font for all text elements") - except IOError: - logging.warning("[NBA] 4x6 font not found, using default PIL font.") - # Use default PIL font as a last resort - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - return fonts - - def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]: - """Load and resize a team logo, with caching.""" - self.logger.debug(f"Loading logo for {team_abbrev}") - - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - return self._logo_cache[team_abbrev] - - logo_path = os.path.join(self.logo_dir, f"{team_abbrev}.png") - self.logger.debug(f"Logo path: {logo_path}") - - try: - # Create test logos if they don't exist - if not os.path.exists(logo_path): - self.logger.info(f"Creating test logo for {team_abbrev}") - os.makedirs(os.path.dirname(logo_path), exist_ok=True) - # Create a simple colored rectangle as a test logo - logo = Image.new('RGBA', (32, 32), (0, 0, 0, 0)) - draw = ImageDraw.Draw(logo) - # Use team abbreviation to determine color - if team_abbrev == "LAL": - color = (253, 185, 39, 255) # Lakers gold - else: - color = (0, 125, 197, 255) # Warriors blue - draw.rectangle([4, 4, 28, 28], fill=color) - # Add team abbreviation - draw.text((8, 8), team_abbrev, fill=(255, 255, 255, 255)) - logo.save(logo_path) - self.logger.info(f"Created test logo at {logo_path}") - - logo = Image.open(logo_path) - self.logger.debug(f"Opened logo for {team_abbrev}, size: {logo.size}, mode: {logo.mode}") - - # Convert to RGBA if not already - if logo.mode != 'RGBA': - self.logger.debug(f"Converting {team_abbrev} logo from {logo.mode} to RGBA") - logo = logo.convert('RGBA') - - # Calculate max size based on display dimensions - # Make logos 150% of display width to allow them to extend off screen - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - - # Resize maintaining aspect ratio - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self.logger.debug(f"Resized {team_abbrev} logo to {logo.size}") - - # Cache the resized logo - self._logo_cache[team_abbrev] = logo - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None + self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}") + self.league = "nba" def _fetch_nba_api_data(self, use_cache: bool = True) -> Optional[Dict]: - """Fetch and cache data for all managers to share.""" + """ + Fetches the full season schedule for NBA using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ now = datetime.now(pytz.utc) - date_str = now.strftime('%Y%m%d') - cache_key = f"nba_api_data_{date_str}" + season_year = now.year + if now.month < 7: + season_year = now.year - 1 + datestring = f"{season_year}1001-{season_year+1}0701" + cache_key = f"{self.sport_key}_schedule_{season_year}" + # Check cache first if use_cache: cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info(f"[NBA] Using cached data for {date_str}") - return cached_data + # Validate cached data structure + if isinstance(cached_data, dict) and 'events' in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info(f"Using cached schedule for {season_year} (legacy format)") + return {'events': cached_data} + else: + self.logger.warning(f"Invalid cached data format for {season_year}: {type(cached_data)}") + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_nba_api_data_sync(use_cache) + + # Start background fetch + self.logger.info(f"Starting background fetch for {season_year} season schedule...") + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info(f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events") + else: + self.logger.error(f"Background fetch failed for {season_year}: {result.error}") + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="nba", + year=season_year, + url=ESPN_NBA_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + + return None + + def _fetch_nba_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"nba_schedule_{current_year}" + + self.logger.info(f"Fetching full {current_year} season schedule from ESPN API (sync mode)...") try: - url = ESPN_NBA_SCOREBOARD_URL - params = {'dates': date_str} - response = requests.get(url, params=params) + response = self.session.get(ESPN_NBA_SCOREBOARD_URL, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15) response.raise_for_status() data = response.json() - - # Increment API counter for sports data call - increment_api_counter('sports', 1) + events = data.get('events', []) if use_cache: - self.cache_manager.set(cache_key, data) - - self.logger.info(f"[NBA] Successfully fetched data from ESPN API for {date_str}") - return data + self.cache_manager.set(cache_key, events) + + self.logger.info(f"Successfully fetched {len(events)} events for the {current_year} season.") + return {'events': events} except requests.exceptions.RequestException as e: - self.logger.error(f"[NBA] Error fetching data from ESPN: {e}") + self.logger.error(f"API error fetching full schedule: {e}") return None - def _fetch_data(self, date_str: str = None) -> Optional[Dict]: - """ - Fetch data using background service cache first, fallback to direct API call. - This eliminates redundant caching and ensures Recent/Upcoming managers - use the same data source as the background service. - """ - return self._fetch_data_with_background_cache( - sport_key='nba', - api_fetch_method=self._fetch_nba_api_data, - live_manager_class=NBALiveManager - ) - - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a specific game if conditions are met.""" - # Check if odds should be shown for this sport - if not self.show_odds: - return - - # Check if we should only fetch for favorite teams - is_favorites_only = self.nba_config.get("show_favorite_teams_only", False) - if is_favorites_only: - home_abbr = game.get('home_abbr') - away_abbr = game.get('away_abbr') - if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams): - self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}") - return - - self.logger.debug(f"Proceeding with odds fetch for game: {game.get('id', 'N/A')}") - - # Fetch odds using OddsManager (ESPN API) - try: - # Determine update interval based on game state - is_live = game.get('status', '').lower() == 'in' - update_interval = self.nba_config.get("live_odds_update_interval", 60) if is_live \ - else self.nba_config.get("odds_update_interval", 3600) - - odds_data = self.odds_manager.get_odds( - sport="basketball", - league="nba", - event_id=game['id'], - update_interval_seconds=update_interval - ) - - if odds_data: - game['odds'] = odds_data - self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") - else: - self.logger.debug(f"No odds data returned for game {game['id']}") - - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN API response.""" - if not game_event: - return None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - - # Parse game date/time - try: - start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) - self.logger.debug(f"[NBA] Parsed game time: {start_time_utc}") - except ValueError: - logging.warning(f"[NBA] Could not parse game date: {game_date_str}") - start_time_utc = None - - home_team = next(c for c in competitors if c.get("homeAway") == "home") - away_team = next(c for c in competitors if c.get("homeAway") == "away") - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record == "0-0": - home_record = '' - if away_record == "0-0": - away_record = '' - - # Format game time and date for display - game_time = "" - game_date = "" - if start_time_utc: - # Convert to local time - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - details = { - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], - "period": status.get("period", 0), - "clock": status.get("displayClock", "0:00"), - "is_live": status["type"]["state"] in ("in", "halftime"), - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - "home_abbr": home_team["team"]["abbreviation"], - "home_score": home_team.get("score", "0"), - "home_record": home_record, - "home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"), - "away_abbr": away_team["team"]["abbreviation"], - "away_score": away_team.get("score", "0"), - "away_record": away_record, - "away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"), - "game_time": game_time, - "game_date": game_date - } - - # Log game details for debugging - self.logger.debug(f"[NBA] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") - self.logger.debug(f"[NBA] Game status: is_final={details['is_final']}, is_within_window={details['is_within_window']}") - - return details - except Exception as e: - logging.error(f"[NBA] Error extracting game details: {e}") - return None - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the scorebug layout for the current game.""" - try: - # Create a new black image for the main display - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - - # Load logos once - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) - - if not home_logo or not away_logo: - self.logger.error("Failed to load one or both team logos") - return - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = self.display_height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = self.display_width - home_logo.width + 12 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -12 - away_y = center_y - (away_logo.height // 2) - - # Paste the away logo onto the overlay - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - main_img = Image.alpha_composite(main_img, overlay) - - # Convert to RGB for final display - main_img = main_img.convert('RGB') - draw = ImageDraw.Draw(main_img) - - # Check if this is an upcoming game - is_upcoming = game.get("is_upcoming", False) - - if is_upcoming: - # For upcoming games, show date and time stacked in the center - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Show "Next Game" at the top - status_text = "Next Game" - status_width = draw.textlength(status_text, font=self.fonts['status']) - status_x = (self.display_width - status_width) // 2 - status_y = 2 - draw.text((status_x, status_y), status_text, font=self.fonts['status'], fill=(255, 255, 255)) - - # Calculate position for the date text (centered horizontally, below "Next Game") - date_width = draw.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = center_y - 5 # Position in center - draw.text((date_x, date_y), game_date, font=self.fonts['time'], fill=(255, 255, 255)) - - # Calculate position for the time text (centered horizontally, in center) - time_width = draw.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 10 # Position below date - draw.text((time_x, time_y), game_time, font=self.fonts['time'], fill=(255, 255, 255)) - else: - # For live/final games, show scores and period/time - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - - # Calculate position for the score text (centered at the bottom) - score_width = draw.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 10 - draw.text((score_x, score_y), score_text, font=self.fonts['score'], fill=(255, 255, 255)) - - # Draw period and time or Final - if game.get("is_final", False): - status_text = "Final" - status_width = draw.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 5 - draw.text((status_x, status_y), status_text, font=self.fonts['time'], fill=(255, 255, 255)) - else: - period = game.get("period", 0) - clock = game.get("clock", "0:00") - - # Format period text for NBA (quarters) - if period > 4: - period_text = "OT" - else: - period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd' if period == 3 else 'th'} Q" - - # Draw period text at the top - period_width = draw.textlength(period_text, font=self.fonts['time']) - period_x = (self.display_width - period_width) // 2 - period_y = 1 - draw.text((period_x, period_y), period_text, font=self.fonts['time'], fill=(255, 255, 255)) - - # Draw clock below period - clock_width = draw.textlength(clock, font=self.fonts['time']) - clock_x = (self.display_width - clock_width) // 2 - clock_y = period_y + 10 # Position below period - draw.text((clock_x, clock_y), clock, font=self.fonts['time'], fill=(255, 255, 255)) - - # Draw odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw, game['odds'], self.display_width, self.display_height) - - # Draw records if enabled - if self.show_records: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') - - record_bbox = draw.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) - - if home_record: - home_record_bbox = draw.textbbox((0,0), home_record, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) - - # Display the image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error displaying game: {e}", exc_info=True) - - def display(self, force_clear: bool = False) -> None: - """Common display method for all NBA managers""" - if not self.current_game: - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - self._last_warning_time > 300: # 5 minutes cooldown - self.logger.warning("[NBA] No game data available to display") - self._last_warning_time = current_time - return - - self._draw_scorebug_layout(self.current_game, force_clear) - - def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: - """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" - home_team_odds = odds.get('home_team_odds', {}) - away_team_odds = odds.get('away_team_odds', {}) - home_spread = home_team_odds.get('spread_odds') - away_spread = away_team_odds.get('spread_odds') - - # Get top-level spread as fallback - top_level_spread = odds.get('spread') - - # If we have a top-level spread and the individual spreads are None or 0, use the top-level - if top_level_spread is not None: - if home_spread is None or home_spread == 0.0: - home_spread = top_level_spread - if away_spread is None: - away_spread = -top_level_spread - - # Determine which team is favored (has negative spread) - home_favored = home_spread is not None and home_spread < 0 - away_favored = away_spread is not None and away_spread < 0 - - # Only show the negative spread (favored team) - favored_spread = None - favored_side = None - - if home_favored: - favored_spread = home_spread - favored_side = 'home' - self.logger.debug(f"Home team favored with spread: {favored_spread}") - elif away_favored: - favored_spread = away_spread - favored_side = 'away' - self.logger.debug(f"Away team favored with spread: {favored_spread}") + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NBALiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() else: - self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") - - # Show the negative spread on the appropriate side - if favored_spread is not None: - spread_text = str(favored_spread) - font = self.fonts['detail'] # Use detail font for odds - - if favored_side == 'home': - # Home team is favored, show spread on right side - spread_width = draw.textlength(spread_text, font=font) - spread_x = width - spread_width # Top right - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing home spread '{spread_text}' on right side") - else: - # Away team is favored, show spread on left side - spread_x = 0 # Top left - spread_y = 0 - self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) - self.logger.debug(f"Showing away spread '{spread_text}' on left side") - - # Show over/under on the opposite side of the favored team - over_under = odds.get('over_under') - if over_under is not None: - ou_text = f"O/U: {over_under}" - font = self.fonts['detail'] # Use detail font for odds - ou_width = draw.textlength(ou_text, font=font) - - if favored_side == 'home': - # Home team is favored, show O/U on left side (opposite of spread) - ou_x = 0 # Top left - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") - elif favored_side == 'away': - # Away team is favored, show O/U on right side (opposite of spread) - ou_x = width - ou_width # Top right - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") - else: - # No clear favorite, show O/U in center - ou_x = (width - ou_width) // 2 - ou_y = 0 - self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") - - self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + # Recent and Upcoming managers should use cached season data + return self._fetch_nba_api_data(use_cache=True) - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): - """Helper to draw text with an outline.""" - draw.text(position, text, font=font, fill=outline_color) - draw.text(position, text, font=font, fill=fill) - - -class NBALiveManager(BaseNBAManager): +class NBALiveManager(BaseNBAManager, BasketballLive): """Manager for live NBA games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.update_interval = self.nba_config.get("live_update_interval", 30) - self.no_data_interval = 300 - self.last_update = 0 - self.logger.info("Initialized NBA Live Manager") - self.live_games = [] - self.current_game_index = 0 - self.last_game_switch = 0 - self.game_display_duration = self.nba_config.get("live_game_duration", 20) - self.last_display_update = 0 - self.last_log_time = 0 - self.log_interval = 300 + self.logger = logging.getLogger('NBALiveManager') # Changed logger name - def update(self): - """Update live game data.""" - if not self.is_enabled: return - current_time = time.time() - interval = self.no_data_interval if not self.live_games else self.update_interval - - if current_time - self.last_update >= interval: - self.last_update = current_time - - # Fetch live game data - data = self._fetch_data() - new_live_games = [] - if data and "events" in data: - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: - self._fetch_odds(details) - new_live_games.append(details) - - # Filter for favorite teams only if the config is set - if self.nba_config.get("show_favorite_teams_only", False): - new_live_games = [game for game in new_live_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Update game list and current game - if new_live_games: - self.live_games = new_live_games - if not self.current_game or self.current_game not in self.live_games: - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None - self.last_game_switch = current_time - else: - # Update current game with fresh data - self.current_game = new_live_games[self.current_game_index] - else: - self.live_games = [] - self.current_game = None - - def display(self, force_clear: bool = False) -> None: - """Display live game information.""" - if not self.current_game: - return - super().display(force_clear) + if self.test_mode: + # More detailed test game for NBA + self.current_game = { + "id": "test001", + "home_abbr": "LAL", "home_id": "123", "away_abbr": "GS", "away_id":"asdf", + "home_score": "21", "away_score": "17", + "period": 3, "period_text": "Q3", "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "LAL.png"), + "away_logo_path": Path(self.logo_dir, "GS.png"), + "is_live": True, "is_final": False, "is_upcoming": False, "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info("Initialized NBALiveManager with test game: BUF vs KC") + else: + self.logger.info(" Initialized NBALiveManager in live mode") -class NBARecentManager(BaseNBAManager): +class NBARecentManager(BaseNBAManager, SportsRecent): """Manager for recently completed NBA games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.recent_games = [] - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.nba_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.recent_games_to_show = self.nba_config.get("recent_games_to_show", 5) # Number of most recent games to display - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each game for 15 seconds + self.logger = logging.getLogger('NBARecentManager') # Changed logger name + self.logger.info(f"Initialized NBARecentManager with {len(self.favorite_teams)} favorite teams") - def update(self): - """Update recent games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - try: - data = self._fetch_data() - if not data or 'events' not in data: - return - - events = data['events'] - new_recent_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_final']: - self._fetch_odds(game) - new_recent_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nba_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_recent_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (most recent game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - else: - team_games = new_recent_games - # Sort games by start time, most recent first, then limit to recent_games_to_show - team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True) - team_games = team_games[:self.recent_games_to_show] - self.recent_games = team_games - - if self.recent_games: - if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.recent_games}: - self.current_game_index = 0 - self.current_game = self.recent_games[0] - self.last_game_switch = current_time - else: - self.current_game = None - - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NBA] Error updating recent games: {e}", exc_info=True) - - def display(self, force_clear=False): - """Display recent games.""" - if not self.recent_games: - return - - try: - current_time = time.time() - - # Check if it's time to switch games - if len(self.recent_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.recent_games) - self.current_game = self.recent_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NBA Recent] Showing {away_abbr} vs {home_abbr}") - - # Draw the scorebug layout - self._draw_scorebug_layout(self.current_game, force_clear) - - except Exception as e: - self.logger.error(f"[NBA] Error displaying recent game: {e}", exc_info=True) - - -class NBAUpcomingManager(BaseNBAManager): +class NBAUpcomingManager(BaseNBAManager, SportsUpcoming): """Manager for upcoming NBA games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): super().__init__(config, display_manager, cache_manager) - self.upcoming_games = [] - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.nba_config.get("upcoming_update_interval", 3600) # Use config, default 1 hour - self.upcoming_games_to_show = self.nba_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display - self.last_game_switch = 0 - self.game_display_duration = 15 # Display each game for 15 seconds + self.logger = logging.getLogger('NBAUpcomingManager') # Changed logger name + self.logger.info(f"Initialized NBAUpcomingManager with {len(self.favorite_teams)} favorite teams") - def update(self): - """Update upcoming games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - try: - data = self._fetch_data() - if not data or 'events' not in data: - return - - events = data['events'] - new_upcoming_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_upcoming']: - self._fetch_odds(game) - new_upcoming_games.append(game) - - # Filter for favorite teams only if the config is set - if self.nba_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_upcoming_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (earliest upcoming game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the earliest - team_specific_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time - team_games.sort(key=lambda g: g.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - else: - team_games = new_upcoming_games - # Sort games by start time, soonest first, then limit to configured count - team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc)) - team_games = team_games[:self.upcoming_games_to_show] - self.upcoming_games = team_games - - if self.upcoming_games: - if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.upcoming_games}: - self.current_game_index = 0 - self.current_game = self.upcoming_games[0] - else: - self.current_game = None - - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NBA] Error updating upcoming games: {e}", exc_info=True) - - def display(self, force_clear=False): """Display upcoming games.""" if not self.upcoming_games: return diff --git a/src/ncaam_basketball_managers.py b/src/ncaam_basketball_managers.py index 18e5db6c..fad9f52d 100644 --- a/src/ncaam_basketball_managers.py +++ b/src/ncaam_basketball_managers.py @@ -1,17 +1,15 @@ -import os -import time import logging -import requests -import json -from typing import Dict, Any, Optional, List -from PIL import Image, ImageDraw, ImageFont +from datetime import datetime from pathlib import Path -from datetime import datetime, timedelta, timezone -from src.display_manager import DisplayManager -from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from src.odds_manager import OddsManager +from typing import Any, Dict, Optional + import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager # Import the API counter function from web interface try: @@ -21,18 +19,14 @@ except ImportError: def increment_api_counter(kind: str, count: int = 1): pass + # Constants ESPN_NCAAMB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard" -# Configure logging to match main configuration -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -class BaseNCAAMBasketballManager: +class BaseNCAAMBasketballManager(Basketball): """Base class for NCAA MB managers with common functionality.""" + # Class variables for warning tracking _no_data_warning_logged = False _last_warning_time = 0 @@ -40,1075 +34,241 @@ class BaseNCAAMBasketballManager: _last_log_times = {} _shared_data = None _last_shared_update = 0 - - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): - self.display_manager = display_manager - # Store reference to config instead of creating new ConfigManager - self.config_manager = None # Not used in this class - self.config = config - self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, None) - self.logger = logging.getLogger(__name__) - self.ncaam_basketball_config = config.get("ncaam_basketball_scoreboard", {}) - self.is_enabled = self.ncaam_basketball_config.get("enabled", False) - self.show_odds = self.ncaam_basketball_config.get("show_odds", False) - self.test_mode = self.ncaam_basketball_config.get("test_mode", False) - self.logo_dir = self.ncaam_basketball_config.get("logo_dir", "assets/sports/ncaa_logos") - self.update_interval = self.ncaam_basketball_config.get("update_interval_seconds", 60) - self.show_records = self.ncaam_basketball_config.get('show_records', False) - self.last_update = 0 - self.current_game = None - self.fonts = self._load_fonts() - self.favorite_teams = self.ncaam_basketball_config.get("favorite_teams", []) - - # Set logging level to INFO to reduce noise - self.logger.setLevel(logging.INFO) - - # Get display dimensions from matrix - self.display_width = self.display_manager.matrix.width - self.display_height = self.display_manager.matrix.height - - # Cache for loaded logos - self._logo_cache = {} - - self.logger.info(f"Initialized NCAAMBasketball manager with display dimensions: {self.display_width}x{self.display_height}") + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("NCAAMB") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaam_basketball", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaam_basketball_recent", False) + self.upcoming_enabled = display_modes.get("ncaam_basketball_upcoming", False) + self.live_enabled = display_modes.get("ncaam_basketball_live", False) + + self.logger.info( + f"Initialized NCAA Mens Basketball manager with display dimensions: {self.display_width}x{self.display_height}" + ) self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "mens-college-basketball" - def _fetch_odds(self, game: Dict) -> None: - """Fetch odds for a game and attach it to the game dictionary.""" - # Check if odds should be shown for this sport - if not self.show_odds: - return - - # Check if we should only fetch for favorite teams - is_favorites_only = self.ncaam_basketball_config.get("show_favorite_teams_only", False) - if is_favorites_only: - home_abbr = game.get('home_abbr') - away_abbr = game.get('away_abbr') - if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams): - self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}") - return - - self.logger.debug(f"Proceeding with odds fetch for game: {game.get('id', 'N/A')}") - - try: - odds_data = self.odds_manager.get_odds( - sport="basketball", - league="mens-college-basketball", - event_id=game["id"] - ) - if odds_data: - game['odds'] = odds_data - except Exception as e: - self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") - - def _get_timezone(self): - try: - timezone_str = self.config.get('timezone', 'UTC') - return pytz.timezone(timezone_str) - except pytz.UnknownTimeZoneError: - return pytz.utc - - def _should_log(self, message_type: str, cooldown: int = 300) -> bool: - """Check if a message should be logged based on cooldown period.""" - current_time = time.time() - last_time = self._last_log_times.get(message_type, 0) - - if current_time - last_time >= cooldown: - self._last_log_times[message_type] = current_time - return True - return False - - def _load_test_data(self) -> Dict: - """Load test data for development and testing.""" - self.logger.info("[NCAAMBasketball] Loading test data") - - # Create test data with current time - now = datetime.now(self._get_timezone()) - - # Create test events for different scenarios - events = [] - - # Live game (2nd Half) - live_game = { - "date": now.isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "in", - "shortDetail": "H2 5:23" # Changed from Q3 - }, - "period": 2, # Changed from 3 - "displayClock": "5:23" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UGA"}, - "score": "75" # Adjusted score - }, - { - "homeAway": "away", - "team": {"abbreviation": "AUB"}, - "score": "72" # Adjusted score - } - ] - }] - } - events.append(live_game) - - # Recent game (yesterday) - recent_game = { - "date": (now - timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "post", - "shortDetail": "Final" - }, - "period": 2, # Changed from 4 - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UCLA"}, # Changed from BOS - "score": "88" # Adjusted score - }, - { - "homeAway": "away", - "team": {"abbreviation": "ZAGA"}, # Changed from MIA - "score": "85" # Adjusted score - } - ] - }] - } - events.append(recent_game) - - # Upcoming game (tomorrow) - upcoming_game = { - "date": (now + timedelta(days=1)).isoformat(), - "competitions": [{ - "status": { - "type": { - "state": "pre", - "shortDetail": "8:00 PM ET" # Adjusted time - }, - "period": 0, - "displayClock": "0:00" - }, - "competitors": [ - { - "homeAway": "home", - "team": {"abbreviation": "UGA"}, # Changed from PHX - "score": "0" - }, - { - "homeAway": "away", - "team": {"abbreviation": "AUB"}, # Changed from DEN - "score": "0" - } - ] - }] - } - events.append(upcoming_game) - - return {"events": events} - - def _load_fonts(self): - """Load fonts used by the scoreboard.""" - fonts = {} - try: - fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) - fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - logging.info("[NCAAMBasketball] Successfully loaded Press Start 2P font for all text elements") - except IOError: - logging.warning("[NCAAMBasketball] Press Start 2P font not found, trying 4x6 font.") - try: - # Try to load the 4x6 font as a fallback - fonts['score'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) - fonts['time'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['team'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) - fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 9) - logging.info("[NCAAMBasketball] Successfully loaded 4x6 font for all text elements") - except IOError: - logging.warning("[NCAAMBasketball] 4x6 font not found, using default PIL font.") - # Use default PIL font as a last resort - fonts['score'] = ImageFont.load_default() - fonts['time'] = ImageFont.load_default() - fonts['team'] = ImageFont.load_default() - fonts['status'] = ImageFont.load_default() - return fonts - - def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]: - """Load and resize a team logo, with caching.""" - self.logger.debug(f"Loading logo for {team_abbrev}") - - if team_abbrev in self._logo_cache: - self.logger.debug(f"Using cached logo for {team_abbrev}") - return self._logo_cache[team_abbrev] - - logo_path = os.path.join(self.logo_dir, f"{team_abbrev}.png") - self.logger.debug(f"Logo path: {logo_path}") - - try: - # Create test logos if they don't exist (Simple placeholder logic) - if not os.path.exists(logo_path): - self.logger.info(f"Creating test logo for {team_abbrev}") - os.makedirs(os.path.dirname(logo_path), exist_ok=True) - logo = Image.new('RGBA', (32, 32), (0, 0, 0, 0)) - draw = ImageDraw.Draw(logo) - # Basic color logic for test logos - color = (sum(ord(c) for c in team_abbrev) % 200 + 55, # R - sum(ord(c)**2 for c in team_abbrev) % 200 + 55, # G - sum(ord(c)**3 for c in team_abbrev) % 200 + 55, # B - 255) # Alpha - draw.rectangle([4, 4, 28, 28], fill=color) - draw.text((8, 8), team_abbrev, fill=(255, 255, 255, 255)) - logo.save(logo_path) - self.logger.info(f"Created test logo at {logo_path}") - - logo = Image.open(logo_path) - self.logger.debug(f"Opened logo for {team_abbrev}, size: {logo.size}, mode: {logo.mode}") - - # Convert to RGBA if not already - if logo.mode != 'RGBA': - self.logger.debug(f"Converting {team_abbrev} logo from {logo.mode} to RGBA") - logo = logo.convert('RGBA') - - # Calculate max size based on display dimensions - max_width = int(self.display_width * 1.5) - max_height = int(self.display_height * 1.5) - - # Resize maintaining aspect ratio - logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) - self.logger.debug(f"Resized {team_abbrev} logo to {logo.size}") - - # Cache the resized logo - self._logo_cache[team_abbrev] = logo - return logo - - except Exception as e: - self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True) - return None - - def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + def _fetch_ncaam_basketball_api_data( + self, use_cache: bool = True + ) -> Optional[Dict]: """ - Draw text with a black outline for better readability. - - Args: - draw: ImageDraw object - text: Text to draw - position: (x, y) position to draw the text - font: Font to use - fill: Text color (default: white) - outline_color: Outline color (default: black) + Fetches the full season schedule for NCAA Mens Basketball using background threading. + Returns cached data immediately if available, otherwise starts background fetch. """ - x, y = position - - # Draw the outline by drawing the text in black at 8 positions around the text - for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: - draw.text((x + dx, y + dy), text, font=font, fill=outline_color) - - # Draw the text in the specified color - draw.text((x, y), text, font=font, fill=fill) - - def _fetch_ncaam_basketball_api_data(self, use_cache: bool = True) -> Optional[Dict]: - """Fetch and cache data for all managers to share.""" now = datetime.now(pytz.utc) - date_str = now.strftime('%Y%m%d') - cache_key = f"ncaam_basketball_{date_str}" + season_year = now.year + cache_key = f"{self.sport_key}_schedule_{season_year}" + # Check cache first if use_cache: cached_data = self.cache_manager.get(cache_key) if cached_data: - self.logger.info(f"[NCAAMBasketball] Using cached data for {date_str}") - return cached_data - + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaam_basketball_api_data_sync(use_cache) + + # Start background fetch + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="ncaa_mens_basketball", + year=season_year, + url=ESPN_NCAAMB_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": season_year, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + + return None + + def _fetch_ncaam_basketball_api_data_sync( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_mens_basketball_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) try: - url = ESPN_NCAAMB_SCOREBOARD_URL - params = {'dates': date_str} - response = requests.get(url, params=params) + response = self.session.get( + ESPN_NCAAMB_SCOREBOARD_URL, + params={"dates": current_year, "limit": 1000}, + headers=self.headers, + timeout=15, + ) response.raise_for_status() data = response.json() - - # Increment API counter for sports data - increment_api_counter('sports', 1) - + events = data.get("events", []) + if use_cache: - self.cache_manager.set(cache_key, data) - - self.logger.info(f"[NCAAMBasketball] Successfully fetched data from ESPN API for {date_str}") - return data - except requests.exceptions.RequestException as e: - self.logger.error(f"[NCAAMBasketball] Error fetching data from ESPN: {e}") - return None + self.cache_manager.set(cache_key, events) - def _fetch_data(self, date_str: str = None) -> Optional[Dict]: - """ - Fetch data using background service cache first, fallback to direct API call. - This eliminates redundant caching and ensures Recent/Upcoming managers - use the same data source as the background service. - """ - # For Live managers, always fetch fresh data - if isinstance(self, NCAAMBasketballLiveManager): - return self._fetch_ncaam_basketball_api_data(use_cache=False) - - # For Recent/Upcoming managers, try to use background service cache first - from datetime import datetime - import pytz - cache_key = f"ncaam_basketball_{datetime.now(pytz.utc).strftime('%Y%m%d')}" - - # Check if background service has fresh data - if self.cache_manager.is_background_data_available(cache_key, 'ncaam_basketball'): - cached_data = self.cache_manager.get_background_cached_data(cache_key, 'ncaam_basketball') - if cached_data: - self.logger.info(f"[NCAAMBasketball] Using background service cache for {cache_key}") - return cached_data - - # Fallback to direct API call if background data not available - self.logger.info(f"[NCAAMBasketball] Background data not available, fetching directly for {cache_key}") - return self._fetch_ncaam_basketball_api_data(use_cache=True) - - def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: - """Extract relevant game details from ESPN API response.""" - if not game_event: - return None - - try: - competition = game_event["competitions"][0] - status = competition["status"] - competitors = competition["competitors"] - game_date_str = game_event["date"] - - # Parse game date/time - try: - start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) - self.logger.debug(f"[NCAAMBasketball] Parsed game time: {start_time_utc}") - except ValueError: - logging.warning(f"[NCAAMBasketball] Could not parse game date: {game_date_str}") - start_time_utc = None - - home_team = next(c for c in competitors if c.get("homeAway") == "home") - away_team = next(c for c in competitors if c.get("homeAway") == "away") - home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' - away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' - - # Don't show "0-0" records - set to blank instead - if home_record == "0-0": - home_record = '' - if away_record == "0-0": - away_record = '' - - # Format game time and date for display - game_time = "" - game_date = "" - if start_time_utc: - # Convert to local time - local_time = start_time_utc.astimezone(self._get_timezone()) - game_time = local_time.strftime("%I:%M%p").lstrip('0') - - # Check date format from config - use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False) - if use_short_date_format: - game_date = local_time.strftime("%-m/%-d") - else: - game_date = self.display_manager.format_date_with_ordinal(local_time) - - details = { - "start_time_utc": start_time_utc, - "status_text": status["type"]["shortDetail"], - "period": status.get("period", 0), - "clock": status.get("displayClock", "0:00"), - "is_live": status["type"]["state"] in ("in", "halftime"), # Include halftime as live - "is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", - "is_final": status["type"]["state"] == "post", - "is_upcoming": status["type"]["state"] == "pre", - "home_abbr": home_team["team"]["abbreviation"], - "home_score": home_team.get("score", "0"), - "home_record": home_record, - "home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"), - "away_abbr": away_team["team"]["abbreviation"], - "away_score": away_team.get("score", "0"), - "away_record": away_record, - "away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"), - "game_time": game_time, - "game_date": game_date, - "id": game_event.get("id") - } - - # Log game details for debugging - self.logger.debug(f"[NCAAMBasketball] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}") - self.logger.debug(f"[NCAAMBasketball] Game status: is_final={details['is_final']}, is_within_window={details['is_within_window']}") - - return details - except Exception as e: - logging.error(f"[NCAAMBasketball] Error extracting game details: {e}") - return None - - def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: - """Draw the scorebug layout for the current game.""" - try: - # Create a new black image for the main display - main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255)) - - # Load logos once - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) - - if not home_logo or not away_logo: - self.logger.error("Failed to load one or both team logos") - return - - # Create a single overlay for both logos - overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) - - # Calculate vertical center line for alignment - center_y = self.display_height // 2 - - # Draw home team logo (far right, extending beyond screen) - home_x = self.display_width - home_logo.width + 12 - home_y = center_y - (home_logo.height // 2) - - # Paste the home logo onto the overlay - overlay.paste(home_logo, (home_x, home_y), home_logo) - - # Draw away team logo (far left, extending beyond screen) - away_x = -12 - away_y = center_y - (away_logo.height // 2) - - # Paste the away logo onto the overlay - overlay.paste(away_logo, (away_x, away_y), away_logo) - - # Composite the overlay with the main image - main_img = Image.alpha_composite(main_img, overlay) - - # Convert to RGB for final display - main_img = main_img.convert('RGB') - draw = ImageDraw.Draw(main_img) - - # Check if this is an upcoming game - is_upcoming = game.get("is_upcoming", False) - - if is_upcoming: - # For upcoming games, show date and time stacked in the center - game_date = game.get("game_date", "") - game_time = game.get("game_time", "") - - # Show "Next Game" at the top - status_text = "Next Game" - status_width = draw.textlength(status_text, font=self.fonts['status']) - status_x = (self.display_width - status_width) // 2 - status_y = 2 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['status']) - - # Calculate position for the date text (centered horizontally, below "Next Game") - date_width = draw.textlength(game_date, font=self.fonts['time']) - date_x = (self.display_width - date_width) // 2 - date_y = center_y - 5 # Position in center - self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time']) - - # Calculate position for the time text (centered horizontally, in center) - time_width = draw.textlength(game_time, font=self.fonts['time']) - time_x = (self.display_width - time_width) // 2 - time_y = date_y + 10 # Position below date - self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time']) - else: - # For live/final games, show scores and period/time - home_score = str(game.get("home_score", "0")) - away_score = str(game.get("away_score", "0")) - score_text = f"{away_score}-{home_score}" - - # Calculate position for the score text (centered at the bottom) - score_width = draw.textlength(score_text, font=self.fonts['score']) - score_x = (self.display_width - score_width) // 2 - score_y = self.display_height - 10 - self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score']) - - # Draw period and time or Final - if game.get("is_final", False): - status_text = "Final" - status_width = draw.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 5 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) - elif game.get("is_halftime", False): - status_text = "Halftime" - status_width = draw.textlength(status_text, font=self.fonts['time']) - status_x = (self.display_width - status_width) // 2 - status_y = 5 - self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time']) - else: - period = game.get("period", 0) - clock = game.get("clock", "0:00") - - # Format period text for NCAA MB (Halves/OT) - if period == 1: - period_text = "1st H" - elif period == 2: - period_text = "2nd H" - elif period == 3: - period_text = "OT" - elif period > 3: - period_text = f"{period - 2}OT" # 2OT, 3OT etc. - else: - period_text = "" # Should not happen in live game normally - - # Draw period text at the top - period_width = draw.textlength(period_text, font=self.fonts['time']) - period_x = (self.display_width - period_width) // 2 - period_y = 1 - self._draw_text_with_outline(draw, period_text, (period_x, period_y), self.fonts['time']) - - # Draw clock below period - clock_width = draw.textlength(clock, font=self.fonts['time']) - clock_x = (self.display_width - clock_width) // 2 - clock_y = period_y + 10 # Position below period - self._draw_text_with_outline(draw, clock, (clock_x, clock_y), self.fonts['time']) - - # Display odds if available - if 'odds' in game and game['odds']: - self._draw_dynamic_odds(draw, game['odds'], self.display_width, self.display_height) - - # Draw records if enabled - if self.show_records: - try: - record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) - except IOError: - record_font = ImageFont.load_default() - - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') - - record_bbox = draw.textbbox((0,0), "0-0", font=record_font) - record_height = record_bbox[3] - record_bbox[1] - record_y = self.display_height - record_height - - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font) - - if home_record: - home_record_bbox = draw.textbbox((0,0), home_record, font=record_font) - home_record_width = home_record_bbox[2] - home_record_bbox[0] - home_record_x = self.display_width - home_record_width - self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font) - - # Display the image - self.display_manager.image.paste(main_img, (0, 0)) - self.display_manager.update_display() - - except Exception as e: - self.logger.error(f"Error displaying game: {e}", exc_info=True) - - def display(self, force_clear: bool = False) -> None: - """Common display method for all NCAAMBasketball managers""" - if not self.current_game: - current_time = time.time() - if not hasattr(self, '_last_warning_time'): - self._last_warning_time = 0 - if current_time - self._last_warning_time > 300: # 5 minutes cooldown - self.logger.warning("[NCAAMBasketball] No game data available to display") - self._last_warning_time = current_time - return - - self._draw_scorebug_layout(self.current_game, force_clear) - -class NCAAMBasketballLiveManager(BaseNCAAMBasketballManager): - """Manager for live NCAA MB games.""" - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): - super().__init__(config, display_manager, cache_manager) - self.update_interval = self.ncaam_basketball_config.get("live_update_interval", 15) # 15 seconds for live games - self.no_data_interval = 300 # 5 minutes when no live games - self.last_update = 0 - self.logger.info("Initialized NCAAMBasketball Live Manager") - self.live_games = [] # List to store all live games - self.current_game_index = 0 # Index to track which game to show - self.last_game_switch = 0 # Track when we last switched games - self.game_display_duration = self.ncaam_basketball_config.get("live_game_duration", 20) # Display each live game for 20 seconds - self.last_display_update = 0 # Track when we last updated the display - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - self.has_favorite_team_game = False # Track if we have any favorite team games - - # Initialize with test game only if test mode is enabled - if self.test_mode: - # Use the live game from _load_test_data - test_data = self._load_test_data() - live_test_event = next((e for e in test_data.get("events", []) if e["competitions"][0]["status"]["type"]["state"] == "in"), None) - if live_test_event: - self.current_game = self._extract_game_details(live_test_event) - if self.current_game: - self.live_games = [self.current_game] - self.logger.info(f"[NCAAMBasketball] Initialized NCAAMBasketballLiveManager with test game: {self.current_game['away_abbr']} vs {self.current_game['home_abbr']}") - else: - self.logger.warning("[NCAAMBasketball] Could not find live test game data to initialize.") - else: - self.logger.info("[NCAAMBasketball] Initialized NCAAMBasketballLiveManager in live mode") - - def update(self): - """Update live game data.""" - current_time = time.time() - - # Determine update interval based on whether we have favorite team games - if self.has_favorite_team_game: - interval = self.update_interval # Short interval for live favorite team games - else: - interval = self.no_data_interval # Longer interval when no favorite team games live - - if current_time - self.last_update >= interval: - self.last_update = current_time - - if self.test_mode: - # For testing, update the clock and maybe period - if self.current_game: - try: # Add try-except for robust clock parsing - minutes_str, seconds_str = self.current_game["clock"].split(":") - minutes = int(minutes_str) - seconds = int(seconds_str) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - # Simulate moving from H1 to H2 or H2 to OT - if self.current_game["period"] == 1: - self.current_game["period"] = 2 - minutes = 19 # Reset clock for H2 - seconds = 59 - elif self.current_game["period"] == 2: - self.current_game["period"] = 3 # Go to OT - minutes = 4 # Reset clock for OT - seconds = 59 - elif self.current_game["period"] >= 3: # OT+ - self.current_game["period"] += 1 - minutes = 4 - seconds = 59 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - self.display(force_clear=True) - except ValueError: - self.logger.warning(f"[NCAAMBasketball] Could not parse clock in test mode: {self.current_game.get('clock')}") - else: - # Fetch live game data from ESPN API - data = self._fetch_data() - if data and "events" in data: - # Find all live games involving favorite teams - new_live_games = [] - has_favorite_team = False - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: # is_live includes 'in' and 'halftime' - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_config.get("show_favorite_teams_only", False): - if not (details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams): - continue - - self._fetch_odds(details) - new_live_games.append(details) - if self.favorite_teams and ( - details["home_abbr"] in self.favorite_teams or - details["away_abbr"] in self.favorite_teams - ): - has_favorite_team = True - - # Update favorite team game status - self.has_favorite_team_game = has_favorite_team - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_live_games) != len(self.live_games) or - not self.live_games or # Log if we had no games before - has_favorite_team != self.has_favorite_team_game # Log if favorite team status changed - ) - - if should_log: - if new_live_games: - filter_text = "favorite teams" if self.ncaam_basketball_config.get("show_favorite_teams_only", False) else "all teams" - self.logger.info(f"[NCAAMBasketball] Found {len(new_live_games)} live games involving {filter_text}") - for game in new_live_games: - period = game.get('period', 0) - if game.get('is_halftime'): - status_str = "Halftime" - elif period == 1: - status_str = "H1" - elif period == 2: - status_str = "H2" - elif period == 3: - status_str = "OT" - elif period > 3: - status_str = f"{period-2}OT" - else: - status_str = f"P{period}" # Fallback - self.logger.info(f"[NCAAMBasketball] Live game: {game['away_abbr']} vs {game['home_abbr']} - {status_str}, {game['clock']}") - if has_favorite_team: - self.logger.info("[NCAAMBasketball] Found live game(s) for favorite team(s)") - else: - filter_text = "favorite teams" if self.ncaam_basketball_config.get("show_favorite_teams_only", False) else "criteria" - self.logger.info(f"[NCAAMBasketball] No live games found matching {filter_text}") - self.last_log_time = current_time - - if new_live_games: - # Update the current game with the latest data if it matches - current_game_updated = False - if self.current_game: # Ensure current_game is not None - for new_game in new_live_games: - if (new_game["home_abbr"] == self.current_game["home_abbr"] and - new_game["away_abbr"] == self.current_game["away_abbr"]) or \ - (new_game["home_abbr"] == self.current_game["away_abbr"] and - new_game["away_abbr"] == self.current_game["home_abbr"]): - self.current_game = new_game - current_game_updated = True - break - - # Only update the games list if there's a structural change - if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games): - self.live_games = new_live_games - # If we don't have a current game, it's not in the new list, or the list was empty, reset - if not self.current_game or not current_game_updated or not self.live_games: # Check self.live_games is not empty - self.current_game_index = 0 - self.current_game = self.live_games[0] if self.live_games else None # Handle empty self.live_games - self.last_game_switch = current_time - - # Cycle through games if multiple are present - elif len(self.live_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - self.current_game_index = (self.current_game_index + 1) % len(self.live_games) - self.current_game = self.live_games[self.current_game_index] - self.last_game_switch = current_time - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NCAAMBASKETBALL Live] Showing {away_abbr} vs {home_abbr}") - - - # Only update display if we have new data and enough time has passed - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - else: - # No live games found - self.live_games = [] - self.current_game = None - self.has_favorite_team_game = False - - def display(self, force_clear: bool = False): - """Display live game information.""" - if not self.current_game: - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - super().display(force_clear) # Call parent class's display method - -class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager): - """Manager for recently completed NCAA MB games.""" - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): - super().__init__(config, display_manager, cache_manager) - self.recent_games = [] - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaam_basketball_config.get("recent_update_interval", 3600) # Use config, default 1 hour - self.recent_games_to_show = self.ncaam_basketball_config.get("recent_games_to_show", 5) # Number of most recent games to display - self.last_game_switch = 0 - self.game_display_duration = self.ncaam_basketball_config.get("recent_game_duration", 15) # Configurable duration - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.logger.info(f"Initialized NCAAMBasketballRecentManager with {len(self.favorite_teams)} favorite teams") - - def update(self): - """Update recent games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - try: - # Fetch data from ESPN API (uses shared cache) - data = self._fetch_data() - if not data or 'events' not in data: - if self._should_log("no_events", 600): # Log less frequently for no events - self.logger.warning("[NCAAMBasketball] No events found in ESPN API response for recent games") - self.recent_games = [] - self.current_game = None - self.last_update = current_time - return - - events = data['events'] - - # Process games - new_recent_games = [] - for event in events: - game = self._extract_game_details(event) - # Filter for recent games: must be final - if game and game['is_final']: - self._fetch_odds(game) - new_recent_games.append(game) - - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_recent_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (most recent game for each team) - new_team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the most recent - team_specific_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - new_team_games.append(team_specific_games[0]) - - # Sort the final list by game time (most recent first) - new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - else: - new_team_games = new_recent_games - # Sort by game time (most recent first), then limit to recent_games_to_show - new_team_games.sort(key=lambda g: g.get('start_time_utc', datetime.min.replace(tzinfo=timezone.utc)), reverse=True) - new_team_games = new_team_games[:self.recent_games_to_show] - - # Only log if there's a change in games or enough time has passed - should_log = ( - current_time - self.last_log_time >= self.log_interval or - len(new_team_games) != len(self.recent_games) or - (new_team_games and not self.recent_games) # Log if we found games after having none + self.logger.info( + f"Successfully fetched {len(events)} events for the {current_year} season." ) + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error(f"API error fetching full schedule: {e}") + return None - if should_log: - if new_team_games: - self.logger.info(f"[NCAAMBasketball] Found {len(new_team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})") - elif self.favorite_teams: # Only log "none found" if favorites are configured - self.logger.info("[NCAAMBasketball] No recent games found for favorite teams") - self.last_log_time = current_time - - if new_team_games: - # Check if the games list actually changed before resetting index - if (len(new_team_games) != len(self.recent_games) or - any(g1 != g2 for g1, g2 in zip(new_team_games, self.recent_games))): - self.recent_games = new_team_games - self.current_game_index = 0 - self.current_game = self.recent_games[0] - self.last_game_switch = current_time # Reset switch timer on list update - else: - self.recent_games = [] - self.current_game = None + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAAMBasketballLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ncaam_basketball_api_data(use_cache=True) - self.last_update = current_time +class NCAAMBasketballLiveManager(BaseNCAAMBasketballManager, BasketballLive): + """Manager for live NCAA MB games.""" - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error updating recent games: {e}", exc_info=True) - self.recent_games = [] # Clear games on error - self.current_game = None - self.last_update = current_time # Still update time to prevent fast retry loops - - def display(self, force_clear=False): - """Display recent games.""" - if not self.recent_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - # Only log if favorite teams are configured - if self.favorite_teams: - self.logger.info("[NCAAMBasketball] No recent games for favorite teams to display") - else: - self.logger.info("[NCAAMBasketball] No recent games to display") - self.last_warning_time = current_time - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - - try: - current_time = time.time() - - # Check if it's time to switch games (only if more than one game) - if len(self.recent_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - # Move to next game - self.current_game_index = (self.current_game_index + 1) % len(self.recent_games) - self.current_game = self.recent_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force clear when switching games - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NCAAMBASKETBALL Recent] Showing {away_abbr} vs {home_abbr}") - - # If only one game, ensure it's set correctly - elif len(self.recent_games) == 1: - self.current_game = self.recent_games[0] - - # Draw the scorebug layout - if self.current_game: # Ensure we have a game before drawing - self._draw_scorebug_layout(self.current_game, force_clear) - # Update display - self.display_manager.update_display() - else: - self.logger.warning("[NCAAMBasketball] Current game is None in RecentManager display, despite having recent_games list.") - - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error displaying recent game: {e}", exc_info=True) - -class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager): - """Manager for upcoming NCAA MB games.""" - def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): super().__init__(config, display_manager, cache_manager) - self.upcoming_games = [] - self.current_game_index = 0 - self.last_update = 0 - self.update_interval = self.ncaam_basketball_config.get("upcoming_update_interval", 3600) # Use config, default 1 hour - self.upcoming_games_to_show = self.ncaam_basketball_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.last_game_switch = 0 - self.game_display_duration = self.ncaam_basketball_config.get("upcoming_game_duration", 15) # Configurable duration - self.logger.info(f"Initialized NCAAMBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams") + self.logger = logging.getLogger( + "NCAAMBasketballLiveManager" + ) # Changed logger name - def update(self): - """Update upcoming games data.""" - current_time = time.time() - if current_time - self.last_update < self.update_interval: - return - - try: - # Fetch data from ESPN API (uses shared cache) - data = self._fetch_data() - if not data or 'events' not in data: - if self._should_log("no_events_upcoming", 600): - self.logger.warning("[NCAAMBasketball] No events found in ESPN API response for upcoming games") - self.upcoming_games = [] - self.current_game = None - self.last_update = current_time - return - - events = data['events'] - if self._should_log("fetch_success_upcoming", 300): - self.logger.info(f"[NCAAMBasketball] Successfully fetched {len(events)} events from ESPN API for upcoming check") - - # Process games - new_upcoming_games = [] - for event in events: - game = self._extract_game_details(event) - if game and game['is_upcoming']: - self._fetch_odds(game) - new_upcoming_games.append(game) - self.logger.debug(f"Processing upcoming game: {game['away_abbr']} vs {game['home_abbr']}") - - # Filter for favorite teams only if the config is set - if self.ncaam_basketball_config.get("show_favorite_teams_only", False): - # Get all games involving favorite teams - favorite_team_games = [game for game in new_upcoming_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # Select one game per favorite team (earliest upcoming game for each team) - team_games = [] - for team in self.favorite_teams: - # Find games where this team is playing - team_specific_games = [game for game in favorite_team_games - if game['home_abbr'] == team or game['away_abbr'] == team] - - if team_specific_games: - # Sort by game time and take the earliest - team_specific_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc))) - team_games.append(team_specific_games[0]) - - # Sort the final list by game time - team_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc))) - else: - team_games = new_upcoming_games - # Sort by game time (soonest first), then limit to configured count - team_games.sort(key=lambda g: g.get('start_time_utc', datetime.max.replace(tzinfo=timezone.utc))) - team_games = team_games[:self.upcoming_games_to_show] + if self.test_mode: + # More detailed test game for NCAA MB + self.current_game = { + "id": "test001", + "home_abbr": "AUB", + "home_id": "123", + "away_abbr": "GT", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "AUB.png"), + "away_logo_path": Path(self.logo_dir, "GT.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAMBasketballLiveManager with test game: GT vs AUB" + ) + else: + self.logger.info(" Initialized NCAAMBasketballLiveManager in live mode") - if self._should_log("team_games_upcoming", 300): - if team_games: - self.logger.info(f"[NCAAMBasketball] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})") - elif self.favorite_teams: # Only log "none found" if favorites configured - self.logger.info("[NCAAMBasketball] No upcoming games found for favorite teams") +class NCAAMBasketballRecentManager(BaseNCAAMBasketballManager, SportsRecent): + """Manager for recently completed NCAA MB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAMBasketballRecentManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAMBasketballRecentManager with {len(self.favorite_teams)} favorite teams" + ) - if team_games: - # Check if the games list actually changed before resetting index - if (len(team_games) != len(self.upcoming_games) or - any(g1 != g2 for g1, g2 in zip(team_games, self.upcoming_games))): - self.upcoming_games = team_games - self.current_game_index = 0 - self.current_game = self.upcoming_games[0] - self.last_game_switch = current_time # Reset switch timer - else: - self.upcoming_games = [] - self.current_game = None +class NCAAMBasketballUpcomingManager(BaseNCAAMBasketballManager, SportsUpcoming): + """Manager for upcoming NCAA MB games.""" - self.last_update = current_time - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error updating upcoming games: {e}", exc_info=True) - self.upcoming_games = [] # Clear games on error - self.current_game = None - self.last_update = current_time # Still update time - - def display(self, force_clear=False): - """Display upcoming games.""" - if not self.upcoming_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - if self.favorite_teams: - self.logger.info("[NCAAMBasketball] No upcoming games for favorite teams to display") - else: - self.logger.info("[NCAAMBasketball] No upcoming games to display") - self.last_warning_time = current_time - # Explicitly clear display if there's nothing to show - img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0)) - self.display_manager.image.paste(img, (0, 0)) - self.display_manager.update_display() - return - - try: - current_time = time.time() - - # Check if it's time to switch games (only if more than one game) - if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration: - # Move to next game - self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games) - self.current_game = self.upcoming_games[self.current_game_index] - self.last_game_switch = current_time - force_clear = True # Force clear when switching games - - # Log team switching - if self.current_game: - away_abbr = self.current_game.get('away_abbr', 'UNK') - home_abbr = self.current_game.get('home_abbr', 'UNK') - self.logger.info(f"[NCAAMBASKETBALL Upcoming] Showing {away_abbr} vs {home_abbr}") - - # If only one game, ensure it's set - elif len(self.upcoming_games) == 1: - self.current_game = self.upcoming_games[0] - - - # Draw the scorebug layout - if self.current_game: # Ensure we have a game to draw - self._draw_scorebug_layout(self.current_game, force_clear) - # Update display - self.display_manager.update_display() - else: - self.logger.warning("[NCAAMBasketball] Current game is None in UpcomingManager display, despite having upcoming_games list.") - - - except Exception as e: - self.logger.error(f"[NCAAMBasketball] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAMBasketballUpcomingManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAMBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/src/ncaaw_basketball_managers.py b/src/ncaaw_basketball_managers.py new file mode 100644 index 00000000..2b865e0e --- /dev/null +++ b/src/ncaaw_basketball_managers.py @@ -0,0 +1,274 @@ +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager + +# Import the API counter function from web interface +try: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + + +# Constants +ESPN_NCAAWB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard" + + +class BaseNCAAWBasketballManager(Basketball): + """Base class for NCAA WB managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _last_log_times = {} + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("NCAAWB") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="ncaaw_basketball", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("ncaaw_basketball_recent", False) + self.upcoming_enabled = display_modes.get("ncaaw_basketball_upcoming", False) + self.live_enabled = display_modes.get("ncaaw_basketball_live", False) + + self.logger.info( + f"Initialized NCAA Womens Basketball manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "womens-college-basketball" + + def _fetch_ncaaw_basketball_api_data( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Fetches the full season schedule for NCAA Womens Basketball using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_ncaaw_basketball_api_data_sync(use_cache) + + # Start background fetch + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="ncaa_womens_basketball", + year=season_year, + url=ESPN_NCAAWB_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": season_year, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + + return None + + def _fetch_ncaaw_basketball_api_data_sync( + self, use_cache: bool = True + ) -> Optional[Dict]: + """ + Synchronous fallback for fetching NFL data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"ncaa_womens_basketball_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) + try: + response = self.session.get( + ESPN_NCAAWB_SCOREBOARD_URL, + params={"dates": current_year, "limit": 1000}, + headers=self.headers, + timeout=15, + ) + response.raise_for_status() + data = response.json() + events = data.get("events", []) + + if use_cache: + self.cache_manager.set(cache_key, events) + + self.logger.info( + f"Successfully fetched {len(events)} events for the {current_year} season." + ) + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error(f"API error fetching full schedule: {e}") + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, NCAAWBasketballLiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_ncaaw_basketball_api_data(use_cache=True) + + +class NCAAWBasketballLiveManager(BaseNCAAWBasketballManager, BasketballLive): + """Manager for live NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballLiveManager" + ) # Changed logger name + + if self.test_mode: + # More detailed test game for NCAA WB + self.current_game = { + "id": "test001", + "home_abbr": "AUB", + "home_id": "123", + "away_abbr": "GT", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "AUB.png"), + "away_logo_path": Path(self.logo_dir, "GT.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info( + "Initialized NCAAWBasketballLiveManager with test game: GT vs AUB" + ) + else: + self.logger.info(" Initialized NCAAWBasketballLiveManager in live mode") + + +class NCAAWBasketballRecentManager(BaseNCAAWBasketballManager, SportsRecent): + """Manager for recently completed NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballRecentManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWBasketballRecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class NCAAWBasketballUpcomingManager(BaseNCAAWBasketballManager, SportsUpcoming): + """Manager for upcoming NCAA WB games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger( + "NCAAWBasketballUpcomingManager" + ) # Changed logger name + self.logger.info( + f"Initialized NCAAWBasketballUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) diff --git a/src/nfl_managers.py b/src/nfl_managers.py index 730e0669..49e7ff2b 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -23,13 +23,10 @@ class BaseNFLManager(Football): # Renamed class _warning_cooldown = 60 # Only log warnings once per minute _shared_data = None _last_shared_update = 0 - + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.logger = logging.getLogger('NFL') # Changed logger name super().__init__(config=config, display_manager=display_manager, cache_manager=cache_manager, logger=self.logger, sport_key="nfl") - - # Configuration is already set in base class - # self.logo_dir and self.update_interval are already configured # Check display modes to determine what data to fetch display_modes = self.mode_config.get("display_modes", {}) diff --git a/src/wnba_managers.py b/src/wnba_managers.py new file mode 100644 index 00000000..88fc918f --- /dev/null +++ b/src/wnba_managers.py @@ -0,0 +1,304 @@ +import logging +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytz +import requests + +from src.base_classes.basketball import Basketball, BasketballLive +from src.base_classes.sports import SportsRecent, SportsUpcoming +from src.cache_manager import CacheManager +from src.display_manager import DisplayManager + +# Import the API counter function from web interface +try: + from web_interface_v2 import increment_api_counter +except ImportError: + # Fallback if web interface is not available + def increment_api_counter(kind: str, count: int = 1): + pass + + +# Constants +ESPN_WNBA_SCOREBOARD_URL = ( + "https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard" +) + + +class BaseWNBAManager(Basketball): + """Base class for WNBA managers with common functionality.""" + + # Class variables for warning tracking + _no_data_warning_logged = False + _last_warning_time = 0 + _warning_cooldown = 60 # Only log warnings once per minute + _last_log_times = {} + _shared_data = None + _last_shared_update = 0 + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + self.logger = logging.getLogger("WNBA") # Changed logger name + super().__init__( + config=config, + display_manager=display_manager, + cache_manager=cache_manager, + logger=self.logger, + sport_key="wnba", + ) + + # Check display modes to determine what data to fetch + display_modes = self.mode_config.get("display_modes", {}) + self.recent_enabled = display_modes.get("wnba_recent", False) + self.upcoming_enabled = display_modes.get("wnba_upcoming", False) + self.live_enabled = display_modes.get("wnba_live", False) + + self.logger.info( + f"Initialized WNBA manager with display dimensions: {self.display_width}x{self.display_height}" + ) + self.logger.info(f"Logo directory: {self.logo_dir}") + self.logger.info( + f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}" + ) + self.league = "wnba" + + def _fetch_wnba_api_data(self, use_cache: bool = True) -> Optional[Dict]: + """ + Fetches the full season schedule for WNBA using background threading. + Returns cached data immediately if available, otherwise starts background fetch. + """ + now = datetime.now(pytz.utc) + season_year = now.year + if now.month < 2: + season_year = now.year - 1 + datestring = f"{season_year}0401-{season_year+1}1101" + cache_key = f"{self.sport_key}_schedule_{season_year}" + + # Check cache first + if use_cache: + cached_data = self.cache_manager.get(cache_key) + if cached_data: + # Validate cached data structure + if isinstance(cached_data, dict) and "events" in cached_data: + self.logger.info(f"Using cached schedule for {season_year}") + return cached_data + elif isinstance(cached_data, list): + # Handle old cache format (list of events) + self.logger.info( + f"Using cached schedule for {season_year} (legacy format)" + ) + return {"events": cached_data} + else: + self.logger.warning( + f"Invalid cached data format for {season_year}: {type(cached_data)}" + ) + # Clear invalid cache + self.cache_manager.clear_cache(cache_key) + + # If background service is disabled, fall back to synchronous fetch + if not self.background_enabled or not self.background_service: + return self._fetch_wnba_api_data_sync(use_cache) + + # Start background fetch + self.logger.info( + f"Starting background fetch for {season_year} season schedule..." + ) + + def fetch_callback(result): + """Callback when background fetch completes.""" + if result.success: + self.logger.info( + f"Background fetch completed for {season_year}: {len(result.data.get('events'))} events" + ) + else: + self.logger.error( + f"Background fetch failed for {season_year}: {result.error}" + ) + + # Clean up request tracking + if season_year in self.background_fetch_requests: + del self.background_fetch_requests[season_year] + + # Get background service configuration + background_config = self.mode_config.get("background_service", {}) + timeout = background_config.get("request_timeout", 30) + max_retries = background_config.get("max_retries", 3) + priority = background_config.get("priority", 2) + + # Submit background fetch request + request_id = self.background_service.submit_fetch_request( + sport="nba", + year=season_year, + url=ESPN_WNBA_SCOREBOARD_URL, + cache_key=cache_key, + params={"dates": datestring, "limit": 1000}, + headers=self.headers, + timeout=timeout, + max_retries=max_retries, + priority=priority, + callback=fetch_callback, + ) + + # Track the request + self.background_fetch_requests[season_year] = request_id + + # For immediate response, try to get partial data + partial_data = self._get_weeks_data() + if partial_data: + return partial_data + + return None + + def _fetch_wnba_api_data_sync(self, use_cache: bool = True) -> Optional[Dict]: + """ + Synchronous fallback for fetching WNBA data when background service is disabled. + """ + now = datetime.now(pytz.utc) + current_year = now.year + cache_key = f"nba_schedule_{current_year}" + + self.logger.info( + f"Fetching full {current_year} season schedule from ESPN API (sync mode)..." + ) + try: + response = self.session.get( + ESPN_WNBA_SCOREBOARD_URL, + params={"dates": current_year, "limit": 1000}, + headers=self.headers, + timeout=15, + ) + response.raise_for_status() + data = response.json() + events = data.get("events", []) + + if use_cache: + self.cache_manager.set(cache_key, events) + + self.logger.info( + f"Successfully fetched {len(events)} events for the {current_year} season." + ) + return {"events": events} + except requests.exceptions.RequestException as e: + self.logger.error(f"API error fetching full schedule: {e}") + return None + + def _fetch_data(self) -> Optional[Dict]: + """Fetch data using shared data mechanism or direct fetch for live.""" + if isinstance(self, WNBALiveManager): + # Live games should fetch only current games, not entire season + return self._fetch_todays_games() + else: + # Recent and Upcoming managers should use cached season data + return self._fetch_wnba_api_data(use_cache=True) + + +class WNBALiveManager(BaseWNBAManager, BasketballLive): + """Manager for live NBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBALiveManager") # Changed logger name + + if self.test_mode: + # More detailed test game for NBA + self.current_game = { + "id": "test001", + "home_abbr": "CHI", + "home_id": "123", + "away_abbr": "ATL", + "away_id": "asdf", + "home_score": "21", + "away_score": "17", + "period": 3, + "period_text": "Q3", + "clock": "5:24", + "home_logo_path": Path(self.logo_dir, "CHI.png"), + "away_logo_path": Path(self.logo_dir, "ATL.png"), + "is_live": True, + "is_final": False, + "is_upcoming": False, + "is_halftime": False, + } + self.live_games = [self.current_game] + self.logger.info("Initialized WNBALiveManager with test game: BUF vs KC") + else: + self.logger.info(" Initialized WNBALiveManager in live mode") + + +class WNBARecentManager(BaseWNBAManager, SportsRecent): + """Manager for recently completed WNBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBARecentManager") # Changed logger name + self.logger.info( + f"Initialized WNBARecentManager with {len(self.favorite_teams)} favorite teams" + ) + + +class WNBAUpcomingManager(BaseWNBAManager, SportsUpcoming): + """Manager for upcoming WNBA games.""" + + def __init__( + self, + config: Dict[str, Any], + display_manager: DisplayManager, + cache_manager: CacheManager, + ): + super().__init__(config, display_manager, cache_manager) + self.logger = logging.getLogger("WNBAUpcomingManager") # Changed logger name + self.logger.info( + f"Initialized WNBAUpcomingManager with {len(self.favorite_teams)} favorite teams" + ) + + """Display upcoming games.""" + if not self.upcoming_games: + return + + try: + current_time = time.time() + + # Check if it's time to switch games + if ( + len(self.upcoming_games) > 1 + and current_time - self.last_game_switch >= self.game_display_duration + ): + # Move to next game + self.current_game_index = (self.current_game_index + 1) % len( + self.upcoming_games + ) + self.current_game = self.upcoming_games[self.current_game_index] + self.last_game_switch = current_time + force_clear = True + + # Log team switching + if self.current_game: + away_abbr = self.current_game.get("away_abbr", "UNK") + home_abbr = self.current_game.get("home_abbr", "UNK") + self.logger.info( + f"[NBA Upcoming] Showing {away_abbr} vs {home_abbr}" + ) + + # Draw the scorebug layout + self._draw_scorebug_layout(self.current_game, force_clear) + + except Exception as e: + self.logger.error( + f"[NBA] Error displaying upcoming game: {e}", exc_info=True + )