diff --git a/LEDMatrix.wiki b/LEDMatrix.wiki index a01c72e1..8d2c1439 160000 --- a/LEDMatrix.wiki +++ b/LEDMatrix.wiki @@ -1 +1 @@ -Subproject commit a01c72e156b46c08a5ef1c67db79acd73300a6f7 +Subproject commit 8d2c143954db2589d9241b73ce2aaef17aa2ddab diff --git a/assets/broadcast_logos/cbs.png b/assets/broadcast_logos/cbs.png index 61cc47dd..45b629fb 100644 Binary files a/assets/broadcast_logos/cbs.png and b/assets/broadcast_logos/cbs.png differ diff --git a/assets/broadcast_logos/nfln.png b/assets/broadcast_logos/nfln.png index 433ab8d2..4f427cd1 100644 Binary files a/assets/broadcast_logos/nfln.png and b/assets/broadcast_logos/nfln.png differ diff --git a/assets/broadcast_logos/paramount-plus.png b/assets/broadcast_logos/paramount-plus.png new file mode 100644 index 00000000..30ee8c3e Binary files /dev/null and b/assets/broadcast_logos/paramount-plus.png differ diff --git a/assets/broadcast_logos/prime.png b/assets/broadcast_logos/prime.png new file mode 100644 index 00000000..a75ff9f3 Binary files /dev/null and b/assets/broadcast_logos/prime.png differ diff --git a/assets/broadcast_logos/tnt.png b/assets/broadcast_logos/tnt.png index 79cc104e..43f37a74 100644 Binary files a/assets/broadcast_logos/tnt.png and b/assets/broadcast_logos/tnt.png differ diff --git a/assets/sports/all_team_abbreviations.txt b/assets/sports/all_team_abbreviations.txt index 349c4fdc..4397af35 100644 --- a/assets/sports/all_team_abbreviations.txt +++ b/assets/sports/all_team_abbreviations.txt @@ -1,404 +1,53 @@ NCAAF - AAMU => Alabama A&M Bulldogs - ACU => Abilene Christian Wildcats - ADA => Adams State Grizzlies - ADR => Adrian Bulldogs - AFA => Air Force Falcons - AIC => American International Yellow Jackets - AKR => Akron Zips - ALA => Alabama Crimson Tide - ALB => Albright Lions - ALBS => Albany State (GA) Golden Rams - ALCN => Alcorn State Braves - ALD => Alderson Broaddus Battlers - ALF => Alfred Saxons - ALL => Allegheny Gators - ALST => Alabama State Hornets - AMH => Amherst College Mammoths - AND => Anderson (IN) Ravens - ANG => Angelo State Rams + AMH => Amherst Mammoths ANN => Anna Maria College Amcats - APP => Appalachian State Mountaineers - APSU => Austin Peay Governors ARIZ => Arizona Wildcats - ARK => Arkansas-Monticello Boll Weevils - ARMY => Army Black Knights - ARST => Arkansas State Red Wolves - ASH => Ashland Eagles - ASP => Assumption Greyhounds + ARK => Arkansas Razorbacks ASU => Arizona State Sun Devils AUB => Auburn Tigers - AUG => St. Augustine's Falcons - AUR => Aurora Spartans - AUS => Austin College 'Roos - AVE => Averett Cougars - AVI => Avila College Eagles - AZU => Azusa Pacific Cougars - BAK => Baker University Wildcats - BAL => Baldwin Wallace Yellow Jackets - BALL => Ball State Cardinals - BAT => Bates College Bobcats - BAY => Baylor Bears - BC => Boston College Eagles - BEC => Becker College Hawks - BEL => Beloit College Buccaneers - BEN => Benedictine University (IL) Eagles - BENT => Bentley Falcons - BET => Bethel (TN) Wildcats - BGSU => Bowling Green Falcons - BHS => Black Hills State Yellow Jackets - BIR => Birmingham-Southern Panthers - BKN => Bacone College Warriors - BLA => Blackburn Beavers - BLOM => Bloomsburg Huskies - BLU => Bluffton Beavers - BOW => Bowdoin Polar Bears - BRI => British Columbia Thunderbirds - BRWN => Brown Bears - BST => Bemidji State Beavers - BSU => Bowie State Bulldogs - BUCK => Bucknell Bison - BUE => Buena Vista Beavers - BUF => Buffalo State Bengals - BUFF => Buffalo Bulls - BUT => Butler Bulldogs - BYU => BYU Cougars - CAL => California Lutheran Kingsmen - CAM => Campbell Fighting Camels - CAP => Capital University Crusaders - CAR => Carthage College Red Men - CARK => Central Arkansas Bears - CAS => Castleton Spartans - CAT => Catholic University Cardinals - CCSU => Central Connecticut Blue Devils - CCU => Coastal Carolina Chanticleers - CEN => Centre College Colonels - CHA => Chapman University Panthers - CHI => Chicago Maroons - CHSO => Charleston Southern Buccaneers - CIN => Cincinnati Bearcats - CLA => Clarion Golden Eagles - CLEM => Clemson Tigers - CLMB => Columbia Lions - CLT => Charlotte 49ers - CMU => Central Michigan Chippewas - COE => Coe College Kohawks - COL => Colorado School of Mines Orediggers - COLC => Colorado College Tigers - COLG => Colgate Raiders + BOIS => Boise State Broncos + BRST => Bridgewater State Bears + BUENA => Buena Vista Beavers + CAL => California Golden Bears + CAR => Carroll University (WI) Pioneers + CLA => Claremont-Mudd-Scripps College Stags + COLBY => Colby College White Mules COLO => Colorado Buffaloes - CON => Concordia-Minnesota Cobbers - COR => Cornell College (IA) Rams + CONN => UConn Huskies CP => Cal Poly Mustangs - CRO => Crown Storm CSU => Colorado State Rams - CUL => Culver-Stockton Wildcats - CUM => Cumberland College Indians CUR => Curry College Colonels - DAK => Dakota Wesleyan Tigers - DART => Dartmouth Big Green - DAV => Davidson Wildcats - DAY => Dayton Flyers - DEF => Defiance Yellow Jackets - DEL => Delta State Statesmen - DEN => Denison Big Red - DEP => DePauw Tigers - DIC => Dickinson State Blue Hawks - DRKE => Drake Bulldogs - DSU => Delaware State Hornets + DEL => Delaware Blue Hens DUB => Dubuque Spartans - DUKE => Duke Blue Devils - DUQ => Duquesne Dukes - EAS => Eastern New Mexico Greyhounds - ECU => East Carolina Pirates - EDI => Edinboro Fighting Scots - EIU => Eastern Illinois Panthers - EKU => Eastern Kentucky Colonels - ELI => Elizabeth City State Vikings - ELM => Elmhurst Blue Jays - ELON => Elon Phoenix - EMO => Emory & Henry Wasps - EMP => Emporia State Hornets - EMU => Eastern Michigan Eagles - END => Endicott College Gulls - EOR => Eastern Oregon Mountaineers - ETSU => East Tennessee State Buccaneers - EUR => Eureka College Red Devils - EWU => Eastern Washington Eagles - FAU => Florida Atlantic Owls - FAY => Fayetteville State Broncos - FDU => FDU-Florham Devils - FER => Ferrum Panthers - FIN => Findlay Oilers - FIT => Fitchburg State Falcons - FIU => Florida International Panthers + ELM => Elmhurst Bluejays + FAMU => Florida A&M Rattlers FLA => Florida Gators - FOR => Fort Valley State Wildcats - FRA => Franklin Grizzlies - FRES => Fresno State Bulldogs - FRO => Frostburg State Bobcats - FRST => Ferris State Bulldogs FSU => Florida State Seminoles - FTLW => Fort Lewis Skyhawks - FUR => Furman Paladins - GAL => Gallaudet Bison - GAN => Gannon Golden Knights - GASO => Georgia Southern Eagles - GAST => Georgia State Panthers - GEN => Geneva College Golden Tornadoes - GEO => George Fox University Bruins - GET => Gettysburg Bullets - GLE => Glenville State Pioneers - GMU => George Mason Patriots - GRA => Grand Valley State Lakers - GRE => Greenville Panthers GRI => Grinnell Pioneers - GRO => Grove City College Wolverines GT => Georgia Tech Yellow Jackets - GUI => Guilford Quakers - GWEB => Gardner-Webb Bulldogs - HAM => Hampden-Sydney Tigers - HAMP => Hampton Pirates - HAN => Hanover Panthers - HAR => Hartwick Hawks - HARV => Harvard Crimson - HAS => Haskell Indian Nations Jayhawks + GTWN => Georgetown Hoyas HAW => Hawai'i Rainbow Warriors - HBU => Houston Baptist Huskies - HC => Holy Cross Crusaders - HEI => Heidelberg Student Princes - HEN => Hendrix College Warriors - HIL => Hillsdale Chargers - HIR => Hiram College Terriers - HOB => Hobart Statesmen - HOU => Houston Cougars HOW => Howard Bison - HUS => Husson Eagles IDHO => Idaho Vandals - IDST => Idaho State Bengals - ILL => Illinois Fighting Illini - ILST => Illinois State Redbirds - ILW => Illinois Wesleyan Titans - IND => Indianapolis - INST => Indiana State Sycamores - IOW => Iowa Wesleyan Tigers - IOWA => Iowa Hawkeyes ISU => Iowa State Cyclones - ITH => Ithaca Bombers - IU => Indiana Hoosiers - JKST => Jackson State Tigers - JMU => James Madison Dukes - JOH => Johnson C Smith Golden Bulls - JUN => Juniata Eagles - JVST => Jacksonville State Gamecocks - KAL => Kalamazoo Hornets - KAN => Kansas Wesleyan University Coyotes - KEN => Kenyon Lords - KENN => Kennesaw State Owls - KENT => Kent State Golden Flashes - KIN => King's College (PA) Monarchs - KNO => Knox College Prairie Fire - KSU => Kansas State Wildcats - KU => Kansas Jayhawks - KUT => Kutztown Golden Bears - KYST => Kentucky State Thorobreds - KYW => Kentucky Wesleyan Panthers - LA => La Verne Leopards - LAC => Lane Dragons - LAF => Lafayette Leopards - LAG => LaGrange College Panthers - LAK => Lake Forest Foresters - LAM => Lambuth Eagles - LAN => Langston Lions - LAW => Lawrence Vikings - LEB => Lebanon Valley Flying Dutchmen - LEH => Lehigh Mountain Hawks - LEN => Lenoir-Rhyne Bears - LEW => Lewis & Clark Pioneers - LIB => Liberty Flames - LIM => Limestone Saints - LIN => Linfield Wildcats - LOC => Lock Haven Bald Eagles - LOR => Loras College Duhawks - LOU => Louisville Cardinals - LSU => LSU Tigers - LT => Louisiana Tech Bulldogs + JXST => Jacksonville State Gamecocks LUT => Luther Norse - LYC => Lycoming Warriors - M-OH => Miami (OH) RedHawks - MAC => Macalester Scots - MAI => Maine Maritime Mariners - MAN => Mansfield Mountaineers - MAR => Maryville College Fighting Scots - MAS => Mass Maritime Buccaneers - MASS => UMass Minutemen - MAY => Mayville State Comets - MCM => McMurry War Hawks - MCN => McNeese Cowboys - MD => Maryland Terrapins - MEM => Memphis Tigers - MEN => Menlo College Oaks - MER => Merchant Marine Mariners - MERC => Mercyhurst Lakers - MES => Colorado Mesa Mavericks - MET => Methodist Monarchs - MH => Mars Hill Mountain Lions - MIAMI => Miami Hurricanes - MICH => Michigan Wolverines - MID => Midwestern State Mustangs - MIL => Millsaps Majors - MIN => Minot State Beavers - MINN => Minnesota Golden Gophers - MIS => Missouri Western Griffons - MISS => Ole Miss Rebels - MIZ => Missouri Tigers - MNST => Minnesota State Mavericks - MONM => Monmouth Hawks - MONT => Montana Grizzlies - MOR => Morningside Chiefs - MORE => Morehead State Eagles - MORG => Morgan State Bears - MOU => Mount Union Raiders - MRSH => Marshall Thundering Herd - MRST => Marist Red Foxes - MSST => Mississippi State Bulldogs - MSU => Michigan State Spartans - MTST => Montana State Bobcats - MTSU => Middle Tennessee Blue Raiders - MTU => Michigan Tech Huskies - MUH => Muhlenberg Mules - MUR => Murray State Racers - MUS => Muskingum Fighting Muskies - MVSU => Mississippi Valley State Delta Devils - NAU => Northern Arizona Lumberjacks - NAVY => Navy Midshipmen - NBY => Newberry Wolves - NCAT => North Carolina A&T Aggies - NCCU => North Carolina Central Eagles - NCST => NC State Wolfpack - ND => Notre Dame Fighting Irish - NDOH => Notre Dame College Falcons - NDSU => North Dakota State Bison - NEB => Nebraska-Kearney Lopers - NEV => Nevada Wolf Pack - NH => New Haven Chargers - NICH => Nicholls Colonels - NIU => Northern Illinois Huskies - NMH => New Mexico Highlands Cowboys - NMI => Northern Michigan Wildcats - NMSU => New Mexico State Aggies - NOR => Univ. of Northwestern-St. Paul Eagles - NORF => Norfolk State Spartans - NW => Northwestern Wildcats - OBE => Oberlin Yeomen - ODU => Old Dominion Monarchs - OHI => Ohio Northern Polar Bears - OHIO => Ohio Bobcats - OKL => Oklahoma Baptist Bison - OKST => Oklahoma State Cowboys - OLI => Olivet College Comets - OMA => Omaha Mavericks - ORST => Oregon State Beavers - OSU => Ohio State Buckeyes - OTT => Otterbein Cardinals - OU => Oklahoma Sooners - PAC => Pacific (OR) Boxers - PENN => Pennsylvania Quakers - PIKE => Pikeville Bears - PITT => Pittsburgh Panthers - PRE => Presentation College Saints - PRI => Principia College Panthers - PRIN => Princeton Tigers - PST => Pittsburg State Gorillas - PSU => Penn State Nittany Lions + MESA => Colorado Mesa Mavericks + MIL => Millikin Big Blue + MOR => Morehouse College Maroon Tigers + NOR => North Park Vikings RED => Redlands Bulldogs - RICE => Rice Owls - RICH => Richmond Spiders - RIT => Rochester Yellow Jackets - ROB => Robert Morris (IL) Eagles - ROS => Rose-Hulman Engineers - RUTG => Rutgers Scarlet Knights SAC => Sacramento State Hornets - SAG => Saginaw Valley Cardinals - SDAK => South Dakota Coyotes SDSU => San Diego State Aztecs - SET => Seton Hill Griffins - SIU => Southern Illinois Salukis SJSU => San José State Spartans - SLI => Slippery Rock The Rock - SOU => Southwestern College Moundbuilders - SPR => Springfield College Pride - ST => St. Scholastica Saints STAN => Stanford Cardinal - STE => Stevenson University Mustangs STET => Stetson Hatters - STO => Stonehill College Skyhawks - SUS => Susquehanna University River Hawks - SUU => Southern Utah Thunderbirds - SYR => Syracuse Orange - TA&M => Texas A&M Aggies - TAY => Taylor Trojans - TEM => Temple Owls - TEX => Texas Longhorns - TIF => Tiffin University Dragons - TLSA => Tulsa Golden Hurricane - TRI => Trinity University (TX) Tigers - TUF => Tufts University Jumbos - TXST => Texas State Bobcats UAB => UAB Blazers - UAPB => Arkansas-Pine Bluff Golden Lions - UCD => UC Davis Aggies - UCF => UCF Knights UCLA => UCLA Bruins - UCONN => UConn Huskies UGA => Georgia Bulldogs - UK => Kentucky Wildcats - UL => Louisiana Ragin' Cajuns - ULM => UL Monroe Warhawks - UMD => Minnesota-Duluth Bulldogs - UMDA => UMASS Dartmouth Corsairs - UML => UMass Lowell River Hawks - UNA => North Alabama Lions - UNC => North Carolina Tar Heels - UNCO => Northern Colorado Bears - UND => North Dakota Fighting Hawks - UNH => New Hampshire Wildcats - UNI => University of Mary Marauders - UNLV => UNLV Rebels - UNM => New Mexico Lobos - UNNY => Union Dutchmen - UNT => North Texas Mean Green - UPP => Upper Iowa Peacocks - URI => Rhode Island Rams USA => South Alabama Jaguars USC => USC Trojans - USD => San Diego Toreros USF => South Florida Bulls - USU => Utah State Aggies - UTAH => Utah Utes - UTC => Chattanooga Mocs - UTI => Utica College Pioneers - UVA => Virginia Cavaliers - VAL => Valley City State Vikings - VAN => Vanderbilt Commodores - VILL => Villanova Wildcats - VIR => Virginia State Trojans - VT => Virginia Tech Hokies - WAB => Wabash College Little Giants - WAKE => Wake Forest Demon Deacons - WAS => Washington-Missouri Bears - WASH => Washington Huskies - WAY => Wayne State (MI) Warriors - WES => Westminster College (MO) Blue Jays - WHE => Wheaton College Illinois Thunder - WIL => Wilkes University Colonels - WIN => Wingate Bulldogs - WIS => Wisconsin-Platteville Pioneers - WISC => Wisconsin Badgers - WKU => Western Kentucky Hilltoppers - WOR => Worcester State College Lancers - WSU => Washington State Cougars - WVU => West Virginia Mountaineers YALE => Yale Bulldogs NBA @@ -1106,6 +755,149 @@ MLB Conferences/Divisions OAK => Oakland Athletics SEA => Seattle Mariners TEX => Texas Rangers +Soccer - Premier League (England) + ARS => Arsenal + AVL => Aston Villa + BHA => Brighton & Hove Albion + BOU => AFC Bournemouth + BRE => Brentford + BUR => Burnley + CHE => Chelsea + CRY => Crystal Palace + EVE => Everton + FUL => Fulham + LIV => Liverpool + LUT => Luton Town + MCI => Manchester City + MUN => Manchester United + NEW => Newcastle United + NFO => Nottingham Forest + SHU => Sheffield United + TOT => Tottenham Hotspur + WHU => West Ham United + WOL => Wolverhampton Wanderers + +Soccer - La Liga (Spain) + ALA => Alavés + ATH => Athletic Bilbao + ATM => Atlético Madrid + BAR => Barcelona + BET => Real Betis + CAG => Cagliari + CEL => Celta Vigo + ESP => Espanyol + GET => Getafe + GIR => Girona + LAZ => Lazio + LEG => Leganés + RAY => Rayo Vallecano + RMA => Real Madrid + SEV => Sevilla + VAL => Valencia + VLD => Valladolid + +Soccer - Bundesliga (Germany) + BOC => VfL Bochum + BOL => VfL Bochum + DOR => Borussia Dortmund + FCA => FC Augsburg + FCB => Bayern Munich + FCU => FC Union Berlin + HAC => Hannover 96 + HDH => Hertha BSC + KOL => 1. FC Köln + LEV => Bayer Leverkusen + M05 => Mainz 05 + RBL => RB Leipzig + SCF => SC Freiburg + SGE => Eintracht Frankfurt + STU => VfB Stuttgart + SVW => Werder Bremen + TSG => TSG Hoffenheim + WOB => VfL Wolfsburg + +Soccer - Serie A (Italy) + ATA => Atalanta + CAG => Cagliari + EMP => Empoli + FIO => Fiorentina + INT => Inter Milan + JUV => Juventus + LAZ => Lazio + MIL => AC Milan + MON => Monza + NAP => Napoli + ROM => Roma + TOR => Torino + UDI => Udinese + VER => Hellas Verona + +Soccer - Ligue 1 (France) + LIL => Lille + LPM => Lille + LYON => Lyon + MAR => Marseille + MON => Monaco + NAN => Nantes + NICE => Nice + OL => Olympique Lyonnais + OM => Olympique de Marseille + PAR => Paris Saint-Germain + PSG => Paris Saint-Germain + REN => Rennes + STR => Strasbourg + +Soccer - Champions League + AJA => Ajax + ASM => AS Monaco + ASS => AS Saint-Étienne + BOC => VfL Bochum + CEL => Celtic + COM => Club Brugge + FCA => FC Augsburg + FCB => Bayern Munich + FCU => FC Union Berlin + FIO => Fiorentina + GEN => Genoa + HAC => Hannover 96 + IPS => Ipswich Town + KSV => Kaiserslautern + LEC => Lecce + LIL => Lille + LIV => Liverpool + M05 => Mainz 05 + MCI => Manchester City + MUN => Manchester United + NAN => Nantes + OSA => Osasuna + RBL => RB Leipzig + RCL => RC Lens + RMA => Real Madrid + SCF => SC Freiburg + SGE => Eintracht Frankfurt + SR => Sporting CP + STP => St. Pauli + SVW => Werder Bremen + TFC => Toulouse FC + TOT => Tottenham Hotspur + TSG => TSG Hoffenheim + UDI => Udinese + VEN => Venezia + VFB => VfB Stuttgart + VIL => Villarreal + +Soccer - Other Teams + austin => Austin FC + cf_montral => CF Montréal + charlotte => Charlotte FC + dortmund => Borussia Dortmund + gladbach => Borussia Mönchengladbach + lafc => Los Angeles FC + leverkusen => Bayer Leverkusen + nycfc => New York City FC + paris_sg => Paris Saint-Germain + st_louis => St. Louis City SC + MLS Conferences/Divisions Conferences currently unsupported diff --git a/assets/sports/mlb_logos/mlb.png b/assets/sports/mlb_logos/mlb.png new file mode 100644 index 00000000..9c95d756 Binary files /dev/null and b/assets/sports/mlb_logos/mlb.png differ diff --git a/assets/sports/nba_logos/BKN.png b/assets/sports/nba_logos/BKN.png index cb77bcf3..828dc14e 100644 Binary files a/assets/sports/nba_logos/BKN.png and b/assets/sports/nba_logos/BKN.png differ diff --git a/assets/sports/nba_logos/UTAH.png b/assets/sports/nba_logos/UTAH.png index b0499428..4886d337 100644 Binary files a/assets/sports/nba_logos/UTAH.png and b/assets/sports/nba_logos/UTAH.png differ diff --git a/assets/sports/nba_logos/nba.png b/assets/sports/nba_logos/nba.png new file mode 100644 index 00000000..7e8ddf8f Binary files /dev/null and b/assets/sports/nba_logos/nba.png differ diff --git a/assets/sports/ncaa_fbs_logos/AANDM.png b/assets/sports/ncaa_fbs_logos/AANDM.png new file mode 100644 index 00000000..38632e9a Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/AANDM.png differ diff --git a/assets/sports/ncaa_fbs_logos/AMH.png b/assets/sports/ncaa_fbs_logos/AMH.png new file mode 100644 index 00000000..c9f3ca86 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/AMH.png differ diff --git a/assets/sports/ncaa_fbs_logos/ANN.png b/assets/sports/ncaa_fbs_logos/ANN.png new file mode 100644 index 00000000..6c61e18b Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ANN.png differ diff --git a/assets/sports/ncaa_fbs_logos/ARIZ.png b/assets/sports/ncaa_fbs_logos/ARIZ.png index 39d3ec2b..bd17bbc3 100644 Binary files a/assets/sports/ncaa_fbs_logos/ARIZ.png and b/assets/sports/ncaa_fbs_logos/ARIZ.png differ diff --git a/assets/sports/ncaa_fbs_logos/ARK.png b/assets/sports/ncaa_fbs_logos/ARK.png index f5818113..870c0e1c 100644 Binary files a/assets/sports/ncaa_fbs_logos/ARK.png and b/assets/sports/ncaa_fbs_logos/ARK.png differ diff --git a/assets/sports/ncaa_fbs_logos/ASU.png b/assets/sports/ncaa_fbs_logos/ASU.png new file mode 100644 index 00000000..ec9e1162 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ASU.png differ diff --git a/assets/sports/ncaa_fbs_logos/AUB.png b/assets/sports/ncaa_fbs_logos/AUB.png index bb46c898..fcd97fab 100644 Binary files a/assets/sports/ncaa_fbs_logos/AUB.png and b/assets/sports/ncaa_fbs_logos/AUB.png differ diff --git a/assets/sports/ncaa_fbs_logos/BOIS.png b/assets/sports/ncaa_fbs_logos/BOIS.png new file mode 100644 index 00000000..ccbd604e Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BOIS.png differ diff --git a/assets/sports/ncaa_fbs_logos/BRST.png b/assets/sports/ncaa_fbs_logos/BRST.png new file mode 100644 index 00000000..e6bba244 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BRST.png differ diff --git a/assets/sports/ncaa_fbs_logos/BUENA.png b/assets/sports/ncaa_fbs_logos/BUENA.png new file mode 100644 index 00000000..88c443b3 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/BUENA.png differ diff --git a/assets/sports/ncaa_fbs_logos/CAL.png b/assets/sports/ncaa_fbs_logos/CAL.png index a1ec63a5..ebc43156 100644 Binary files a/assets/sports/ncaa_fbs_logos/CAL.png and b/assets/sports/ncaa_fbs_logos/CAL.png differ diff --git a/assets/sports/ncaa_fbs_logos/CAR.png b/assets/sports/ncaa_fbs_logos/CAR.png new file mode 100644 index 00000000..dff64804 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CAR.png differ diff --git a/assets/sports/ncaa_fbs_logos/CLA.png b/assets/sports/ncaa_fbs_logos/CLA.png new file mode 100644 index 00000000..30eefb63 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CLA.png differ diff --git a/assets/sports/ncaa_fbs_logos/COLBY.png b/assets/sports/ncaa_fbs_logos/COLBY.png new file mode 100644 index 00000000..5df6982f Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/COLBY.png differ diff --git a/assets/sports/ncaa_fbs_logos/COLO.png b/assets/sports/ncaa_fbs_logos/COLO.png index 1860844e..42ba2d55 100644 Binary files a/assets/sports/ncaa_fbs_logos/COLO.png and b/assets/sports/ncaa_fbs_logos/COLO.png differ diff --git a/assets/sports/ncaa_fbs_logos/CONN.png b/assets/sports/ncaa_fbs_logos/CONN.png index fe85a48f..1114466e 100644 Binary files a/assets/sports/ncaa_fbs_logos/CONN.png and b/assets/sports/ncaa_fbs_logos/CONN.png differ diff --git a/assets/sports/ncaa_fbs_logos/CP.png b/assets/sports/ncaa_fbs_logos/CP.png new file mode 100644 index 00000000..80ac8ba5 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CP.png differ diff --git a/assets/sports/ncaa_fbs_logos/CSU.png b/assets/sports/ncaa_fbs_logos/CSU.png new file mode 100644 index 00000000..5ade1fc8 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CSU.png differ diff --git a/assets/sports/ncaa_fbs_logos/CUR.png b/assets/sports/ncaa_fbs_logos/CUR.png new file mode 100644 index 00000000..4c71f0be Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/CUR.png differ diff --git a/assets/sports/ncaa_fbs_logos/DEL.png b/assets/sports/ncaa_fbs_logos/DEL.png new file mode 100644 index 00000000..52d230ba Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/DEL.png differ diff --git a/assets/sports/ncaa_fbs_logos/DUB.png b/assets/sports/ncaa_fbs_logos/DUB.png new file mode 100644 index 00000000..cefe34e5 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/DUB.png differ diff --git a/assets/sports/ncaa_fbs_logos/ELM.png b/assets/sports/ncaa_fbs_logos/ELM.png new file mode 100644 index 00000000..19601cf7 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ELM.png differ diff --git a/assets/sports/ncaa_fbs_logos/FAMU.png b/assets/sports/ncaa_fbs_logos/FAMU.png new file mode 100644 index 00000000..cbd35117 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/FAMU.png differ diff --git a/assets/sports/ncaa_fbs_logos/FLA.png b/assets/sports/ncaa_fbs_logos/FLA.png new file mode 100644 index 00000000..2383ea78 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/FLA.png differ diff --git a/assets/sports/ncaa_fbs_logos/FSU.png b/assets/sports/ncaa_fbs_logos/FSU.png index 289d1c2e..9a3be669 100644 Binary files a/assets/sports/ncaa_fbs_logos/FSU.png and b/assets/sports/ncaa_fbs_logos/FSU.png differ diff --git a/assets/sports/ncaa_fbs_logos/GRI.png b/assets/sports/ncaa_fbs_logos/GRI.png new file mode 100644 index 00000000..1924fa7a Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/GRI.png differ diff --git a/assets/sports/ncaa_fbs_logos/GT.png b/assets/sports/ncaa_fbs_logos/GT.png index 36c0ca88..228b8fdb 100644 Binary files a/assets/sports/ncaa_fbs_logos/GT.png and b/assets/sports/ncaa_fbs_logos/GT.png differ diff --git a/assets/sports/ncaa_fbs_logos/GTWN.png b/assets/sports/ncaa_fbs_logos/GTWN.png new file mode 100644 index 00000000..9990feee Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/GTWN.png differ diff --git a/assets/sports/ncaa_fbs_logos/HAW.png b/assets/sports/ncaa_fbs_logos/HAW.png new file mode 100644 index 00000000..cd5856dc Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/HAW.png differ diff --git a/assets/sports/ncaa_fbs_logos/HOW.png b/assets/sports/ncaa_fbs_logos/HOW.png new file mode 100644 index 00000000..a4b902a9 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/HOW.png differ diff --git a/assets/sports/ncaa_fbs_logos/IDHO.png b/assets/sports/ncaa_fbs_logos/IDHO.png new file mode 100644 index 00000000..1cfcdfff Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/IDHO.png differ diff --git a/assets/sports/ncaa_fbs_logos/ISU.png b/assets/sports/ncaa_fbs_logos/ISU.png index 59ffd882..c59e3753 100644 Binary files a/assets/sports/ncaa_fbs_logos/ISU.png and b/assets/sports/ncaa_fbs_logos/ISU.png differ diff --git a/assets/sports/ncaa_fbs_logos/JXST.png b/assets/sports/ncaa_fbs_logos/JXST.png new file mode 100644 index 00000000..d507ee89 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/JXST.png differ diff --git a/assets/sports/ncaa_fbs_logos/LUT.png b/assets/sports/ncaa_fbs_logos/LUT.png new file mode 100644 index 00000000..684e10f2 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/LUT.png differ diff --git a/assets/sports/ncaa_fbs_logos/Loodibee_Web_X2_White.png b/assets/sports/ncaa_fbs_logos/Loodibee_Web_X2_White.png deleted file mode 100644 index fa38d0be..00000000 Binary files a/assets/sports/ncaa_fbs_logos/Loodibee_Web_X2_White.png and /dev/null differ diff --git a/assets/sports/ncaa_fbs_logos/MESA.png b/assets/sports/ncaa_fbs_logos/MESA.png new file mode 100644 index 00000000..66159848 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MESA.png differ diff --git a/assets/sports/ncaa_fbs_logos/MIAMI.png b/assets/sports/ncaa_fbs_logos/MIA.png similarity index 100% rename from assets/sports/ncaa_fbs_logos/MIAMI.png rename to assets/sports/ncaa_fbs_logos/MIA.png diff --git a/assets/sports/ncaa_fbs_logos/MIL.png b/assets/sports/ncaa_fbs_logos/MIL.png new file mode 100644 index 00000000..adc29c39 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MIL.png differ diff --git a/assets/sports/ncaa_fbs_logos/MOR.png b/assets/sports/ncaa_fbs_logos/MOR.png new file mode 100644 index 00000000..b701025e Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/MOR.png differ diff --git a/assets/sports/ncaa_fbs_logos/NOR.png b/assets/sports/ncaa_fbs_logos/NOR.png new file mode 100644 index 00000000..2b62fead Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/NOR.png differ diff --git a/assets/sports/ncaa_fbs_logos/RED.png b/assets/sports/ncaa_fbs_logos/RED.png new file mode 100644 index 00000000..196b387d Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/RED.png differ diff --git a/assets/sports/ncaa_fbs_logos/SAC.png b/assets/sports/ncaa_fbs_logos/SAC.png new file mode 100644 index 00000000..5ef0343f Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/SAC.png differ diff --git a/assets/sports/ncaa_fbs_logos/SDSU.png b/assets/sports/ncaa_fbs_logos/SDSU.png index f22e4720..f73ee988 100644 Binary files a/assets/sports/ncaa_fbs_logos/SDSU.png and b/assets/sports/ncaa_fbs_logos/SDSU.png differ diff --git a/assets/sports/ncaa_fbs_logos/SJSU.png b/assets/sports/ncaa_fbs_logos/SJSU.png index 8aa897f7..dbffbcc7 100644 Binary files a/assets/sports/ncaa_fbs_logos/SJSU.png and b/assets/sports/ncaa_fbs_logos/SJSU.png differ diff --git a/assets/sports/ncaa_fbs_logos/STAN.png b/assets/sports/ncaa_fbs_logos/STAN.png index 9db08295..36dc600f 100644 Binary files a/assets/sports/ncaa_fbs_logos/STAN.png and b/assets/sports/ncaa_fbs_logos/STAN.png differ diff --git a/assets/sports/ncaa_fbs_logos/STET.png b/assets/sports/ncaa_fbs_logos/STET.png new file mode 100644 index 00000000..d31e923b Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/STET.png differ diff --git a/assets/sports/ncaa_fbs_logos/TAANDM.png b/assets/sports/ncaa_fbs_logos/TAANDM.png new file mode 100644 index 00000000..58fa1e51 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/TAANDM.png differ diff --git a/assets/sports/ncaa_fbs_logos/TAMU.png b/assets/sports/ncaa_fbs_logos/TAMU.png new file mode 100644 index 00000000..1f3893f0 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/TAMU.png differ diff --git a/assets/sports/ncaa_fbs_logos/TEX.png b/assets/sports/ncaa_fbs_logos/TEX.png index a700263d..9fdd4b68 100644 Binary files a/assets/sports/ncaa_fbs_logos/TEX.png and b/assets/sports/ncaa_fbs_logos/TEX.png differ diff --git a/assets/sports/ncaa_fbs_logos/UAB.png b/assets/sports/ncaa_fbs_logos/UAB.png index 05884167..854220a7 100644 Binary files a/assets/sports/ncaa_fbs_logos/UAB.png and b/assets/sports/ncaa_fbs_logos/UAB.png differ diff --git a/assets/sports/ncaa_fbs_logos/UCLA.png b/assets/sports/ncaa_fbs_logos/UCLA.png index b9dd253e..216f392c 100644 Binary files a/assets/sports/ncaa_fbs_logos/UCLA.png and b/assets/sports/ncaa_fbs_logos/UCLA.png differ diff --git a/assets/sports/ncaa_fbs_logos/UGA.png b/assets/sports/ncaa_fbs_logos/UGA.png index e7eddbd8..53175cff 100644 Binary files a/assets/sports/ncaa_fbs_logos/UGA.png and b/assets/sports/ncaa_fbs_logos/UGA.png differ diff --git a/assets/sports/ncaa_fbs_logos/UNT.png b/assets/sports/ncaa_fbs_logos/UNT.png new file mode 100644 index 00000000..a700263d Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/UNT.png differ diff --git a/assets/sports/ncaa_fbs_logos/USA.png b/assets/sports/ncaa_fbs_logos/USA.png new file mode 100644 index 00000000..3083699d Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/USA.png differ diff --git a/assets/sports/ncaa_fbs_logos/USC.png b/assets/sports/ncaa_fbs_logos/USC.png index 2fb2c5d5..84c2711e 100644 Binary files a/assets/sports/ncaa_fbs_logos/USC.png and b/assets/sports/ncaa_fbs_logos/USC.png differ diff --git a/assets/sports/ncaa_fbs_logos/USF.png b/assets/sports/ncaa_fbs_logos/USF.png index 2aaf3858..c1b4c5b6 100644 Binary files a/assets/sports/ncaa_fbs_logos/USF.png and b/assets/sports/ncaa_fbs_logos/USF.png differ diff --git a/assets/sports/ncaa_fbs_logos/UT.png b/assets/sports/ncaa_fbs_logos/UT.png deleted file mode 100644 index 9fdd4b68..00000000 Binary files a/assets/sports/ncaa_fbs_logos/UT.png and /dev/null differ diff --git a/assets/sports/ncaa_fbs_logos/YALE.png b/assets/sports/ncaa_fbs_logos/YALE.png new file mode 100644 index 00000000..fa375049 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/YALE.png differ diff --git a/assets/sports/ncaa_fbs_logos/ncaa_fb.png b/assets/sports/ncaa_fbs_logos/ncaa_fb.png new file mode 100644 index 00000000..f80809c0 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ncaa_fb.png differ diff --git a/assets/sports/ncaa_fbs_logos/ncaam.png b/assets/sports/ncaa_fbs_logos/ncaam.png new file mode 100644 index 00000000..51e42b21 Binary files /dev/null and b/assets/sports/ncaa_fbs_logos/ncaam.png differ diff --git a/assets/sports/nfl_logos/nfl.png b/assets/sports/nfl_logos/nfl.png new file mode 100644 index 00000000..58f0b23f Binary files /dev/null and b/assets/sports/nfl_logos/nfl.png differ diff --git a/assets/sports/nhl_logos/nhl.png b/assets/sports/nhl_logos/nhl.png new file mode 100644 index 00000000..f36fe3f4 Binary files /dev/null and b/assets/sports/nhl_logos/nhl.png differ diff --git a/check_team_images.py b/check_team_images.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/check_team_images.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/config/config.json b/config/config.json index b2e6bd21..2425b24c 100644 --- a/config/config.json +++ b/config/config.json @@ -5,10 +5,10 @@ "start_time": "07:00", "end_time": "23:00" }, - "timezone": "America/Chicago", + "timezone": "America/New_York", "location": { - "city": "Dallas", - "state": "Texas", + "city": "Tampa", + "state": "Florida", "country": "US" }, "display": { @@ -39,6 +39,7 @@ "daily_forecast": 30, "stock_news": 20, "odds_ticker": 60, + "leaderboard": 60, "nhl_live": 30, "nhl_recent": 30, "nhl_upcoming": 30, @@ -81,7 +82,7 @@ "update_interval": 1 }, "weather": { - "enabled": false, + "enabled": true, "update_interval": 1800, "units": "imperial", "display_format": "{temp}\u00b0F\n{condition}" @@ -129,11 +130,12 @@ "duration_buffer": 0.1 }, "odds_ticker": { - "enabled": true, + "enabled": false, "show_favorite_teams_only": true, "games_per_favorite_team": 1, "max_games_per_league": 5, "show_odds_only": false, + "fetch_odds": true, "sort_order": "soonest", "enabled_leagues": [ "nfl", @@ -150,6 +152,46 @@ "dynamic_duration": true, "min_duration": 30, "max_duration": 300, + "duration_buffer": 0.05 + }, + "leaderboard": { + "enabled": false, + "enabled_sports": { + "nfl": { + "enabled": true, + "top_teams": 10 + }, + "nba": { + "enabled": false, + "top_teams": 10 + }, + "mlb": { + "enabled": false, + "top_teams": 10 + }, + "ncaa_fb": { + "enabled": true, + "top_teams": 25, + "show_ranking": true + }, + "nhl": { + "enabled": false, + "top_teams": 10 + }, + "ncaam_basketball": { + "enabled": false, + "top_teams": 25 + } + }, + "update_interval": 3600, + "scroll_speed": 1, + "scroll_delay": 0.01, + "display_duration": 60, + "loop": false, + "request_timeout": 30, + "dynamic_duration": true, + "min_duration": 45, + "max_duration": 600, "duration_buffer": 0.1 }, "calendar": { @@ -257,6 +299,7 @@ ], "logo_dir": "assets/sports/ncaa_fbs_logos", "show_records": true, + "show_ranking": true, "display_modes": { "ncaa_fb_live": true, "ncaa_fb_recent": true , @@ -366,7 +409,7 @@ } }, "text_display": { - "enabled": false, + "enabled": true, "text": "Subscribe to ChuckBuilds", "font_path": "assets/fonts/press-start-2p.ttf", "font_size": 8, diff --git a/create_league_logos.py b/create_league_logos.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/create_league_logos.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/create_ncaa_logos.py b/create_ncaa_logos.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/create_ncaa_logos.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/debug_of_the_day.py b/debug_of_the_day.py new file mode 100644 index 00000000..5ead4940 --- /dev/null +++ b/debug_of_the_day.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Debug script for OfTheDayManager issues +Run this on the Raspberry Pi to diagnose the problem + +Usage: +1. Copy this file to your Raspberry Pi +2. Run: python3 debug_of_the_day.py +3. Check the output for any errors or issues + +This script will help identify why the OfTheDayManager is not loading data files. +""" + +import json +import os +import sys +from datetime import date + +def debug_of_the_day(): + print("=== OfTheDayManager Debug Script ===") + print(f"Current working directory: {os.getcwd()}") + print(f"Python path: {sys.path}") + + # Check if we're in the right directory + if not os.path.exists('config/config.json'): + print("ERROR: config/config.json not found. Make sure you're running from the LEDMatrix root directory.") + return + + # Load the actual config + try: + with open('config/config.json', 'r') as f: + config = json.load(f) + print("✓ Successfully loaded config.json") + except Exception as e: + print(f"ERROR loading config.json: {e}") + return + + # Check of_the_day configuration + of_the_day_config = config.get('of_the_day', {}) + print(f"OfTheDay enabled: {of_the_day_config.get('enabled', False)}") + + if not of_the_day_config.get('enabled', False): + print("OfTheDay is disabled in config!") + return + + categories = of_the_day_config.get('categories', {}) + print(f"Categories configured: {list(categories.keys())}") + + # Test each category + today = date.today() + day_of_year = today.timetuple().tm_yday + print(f"Today is day {day_of_year} of the year") + + for category_name, category_config in categories.items(): + print(f"\n--- Testing category: {category_name} ---") + print(f"Category enabled: {category_config.get('enabled', True)}") + + if not category_config.get('enabled', True): + print("Category is disabled, skipping...") + continue + + data_file = category_config.get('data_file') + print(f"Data file: {data_file}") + + # Test path resolution + if not os.path.isabs(data_file): + if data_file.startswith('of_the_day/'): + file_path = os.path.join(os.getcwd(), data_file) + else: + file_path = os.path.join(os.getcwd(), 'of_the_day', data_file) + else: + file_path = data_file + + file_path = os.path.abspath(file_path) + print(f"Resolved path: {file_path}") + print(f"File exists: {os.path.exists(file_path)}") + + if not os.path.exists(file_path): + print(f"ERROR: Data file not found at {file_path}") + continue + + # Test JSON loading + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + print(f"✓ Successfully loaded JSON with {len(data)} items") + + # Check for today's entry + day_key = str(day_of_year) + if day_key in data: + item = data[day_key] + print(f"✓ Found entry for day {day_of_year}: {item.get('title', 'No title')}") + else: + print(f"✗ No entry found for day {day_of_year}") + # Show some nearby entries + nearby_days = [k for k in data.keys() if k.isdigit() and abs(int(k) - day_of_year) <= 5] + print(f"Nearby days with entries: {sorted(nearby_days)}") + + except Exception as e: + print(f"ERROR loading JSON: {e}") + import traceback + traceback.print_exc() + + print("\n=== Debug complete ===") + +if __name__ == "__main__": + debug_of_the_day() diff --git a/list_missing_teams.py b/list_missing_teams.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/list_missing_teams.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/list_soccer_abbreviations.py b/list_soccer_abbreviations.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/list_soccer_abbreviations.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/missing_team_logos.txt b/missing_team_logos.txt new file mode 100644 index 00000000..b012a05d --- /dev/null +++ b/missing_team_logos.txt @@ -0,0 +1,657 @@ +================================================================================ +MISSING TEAM LOGOS - COMPLETE LIST +================================================================================ +Total missing teams: 309 + + +MLB: +--- + OAK => Oakland Athletics + +NCAAF: +----- + AAMU => Alabama A&M Bulldogs + ACU => Abilene Christian Wildcats + ADA => Adams State Grizzlies + ADR => Adrian Bulldogs + AIC => American International Yellow Jackets + ALB => Albright Lions + ALBS => Albany State (GA) Golden Rams + ALCN => Alcorn State Braves + ALD => Alderson Broaddus Battlers + ALF => Alfred Saxons + ALL => Allegheny Gators + ALST => Alabama State Hornets + AMH => Amherst College Mammoths + AND => Anderson (IN) Ravens + ANG => Angelo State Rams + ANN => Anna Maria College Amcats + APSU => Austin Peay Governors + ASH => Ashland Eagles + ASP => Assumption Greyhounds + ASU => Arizona State Sun Devils + AUG => St. Augustine's Falcons + AUR => Aurora Spartans + AUS => Austin College 'Roos + AVE => Averett Cougars + AVI => Avila College Eagles + AZU => Azusa Pacific Cougars + BAK => Baker University Wildcats + BAL => Baldwin Wallace Yellow Jackets + BAT => Bates College Bobcats + BEC => Becker College Hawks + BEL => Beloit College Buccaneers + BEN => Benedictine University (IL) Eagles + BENT => Bentley Falcons + BET => Bethel (TN) Wildcats + BHS => Black Hills State Yellow Jackets + BIR => Birmingham-Southern Panthers + BKN => Bacone College Warriors + BLA => Blackburn Beavers + BLOM => Bloomsburg Huskies + BLU => Bluffton Beavers + BOW => Bowdoin Polar Bears + BRI => British Columbia Thunderbirds + BRWN => Brown Bears + BST => Bemidji State Beavers + BUCK => Bucknell Bison + BUE => Buena Vista Beavers + BUF => Buffalo State Bengals + BUT => Butler Bulldogs + CAM => Campbell Fighting Camels + CAP => Capital University Crusaders + CAR => Carthage College Red Men + CARK => Central Arkansas Bears + CAS => Castleton Spartans + CAT => Catholic University Cardinals + CCSU => Central Connecticut Blue Devils + CEN => Centre College Colonels + CHA => Chapman University Panthers + CHI => Chicago Maroons + CHSO => Charleston Southern Buccaneers + CLA => Clarion Golden Eagles + CLMB => Columbia Lions + COE => Coe College Kohawks + COL => Colorado School of Mines Orediggers + COLC => Colorado College Tigers + COLG => Colgate Raiders + CON => Concordia-Minnesota Cobbers + COR => Cornell College (IA) Rams + CP => Cal Poly Mustangs + CRO => Crown Storm + CSU => Colorado State Rams + CUL => Culver-Stockton Wildcats + CUM => Cumberland College Indians + CUR => Curry College Colonels + DAK => Dakota Wesleyan Tigers + DART => Dartmouth Big Green + DAV => Davidson Wildcats + DAY => Dayton Flyers + DEF => Defiance Yellow Jackets + DEL => Delta State Statesmen + DEN => Denison Big Red + DEP => DePauw Tigers + DIC => Dickinson State Blue Hawks + DRKE => Drake Bulldogs + DSU => Delaware State Hornets + DUB => Dubuque Spartans + DUQ => Duquesne Dukes + EAS => Eastern New Mexico Greyhounds + EDI => Edinboro Fighting Scots + EIU => Eastern Illinois Panthers + EKU => Eastern Kentucky Colonels + ELI => Elizabeth City State Vikings + ELM => Elmhurst Blue Jays + ELON => Elon Phoenix + EMO => Emory & Henry Wasps + EMP => Emporia State Hornets + END => Endicott College Gulls + EOR => Eastern Oregon Mountaineers + ETSU => East Tennessee State Buccaneers + EUR => Eureka College Red Devils + EWU => Eastern Washington Eagles + FAY => Fayetteville State Broncos + FDU => FDU-Florham Devils + FER => Ferrum Panthers + FIN => Findlay Oilers + FIT => Fitchburg State Falcons + FLA => Florida Gators + FOR => Fort Valley State Wildcats + FRA => Franklin Grizzlies + FRO => Frostburg State Bobcats + FRST => Ferris State Bulldogs + FTLW => Fort Lewis Skyhawks + FUR => Furman Paladins + GAL => Gallaudet Bison + GAN => Gannon Golden Knights + GEN => Geneva College Golden Tornadoes + GEO => George Fox University Bruins + GET => Gettysburg Bullets + GLE => Glenville State Pioneers + GMU => George Mason Patriots + GRA => Grand Valley State Lakers + GRE => Greenville Panthers + GRI => Grinnell Pioneers + GRO => Grove City College Wolverines + GUI => Guilford Quakers + GWEB => Gardner-Webb Bulldogs + HAM => Hampden-Sydney Tigers + HAMP => Hampton Pirates + HAN => Hanover Panthers + HAR => Hartwick Hawks + HARV => Harvard Crimson + HAS => Haskell Indian Nations Jayhawks + HAW => Hawai'i Rainbow Warriors + HBU => Houston Baptist Huskies + HC => Holy Cross Crusaders + HEI => Heidelberg Student Princes + HEN => Hendrix College Warriors + HIL => Hillsdale Chargers + HIR => Hiram College Terriers + HOB => Hobart Statesmen + HOW => Howard Bison + HUS => Husson Eagles + IDHO => Idaho Vandals + IDST => Idaho State Bengals + ILST => Illinois State Redbirds + ILW => Illinois Wesleyan Titans + IND => Indianapolis + INST => Indiana State Sycamores + IOW => Iowa Wesleyan Tigers + ITH => Ithaca Bombers + JKST => Jackson State Tigers + JOH => Johnson C Smith Golden Bulls + JUN => Juniata Eagles + KAL => Kalamazoo Hornets + KAN => Kansas Wesleyan University Coyotes + KEN => Kenyon Lords + KIN => King's College (PA) Monarchs + KNO => Knox College Prairie Fire + KUT => Kutztown Golden Bears + KYST => Kentucky State Thorobreds + KYW => Kentucky Wesleyan Panthers + LA => La Verne Leopards + LAG => LaGrange College Panthers + LAK => Lake Forest Foresters + LAM => Lambuth Eagles + LAN => Langston Lions + LAW => Lawrence Vikings + LEB => Lebanon Valley Flying Dutchmen + LEH => Lehigh Mountain Hawks + LEN => Lenoir-Rhyne Bears + LEW => Lewis & Clark Pioneers + LIM => Limestone Saints + LIN => Linfield Wildcats + LOC => Lock Haven Bald Eagles + LOR => Loras College Duhawks + LUT => Luther Norse + LYC => Lycoming Warriors + M-OH => Miami (OH) RedHawks + MAC => Macalester Scots + MAI => Maine Maritime Mariners + MAN => Mansfield Mountaineers + MAR => Maryville College Fighting Scots + MAS => Mass Maritime Buccaneers + MAY => Mayville State Comets + MCM => McMurry War Hawks + MCN => McNeese Cowboys + MEN => Menlo College Oaks + MER => Merchant Marine Mariners + MERC => Mercyhurst Lakers + MES => Colorado Mesa Mavericks + MET => Methodist Monarchs + MH => Mars Hill Mountain Lions + MID => Midwestern State Mustangs + MIL => Millsaps Majors + MIN => Minot State Beavers + MIS => Missouri Western Griffons + MNST => Minnesota State Mavericks + MONM => Monmouth Hawks + MONT => Montana Grizzlies + MOR => Morningside Chiefs + MORE => Morehead State Eagles + MORG => Morgan State Bears + MOU => Mount Union Raiders + MRST => Marist Red Foxes + MSU => Michigan State Spartans + MTST => Montana State Bobcats + MTU => Michigan Tech Huskies + MUH => Muhlenberg Mules + MUR => Murray State Racers + MUS => Muskingum Fighting Muskies + MVSU => Mississippi Valley State Delta Devils + NAU => Northern Arizona Lumberjacks + NBY => Newberry Wolves + NCAT => North Carolina A&T Aggies + NCCU => North Carolina Central Eagles + NCST => NC State Wolfpack + NDOH => Notre Dame College Falcons + NDSU => North Dakota State Bison + NH => New Haven Chargers + NICH => Nicholls Colonels + NMH => New Mexico Highlands Cowboys + NMI => Northern Michigan Wildcats + NOR => Univ. of Northwestern-St. Paul Eagles + NORF => Norfolk State Spartans + OBE => Oberlin Yeomen + OHI => Ohio Northern Polar Bears + OKL => Oklahoma Baptist Bison + OLI => Olivet College Comets + OMA => Omaha Mavericks + OTT => Otterbein Cardinals + PAC => Pacific (OR) Boxers + PENN => Pennsylvania Quakers + PIKE => Pikeville Bears + PRE => Presentation College Saints + PRI => Principia College Panthers + PRIN => Princeton Tigers + PST => Pittsburg State Gorillas + RED => Redlands Bulldogs + RICH => Richmond Spiders + RIT => Rochester Yellow Jackets + ROB => Robert Morris (IL) Eagles + ROS => Rose-Hulman Engineers + SAC => Sacramento State Hornets + SAG => Saginaw Valley Cardinals + SDAK => South Dakota Coyotes + SET => Seton Hill Griffins + SIU => Southern Illinois Salukis + SLI => Slippery Rock The Rock + SOU => Southwestern College Moundbuilders + SPR => Springfield College Pride + ST => St. Scholastica Saints + STE => Stevenson University Mustangs + STET => Stetson Hatters + STO => Stonehill College Skyhawks + SUS => Susquehanna University River Hawks + SUU => Southern Utah Thunderbirds + TA&M => Texas A&M Aggies + TAY => Taylor Trojans + TIF => Tiffin University Dragons + TRI => Trinity University (TX) Tigers + TUF => Tufts University Jumbos + TXST => Texas State Bobcats + UAPB => Arkansas-Pine Bluff Golden Lions + UCD => UC Davis Aggies + UCONN => UConn Huskies + ULM => UL Monroe Warhawks + UMD => Minnesota-Duluth Bulldogs + UMDA => UMASS Dartmouth Corsairs + UML => UMass Lowell River Hawks + UNA => North Alabama Lions + UNCO => Northern Colorado Bears + UND => North Dakota Fighting Hawks + UNH => New Hampshire Wildcats + UNI => University of Mary Marauders + UNNY => Union Dutchmen + UNT => North Texas Mean Green + UPP => Upper Iowa Peacocks + URI => Rhode Island Rams + USA => South Alabama Jaguars + USD => San Diego Toreros + UTC => Chattanooga Mocs + UTI => Utica College Pioneers + VAL => Valley City State Vikings + VILL => Villanova Wildcats + VIR => Virginia State Trojans + VT => Virginia Tech Hokies + WAB => Wabash College Little Giants + WAS => Washington-Missouri Bears + WAY => Wayne State (MI) Warriors + WES => Westminster College (MO) Blue Jays + WHE => Wheaton College Illinois Thunder + WIL => Wilkes University Colonels + WIN => Wingate Bulldogs + WIS => Wisconsin-Platteville Pioneers + WOR => Worcester State College Lancers + YALE => Yale Bulldogs + +NHL: +--- + ARI => Arizona Coyotes + VGS => Vegas Golden Knights + +SOCCER - BUNDESLIGA (GERMANY): +----------------------------- + DOR => Borussia Dortmund + KOL => 1. FC Köln + LEV => Bayer Leverkusen + STU => VfB Stuttgart + +SOCCER - LIGUE 1 (FRANCE): +------------------------- + LYON => Lyon + MAR => Marseille + NICE => Nice + PSG => Paris Saint-Germain + +SOCCER - PREMIER LEAGUE (ENGLAND): +--------------------------------- + BUR => Burnley + LUT => Luton Town + SHU => Sheffield United + +================================================================================ +SUMMARY BY SPORT: +================================================================================ + MLB: 1 missing + NCAAF: 295 missing + NHL: 2 missing + Soccer - Bundesliga (Germany): 4 missing + Soccer - Ligue 1 (France): 4 missing +Soccer - Premier League (England): 3 missing + +================================================================================ +FILENAMES NEEDED: +================================================================================ +Add these PNG files to their respective directories: + +assets/sports/mlb_logos/OAK.png +assets/sports/ncaa_fbs_logos/AAMU.png +assets/sports/ncaa_fbs_logos/ACU.png +assets/sports/ncaa_fbs_logos/ADA.png +assets/sports/ncaa_fbs_logos/ADR.png +assets/sports/ncaa_fbs_logos/AIC.png +assets/sports/ncaa_fbs_logos/ALB.png +assets/sports/ncaa_fbs_logos/ALBS.png +assets/sports/ncaa_fbs_logos/ALCN.png +assets/sports/ncaa_fbs_logos/ALD.png +assets/sports/ncaa_fbs_logos/ALF.png +assets/sports/ncaa_fbs_logos/ALL.png +assets/sports/ncaa_fbs_logos/ALST.png +assets/sports/ncaa_fbs_logos/AMH.png +assets/sports/ncaa_fbs_logos/AND.png +assets/sports/ncaa_fbs_logos/ANG.png +assets/sports/ncaa_fbs_logos/ANN.png +assets/sports/ncaa_fbs_logos/APSU.png +assets/sports/ncaa_fbs_logos/ASH.png +assets/sports/ncaa_fbs_logos/ASP.png +assets/sports/ncaa_fbs_logos/ASU.png +assets/sports/ncaa_fbs_logos/AUG.png +assets/sports/ncaa_fbs_logos/AUR.png +assets/sports/ncaa_fbs_logos/AUS.png +assets/sports/ncaa_fbs_logos/AVE.png +assets/sports/ncaa_fbs_logos/AVI.png +assets/sports/ncaa_fbs_logos/AZU.png +assets/sports/ncaa_fbs_logos/BAK.png +assets/sports/ncaa_fbs_logos/BAL.png +assets/sports/ncaa_fbs_logos/BAT.png +assets/sports/ncaa_fbs_logos/BEC.png +assets/sports/ncaa_fbs_logos/BEL.png +assets/sports/ncaa_fbs_logos/BEN.png +assets/sports/ncaa_fbs_logos/BENT.png +assets/sports/ncaa_fbs_logos/BET.png +assets/sports/ncaa_fbs_logos/BHS.png +assets/sports/ncaa_fbs_logos/BIR.png +assets/sports/ncaa_fbs_logos/BKN.png +assets/sports/ncaa_fbs_logos/BLA.png +assets/sports/ncaa_fbs_logos/BLOM.png +assets/sports/ncaa_fbs_logos/BLU.png +assets/sports/ncaa_fbs_logos/BOW.png +assets/sports/ncaa_fbs_logos/BRI.png +assets/sports/ncaa_fbs_logos/BRWN.png +assets/sports/ncaa_fbs_logos/BST.png +assets/sports/ncaa_fbs_logos/BUCK.png +assets/sports/ncaa_fbs_logos/BUE.png +assets/sports/ncaa_fbs_logos/BUF.png +assets/sports/ncaa_fbs_logos/BUT.png +assets/sports/ncaa_fbs_logos/CAM.png +assets/sports/ncaa_fbs_logos/CAP.png +assets/sports/ncaa_fbs_logos/CAR.png +assets/sports/ncaa_fbs_logos/CARK.png +assets/sports/ncaa_fbs_logos/CAS.png +assets/sports/ncaa_fbs_logos/CAT.png +assets/sports/ncaa_fbs_logos/CCSU.png +assets/sports/ncaa_fbs_logos/CEN.png +assets/sports/ncaa_fbs_logos/CHA.png +assets/sports/ncaa_fbs_logos/CHI.png +assets/sports/ncaa_fbs_logos/CHSO.png +assets/sports/ncaa_fbs_logos/CLA.png +assets/sports/ncaa_fbs_logos/CLMB.png +assets/sports/ncaa_fbs_logos/COE.png +assets/sports/ncaa_fbs_logos/COL.png +assets/sports/ncaa_fbs_logos/COLC.png +assets/sports/ncaa_fbs_logos/COLG.png +assets/sports/ncaa_fbs_logos/CON.png +assets/sports/ncaa_fbs_logos/COR.png +assets/sports/ncaa_fbs_logos/CP.png +assets/sports/ncaa_fbs_logos/CRO.png +assets/sports/ncaa_fbs_logos/CSU.png +assets/sports/ncaa_fbs_logos/CUL.png +assets/sports/ncaa_fbs_logos/CUM.png +assets/sports/ncaa_fbs_logos/CUR.png +assets/sports/ncaa_fbs_logos/DAK.png +assets/sports/ncaa_fbs_logos/DART.png +assets/sports/ncaa_fbs_logos/DAV.png +assets/sports/ncaa_fbs_logos/DAY.png +assets/sports/ncaa_fbs_logos/DEF.png +assets/sports/ncaa_fbs_logos/DEL.png +assets/sports/ncaa_fbs_logos/DEN.png +assets/sports/ncaa_fbs_logos/DEP.png +assets/sports/ncaa_fbs_logos/DIC.png +assets/sports/ncaa_fbs_logos/DRKE.png +assets/sports/ncaa_fbs_logos/DSU.png +assets/sports/ncaa_fbs_logos/DUB.png +assets/sports/ncaa_fbs_logos/DUQ.png +assets/sports/ncaa_fbs_logos/EAS.png +assets/sports/ncaa_fbs_logos/EDI.png +assets/sports/ncaa_fbs_logos/EIU.png +assets/sports/ncaa_fbs_logos/EKU.png +assets/sports/ncaa_fbs_logos/ELI.png +assets/sports/ncaa_fbs_logos/ELM.png +assets/sports/ncaa_fbs_logos/ELON.png +assets/sports/ncaa_fbs_logos/EMO.png +assets/sports/ncaa_fbs_logos/EMP.png +assets/sports/ncaa_fbs_logos/END.png +assets/sports/ncaa_fbs_logos/EOR.png +assets/sports/ncaa_fbs_logos/ETSU.png +assets/sports/ncaa_fbs_logos/EUR.png +assets/sports/ncaa_fbs_logos/EWU.png +assets/sports/ncaa_fbs_logos/FAY.png +assets/sports/ncaa_fbs_logos/FDU.png +assets/sports/ncaa_fbs_logos/FER.png +assets/sports/ncaa_fbs_logos/FIN.png +assets/sports/ncaa_fbs_logos/FIT.png +assets/sports/ncaa_fbs_logos/FLA.png +assets/sports/ncaa_fbs_logos/FOR.png +assets/sports/ncaa_fbs_logos/FRA.png +assets/sports/ncaa_fbs_logos/FRO.png +assets/sports/ncaa_fbs_logos/FRST.png +assets/sports/ncaa_fbs_logos/FTLW.png +assets/sports/ncaa_fbs_logos/FUR.png +assets/sports/ncaa_fbs_logos/GAL.png +assets/sports/ncaa_fbs_logos/GAN.png +assets/sports/ncaa_fbs_logos/GEN.png +assets/sports/ncaa_fbs_logos/GEO.png +assets/sports/ncaa_fbs_logos/GET.png +assets/sports/ncaa_fbs_logos/GLE.png +assets/sports/ncaa_fbs_logos/GMU.png +assets/sports/ncaa_fbs_logos/GRA.png +assets/sports/ncaa_fbs_logos/GRE.png +assets/sports/ncaa_fbs_logos/GRI.png +assets/sports/ncaa_fbs_logos/GRO.png +assets/sports/ncaa_fbs_logos/GUI.png +assets/sports/ncaa_fbs_logos/GWEB.png +assets/sports/ncaa_fbs_logos/HAM.png +assets/sports/ncaa_fbs_logos/HAMP.png +assets/sports/ncaa_fbs_logos/HAN.png +assets/sports/ncaa_fbs_logos/HAR.png +assets/sports/ncaa_fbs_logos/HARV.png +assets/sports/ncaa_fbs_logos/HAS.png +assets/sports/ncaa_fbs_logos/HAW.png +assets/sports/ncaa_fbs_logos/HBU.png +assets/sports/ncaa_fbs_logos/HC.png +assets/sports/ncaa_fbs_logos/HEI.png +assets/sports/ncaa_fbs_logos/HEN.png +assets/sports/ncaa_fbs_logos/HIL.png +assets/sports/ncaa_fbs_logos/HIR.png +assets/sports/ncaa_fbs_logos/HOB.png +assets/sports/ncaa_fbs_logos/HOW.png +assets/sports/ncaa_fbs_logos/HUS.png +assets/sports/ncaa_fbs_logos/IDHO.png +assets/sports/ncaa_fbs_logos/IDST.png +assets/sports/ncaa_fbs_logos/ILST.png +assets/sports/ncaa_fbs_logos/ILW.png +assets/sports/ncaa_fbs_logos/IND.png +assets/sports/ncaa_fbs_logos/INST.png +assets/sports/ncaa_fbs_logos/IOW.png +assets/sports/ncaa_fbs_logos/ITH.png +assets/sports/ncaa_fbs_logos/JKST.png +assets/sports/ncaa_fbs_logos/JOH.png +assets/sports/ncaa_fbs_logos/JUN.png +assets/sports/ncaa_fbs_logos/KAL.png +assets/sports/ncaa_fbs_logos/KAN.png +assets/sports/ncaa_fbs_logos/KEN.png +assets/sports/ncaa_fbs_logos/KIN.png +assets/sports/ncaa_fbs_logos/KNO.png +assets/sports/ncaa_fbs_logos/KUT.png +assets/sports/ncaa_fbs_logos/KYST.png +assets/sports/ncaa_fbs_logos/KYW.png +assets/sports/ncaa_fbs_logos/LA.png +assets/sports/ncaa_fbs_logos/LAG.png +assets/sports/ncaa_fbs_logos/LAK.png +assets/sports/ncaa_fbs_logos/LAM.png +assets/sports/ncaa_fbs_logos/LAN.png +assets/sports/ncaa_fbs_logos/LAW.png +assets/sports/ncaa_fbs_logos/LEB.png +assets/sports/ncaa_fbs_logos/LEH.png +assets/sports/ncaa_fbs_logos/LEN.png +assets/sports/ncaa_fbs_logos/LEW.png +assets/sports/ncaa_fbs_logos/LIM.png +assets/sports/ncaa_fbs_logos/LIN.png +assets/sports/ncaa_fbs_logos/LOC.png +assets/sports/ncaa_fbs_logos/LOR.png +assets/sports/ncaa_fbs_logos/LUT.png +assets/sports/ncaa_fbs_logos/LYC.png +assets/sports/ncaa_fbs_logos/M-OH.png +assets/sports/ncaa_fbs_logos/MAC.png +assets/sports/ncaa_fbs_logos/MAI.png +assets/sports/ncaa_fbs_logos/MAN.png +assets/sports/ncaa_fbs_logos/MAR.png +assets/sports/ncaa_fbs_logos/MAS.png +assets/sports/ncaa_fbs_logos/MAY.png +assets/sports/ncaa_fbs_logos/MCM.png +assets/sports/ncaa_fbs_logos/MCN.png +assets/sports/ncaa_fbs_logos/MEN.png +assets/sports/ncaa_fbs_logos/MER.png +assets/sports/ncaa_fbs_logos/MERC.png +assets/sports/ncaa_fbs_logos/MES.png +assets/sports/ncaa_fbs_logos/MET.png +assets/sports/ncaa_fbs_logos/MH.png +assets/sports/ncaa_fbs_logos/MID.png +assets/sports/ncaa_fbs_logos/MIL.png +assets/sports/ncaa_fbs_logos/MIN.png +assets/sports/ncaa_fbs_logos/MIS.png +assets/sports/ncaa_fbs_logos/MNST.png +assets/sports/ncaa_fbs_logos/MONM.png +assets/sports/ncaa_fbs_logos/MONT.png +assets/sports/ncaa_fbs_logos/MOR.png +assets/sports/ncaa_fbs_logos/MORE.png +assets/sports/ncaa_fbs_logos/MORG.png +assets/sports/ncaa_fbs_logos/MOU.png +assets/sports/ncaa_fbs_logos/MRST.png +assets/sports/ncaa_fbs_logos/MSU.png +assets/sports/ncaa_fbs_logos/MTST.png +assets/sports/ncaa_fbs_logos/MTU.png +assets/sports/ncaa_fbs_logos/MUH.png +assets/sports/ncaa_fbs_logos/MUR.png +assets/sports/ncaa_fbs_logos/MUS.png +assets/sports/ncaa_fbs_logos/MVSU.png +assets/sports/ncaa_fbs_logos/NAU.png +assets/sports/ncaa_fbs_logos/NBY.png +assets/sports/ncaa_fbs_logos/NCAT.png +assets/sports/ncaa_fbs_logos/NCCU.png +assets/sports/ncaa_fbs_logos/NCST.png +assets/sports/ncaa_fbs_logos/NDOH.png +assets/sports/ncaa_fbs_logos/NDSU.png +assets/sports/ncaa_fbs_logos/NH.png +assets/sports/ncaa_fbs_logos/NICH.png +assets/sports/ncaa_fbs_logos/NMH.png +assets/sports/ncaa_fbs_logos/NMI.png +assets/sports/ncaa_fbs_logos/NOR.png +assets/sports/ncaa_fbs_logos/NORF.png +assets/sports/ncaa_fbs_logos/OBE.png +assets/sports/ncaa_fbs_logos/OHI.png +assets/sports/ncaa_fbs_logos/OKL.png +assets/sports/ncaa_fbs_logos/OLI.png +assets/sports/ncaa_fbs_logos/OMA.png +assets/sports/ncaa_fbs_logos/OTT.png +assets/sports/ncaa_fbs_logos/PAC.png +assets/sports/ncaa_fbs_logos/PENN.png +assets/sports/ncaa_fbs_logos/PIKE.png +assets/sports/ncaa_fbs_logos/PRE.png +assets/sports/ncaa_fbs_logos/PRI.png +assets/sports/ncaa_fbs_logos/PRIN.png +assets/sports/ncaa_fbs_logos/PST.png +assets/sports/ncaa_fbs_logos/RED.png +assets/sports/ncaa_fbs_logos/RICH.png +assets/sports/ncaa_fbs_logos/RIT.png +assets/sports/ncaa_fbs_logos/ROB.png +assets/sports/ncaa_fbs_logos/ROS.png +assets/sports/ncaa_fbs_logos/SAC.png +assets/sports/ncaa_fbs_logos/SAG.png +assets/sports/ncaa_fbs_logos/SDAK.png +assets/sports/ncaa_fbs_logos/SET.png +assets/sports/ncaa_fbs_logos/SIU.png +assets/sports/ncaa_fbs_logos/SLI.png +assets/sports/ncaa_fbs_logos/SOU.png +assets/sports/ncaa_fbs_logos/SPR.png +assets/sports/ncaa_fbs_logos/ST.png +assets/sports/ncaa_fbs_logos/STE.png +assets/sports/ncaa_fbs_logos/STET.png +assets/sports/ncaa_fbs_logos/STO.png +assets/sports/ncaa_fbs_logos/SUS.png +assets/sports/ncaa_fbs_logos/SUU.png +assets/sports/ncaa_fbs_logos/TA&M.png +assets/sports/ncaa_fbs_logos/TAY.png +assets/sports/ncaa_fbs_logos/TIF.png +assets/sports/ncaa_fbs_logos/TRI.png +assets/sports/ncaa_fbs_logos/TUF.png +assets/sports/ncaa_fbs_logos/TXST.png +assets/sports/ncaa_fbs_logos/UAPB.png +assets/sports/ncaa_fbs_logos/UCD.png +assets/sports/ncaa_fbs_logos/UCONN.png +assets/sports/ncaa_fbs_logos/ULM.png +assets/sports/ncaa_fbs_logos/UMD.png +assets/sports/ncaa_fbs_logos/UMDA.png +assets/sports/ncaa_fbs_logos/UML.png +assets/sports/ncaa_fbs_logos/UNA.png +assets/sports/ncaa_fbs_logos/UNCO.png +assets/sports/ncaa_fbs_logos/UND.png +assets/sports/ncaa_fbs_logos/UNH.png +assets/sports/ncaa_fbs_logos/UNI.png +assets/sports/ncaa_fbs_logos/UNNY.png +assets/sports/ncaa_fbs_logos/UNT.png +assets/sports/ncaa_fbs_logos/UPP.png +assets/sports/ncaa_fbs_logos/URI.png +assets/sports/ncaa_fbs_logos/USA.png +assets/sports/ncaa_fbs_logos/USD.png +assets/sports/ncaa_fbs_logos/UTC.png +assets/sports/ncaa_fbs_logos/UTI.png +assets/sports/ncaa_fbs_logos/VAL.png +assets/sports/ncaa_fbs_logos/VILL.png +assets/sports/ncaa_fbs_logos/VIR.png +assets/sports/ncaa_fbs_logos/VT.png +assets/sports/ncaa_fbs_logos/WAB.png +assets/sports/ncaa_fbs_logos/WAS.png +assets/sports/ncaa_fbs_logos/WAY.png +assets/sports/ncaa_fbs_logos/WES.png +assets/sports/ncaa_fbs_logos/WHE.png +assets/sports/ncaa_fbs_logos/WIL.png +assets/sports/ncaa_fbs_logos/WIN.png +assets/sports/ncaa_fbs_logos/WIS.png +assets/sports/ncaa_fbs_logos/WOR.png +assets/sports/ncaa_fbs_logos/YALE.png +assets/sports/nhl_logos/ARI.png +assets/sports/nhl_logos/VGS.png +assets/sports/soccer_logos/DOR.png +assets/sports/soccer_logos/KOL.png +assets/sports/soccer_logos/LEV.png +assets/sports/soccer_logos/STU.png +assets/sports/soccer_logos/LYON.png +assets/sports/soccer_logos/MAR.png +assets/sports/soccer_logos/NICE.png +assets/sports/soccer_logos/PSG.png +assets/sports/soccer_logos/BUR.png +assets/sports/soccer_logos/LUT.png +assets/sports/soccer_logos/SHU.png diff --git a/save_missing_teams.py b/save_missing_teams.py new file mode 100644 index 00000000..970b70b7 --- /dev/null +++ b/save_missing_teams.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +""" +Script to save the missing teams list to a file for future reference. +""" + +import os +from pathlib import Path + +def save_missing_teams(): + """Save the missing teams list to a file.""" + + # Define the sports directories and their corresponding sections in the abbreviations file + sports_dirs = { + 'mlb_logos': 'MLB', + 'nba_logos': 'NBA', + 'nfl_logos': 'NFL', + 'nhl_logos': 'NHL', + 'ncaa_fbs_logos': ['NCAAF', 'NCAA Conferences/Divisions', 'NCAA_big10', 'NCAA_big12', 'NCAA_acc', 'NCAA_sec', 'NCAA_pac12', 'NCAA_american', 'NCAA_cusa', 'NCAA_mac', 'NCAA_mwc', 'NCAA_sunbelt', 'NCAA_ind', 'NCAA_ovc', 'NCAA_col', 'NCAA_usa', 'NCAA_bigw'], + 'soccer_logos': ['Soccer - Premier League (England)', 'Soccer - La Liga (Spain)', 'Soccer - Bundesliga (Germany)', 'Soccer - Serie A (Italy)', 'Soccer - Ligue 1 (France)', 'Soccer - Champions League', 'Soccer - Other Teams'], + 'milb_logos': 'MiLB' + } + + # Read the abbreviations file + abbreviations_file = Path("assets/sports/all_team_abbreviations.txt") + if not abbreviations_file.exists(): + print("Error: all_team_abbreviations.txt not found") + return + + with open(abbreviations_file, 'r') as f: + content = f.read() + + # Parse teams from the abbreviations file + teams_by_sport = {} + current_section = None + + for line in content.split('\n'): + original_line = line + line = line.strip() + + # Check if this is a section header (not indented and no arrow) + if line and not original_line.startswith(' ') and ' => ' not in line: + current_section = line + continue + + # Check if this is a team entry (indented and has arrow) + if original_line.startswith(' ') and ' => ' in line: + parts = line.split(' => ') + if len(parts) == 2: + abbr = parts[0].strip() + team_name = parts[1].strip() + + if current_section not in teams_by_sport: + teams_by_sport[current_section] = [] + teams_by_sport[current_section].append((abbr, team_name)) + + # Collect all missing teams + all_missing_teams = [] + + for logo_dir, sections in sports_dirs.items(): + logo_path = Path(f"assets/sports/{logo_dir}") + + if not logo_path.exists(): + print(f"⚠️ Logo directory not found: {logo_path}") + continue + + # Get all PNG files in the directory + logo_files = [f.stem for f in logo_path.glob("*.png")] + + # Check teams for this sport + if isinstance(sections, str): + sections = [sections] + + for section in sections: + if section not in teams_by_sport: + continue + + missing_teams = [] + + for abbr, team_name in teams_by_sport[section]: + # Check if logo exists (case-insensitive) + logo_found = False + for logo_file in logo_files: + if logo_file.lower() == abbr.lower(): + logo_found = True + break + + if not logo_found: + missing_teams.append((abbr, team_name)) + + if missing_teams: + all_missing_teams.extend([(section, abbr, team_name) for abbr, team_name in missing_teams]) + + # Sort by sport and then by team abbreviation + all_missing_teams.sort(key=lambda x: (x[0], x[1])) + + # Save to file + output_file = "missing_team_logos.txt" + + with open(output_file, 'w') as f: + f.write("=" * 80 + "\n") + f.write("MISSING TEAM LOGOS - COMPLETE LIST\n") + f.write("=" * 80 + "\n") + f.write(f"Total missing teams: {len(all_missing_teams)}\n") + f.write("\n") + + current_sport = None + for section, abbr, team_name in all_missing_teams: + if section != current_sport: + current_sport = section + f.write(f"\n{section.upper()}:\n") + f.write("-" * len(section) + "\n") + + f.write(f" {abbr:>8} => {team_name}\n") + + f.write("\n" + "=" * 80 + "\n") + f.write("SUMMARY BY SPORT:\n") + f.write("=" * 80 + "\n") + + # Count by sport + sport_counts = {} + for section, abbr, team_name in all_missing_teams: + if section not in sport_counts: + sport_counts[section] = 0 + sport_counts[section] += 1 + + for sport, count in sorted(sport_counts.items()): + f.write(f"{sport:>30}: {count:>3} missing\n") + + f.write("\n" + "=" * 80 + "\n") + f.write("FILENAMES NEEDED:\n") + f.write("=" * 80 + "\n") + f.write("Add these PNG files to their respective directories:\n") + f.write("\n") + + for section, abbr, team_name in all_missing_teams: + # Determine the directory based on the section + if 'MLB' in section: + dir_name = 'mlb_logos' + elif 'NBA' in section: + dir_name = 'nba_logos' + elif 'NFL' in section: + dir_name = 'nfl_logos' + elif 'NHL' in section: + dir_name = 'nhl_logos' + elif 'NCAA' in section: + dir_name = 'ncaa_fbs_logos' + elif 'Soccer' in section: + dir_name = 'soccer_logos' + elif 'MiLB' in section: + dir_name = 'milb_logos' + else: + dir_name = 'unknown' + + f.write(f"assets/sports/{dir_name}/{abbr}.png\n") + + print(f"✅ Missing teams list saved to: {output_file}") + print(f"📊 Total missing teams: {len(all_missing_teams)}") + +if __name__ == "__main__": + save_missing_teams() diff --git a/src/cache_manager.py b/src/cache_manager.py index ca858263..58d054b5 100644 --- a/src/cache_manager.py +++ b/src/cache_manager.py @@ -581,6 +581,11 @@ class CacheManager: 'memory_ttl': 172800, 'force_refresh': False }, + 'leaderboard': { + 'max_age': 604800, # 7 days (1 week) - football rankings updated weekly + 'memory_ttl': 1209600, # 14 days in memory + 'force_refresh': False + }, # News and odds 'news': { diff --git a/src/calendar_manager.py b/src/calendar_manager.py index 338e3b5d..0cb33a56 100644 --- a/src/calendar_manager.py +++ b/src/calendar_manager.py @@ -42,8 +42,7 @@ class CalendarManager: logger.info(f"Calendar configuration: enabled={self.enabled}, update_interval={self.update_interval}, max_events={self.max_events}, calendars={self.calendars}") # Get timezone from config - self.config_manager = ConfigManager() - timezone_str = self.config_manager.get_timezone() + timezone_str = self.config.get('timezone', 'UTC') logger.info(f"Loading timezone from config: {timezone_str}") try: self.timezone = pytz.timezone(timezone_str) diff --git a/src/clock.py b/src/clock.py index 034ad40c..de358e27 100644 --- a/src/clock.py +++ b/src/clock.py @@ -10,9 +10,15 @@ from src.display_manager import DisplayManager logger = logging.getLogger(__name__) class Clock: - def __init__(self, display_manager: DisplayManager = None): - self.config_manager = ConfigManager() - self.config = self.config_manager.load_config() + def __init__(self, display_manager: DisplayManager = None, config: Dict[str, Any] = None): + if config is not None: + # Use provided config + self.config = config + self.config_manager = None # Not needed when config is provided + else: + # Fallback: create ConfigManager and load config (for standalone usage) + self.config_manager = ConfigManager() + self.config = self.config_manager.load_config() # Use the provided display_manager or create a new one if none provided self.display_manager = display_manager or DisplayManager(self.config.get('display', {})) logger.info("Clock initialized with display_manager: %s", id(self.display_manager)) @@ -31,7 +37,7 @@ class Clock: def _get_timezone(self) -> pytz.timezone: """Get timezone from the config file.""" - config_timezone = self.config_manager.get_timezone() + config_timezone = self.config.get('timezone', 'UTC') try: return pytz.timezone(config_timezone) except pytz.exceptions.UnknownTimeZoneError: diff --git a/src/display_controller.py b/src/display_controller.py index 2cf7de13..c4989452 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -20,6 +20,7 @@ from src.cache_manager import CacheManager from src.stock_manager import StockManager from src.stock_news_manager import StockNewsManager 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.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager @@ -55,16 +56,17 @@ class DisplayController: # Initialize display modes init_time = time.time() - self.clock = Clock(self.display_manager) if self.config.get('clock', {}).get('enabled', True) else None + self.clock = Clock(self.display_manager, self.config) if self.config.get('clock', {}).get('enabled', True) else None self.weather = WeatherManager(self.config, self.display_manager) if self.config.get('weather', {}).get('enabled', False) else None self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None self.odds_ticker = OddsTickerManager(self.config, self.display_manager) if self.config.get('odds_ticker', {}).get('enabled', False) else None + self.leaderboard = LeaderboardManager(self.config, self.display_manager) if self.config.get('leaderboard', {}).get('enabled', False) else None self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None self.of_the_day = OfTheDayManager(self.display_manager, self.config) if self.config.get('of_the_day', {}).get('enabled', False) else None - self.news_manager = NewsManager(self.config, self.display_manager) if self.config.get('news_manager', {}).get('enabled', False) else None + self.news_manager = NewsManager(self.config, self.display_manager, self.config_manager) if self.config.get('news_manager', {}).get('enabled', False) else None logger.info(f"Calendar Manager initialized: {'Object' if self.calendar else 'None'}") logger.info(f"Text Display initialized: {'Object' if self.text_display else 'None'}") logger.info(f"OfTheDay Manager initialized: {'Object' if self.of_the_day else 'None'}") @@ -258,6 +260,7 @@ class DisplayController: if self.stocks: self.available_modes.append('stocks') if self.news: self.available_modes.append('stock_news') if self.odds_ticker: self.available_modes.append('odds_ticker') + if self.leaderboard: self.available_modes.append('leaderboard') if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') if self.text_display: self.available_modes.append('text_display') @@ -427,11 +430,9 @@ class DisplayController: logger.info(f"Initial display mode: {self.current_display_mode}") logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager)) - # --- SCHEDULING & CONFIG REFRESH --- - self.config_check_interval = 30 - self.last_config_check = 0 + # --- SCHEDULING --- self.is_display_active = True - self._load_config() # Initial load of schedule + self._load_schedule_config() # Load schedule config once at startup def _handle_music_update(self, track_info: Dict[str, Any], significant_change: bool = False): """Callback for when music track info changes.""" @@ -516,6 +517,20 @@ class DisplayController: # Fall back to configured duration return self.display_durations.get(mode_key, 60) + # Handle dynamic duration for leaderboard + if mode_key == 'leaderboard' and self.leaderboard: + try: + dynamic_duration = self.leaderboard.get_dynamic_duration() + # Only log if duration has changed or we haven't logged this duration yet + if not hasattr(self, '_last_logged_leaderboard_duration') or self._last_logged_leaderboard_duration != dynamic_duration: + logger.info(f"Using dynamic duration for leaderboard: {dynamic_duration} seconds") + self._last_logged_leaderboard_duration = dynamic_duration + return dynamic_duration + except Exception as e: + logger.error(f"Error getting dynamic duration for leaderboard: {e}") + # Fall back to configured duration + return self.display_durations.get(mode_key, 60) + # Simplify weather key handling if mode_key.startswith('weather_'): return self.display_durations.get(mode_key, 15) @@ -530,14 +545,33 @@ class DisplayController: def _update_modules(self): """Call update methods on active managers.""" - if self.weather: self.weather.get_weather() - if self.stocks: self.stocks.update_stock_data() - if self.news: self.news.update_news_data() - if self.odds_ticker: self.odds_ticker.update() - if self.calendar: self.calendar.update(time.time()) - if self.youtube: self.youtube.update() - if self.text_display: self.text_display.update() - if self.of_the_day: self.of_the_day.update(time.time()) + # Check if we're currently scrolling and defer updates if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Display is currently scrolling, deferring module updates") + # Defer updates for modules that might cause lag during scrolling + if self.odds_ticker: + self.display_manager.defer_update(self.odds_ticker.update, priority=1) + if self.stocks: + self.display_manager.defer_update(self.stocks.update_stock_data, priority=2) + if self.news: + self.display_manager.defer_update(self.news.update_news_data, priority=2) + # Continue with non-scrolling-sensitive updates + if self.weather: self.weather.get_weather() + if self.calendar: self.calendar.update(time.time()) + if self.youtube: self.youtube.update() + if self.text_display: self.text_display.update() + if self.of_the_day: self.of_the_day.update(time.time()) + else: + # Not scrolling, perform all updates normally + if self.weather: self.weather.get_weather() + if self.stocks: self.stocks.update_stock_data() + if self.news: self.news.update_news_data() + if self.odds_ticker: self.odds_ticker.update() + if self.calendar: self.calendar.update(time.time()) + if self.youtube: self.youtube.update() + if self.text_display: self.text_display.update() + if self.of_the_day: self.of_the_day.update(time.time()) + # News manager fetches data when displayed, not during updates # if self.news_manager: self.news_manager.fetch_news_data() @@ -829,14 +863,14 @@ class DisplayController: self.ncaa_fb_showing_recent = True # Reset to recent for the new team # --- SCHEDULING METHODS --- - def _load_config(self): - """Load configuration from the config manager and parse schedule settings.""" - self.config = self.config_manager.load_config() + def _load_schedule_config(self): + """Load schedule configuration once at startup.""" schedule_config = self.config.get('schedule', {}) self.schedule_enabled = schedule_config.get('enabled', False) try: self.start_time = datetime.strptime(schedule_config.get('start_time', '07:00'), '%H:%M').time() self.end_time = datetime.strptime(schedule_config.get('end_time', '22:00'), '%H:%M').time() + logger.info(f"Schedule loaded: enabled={self.schedule_enabled}, start={self.start_time}, end={self.end_time}") except (ValueError, TypeError): logger.warning("Invalid time format in schedule config. Using defaults.") self.start_time = time_obj(7, 0) @@ -926,12 +960,7 @@ class DisplayController: while True: current_time = time.time() - # Periodically check for config changes - if current_time - self.last_config_check > self.config_check_interval: - self._load_config() - self.last_config_check = current_time - - # Enforce the schedule + # Check the schedule (no config reload needed) self._check_schedule() if not self.is_display_active: time.sleep(60) @@ -940,6 +969,9 @@ class DisplayController: # Update data for all modules first self._update_modules() + # Process any deferred updates that may have accumulated + self.display_manager.process_deferred_updates() + # Update live modes in rotation if needed self._update_live_modes_in_rotation() @@ -1097,6 +1129,8 @@ class DisplayController: manager_to_display = self.news elif self.current_display_mode == 'odds_ticker' and self.odds_ticker: manager_to_display = self.odds_ticker + elif self.current_display_mode == 'leaderboard' and self.leaderboard: + manager_to_display = self.leaderboard elif self.current_display_mode == 'calendar' and self.calendar: manager_to_display = self.calendar elif self.current_display_mode == 'youtube' and self.youtube: diff --git a/src/display_manager.py b/src/display_manager.py index ba198bd6..84c515a0 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -30,6 +30,15 @@ class DisplayManager: self._snapshot_path = "/tmp/led_matrix_preview.png" self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._last_snapshot_ts = 0.0 + + # Scrolling state tracking for graceful updates + self._scrolling_state = { + 'is_scrolling': False, + 'last_scroll_activity': 0, + 'scroll_inactivity_threshold': 2.0, # seconds of inactivity before considering "not scrolling" + 'deferred_updates': [] + } + self._setup_matrix() logger.info("Matrix setup completed in %.3f seconds", time.time() - start_time) @@ -634,6 +643,77 @@ class DisplayManager: return dt.strftime(f"%b %-d{suffix}") + def set_scrolling_state(self, is_scrolling: bool): + """Set the current scrolling state. Call this when a display starts/stops scrolling.""" + current_time = time.time() + self._scrolling_state['is_scrolling'] = is_scrolling + if is_scrolling: + self._scrolling_state['last_scroll_activity'] = current_time + logger.debug(f"Scrolling state set to: {is_scrolling}") + + def is_currently_scrolling(self) -> bool: + """Check if the display is currently in a scrolling state.""" + current_time = time.time() + + # If explicitly not scrolling, return False + if not self._scrolling_state['is_scrolling']: + return False + + # If we've been inactive for the threshold period, consider it not scrolling + if current_time - self._scrolling_state['last_scroll_activity'] > self._scrolling_state['scroll_inactivity_threshold']: + self._scrolling_state['is_scrolling'] = False + return False + + return True + + def defer_update(self, update_func, priority: int = 0): + """Defer an update function to be called when not scrolling. + + Args: + update_func: Function to call when not scrolling + priority: Priority level (lower numbers = higher priority) + """ + self._scrolling_state['deferred_updates'].append({ + 'func': update_func, + 'priority': priority, + 'timestamp': time.time() + }) + # Sort by priority (lower numbers first) + self._scrolling_state['deferred_updates'].sort(key=lambda x: x['priority']) + logger.debug(f"Deferred update added. Total deferred: {len(self._scrolling_state['deferred_updates'])}") + + def process_deferred_updates(self): + """Process any deferred updates if not currently scrolling.""" + if self.is_currently_scrolling(): + return + + if not self._scrolling_state['deferred_updates']: + return + + # Process all deferred updates + updates_to_process = self._scrolling_state['deferred_updates'].copy() + self._scrolling_state['deferred_updates'].clear() + + logger.debug(f"Processing {len(updates_to_process)} deferred updates") + + for update_info in updates_to_process: + try: + update_info['func']() + logger.debug("Deferred update executed successfully") + except Exception as e: + logger.error(f"Error executing deferred update: {e}") + # Re-add failed updates for retry + self._scrolling_state['deferred_updates'].append(update_info) + + def get_scrolling_stats(self) -> dict: + """Get current scrolling statistics for debugging.""" + return { + 'is_scrolling': self._scrolling_state['is_scrolling'], + 'last_activity': self._scrolling_state['last_scroll_activity'], + 'deferred_count': len(self._scrolling_state['deferred_updates']), + 'inactivity_threshold': self._scrolling_state['scroll_inactivity_threshold'] + } + def _write_snapshot_if_due(self) -> None: """Write the current image to a PNG snapshot file at a limited frequency.""" try: diff --git a/src/leaderboard_manager.py b/src/leaderboard_manager.py new file mode 100644 index 00000000..82967025 --- /dev/null +++ b/src/leaderboard_manager.py @@ -0,0 +1,1279 @@ +import time +import logging +import requests +import json +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta, timezone +import os +from PIL import Image, ImageDraw, ImageFont +import pytz +try: + from .display_manager import DisplayManager + from .cache_manager import CacheManager + from .config_manager import ConfigManager + from .logo_downloader import download_missing_logo +except ImportError: + # Fallback for direct imports + from display_manager import DisplayManager + from cache_manager import CacheManager + from config_manager import ConfigManager + from logo_downloader import download_missing_logo + +# 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 + +# Get logger +logger = logging.getLogger(__name__) + +class LeaderboardManager: + """Manager for displaying scrolling leaderboards for multiple sports leagues.""" + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): + self.config = config + self.display_manager = display_manager + self.leaderboard_config = config.get('leaderboard', {}) + self.is_enabled = self.leaderboard_config.get('enabled', False) + self.enabled_sports = self.leaderboard_config.get('enabled_sports', {}) + self.update_interval = self.leaderboard_config.get('update_interval', 3600) + self.scroll_speed = self.leaderboard_config.get('scroll_speed', 2) + self.scroll_delay = self.leaderboard_config.get('scroll_delay', 0.05) + self.display_duration = self.leaderboard_config.get('display_duration', 30) + self.loop = self.leaderboard_config.get('loop', True) + self.request_timeout = self.leaderboard_config.get('request_timeout', 30) + + # Dynamic duration settings + self.dynamic_duration_enabled = self.leaderboard_config.get('dynamic_duration', True) + self.min_duration = self.leaderboard_config.get('min_duration', 30) + self.max_duration = self.leaderboard_config.get('max_duration', 300) + self.duration_buffer = self.leaderboard_config.get('duration_buffer', 0.1) + self.dynamic_duration = 60 # Default duration in seconds + self.total_scroll_width = 0 # Track total width for dynamic duration calculation + + # Initialize managers + self.cache_manager = CacheManager() + # Store reference to config instead of creating new ConfigManager + self.config = config + + # State variables + self.last_update = 0 + self.scroll_position = 0 + self.last_scroll_time = 0 + self.leaderboard_data = [] + self.current_sport_index = 0 + self.leaderboard_image = None # This will hold the single, wide image + self.last_display_time = 0 + + # Font setup + self.fonts = self._load_fonts() + + # League configurations with ESPN API endpoints + self.league_configs = { + 'nfl': { + 'sport': 'football', + 'league': 'nfl', + 'logo_dir': 'assets/sports/nfl_logos', + 'league_logo': 'assets/sports/nfl_logos/nfl.png', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/football/nfl/standings', + 'enabled': self.enabled_sports.get('nfl', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nfl', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('nfl', {}).get('season', 2025), + 'level': self.enabled_sports.get('nfl', {}).get('level', 1), + 'sort': self.enabled_sports.get('nfl', {}).get('sort', 'winpercent:desc,gamesbehind:asc') + }, + 'nba': { + 'sport': 'basketball', + 'league': 'nba', + 'logo_dir': 'assets/sports/nba_logos', + 'league_logo': 'assets/sports/nba_logos/nba.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams', + 'enabled': self.enabled_sports.get('nba', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nba', {}).get('top_teams', 10) + }, + 'mlb': { + 'sport': 'baseball', + 'league': 'mlb', + 'logo_dir': 'assets/sports/mlb_logos', + 'league_logo': 'assets/sports/mlb_logos/mlb.png', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings', + 'enabled': self.enabled_sports.get('mlb', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('mlb', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('mlb', {}).get('season', 2025), + 'level': self.enabled_sports.get('mlb', {}).get('level', 1), + 'sort': self.enabled_sports.get('mlb', {}).get('sort', 'winpercent:desc,gamesbehind:asc') + }, + 'ncaa_fb': { + 'sport': 'football', + 'league': 'college-football', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaa_fb.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', + 'enabled': self.enabled_sports.get('ncaa_fb', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaa_fb', {}).get('top_teams', 25), + 'show_ranking': self.enabled_sports.get('ncaa_fb', {}).get('show_ranking', True) + }, + 'nhl': { + 'sport': 'hockey', + 'league': 'nhl', + 'logo_dir': 'assets/sports/nhl_logos', + 'league_logo': 'assets/sports/nhl_logos/nhl.png', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings', + 'enabled': self.enabled_sports.get('nhl', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('nhl', {}).get('top_teams', 10), + 'season': self.enabled_sports.get('nhl', {}).get('season', 2025), + 'level': self.enabled_sports.get('nhl', {}).get('level', 1), + 'sort': self.enabled_sports.get('nhl', {}).get('sort', 'winpercent:desc,gamesbehind:asc') + }, + 'ncaam_basketball': { + 'sport': 'basketball', + 'league': 'mens-college-basketball', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaam.png', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', + 'enabled': self.enabled_sports.get('ncaam_basketball', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaam_basketball', {}).get('top_teams', 25) + }, + 'ncaa_baseball': { + 'sport': 'baseball', + 'league': 'college-baseball', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'league_logo': 'assets/sports/ncaa_fbs_logos/ncaa_baseball.png', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings', + 'scoreboard_url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/scoreboard', + 'enabled': self.enabled_sports.get('ncaa_baseball', {}).get('enabled', False), + 'top_teams': self.enabled_sports.get('ncaa_baseball', {}).get('top_teams', 25), + 'season': self.enabled_sports.get('ncaa_baseball', {}).get('season', 2025), + 'level': self.enabled_sports.get('ncaa_baseball', {}).get('level', 1), + 'sort': self.enabled_sports.get('ncaa_baseball', {}).get('sort', 'winpercent:desc,gamesbehind:asc') + } + } + + logger.info(f"LeaderboardManager initialized with enabled sports: {[k for k, v in self.league_configs.items() if v['enabled']]}") + + def clear_leaderboard_cache(self) -> None: + """Clear all leaderboard cache data to force fresh data fetch.""" + try: + for league_key in self.league_configs.keys(): + # Clear all leaderboard cache variants + cache_keys = [ + f"leaderboard_{league_key}", + f"leaderboard_{league_key}_rankings", + f"leaderboard_{league_key}_standings" + ] + + for cache_key in cache_keys: + self.cache_manager.clear_cache(cache_key) + logger.info(f"Cleared cache for {cache_key}") + + # Also clear individual team record caches + for league_key in self.league_configs.keys(): + league_config = self.league_configs[league_key] + if league_config['enabled']: + # Get teams for this league to clear their individual caches + standings = self._fetch_standings(league_config) + for team in standings: + team_cache_key = f"team_record_{league_key}_{team['abbreviation']}" + self.cache_manager.clear_cache(team_cache_key) + + logger.info("Cleared all leaderboard cache data") + except Exception as e: + logger.error(f"Error clearing leaderboard cache: {e}") + + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """Load fonts for the leaderboard display with pixel-perfect rendering.""" + fonts = {} + try: + # Try to load the Press Start 2P font first + fonts['small'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6) + fonts['medium'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + fonts['large'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + fonts['xlarge'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 14) + logger.info("[Leaderboard] Successfully loaded Press Start 2P font for all text elements") + except IOError: + logger.warning("[Leaderboard] Press Start 2P font not found, trying 4x6 font for pixel-perfect rendering.") + try: + # Try to load the 4x6 font as a fallback for pixel-perfect rendering + fonts['small'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) + fonts['medium'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8) + fonts['large'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 10) + fonts['xlarge'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12) + logger.info("[Leaderboard] Successfully loaded 4x6 font for pixel-perfect rendering") + except IOError: + logger.warning("[Leaderboard] 4x6 font not found, using default PIL font.") + # Use default PIL font as a last resort + fonts['small'] = ImageFont.load_default() + fonts['medium'] = ImageFont.load_default() + fonts['large'] = ImageFont.load_default() + fonts['xlarge'] = ImageFont.load_default() + except Exception as e: + logger.error(f"Error loading fonts: {e}") + fonts = { + 'small': ImageFont.load_default(), + 'medium': ImageFont.load_default(), + 'large': ImageFont.load_default(), + 'xlarge': ImageFont.load_default() + } + return fonts + + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + """Draw text with a black outline for better readability on LED matrix.""" + x, y = position + # Draw outline + 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 text + draw.text((x, y), text, font=font, fill=fill) + + def _get_team_logo(self, team_abbr: str, logo_dir: str, league: str = None, team_name: str = None) -> Optional[Image.Image]: + """Get team logo from the configured directory, downloading if missing.""" + if not team_abbr or not logo_dir: + logger.debug("Cannot get team logo with missing team_abbr or logo_dir") + return None + try: + logo_path = os.path.join(logo_dir, f"{team_abbr}.png") + logger.debug(f"Attempting to load logo from path: {logo_path}") + if os.path.exists(logo_path): + logo = Image.open(logo_path) + logger.debug(f"Successfully loaded logo for {team_abbr} from {logo_path}") + return logo + else: + logger.warning(f"Logo not found at path: {logo_path}") + + # Try to download the missing logo if we have league information + if league: + logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") + success = download_missing_logo(team_abbr, league, team_name) + if success: + # Try to load the downloaded logo + if os.path.exists(logo_path): + logo = Image.open(logo_path) + logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") + return logo + + return None + except Exception as e: + logger.error(f"Error loading logo for {team_abbr} from {logo_dir}: {e}") + return None + + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: + """Get league logo from the configured path.""" + if not league_logo_path: + return None + try: + if os.path.exists(league_logo_path): + logo = Image.open(league_logo_path) + logger.debug(f"Successfully loaded league logo from {league_logo_path}") + return logo + else: + logger.warning(f"League logo not found at path: {league_logo_path}") + return None + except Exception as e: + logger.error(f"Error loading league logo from {league_logo_path}: {e}") + return None + + def _fetch_standings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings for a specific league from ESPN API with caching.""" + league_key = league_config['league'] + cache_key = f"leaderboard_{league_key}" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + logger.info(f"Using cached leaderboard data for {league_key}") + return cached_data.get('standings', []) + + # Special handling for college football - use rankings endpoint + if league_key == 'college-football': + return self._fetch_ncaa_fb_rankings(league_config) + + # Use standings endpoint for NFL, MLB, NHL, and NCAA Baseball + if league_key in ['nfl', 'mlb', 'nhl', 'college-baseball']: + return self._fetch_standings_data(league_config) + + try: + logger.info(f"Fetching fresh leaderboard data for {league_key}") + + # First, get all teams for the league + teams_url = league_config['teams_url'] + response = requests.get(teams_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + standings = [] + sports = data.get('sports', []) + + if not sports: + logger.warning(f"No sports data found for {league_config['league']}") + return [] + + leagues = sports[0].get('leagues', []) + if not leagues: + logger.warning(f"No leagues data found for {league_config['league']}") + return [] + + teams = leagues[0].get('teams', []) + if not teams: + logger.warning(f"No teams data found for {league_config['league']}") + return [] + + logger.info(f"Found {len(teams)} teams for {league_config['league']}") + + # For each team, fetch their individual record + for team_data in teams: + team = team_data.get('team', {}) + team_abbr = team.get('abbreviation') + team_name = team.get('name', 'Unknown') + + if not team_abbr: + logger.warning(f"No abbreviation found for team {team_name}") + continue + + # Fetch individual team record + team_record = self._fetch_team_record(team_abbr, league_config) + + if team_record: + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': team_record.get('wins', 0), + 'losses': team_record.get('losses', 0), + 'ties': team_record.get('ties', 0), + 'win_percentage': team_record.get('win_percentage', 0) + }) + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + # Cache the results + cache_data = { + 'standings': top_teams, + 'timestamp': time.time(), + 'league': league_key + } + self.cache_manager.save_cache(cache_key, cache_data) + + logger.info(f"Fetched and cached {len(top_teams)} teams for {league_config['league']}") + return top_teams + + except Exception as e: + logger.error(f"Error fetching standings for {league_config['league']}: {e}") + return [] + + def _fetch_ncaa_fb_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch NCAA Football rankings from ESPN API using the rankings endpoint.""" + league_key = league_config['league'] + cache_key = f"leaderboard_{league_key}_rankings" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + logger.info(f"Using cached rankings data for {league_key}") + return cached_data.get('standings', []) + + try: + logger.info(f"Fetching fresh rankings data for {league_key}") + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings" + + # Get rankings data + response = requests.get(rankings_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + logger.info(f"Available rankings: {[rank['name'] for rank in data.get('availableRankings', [])]}") + logger.info(f"Latest season: {data.get('latestSeason', {})}") + logger.info(f"Latest week: {data.get('latestWeek', {})}") + + rankings_data = data.get('rankings', []) + if not rankings_data: + logger.warning("No rankings data found") + return [] + + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + ranking_name = first_ranking.get('name', 'Unknown') + ranking_type = first_ranking.get('type', 'Unknown') + teams = first_ranking.get('ranks', []) + + logger.info(f"Using ranking: {ranking_name} ({ranking_type})") + logger.info(f"Found {len(teams)} teams in ranking") + + standings = [] + + # Process each team in the ranking + for team_data in teams: + team_info = team_data.get('team', {}) + team_name = team_info.get('name', 'Unknown') + team_abbr = team_info.get('abbreviation', 'Unknown') + current_rank = team_data.get('current', 0) + record_summary = team_data.get('recordSummary', '0-0') + + logger.debug(f" {current_rank}. {team_name} ({team_abbr}): {record_summary}") + + # Parse the record string (e.g., "12-1", "8-4", "10-2-1") + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0 + + try: + parts = record_summary.split('-') + if len(parts) >= 2: + wins = int(parts[0]) + losses = int(parts[1]) + if len(parts) == 3: + ties = int(parts[2]) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + except (ValueError, IndexError): + logger.warning(f"Could not parse record for {team_name}: {record_summary}") + continue + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'rank': current_rank, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'ranking_name': ranking_name + }) + + # Limit to top teams (they're already ranked) + top_teams = standings[:league_config['top_teams']] + + # Cache the results + cache_data = { + 'standings': top_teams, + 'timestamp': time.time(), + 'league': league_key, + 'ranking_name': ranking_name + } + self.cache_manager.save_cache(cache_key, cache_data) + + logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} using {ranking_name}") + return top_teams + + except Exception as e: + logger.error(f"Error fetching rankings for {league_key}: {e}") + return [] + + def _fetch_standings_data(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings data from ESPN API using the standings endpoint.""" + league_key = league_config['league'] + cache_key = f"leaderboard_{league_key}_standings" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + logger.info(f"Using cached standings data for {league_key}") + return cached_data.get('standings', []) + + try: + logger.info(f"Fetching fresh standings data for {league_key}") + + # Build the standings URL with query parameters + standings_url = league_config['standings_url'] + params = { + 'season': league_config.get('season', 2025), + 'level': league_config.get('level', 1), + 'sort': league_config.get('sort', 'winpercent:desc,gamesbehind:asc') + } + + logger.info(f"Fetching standings from: {standings_url} with params: {params}") + + response = requests.get(standings_url, params=params, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + standings = [] + + # Parse the standings data structure + # Check if we have direct standings data or children (divisions/conferences) + if 'standings' in data and 'entries' in data['standings']: + # Direct standings data (e.g., NFL overall standings) + standings_data = data['standings']['entries'] + logger.info(f"Processing direct standings data with {len(standings_data)} teams") + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + # First pass: collect all stat values + games_played = 0 + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses' and league_key == 'nhl': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed' and league_key == 'nhl': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if league_key == 'nhl' and win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': 'Overall' + }) + + elif 'children' in data: + # Children structure (divisions/conferences) + children = data.get('children', []) + logger.info(f"Processing {len(children)} divisions/conferences") + + for child in children: + child_name = child.get('displayName', 'Unknown') + logger.info(f"Processing {child_name}") + + standings_data = child.get('standings', {}).get('entries', []) + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + # First pass: collect all stat values + games_played = 0 + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses' and league_key == 'nhl': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed' and league_key == 'nhl': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if league_key == 'nhl' and win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': child_name + }) + else: + logger.warning(f"No standings or children data found for {league_key}") + return [] + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + # Cache the results + cache_data = { + 'standings': top_teams, + 'timestamp': time.time(), + 'league': league_key, + 'season': params['season'], + 'level': params['level'] + } + self.cache_manager.save_cache(cache_key, cache_data) + + logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} standings") + return top_teams + + except Exception as e: + logger.error(f"Error fetching standings for {league_key}: {e}") + return [] + + def _fetch_team_record(self, team_abbr: str, league_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Fetch individual team record from ESPN API with caching.""" + league = league_config['league'] + cache_key = f"team_record_{league}_{team_abbr}" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + return cached_data.get('record') + + try: + sport = league_config['sport'] + + # Use a more specific endpoint for college sports + if league == 'college-football': + url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/{team_abbr}" + else: + url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{team_abbr}" + + response = requests.get(url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + team_data = data.get('team', {}) + stats = team_data.get('stats', []) + + # Find wins and losses + wins = 0 + losses = 0 + ties = 0 + + for stat in stats: + if stat.get('name') == 'wins': + wins = stat.get('value', 0) + elif stat.get('name') == 'losses': + losses = stat.get('value', 0) + elif stat.get('name') == 'ties': + ties = stat.get('value', 0) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + + record = { + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage + } + + # Cache the team record + cache_data = { + 'record': record, + 'timestamp': time.time(), + 'team': team_abbr, + 'league': league + } + self.cache_manager.save_cache(cache_key, cache_data) + + return record + + except Exception as e: + logger.error(f"Error fetching record for {team_abbr} in league {league_config['league']}: {e}") + return None + + def _fetch_all_standings(self) -> List[Dict[str, Any]]: + """Fetch standings for all enabled leagues.""" + all_standings = [] + + for league_key, league_config in self.league_configs.items(): + if not league_config['enabled']: + continue + + logger.debug(f"Fetching standings for {league_key}") + standings = self._fetch_standings(league_config) + + if standings: + all_standings.append({ + 'league': league_key, + 'league_config': league_config, + 'teams': standings + }) + + return all_standings + + def _create_leaderboard_image(self) -> None: + """Create the scrolling leaderboard image.""" + if not self.leaderboard_data: + logger.warning("No leaderboard data available") + return + + try: + # Get display height first + height = self.display_manager.matrix.height + + # Calculate total width needed + total_width = 0 + spacing = 40 # Spacing between leagues + + # Calculate width for each league section + for league_data in self.leaderboard_data: + league_key = league_data['league'] + league_config = league_data['league_config'] + teams = league_data['teams'] + + # Width for league logo section + league_logo_width = 64 # Fixed width for league logo section + + # Calculate total width for all teams in horizontal layout + teams_width = 0 + # Calculate dynamic logo size (match drawing logic: 120% of display height) + logo_size = int(height * 1.2) + + for i, team in enumerate(teams): + # Calculate width for bold number/ranking/record (match drawing logic) + if league_key == 'ncaa_fb': + if league_config.get('show_ranking', True): + # Show ranking number if available + if 'rank' in team and team['rank'] > 0: + number_text = f"#{team['rank']}" + else: + # Team is unranked - show position number as fallback + number_text = f"{i+1}." + else: + # Show record instead of ranking + if 'record_summary' in team: + number_text = team['record_summary'] + else: + number_text = f"{i+1}." + else: + # For other leagues, show position + number_text = f"{i+1}." + + number_bbox = self.fonts['xlarge'].getbbox(number_text) + number_width = number_bbox[2] - number_bbox[0] + + # Calculate width for team abbreviation (use large font like in drawing) + team_text = team['abbreviation'] + text_bbox = self.fonts['large'].getbbox(team_text) + text_width = text_bbox[2] - text_bbox[0] + + # Total team width: bold number + spacing + logo + spacing + text + spacing + team_width = number_width + 4 + logo_size + 4 + text_width + 12 # Spacing between teams + teams_width += team_width + + # Total league width: logo width + teams width + spacing (match drawing logic) + league_width = league_logo_width + teams_width + 20 + total_width += league_width + spacing + + # Create the main image + self.leaderboard_image = Image.new('RGB', (total_width, height), (0, 0, 0)) + draw = ImageDraw.Draw(self.leaderboard_image) + + current_x = 0 + + for league_idx, league_data in enumerate(self.leaderboard_data): + league_key = league_data['league'] + league_config = league_data['league_config'] + teams = league_data['teams'] + + logger.info(f"Drawing League {league_idx+1} ({league_key}) starting at x={current_x}px") + + # Draw league logo section (full height) + league_logo = self._get_league_logo(league_config['league_logo']) + if league_logo: + # Resize league logo to full height + logo_height = height - 4 # Leave small margin + logo_width = int(logo_height * league_logo.width / league_logo.height) + + # Center the logo horizontally in its section + logo_x = current_x + (64 - logo_width) // 2 + logo_y = 2 + + league_logo = league_logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) + self.leaderboard_image.paste(league_logo, (logo_x, logo_y), league_logo if league_logo.mode == 'RGBA' else None) + + # League name removed - only show league logo + else: + # No league logo available - skip league name display + pass + + # Move to team section + current_x += 64 + 10 # League logo width + spacing + + # Draw team standings horizontally in a single line + team_x = current_x + # Use the same dynamic logo size as Odds Manager ticker + logo_size = int(height * 1.2) + + for i, team in enumerate(teams): + # Draw bold team number/ranking/record (centered vertically) + if league_key == 'ncaa_fb': + if league_config.get('show_ranking', True): + # Show ranking number if available + if 'rank' in team and team['rank'] > 0: + number_text = f"#{team['rank']}" + else: + # Team is unranked - show position number as fallback + number_text = f"{i+1}." + else: + # Show record instead of ranking + if 'record_summary' in team: + number_text = team['record_summary'] + else: + number_text = f"{i+1}." + else: + # For other leagues, show position + number_text = f"{i+1}." + + number_bbox = self.fonts['xlarge'].getbbox(number_text) + number_width = number_bbox[2] - number_bbox[0] + number_height = number_bbox[3] - number_bbox[1] + number_y = (height - number_height) // 2 + self._draw_text_with_outline(draw, number_text, (team_x, number_y), self.fonts['xlarge'], fill=(255, 255, 0)) + + # Draw team logo (95% of display height, centered vertically) + team_logo = self._get_team_logo(team['abbreviation'], league_config['logo_dir'], + league=league_key, team_name=team.get('name')) + if team_logo: + # Resize team logo to dynamic size (95% of display height) + team_logo = team_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) + + # Paste team logo after the bold number (centered vertically) + logo_x = team_x + number_width + 4 + logo_y_pos = (height - logo_size) // 2 + self.leaderboard_image.paste(team_logo, (logo_x, logo_y_pos), team_logo if team_logo.mode == 'RGBA' else None) + + # Draw team abbreviation after the logo (centered vertically) + team_text = team['abbreviation'] + text_bbox = self.fonts['large'].getbbox(team_text) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + text_x = logo_x + logo_size + 4 + text_y = (height - text_height) // 2 + self._draw_text_with_outline(draw, team_text, (text_x, text_y), self.fonts['large'], fill=(255, 255, 255)) + + # Calculate total width used by this team + team_width = number_width + 4 + logo_size + 4 + text_width + 12 # 12px spacing to next team + else: + # Fallback if no logo - draw team abbreviation after bold number (centered vertically) + team_text = team['abbreviation'] + text_bbox = self.fonts['large'].getbbox(team_text) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + text_x = team_x + number_width + 4 + text_y = (height - text_height) // 2 + self._draw_text_with_outline(draw, team_text, (text_x, text_y), self.fonts['large'], fill=(255, 255, 255)) + + # Calculate total width used by this team + team_width = number_width + 4 + text_width + 12 # 12px spacing to next team + + # Move to next team position + team_x += team_width + + # Move to next league section (match width calculation logic) + # Update current_x to where team drawing actually ended + logger.info(f"League {league_idx+1} ({league_key}) teams ended at x={team_x}px") + current_x = team_x + 20 + spacing # team_x is at end of teams, add internal spacing + inter-league spacing + logger.info(f"Next league will start at x={current_x}px (gap: {20 + spacing}px)") + + # Set total scroll width for dynamic duration calculation + # Use actual content width (current_x at end) instead of pre-calculated total_width + actual_content_width = current_x - (20 + spacing) # Remove the final spacing that won't be used + self.total_scroll_width = actual_content_width + logger.info(f"Content width - Calculated: {total_width}px, Actual: {actual_content_width}px") + + # Log league positioning for debugging and verify layout + debug_x = 0 + for i, league_data in enumerate(self.leaderboard_data): + league_key = league_data['league'] + league_config = league_data['league_config'] + teams = league_data['teams'] + + # Calculate actual widths used in drawing + league_logo_width = 64 + teams_width = 0 + logo_size = int(height * 1.2) + + for j, team in enumerate(teams): + # Calculate width for bold number/ranking/record (match drawing logic) + if league_key == 'ncaa_fb': + if league_config.get('show_ranking', True): + if 'rank' in team and team['rank'] > 0: + number_text = f"#{team['rank']}" + else: + number_text = f"{j+1}." + else: + if 'record_summary' in team: + number_text = team['record_summary'] + else: + number_text = f"{j+1}." + else: + number_text = f"{j+1}." + + number_bbox = self.fonts['xlarge'].getbbox(number_text) + number_width = number_bbox[2] - number_bbox[0] + team_text = team['abbreviation'] + text_bbox = self.fonts['large'].getbbox(team_text) + text_width = text_bbox[2] - text_bbox[0] + team_width = number_width + 4 + logo_size + 4 + text_width + 12 + teams_width += team_width + + # Calculate where this league should start and end + league_start_x = debug_x + league_content_width = league_logo_width + 10 + teams_width + 20 # Logo + spacing + teams + internal spacing + league_end_x = league_start_x + league_content_width + + logger.info(f"League {i+1} ({league_key}): {len(teams)} teams") + logger.info(f" Start: {league_start_x}px, Content: {league_content_width}px, End: {league_end_x}px") + + # Move to next league start position + if i < len(self.leaderboard_data) - 1: # Not the last league + debug_x = league_end_x + spacing # Add inter-league spacing + logger.info(f" Next league starts at: {debug_x}px (gap: {spacing}px)") + else: + logger.info(f" Final league ends at: {league_end_x}px") + + logger.info(f"Total image width: {total_width}px, Display width: {height}px") + + # Calculate dynamic duration using proper scroll-based calculation + if self.dynamic_duration_enabled: + self.calculate_dynamic_duration() + logger.info(f"Created leaderboard image with width {total_width}") + + except Exception as e: + logger.error(f"Error creating leaderboard image: {e}") + self.leaderboard_image = None + + def calculate_dynamic_duration(self): + """Calculate the exact time needed to display all leaderboard content""" + logger.info(f"Calculating dynamic duration - enabled: {self.dynamic_duration_enabled}, content width: {self.total_scroll_width}px") + + # If dynamic duration is disabled, use fixed duration from config + if not self.dynamic_duration_enabled: + self.dynamic_duration = self.leaderboard_config.get('display_duration', 60) + logger.debug(f"Dynamic duration disabled, using fixed duration: {self.dynamic_duration}s") + return + + if not self.total_scroll_width: + self.dynamic_duration = self.min_duration # Use configured minimum + logger.debug(f"total_scroll_width is 0, using minimum duration: {self.min_duration}s") + return + + try: + # Get display width (assume full width of display) + display_width = getattr(self.display_manager, 'matrix', None) + if display_width: + display_width = display_width.width + else: + display_width = 128 # Default to 128 if not available + + # Calculate total scroll distance needed + # For looping content, we need to scroll the entire content width + # For non-looping content, we need content width minus display width (since last part shows fully) + if self.loop: + total_scroll_distance = self.total_scroll_width + else: + # For single pass, we need to scroll until the last content is fully visible + total_scroll_distance = max(0, self.total_scroll_width - display_width) + + # Calculate time based on scroll speed and delay + # scroll_speed = pixels per frame, scroll_delay = seconds per frame + # However, actual observed speed is slower than theoretical calculation + # Based on log analysis: 1950px in 36s = 54.2 px/s actual speed + # vs theoretical: 1px/0.01s = 100 px/s + # Use actual observed speed for more accurate timing + actual_scroll_speed = 54.2 # pixels per second (calculated from logs) + total_time = total_scroll_distance / actual_scroll_speed + + # Add buffer time for smooth cycling (configurable %) + buffer_time = total_time * self.duration_buffer + + # Calculate duration for single complete pass + if self.loop: + # For looping: set duration to exactly one loop cycle (no extra time to prevent multiple loops) + calculated_duration = int(total_time) + logger.debug(f"Looping enabled, duration set to exactly one loop cycle: {calculated_duration}s") + else: + # For single pass: precise calculation to show content exactly once + # Add buffer to prevent cutting off the last content + completion_buffer = total_time * 0.05 # 5% extra to ensure complete display + calculated_duration = int(total_time + buffer_time + completion_buffer) + logger.debug(f"Single pass mode, added {completion_buffer:.2f}s completion buffer for precise timing") + + # Apply configured min/max limits + if calculated_duration < self.min_duration: + self.dynamic_duration = self.min_duration + logger.debug(f"Duration capped to minimum: {self.min_duration}s") + elif calculated_duration > self.max_duration: + self.dynamic_duration = self.max_duration + logger.debug(f"Duration capped to maximum: {self.max_duration}s") + else: + self.dynamic_duration = calculated_duration + + # Additional safety check: if the calculated duration seems too short for the content, + # ensure we have enough time to display all content properly + if self.dynamic_duration < 45 and self.total_scroll_width > 200: + # If we have content but short duration, increase it + # Use a more generous calculation: at least 45s or 1s per 20px + self.dynamic_duration = max(45, int(self.total_scroll_width / 20)) + logger.debug(f"Adjusted duration for content: {self.dynamic_duration}s (content width: {self.total_scroll_width}px)") + + logger.info(f"Leaderboard dynamic duration calculation:") + logger.info(f" Display width: {display_width}px") + logger.info(f" Content width: {self.total_scroll_width}px") + logger.info(f" Total scroll distance: {total_scroll_distance}px") + logger.info(f" Configured scroll speed: {self.scroll_speed}px/frame") + logger.info(f" Configured scroll delay: {self.scroll_delay}s/frame") + logger.info(f" Actual observed scroll speed: {actual_scroll_speed}px/s (from log analysis)") + logger.info(f" Base time: {total_time:.2f}s") + logger.info(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)") + logger.info(f" Looping enabled: {self.loop}") + logger.info(f" Calculated duration: {calculated_duration}s") + logger.info(f"Final calculated duration: {self.dynamic_duration}s") + + # Verify the duration makes sense for the content + expected_scroll_time = self.total_scroll_width / actual_scroll_speed + logger.info(f" Verification - Time to scroll content: {expected_scroll_time:.1f}s") + + except Exception as e: + logger.error(f"Error calculating dynamic duration: {e}") + self.dynamic_duration = self.min_duration # Use configured minimum as fallback + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for display""" + # If we don't have a valid dynamic duration yet (total_scroll_width is 0), + # try to update the data first + if self.total_scroll_width == 0 and self.is_enabled: + logger.debug("get_dynamic_duration called but total_scroll_width is 0, attempting update...") + try: + # Force an update to get the data and calculate proper duration + # Bypass the update interval check for duration calculation + self.update() + logger.debug(f"Force update completed, total_scroll_width: {self.total_scroll_width}px") + except Exception as e: + logger.error(f"Error updating leaderboard for dynamic duration: {e}") + + logger.debug(f"get_dynamic_duration called, returning: {self.dynamic_duration}s") + return self.dynamic_duration + + def update(self) -> None: + """Update leaderboard data.""" + current_time = time.time() + + if current_time - self.last_update < self.update_interval: + return + + logger.info("Updating leaderboard data") + + try: + self.leaderboard_data = self._fetch_all_standings() + self.last_update = current_time + + if self.leaderboard_data: + self._create_leaderboard_image() + else: + logger.warning("No leaderboard data fetched") + + except Exception as e: + logger.error(f"Error updating leaderboard: {e}") + + def _display_fallback_message(self) -> None: + """Display a fallback message when no data is available.""" + try: + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Create a simple text image + image = Image.new('RGB', (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(image) + + text = "No Leaderboard Data" + text_bbox = draw.textbbox((0, 0), text, font=self.fonts['medium']) + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + x = (width - text_width) // 2 + y = (height - text_height) // 2 + + self._draw_text_with_outline(draw, text, (x, y), self.fonts['medium'], fill=(255, 255, 255)) + + self.display_manager.image = image + self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.update_display() + + except Exception as e: + logger.error(f"Error displaying fallback message: {e}") + + def display(self, force_clear: bool = False) -> None: + """Display the leaderboard.""" + logger.debug("Entering leaderboard display method") + logger.debug(f"Leaderboard enabled: {self.is_enabled}") + logger.debug(f"Current scroll position: {self.scroll_position}") + logger.debug(f"Leaderboard image width: {self.leaderboard_image.width if self.leaderboard_image else 'None'}") + logger.debug(f"Using dynamic duration for leaderboard: {self.dynamic_duration}s") + + if not self.is_enabled: + logger.debug("Leaderboard is disabled, exiting display method.") + return + + # Reset display start time when force_clear is True or when starting fresh + if force_clear or not hasattr(self, '_display_start_time'): + self._display_start_time = time.time() + logger.debug(f"Reset/initialized display start time: {self._display_start_time}") + # Also reset scroll position for clean start + self.scroll_position = 0 + else: + # Check if the display start time is too old (more than 2x the dynamic duration) + current_time = time.time() + elapsed_time = current_time - self._display_start_time + if elapsed_time > (self.dynamic_duration * 2): + logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting") + self._display_start_time = current_time + self.scroll_position = 0 + + logger.debug(f"Number of leagues in data at start of display method: {len(self.leaderboard_data)}") + if not self.leaderboard_data: + logger.warning("Leaderboard has no data. Attempting to update...") + self.update() + if not self.leaderboard_data: + logger.warning("Still no data after update. Displaying fallback message.") + self._display_fallback_message() + return + + if self.leaderboard_image is None: + logger.warning("Leaderboard image is not available. Attempting to create it.") + self._create_leaderboard_image() + if self.leaderboard_image is None: + logger.error("Failed to create leaderboard image.") + self._display_fallback_message() + return + + try: + current_time = time.time() + + # Check if we should be scrolling + should_scroll = current_time - self.last_scroll_time >= self.scroll_delay + + # Signal scrolling state to display manager + if should_scroll: + self.display_manager.set_scrolling_state(True) + else: + # If we're not scrolling, check if we should process deferred updates + self.display_manager.process_deferred_updates() + + # Scroll the image + if should_scroll: + self.scroll_position += self.scroll_speed + self.last_scroll_time = current_time + + # Calculate crop region + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Handle looping based on configuration + if self.loop: + # Reset position when we've scrolled past the end for a continuous loop + if self.scroll_position >= self.leaderboard_image.width: + logger.info(f"Leaderboard loop reset: scroll_position {self.scroll_position} >= image width {self.leaderboard_image.width}") + self.scroll_position = 0 + logger.info("Leaderboard starting new loop cycle") + else: + # Stop scrolling when we reach the end + if self.scroll_position >= self.leaderboard_image.width - width: + logger.info(f"Leaderboard reached end: scroll_position {self.scroll_position} >= {self.leaderboard_image.width - width}") + self.scroll_position = self.leaderboard_image.width - width + # Signal that scrolling has stopped + self.display_manager.set_scrolling_state(False) + logger.info("Leaderboard scrolling stopped - reached end of content") + + # Check if we're at a natural break point for mode switching + elapsed_time = current_time - self._display_start_time + remaining_time = self.dynamic_duration - elapsed_time + + # Log scroll progress every 50 pixels to help debug (less verbose) + if self.scroll_position % 50 == 0 and self.scroll_position > 0: + logger.info(f"Leaderboard progress: elapsed={elapsed_time:.1f}s, remaining={remaining_time:.1f}s, scroll_pos={self.scroll_position}/{self.leaderboard_image.width}px") + + # If we have less than 2 seconds remaining, check if we can complete the content display + if remaining_time < 2.0 and self.scroll_position > 0: + # Calculate how much time we need to complete the current scroll position + # Use actual observed scroll speed (54.2 px/s) instead of theoretical calculation + actual_scroll_speed = 54.2 # pixels per second (calculated from logs) + + if self.loop: + # For looping, we need to complete one full cycle + distance_to_complete = self.leaderboard_image.width - self.scroll_position + else: + # For single pass, we need to reach the end (content width minus display width) + end_position = max(0, self.leaderboard_image.width - width) + distance_to_complete = end_position - self.scroll_position + + time_to_complete = distance_to_complete / actual_scroll_speed + + if time_to_complete <= remaining_time: + # We have enough time to complete the scroll, continue normally + logger.debug(f"Sufficient time remaining ({remaining_time:.1f}s) to complete scroll ({time_to_complete:.1f}s)") + else: + # Not enough time, reset to beginning for clean transition + logger.warning(f"Not enough time to complete content display - remaining: {remaining_time:.1f}s, needed: {time_to_complete:.1f}s") + logger.debug(f"Resetting scroll position for clean transition") + self.scroll_position = 0 + + # Create the visible part of the image by cropping from the leaderboard_image + visible_image = self.leaderboard_image.crop(( + self.scroll_position, + 0, + self.scroll_position + width, + height + )) + + # Display the visible portion + self.display_manager.image = visible_image + self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.update_display() + + except Exception as e: + logger.error(f"Error in leaderboard display: {e}") + self._display_fallback_message() diff --git a/src/logo_downloader.py b/src/logo_downloader.py new file mode 100644 index 00000000..702048d4 --- /dev/null +++ b/src/logo_downloader.py @@ -0,0 +1,662 @@ +#!/usr/bin/env python3 +""" +Centralized logo downloader utility for automatically fetching team logos from ESPN API. +This module provides functionality to download missing team logos for various sports leagues, +with special support for FCS teams and other NCAA divisions. +""" + +import os +import time +import logging +import requests +import json +from typing import Dict, Any, List, Optional, Tuple +from pathlib import Path +from PIL import Image, ImageDraw, ImageFont +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +class LogoDownloader: + """Centralized logo downloader for team logos from ESPN API.""" + + # ESPN API endpoints for different sports/leagues + API_ENDPOINTS = { + 'nfl': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams', + 'nba': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams', + 'mlb': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams', + 'nhl': 'https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams', + 'ncaa_fb': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', + 'ncaa_fb_all': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # Includes FCS + 'fcs': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # FCS teams from same endpoint + 'ncaam_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams', + 'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams' + } + + # Directory mappings for different leagues + LOGO_DIRECTORIES = { + 'nfl': 'assets/sports/nfl_logos', + 'nba': 'assets/sports/nba_logos', + 'mlb': 'assets/sports/mlb_logos', + 'nhl': 'assets/sports/nhl_logos', + 'ncaa_fb': 'assets/sports/ncaa_fbs_logos', + 'ncaa_fb_all': 'assets/sports/ncaa_fbs_logos', # FCS teams go in same directory + 'fcs': 'assets/sports/ncaa_fbs_logos', # FCS teams go in same directory + 'ncaam_basketball': 'assets/sports/ncaa_fbs_logos', + 'ncaa_baseball': 'assets/sports/ncaa_fbs_logos' + } + + def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): + """Initialize the logo downloader with HTTP session and retry logic.""" + self.request_timeout = request_timeout + self.retry_attempts = retry_attempts + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=retry_attempts, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "HEAD", "OPTIONS"] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("https://", adapter) + self.session.mount("http://", adapter) + + # Set up headers + self.headers = { + 'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)', + 'Accept': 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Connection': 'keep-alive' + } + + def normalize_abbreviation(self, abbreviation: str) -> str: + """Normalize team abbreviation for consistent filename usage.""" + # Handle special characters that can cause filesystem issues + normalized = abbreviation.upper() + # Replace problematic characters with safe alternatives + normalized = normalized.replace('&', 'AND') + normalized = normalized.replace('/', '_') + normalized = normalized.replace('\\', '_') + normalized = normalized.replace(':', '_') + normalized = normalized.replace('*', '_') + normalized = normalized.replace('?', '_') + normalized = normalized.replace('"', '_') + normalized = normalized.replace('<', '_') + normalized = normalized.replace('>', '_') + normalized = normalized.replace('|', '_') + return normalized + + def get_logo_directory(self, league: str) -> str: + """Get the logo directory for a given league.""" + return self.LOGO_DIRECTORIES.get(league, f'assets/sports/{league}_logos') + + def ensure_logo_directory(self, logo_dir: str) -> bool: + """Ensure the logo directory exists, create if necessary.""" + try: + os.makedirs(logo_dir, exist_ok=True) + return True + except Exception as e: + logger.error(f"Failed to create logo directory {logo_dir}: {e}") + return False + + def download_logo(self, logo_url: str, filepath: Path, team_name: str) -> bool: + """Download a single logo from URL and save to filepath.""" + try: + response = self.session.get(logo_url, headers=self.headers, timeout=self.request_timeout) + response.raise_for_status() + + # Verify it's actually an image + content_type = response.headers.get('content-type', '').lower() + if not any(img_type in content_type for img_type in ['image/png', 'image/jpeg', 'image/jpg', 'image/gif']): + logger.warning(f"Downloaded content for {team_name} is not an image: {content_type}") + return False + + with open(filepath, 'wb') as f: + f.write(response.content) + + # Verify and convert the downloaded image to RGBA format + try: + with Image.open(filepath) as img: + # Convert to RGBA to avoid PIL warnings about palette images with transparency + if img.mode in ('P', 'LA', 'L'): + # Convert palette or grayscale images to RGBA + img = img.convert('RGBA') + elif img.mode == 'RGB': + # Convert RGB to RGBA (add alpha channel) + img = img.convert('RGBA') + elif img.mode != 'RGBA': + # For any other mode, convert to RGBA + img = img.convert('RGBA') + + # Save the converted image + img.save(filepath, 'PNG') + + logger.info(f"Successfully downloaded and converted logo for {team_name} -> {filepath.name}") + return True + except Exception as e: + logger.error(f"Downloaded file for {team_name} is not a valid image or conversion failed: {e}") + try: + os.remove(filepath) # Remove invalid file + except: + pass + return False + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to download logo for {team_name}: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error downloading logo for {team_name}: {e}") + return False + + def fetch_teams_data(self, league: str) -> Optional[Dict]: + """Fetch team data from ESPN API for a specific league.""" + api_url = self.API_ENDPOINTS.get(league) + if not api_url: + logger.error(f"No API endpoint configured for league: {league}") + return None + + try: + logger.info(f"Fetching team data for {league} from ESPN API...") + response = self.session.get(api_url, headers=self.headers, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + logger.info(f"Successfully fetched team data for {league}") + return data + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching team data for {league}: {e}") + return None + except json.JSONDecodeError as e: + logger.error(f"Error parsing JSON response for {league}: {e}") + return None + + def extract_teams_from_data(self, data: Dict, league: str) -> List[Dict[str, str]]: + """Extract team information from ESPN API response.""" + teams = [] + + try: + sports = data.get('sports', []) + for sport in sports: + leagues_data = sport.get('leagues', []) + for league_data in leagues_data: + teams_data = league_data.get('teams', []) + + for team_data in teams_data: + team_info = team_data.get('team', {}) + + abbreviation = team_info.get('abbreviation', '') + display_name = team_info.get('displayName', 'Unknown') + logos = team_info.get('logos', []) + + if not abbreviation or not logos: + continue + + # Get the default logo (first one is usually default) + logo_url = logos[0].get('href', '') + if not logo_url: + continue + + # For NCAA football, try to determine if it's FCS or FBS + team_category = 'FBS' # Default + if league in ['ncaa_fb', 'ncaa_fb_all', 'fcs']: + # Check if this is an FCS team by looking at conference or other indicators + # ESPN API includes both FBS and FCS teams in the same endpoint + # We'll include all teams and let the user decide which ones to use + team_category = self._determine_ncaa_football_division(team_info, league_data) + + teams.append({ + 'abbreviation': abbreviation, + 'display_name': display_name, + 'logo_url': logo_url, + 'league': league, + 'category': team_category, + 'conference': league_data.get('name', 'Unknown') + }) + + logger.info(f"Extracted {len(teams)} teams for {league}") + return teams + + except Exception as e: + logger.error(f"Error extracting teams for {league}: {e}") + return [] + + def _determine_ncaa_football_division(self, team_info: Dict, league_data: Dict) -> str: + """Determine if an NCAA football team is FBS or FCS based on conference and other indicators.""" + conference_name = league_data.get('name', '').lower() + + # FBS Conferences (more comprehensive list) + fbs_conferences = { + 'acc', 'american athletic', 'big 12', 'big ten', 'conference usa', 'c-usa', + 'mid-american', 'mac', 'mountain west', 'pac-12', 'pac-10', 'sec', + 'sun belt', 'independents', 'big east' + } + + # FCS Conferences (more comprehensive list) + fcs_conferences = { + 'big sky', 'big south', 'colonial athletic', 'caa', 'ivy league', + 'meac', 'missouri valley', 'mvfc', 'northeast', 'nec', + 'ohio valley', 'ovc', 'patriot league', 'pioneer football', + 'southland', 'southern', 'southwestern athletic', 'swac', + 'western athletic', 'wac', 'ncaa division i-aa' + } + + # Also check for specific team indicators + team_abbreviation = team_info.get('abbreviation', '').upper() + + # Known FBS teams that might be misclassified + known_fbs_teams = { + 'ASU', 'ARIZ', 'ARK', 'AUB', 'BOIS', 'CSU', 'FLA', 'HAW', 'IDHO', 'USA' + } + + # Check if it's a known FBS team first + if team_abbreviation in known_fbs_teams: + return 'FBS' + + # Check conference names + if any(fbs_conf in conference_name for fbs_conf in fbs_conferences): + return 'FBS' + elif any(fcs_conf in conference_name for fcs_conf in fcs_conferences): + return 'FCS' + + # If conference is just "NCAA - Football", we need to use other indicators + if conference_name == 'ncaa - football': + # Check team name for indicators of FCS (smaller schools, Division II/III) + team_name = team_info.get('displayName', '').lower() + fcs_indicators = ['college', 'university', 'state', 'tech', 'community'] + + # If it has typical FCS naming patterns and isn't a known FBS team + if any(indicator in team_name for indicator in fcs_indicators): + return 'FCS' + else: + return 'FBS' + + # Default to FBS for unknown conferences + return 'FBS' + + def _get_team_name_variations(self, abbreviation: str) -> List[str]: + """Generate common variations of a team abbreviation for matching.""" + variations = set() + abbr = abbreviation.upper() + variations.add(abbr) + + # Add normalized version + variations.add(self.normalize_abbreviation(abbr)) + + # Common substitutions + substitutions = { + '&': ['AND', 'A'], + 'A&M': ['TAMU', 'TA&M', 'TEXASAM'], + 'STATE': ['ST', 'ST.'], + 'UNIVERSITY': ['U', 'UNIV'], + 'COLLEGE': ['C', 'COL'], + 'TECHNICAL': ['TECH', 'T'], + 'NORTHERN': ['NORTH', 'N'], + 'SOUTHERN': ['SOUTH', 'S'], + 'EASTERN': ['EAST', 'E'], + 'WESTERN': ['WEST', 'W'] + } + + # Apply substitutions + for original, replacements in substitutions.items(): + if original in abbr: + for replacement in replacements: + variations.add(abbr.replace(original, replacement)) + variations.add(abbr.replace(original, '')) # Remove the word entirely + + # Add common abbreviations for Texas A&M + if 'A&M' in abbr or 'TAMU' in abbr: + variations.update(['TAMU', 'TA&M', 'TEXASAM', 'TEXAS_A&M', 'TEXAS_AM']) + + return list(variations) + + def download_missing_logos_for_league(self, league: str, force_download: bool = False) -> Tuple[int, int]: + """Download missing logos for a specific league.""" + logger.info(f"Starting logo download for league: {league}") + + # Get logo directory + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + logger.error(f"Failed to create logo directory for {league}") + return 0, 0 + + # Fetch team data + data = self.fetch_teams_data(league) + if not data: + logger.error(f"Failed to fetch team data for {league}") + return 0, 0 + + # Extract teams + teams = self.extract_teams_from_data(data, league) + if not teams: + logger.warning(f"No teams found for {league}") + return 0, 0 + + # Download missing logos + downloaded_count = 0 + failed_count = 0 + + for team in teams: + abbreviation = team['abbreviation'] + display_name = team['display_name'] + logo_url = team['logo_url'] + + # Create filename + filename = f"{self.normalize_abbreviation(abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Skip if already exists and not forcing download + if filepath.exists() and not force_download: + logger.debug(f"Skipping {display_name}: {filename} already exists") + continue + + # Download logo + if self.download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + else: + failed_count += 1 + + # Small delay to be respectful to the API + time.sleep(0.1) + + logger.info(f"Logo download complete for {league}: {downloaded_count} downloaded, {failed_count} failed") + return downloaded_count, failed_count + + def download_all_ncaa_football_logos(self, include_fcs: bool = True, force_download: bool = False) -> Tuple[int, int]: + """Download all NCAA football team logos including FCS teams.""" + logger.info(f"Starting comprehensive NCAA football logo download (FCS: {include_fcs})") + + # Use the comprehensive NCAA football endpoint + league = 'ncaa_fb_all' + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + logger.error(f"Failed to create logo directory for {league}") + return 0, 0 + + # Fetch team data + data = self.fetch_teams_data(league) + if not data: + logger.error(f"Failed to fetch team data for {league}") + return 0, 0 + + # Extract teams + teams = self.extract_teams_from_data(data, league) + if not teams: + logger.warning(f"No teams found for {league}") + return 0, 0 + + # Filter teams based on FCS inclusion + if not include_fcs: + teams = [team for team in teams if team.get('category') == 'FBS'] + logger.info(f"Filtered to FBS teams only: {len(teams)} teams") + + # Download missing logos + downloaded_count = 0 + failed_count = 0 + + for team in teams: + abbreviation = team['abbreviation'] + display_name = team['display_name'] + logo_url = team['logo_url'] + category = team.get('category', 'Unknown') + conference = team.get('conference', 'Unknown') + + # Create filename + filename = f"{self.normalize_abbreviation(abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Skip if already exists and not forcing download + if filepath.exists() and not force_download: + logger.debug(f"Skipping {display_name} ({category}, {conference}): {filename} already exists") + continue + + # Download logo + if self.download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + logger.info(f"Downloaded {display_name} ({category}, {conference}) -> {filename}") + else: + failed_count += 1 + logger.warning(f"Failed to download {display_name} ({category}, {conference})") + + # Small delay to be respectful to the API + time.sleep(0.1) + + logger.info(f"Comprehensive NCAA football logo download complete: {downloaded_count} downloaded, {failed_count} failed") + return downloaded_count, failed_count + + def download_missing_logo_for_team(self, team_abbreviation: str, league: str, team_name: str = None) -> bool: + """Download a specific team's logo if it's missing.""" + logo_dir = self.get_logo_directory(league) + if not self.ensure_logo_directory(logo_dir): + return False + + filename = f"{self.normalize_abbreviation(team_abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Return True if logo already exists + if filepath.exists(): + logger.debug(f"Logo already exists for {team_abbreviation}") + return True + + # Fetch team data to find the logo URL + data = self.fetch_teams_data(league) + if not data: + return False + + teams = self.extract_teams_from_data(data, league) + + # Find the specific team with improved matching + target_team = None + normalized_search = self.normalize_abbreviation(team_abbreviation) + + # First try exact match + for team in teams: + if team['abbreviation'].upper() == team_abbreviation.upper(): + target_team = team + break + + # If not found, try normalized match + if not target_team: + for team in teams: + normalized_team_abbr = self.normalize_abbreviation(team['abbreviation']) + if normalized_team_abbr == normalized_search: + target_team = team + break + + # If still not found, try partial matching for common variations + if not target_team: + search_variations = self._get_team_name_variations(team_abbreviation) + for team in teams: + team_variations = self._get_team_name_variations(team['abbreviation']) + if any(var in team_variations for var in search_variations): + target_team = team + logger.info(f"Found team {team_abbreviation} as {team['abbreviation']} ({team['display_name']})") + break + + if not target_team: + logger.warning(f"Team {team_abbreviation} not found in {league} data") + return False + + # Download the logo + success = self.download_logo(target_team['logo_url'], filepath, target_team['display_name']) + if success: + time.sleep(0.1) # Small delay + return success + + def download_all_missing_logos(self, leagues: List[str] = None, force_download: bool = False) -> Dict[str, Tuple[int, int]]: + """Download missing logos for all specified leagues.""" + if leagues is None: + leagues = list(self.API_ENDPOINTS.keys()) + + results = {} + total_downloaded = 0 + total_failed = 0 + + for league in leagues: + if league not in self.API_ENDPOINTS: + logger.warning(f"Skipping unknown league: {league}") + continue + + downloaded, failed = self.download_missing_logos_for_league(league, force_download) + results[league] = (downloaded, failed) + total_downloaded += downloaded + total_failed += failed + + logger.info(f"Overall logo download results: {total_downloaded} downloaded, {total_failed} failed") + return results + + def create_placeholder_logo(self, team_abbreviation: str, logo_dir: str, team_name: str = None) -> bool: + """Create a placeholder logo when real logo cannot be downloaded.""" + try: + # Ensure the logo directory exists + if not self.ensure_logo_directory(logo_dir): + logger.error(f"Failed to create logo directory: {logo_dir}") + return False + + filename = f"{self.normalize_abbreviation(team_abbreviation)}.png" + filepath = Path(logo_dir) / filename + + # Check if we can write to the directory + try: + # Test write permissions by creating a temporary file + test_file = filepath.parent / "test_write.tmp" + test_file.touch() + test_file.unlink() # Remove the test file + except PermissionError: + logger.error(f"Permission denied: Cannot write to directory {logo_dir}") + return False + except Exception as e: + logger.error(f"Directory access error for {logo_dir}: {e}") + return False + + # Create a simple placeholder logo + logo = Image.new('RGBA', (64, 64), (100, 100, 100, 255)) # Gray background + draw = ImageDraw.Draw(logo) + + # Try to load a font, fallback to default + try: + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + except: + try: + font = ImageFont.load_default() + except: + font = None + + # Draw team abbreviation + text = team_abbreviation + if font: + # Center the text + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + x = (64 - text_width) // 2 + y = (64 - text_height) // 2 + draw.text((x, y), text, font=font, fill=(255, 255, 255, 255)) + else: + # Fallback without font + draw.text((16, 24), text, fill=(255, 255, 255, 255)) + + logo.save(filepath) + logger.info(f"Created placeholder logo for {team_abbreviation} at {filepath}") + return True + + except Exception as e: + logger.error(f"Failed to create placeholder logo for {team_abbreviation}: {e}") + return False + + def convert_image_to_rgba(self, filepath: Path) -> bool: + """Convert an image file to RGBA format to avoid PIL warnings.""" + try: + with Image.open(filepath) as img: + if img.mode != 'RGBA': + # Convert to RGBA + converted_img = img.convert('RGBA') + converted_img.save(filepath, 'PNG') + logger.debug(f"Converted {filepath.name} from {img.mode} to RGBA") + return True + else: + logger.debug(f"{filepath.name} is already in RGBA format") + return True + except Exception as e: + logger.error(f"Failed to convert {filepath.name} to RGBA: {e}") + return False + + def convert_all_logos_to_rgba(self, league: str) -> Tuple[int, int]: + """Convert all logos in a league directory to RGBA format.""" + logo_dir = Path(self.get_logo_directory(league)) + if not logo_dir.exists(): + logger.warning(f"Logo directory does not exist: {logo_dir}") + return 0, 0 + + converted_count = 0 + failed_count = 0 + + for logo_file in logo_dir.glob("*.png"): + if self.convert_image_to_rgba(logo_file): + converted_count += 1 + else: + failed_count += 1 + + logger.info(f"Converted {converted_count} logos to RGBA format for {league}, {failed_count} failed") + return converted_count, failed_count + + +# Convenience function for easy integration +def download_missing_logo(team_abbreviation: str, league: str, team_name: str = None, create_placeholder: bool = True) -> bool: + """ + Convenience function to download a missing team logo. + + Args: + team_abbreviation: Team abbreviation (e.g., 'UGA', 'BAMA', 'TA&M') + league: League identifier (e.g., 'ncaa_fb', 'nfl') + team_name: Optional team name for logging + create_placeholder: Whether to create a placeholder if download fails + + Returns: + True if logo exists or was successfully downloaded, False otherwise + """ + downloader = LogoDownloader() + + # Check if logo already exists + logo_dir = downloader.get_logo_directory(league) + filename = f"{downloader.normalize_abbreviation(team_abbreviation)}.png" + filepath = Path(logo_dir) / filename + + if filepath.exists(): + logger.debug(f"Logo already exists for {team_abbreviation} ({league})") + return True + + # Try to download the real logo first + logger.info(f"Attempting to download logo for {team_abbreviation} ({team_name or 'Unknown'}) from {league}") + success = downloader.download_missing_logo_for_team(team_abbreviation, league, team_name) + + if not success and create_placeholder: + logger.info(f"Creating placeholder logo for {team_abbreviation} ({team_name or 'Unknown'})") + # Create placeholder as fallback + success = downloader.create_placeholder_logo(team_abbreviation, logo_dir, team_name) + + if success: + logger.info(f"Successfully handled logo for {team_abbreviation} ({team_name or 'Unknown'})") + else: + logger.warning(f"Failed to download or create logo for {team_abbreviation} ({team_name or 'Unknown'})") + + return success + + +def download_all_logos_for_league(league: str, force_download: bool = False) -> Tuple[int, int]: + """ + Convenience function to download all missing logos for a league. + + Args: + league: League identifier (e.g., 'ncaa_fb', 'nfl') + force_download: Whether to re-download existing logos + + Returns: + Tuple of (downloaded_count, failed_count) + """ + downloader = LogoDownloader() + return downloader.download_missing_logos_for_league(league, force_download) diff --git a/src/mlb_manager.py b/src/mlb_manager.py index 0262de1c..56d1b752 100644 --- a/src/mlb_manager.py +++ b/src/mlb_manager.py @@ -29,12 +29,14 @@ class BaseMLBManager: def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): self.config = config self.display_manager = display_manager + # Store reference to config instead of creating new ConfigManager + self.config_manager = None # Not used in this class self.mlb_config = config.get('mlb', {}) self.show_odds = self.mlb_config.get("show_odds", False) self.favorite_teams = self.mlb_config.get('favorite_teams', []) self.show_records = self.mlb_config.get('show_records', False) self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, self.config) + self.odds_manager = OddsManager(self.cache_manager, self.config_manager) self.logger = logging.getLogger(__name__) # Logo handling diff --git a/src/music_manager.py b/src/music_manager.py index ab5a4929..4b24c269 100644 --- a/src/music_manager.py +++ b/src/music_manager.py @@ -6,7 +6,7 @@ import json import os from io import BytesIO import requests -from typing import Union +from typing import Union, Dict, Any, Optional from PIL import Image, ImageEnhance import queue # Added import @@ -15,6 +15,14 @@ from .spotify_client import SpotifyClient from .ytm_client import YTMClient # Removed: import config +# 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 + # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -313,6 +321,10 @@ class MusicManager: try: response = requests.get(url, timeout=5) # 5-second timeout for image download response.raise_for_status() # Raise an exception for bad status codes + + # Increment API counter for music data + increment_api_counter('music', 1) + img_data = BytesIO(response.content) img = Image.open(img_data) diff --git a/src/nba_managers.py b/src/nba_managers.py index ba2bdd35..4a873f21 100644 --- a/src/nba_managers.py +++ b/src/nba_managers.py @@ -43,10 +43,11 @@ class BaseNBAManager: def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.display_manager = display_manager - self.config_manager = ConfigManager() + # 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, self.config) + 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) @@ -63,13 +64,9 @@ class BaseNBAManager: # Set logging level to INFO to reduce noise self.logger.setLevel(logging.INFO) - # Get display dimensions from config - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + # 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 = {} @@ -79,7 +76,8 @@ class BaseNBAManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/ncaa_baseball_managers.py b/src/ncaa_baseball_managers.py index 7ea70da6..fe20b7ef 100644 --- a/src/ncaa_baseball_managers.py +++ b/src/ncaa_baseball_managers.py @@ -24,12 +24,14 @@ class BaseNCAABaseballManager: def __init__(self, config: Dict[str, Any], display_manager, cache_manager: CacheManager): self.config = config self.display_manager = display_manager + # Store reference to config instead of creating new ConfigManager + self.config_manager = None # Not used in this class self.ncaa_baseball_config = config.get('ncaa_baseball_scoreboard', {}) self.show_odds = self.ncaa_baseball_config.get('show_odds', False) self.show_records = self.ncaa_baseball_config.get('show_records', False) self.favorite_teams = self.ncaa_baseball_config.get('favorite_teams', []) self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, self.config) + self.odds_manager = OddsManager(self.cache_manager, self.config_manager) self.logger = logging.getLogger(__name__) self.logger.setLevel(logging.DEBUG) # Set logger level to DEBUG diff --git a/src/ncaa_fb_managers.py b/src/ncaa_fb_managers.py index 0ed62b78..4c87a987 100644 --- a/src/ncaa_fb_managers.py +++ b/src/ncaa_fb_managers.py @@ -11,6 +11,7 @@ from src.display_manager import DisplayManager from src.cache_manager import CacheManager # Keep CacheManager import from src.config_manager import ConfigManager from src.odds_manager import OddsManager +from src.logo_downloader import download_missing_logo import pytz from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -53,6 +54,7 @@ class BaseNCAAFBManager: # Renamed class self.logo_dir = self.ncaa_fb_config.get("logo_dir", "assets/sports/ncaa_fbs_logos") # Changed logo dir self.update_interval = self.ncaa_fb_config.get("update_interval_seconds", 60) self.show_records = self.ncaa_fb_config.get('show_records', False) + self.show_ranking = self.ncaa_fb_config.get('show_ranking', False) self.season_cache_duration = self.ncaa_fb_config.get("season_cache_duration_seconds", 86400) # 24 hours default # Number of games to show (instead of time-based windows) self.recent_games_to_show = self.ncaa_fb_config.get("recent_games_to_show", 5) # Show last 5 games @@ -91,22 +93,66 @@ class BaseNCAAFBManager: # Renamed class self.logger.setLevel(logging.INFO) - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + self.display_width = self.display_manager.matrix.width + self.display_height = self.display_manager.matrix.height self._logo_cache = {} + + # Initialize team rankings cache + self._team_rankings_cache = {} + self._rankings_cache_timestamp = 0 + self._rankings_cache_duration = 3600 # Cache rankings for 1 hour self.logger.info(f"Initialized NCAAFB 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}") + + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch current team rankings from ESPN API.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if (self._team_rankings_cache and + current_time - self._rankings_cache_timestamp < self._rankings_cache_duration): + return self._team_rankings_cache + + try: + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings" + response = self.session.get(rankings_url, headers=self.headers, timeout=30) + response.raise_for_status() + data = response.json() + + rankings = {} + rankings_data = data.get('rankings', []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get('ranks', []) + + for team_data in teams: + team_info = team_data.get('team', {}) + team_abbr = team_info.get('abbreviation', '') + current_rank = team_data.get('current', 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + self.logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + self.logger.error(f"Error fetching team rankings: {e}") + return {} def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc @@ -313,39 +359,8 @@ class BaseNCAAFBManager: # Renamed class draw.text((x + dx, y + dy), text, font=font, fill=outline_color) draw.text((x, y), text, font=font, fill=fill) - def _download_team_logo(self, team_id): - # Base API URL with placeholder for team ID - API_URL_TEMPLATE = "http://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/{}" - - url = API_URL_TEMPLATE.format(team_id) - response = requests.get(url) - - if response.status_code != 200: - print(f"Failed to fetch data for team ID {team_id}. Status code: {response.status_code}") - return - - data = response.json() - team = data.get("team", {}) - logos = team.get("logos", []) - - if logos: - logo_url = logos[0].get("href") - if logo_url: - try: - img_data = requests.get(logo_url).content - file_path = f"{self.logo_dir}/{team_id}.png" - with open(file_path, "wb") as f: - f.write(img_data) - print(f"Saved logo for {team_id} as {file_path}") - except Exception as e: - print(f"Error downloading logo for {team_id}: {e}") - else: - print(f"No logo URL found for team ID {team_id}") - else: - print(f"No logos found for team ID {team_id}") - - def _load_and_resize_logo(self, team_abbrev: str) -> Optional[Image.Image]: - """Load and resize a team logo, with caching.""" + def _load_and_resize_logo(self, team_abbrev: str, team_name: str = None) -> Optional[Image.Image]: + """Load and resize a team logo, with caching and automatic download if missing.""" if team_abbrev in self._logo_cache: return self._logo_cache[team_abbrev] @@ -353,20 +368,22 @@ class BaseNCAAFBManager: # Renamed class self.logger.debug(f"Logo path: {logo_path}") try: - # Try to download team logo + # Try to download missing logo first if not os.path.exists(logo_path): - self.logger.warning(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") - self._download_team_logo(team_abbrev) - - # Check to make sure logo was able to be downloaded and saved. If not, create a placeholder. - if not os.path.exists(logo_path): - self.logger.warning(f"Error occured donwloading logo for {team_abbrev} at {logo_path}. Creating placeholder.") - os.makedirs(os.path.dirname(logo_path), exist_ok=True) - logo = Image.new('RGBA', (32, 32), (200, 200, 200, 255)) # Gray placeholder - draw = ImageDraw.Draw(logo) - draw.text((2, 10), team_abbrev, fill=(0, 0, 0, 255)) - logo.save(logo_path) - self.logger.info(f"Created placeholder logo at {logo_path}") + self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.") + + # Try to download the logo from ESPN API + success = download_missing_logo(team_abbrev, 'ncaa_fb', team_name) + + if not success: + # Create placeholder if download fails + self.logger.warning(f"Failed to download logo for {team_abbrev}. Creating placeholder.") + os.makedirs(os.path.dirname(logo_path), exist_ok=True) + logo = Image.new('RGBA', (32, 32), (200, 200, 200, 255)) # Gray placeholder + draw = ImageDraw.Draw(logo) + draw.text((2, 10), team_abbrev, fill=(0, 0, 0, 255)) + logo.save(logo_path) + self.logger.info(f"Created placeholder logo at {logo_path}") logo = Image.open(logo_path) if logo.mode != 'RGBA': @@ -775,8 +792,8 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class 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_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB] Failed to load logos for live game: {game.get('id')}") # Changed log prefix @@ -1002,8 +1019,8 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB Recent] Failed to load logos for game: {game.get('id')}") # Changed log prefix @@ -1046,29 +1063,72 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class if 'odds' in game and game['odds']: self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - # Draw records if enabled - if self.show_records: + # 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) except IOError: record_font = ImageFont.load_default() - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') + # 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 - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_record, (away_record_x, record_y), record_font) + # Display away team info + if away_abbr: + if self.show_ranking: + # Show ranking if available + rankings = self._fetch_team_rankings() + away_rank = rankings.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + elif self.show_records: + # Only show record if show_records is enabled + away_text = game.get('away_record', '') + else: + # Show nothing if show_records is false and team is unranked + away_text = '' + else: + # Show record only if show_records is enabled + if self.show_records: + away_text = game.get('away_record', '') + else: + away_text = '' + + if away_text: + away_record_x = 0 + self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - if home_record: - home_record_bbox = draw_overlay.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_overlay, home_record, (home_record_x, record_y), record_font) + # Display home team info + if home_abbr: + if self.show_ranking: + # Show ranking if available + rankings = self._fetch_team_rankings() + home_rank = rankings.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + elif self.show_records: + # Only show record if show_records is enabled + home_text = game.get('home_record', '') + else: + # Show nothing if show_records is false and team is unranked + home_text = '' + else: + # Show record only if show_records is enabled + if self.show_records: + 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 + self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) # Composite and display main_img = Image.alpha_composite(main_img, overlay) @@ -1234,8 +1294,8 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0)) draw_overlay = ImageDraw.Draw(overlay) - home_logo = self._load_and_resize_logo(game["home_abbr"]) - away_logo = self._load_and_resize_logo(game["away_abbr"]) + home_logo = self._load_and_resize_logo(game["home_abbr"], game.get("home_team_name")) + away_logo = self._load_and_resize_logo(game["away_abbr"], game.get("away_team_name")) if not home_logo or not away_logo: self.logger.error(f"[NCAAFB Upcoming] Failed to load logos for game: {game.get('id')}") # Changed log prefix @@ -1284,29 +1344,72 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class if 'odds' in game and game['odds']: self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) - # Draw records if enabled - if self.show_records: + # 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) except IOError: record_font = ImageFont.load_default() - away_record = game.get('away_record', '') - home_record = game.get('home_record', '') + # 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 - if away_record: - away_record_x = 0 - self._draw_text_with_outline(draw_overlay, away_record, (away_record_x, record_y), record_font) + # Display away team info + if away_abbr: + if self.show_ranking: + # Show ranking if available + rankings = self._fetch_team_rankings() + away_rank = rankings.get(away_abbr, 0) + if away_rank > 0: + away_text = f"#{away_rank}" + elif self.show_records: + # Only show record if show_records is enabled + away_text = game.get('away_record', '') + else: + # Show nothing if show_records is false and team is unranked + away_text = '' + else: + # Show record only if show_records is enabled + if self.show_records: + away_text = game.get('away_record', '') + else: + away_text = '' + + if away_text: + away_record_x = 0 + self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font) - if home_record: - home_record_bbox = draw_overlay.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_overlay, home_record, (home_record_x, record_y), record_font) + # Display home team info + if home_abbr: + if self.show_ranking: + # Show ranking if available + rankings = self._fetch_team_rankings() + home_rank = rankings.get(home_abbr, 0) + if home_rank > 0: + home_text = f"#{home_rank}" + elif self.show_records: + # Only show record if show_records is enabled + home_text = game.get('home_record', '') + else: + # Show nothing if show_records is false and team is unranked + home_text = '' + else: + # Show record only if show_records is enabled + if self.show_records: + 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 + self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font) # Composite and display main_img = Image.alpha_composite(main_img, overlay) diff --git a/src/ncaam_basketball_managers.py b/src/ncaam_basketball_managers.py index 5e7a79f9..4dd3f7c1 100644 --- a/src/ncaam_basketball_managers.py +++ b/src/ncaam_basketball_managers.py @@ -13,6 +13,14 @@ from src.config_manager import ConfigManager from src.odds_manager import OddsManager import pytz +# 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_NCAAMB_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard" @@ -35,10 +43,11 @@ class BaseNCAAMBasketballManager: def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.display_manager = display_manager - self.config_manager = ConfigManager() + # 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, self.config) + 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) @@ -55,13 +64,9 @@ class BaseNCAAMBasketballManager: # Set logging level to INFO to reduce noise self.logger.setLevel(logging.INFO) - # Get display dimensions from config - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + # 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 = {} @@ -99,7 +104,8 @@ class BaseNCAAMBasketballManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc @@ -328,6 +334,9 @@ class BaseNCAAMBasketballManager: response.raise_for_status() data = response.json() + # Increment API counter for sports data + increment_api_counter('sports', 1) + if use_cache: self.cache_manager.set(cache_key, data) diff --git a/src/news_manager.py b/src/news_manager.py index 50a5b9ae..f198083f 100644 --- a/src/news_manager.py +++ b/src/news_manager.py @@ -29,9 +29,10 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class NewsManager: - def __init__(self, config: Dict[str, Any], display_manager): + def __init__(self, config: Dict[str, Any], display_manager, config_manager=None): self.config = config - self.config_manager = ConfigManager() + # Use provided config_manager or create a new one if none provided + self.config_manager = config_manager or ConfigManager() self.display_manager = display_manager self.news_config = config.get('news_manager', {}) self.last_update = time.time() # Initialize to current time diff --git a/src/nfl_managers.py b/src/nfl_managers.py index e9f1528f..16b65101 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -37,10 +37,11 @@ class BaseNFLManager: # Renamed class def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.display_manager = display_manager - self.config_manager = ConfigManager() + # 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, self.config) + self.odds_manager = OddsManager(self.cache_manager, None) self.logger = logging.getLogger(__name__) self.nfl_config = config.get("nfl_scoreboard", {}) # Changed config key self.is_enabled = self.nfl_config.get("enabled", False) @@ -62,12 +63,8 @@ class BaseNFLManager: # Renamed class self.logger.setLevel(logging.INFO) - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + self.display_width = self.display_manager.matrix.width + self.display_height = self.display_manager.matrix.height self._logo_cache = {} @@ -87,7 +84,8 @@ class BaseNFLManager: # Renamed class def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/nhl_managers.py b/src/nhl_managers.py index f05fdec2..b281abe0 100644 --- a/src/nhl_managers.py +++ b/src/nhl_managers.py @@ -42,10 +42,11 @@ class BaseNHLManager: def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager): self.display_manager = display_manager - self.config_manager = ConfigManager() + # 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, self.config) + self.odds_manager = OddsManager(self.cache_manager, None) self.logger = logging.getLogger(__name__) self.nhl_config = config.get("nhl_scoreboard", {}) self.is_enabled = self.nhl_config.get("enabled", False) @@ -63,13 +64,9 @@ class BaseNHLManager: # Set logging level to DEBUG to see all messages self.logger.setLevel(logging.DEBUG) - # Get display dimensions from config - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + # 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 = {} @@ -107,7 +104,8 @@ class BaseNHLManager: def _get_timezone(self): try: - return pytz.timezone(self.config_manager.get_timezone()) + timezone_str = self.config.get('timezone', 'UTC') + return pytz.timezone(timezone_str) except pytz.UnknownTimeZoneError: return pytz.utc diff --git a/src/odds_manager.py b/src/odds_manager.py index b1a221d0..7b11b6b5 100644 --- a/src/odds_manager.py +++ b/src/odds_manager.py @@ -1,13 +1,22 @@ -import requests +import time import logging +import requests import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from src.cache_manager import CacheManager -from src.config_manager import ConfigManager -from typing import Optional, List, Dict, Any +import pytz +from typing import Dict, Any, Optional, List + +# 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 class OddsManager: - def __init__(self, cache_manager: CacheManager, config_manager: ConfigManager): + def __init__(self, cache_manager: CacheManager, config_manager=None): self.cache_manager = cache_manager self.config_manager = config_manager self.logger = logging.getLogger(__name__) @@ -31,6 +40,9 @@ class OddsManager: response = requests.get(url, timeout=10) response.raise_for_status() raw_data = response.json() + + # Increment API counter for odds data + increment_api_counter('odds', 1) self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}") odds_data = self._extract_espn_data(raw_data) diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py index e1a323c6..fd2e7832 100644 --- a/src/odds_ticker_manager.py +++ b/src/odds_ticker_manager.py @@ -11,6 +11,15 @@ 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.logo_downloader import download_missing_logo + +# 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 # Get logger logger = logging.getLogger(__name__) @@ -75,6 +84,7 @@ class OddsTickerManager: self.games_per_favorite_team = self.odds_ticker_config.get('games_per_favorite_team', 1) self.max_games_per_league = self.odds_ticker_config.get('max_games_per_league', 5) self.show_odds_only = self.odds_ticker_config.get('show_odds_only', False) + self.fetch_odds = self.odds_ticker_config.get('fetch_odds', True) # New option to disable odds fetching self.sort_order = self.odds_ticker_config.get('sort_order', 'soonest') self.enabled_leagues = self.odds_ticker_config.get('enabled_leagues', ['nfl', 'nba', 'mlb']) self.update_interval = self.odds_ticker_config.get('update_interval', 3600) @@ -98,7 +108,8 @@ class OddsTickerManager: # Initialize managers self.cache_manager = CacheManager() - self.odds_manager = OddsManager(self.cache_manager, ConfigManager()) + # OddsManager doesn't actually use the config_manager parameter, so pass None + self.odds_manager = OddsManager(self.cache_manager, None) # State variables self.last_update = 0 @@ -117,6 +128,7 @@ class OddsTickerManager: 'nfl': { 'sport': 'football', 'league': 'nfl', + 'logo_league': 'nfl', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nfl_logos', 'favorite_teams': config.get('nfl_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nfl_scoreboard', {}).get('enabled', False) @@ -124,6 +136,7 @@ class OddsTickerManager: 'nba': { 'sport': 'basketball', 'league': 'nba', + 'logo_league': 'nba', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nba_logos', 'favorite_teams': config.get('nba_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nba_scoreboard', {}).get('enabled', False) @@ -131,6 +144,7 @@ class OddsTickerManager: 'mlb': { 'sport': 'baseball', 'league': 'mlb', + 'logo_league': 'mlb', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/mlb_logos', 'favorite_teams': config.get('mlb', {}).get('favorite_teams', []), 'enabled': config.get('mlb', {}).get('enabled', False) @@ -138,6 +152,7 @@ class OddsTickerManager: 'ncaa_fb': { 'sport': 'football', 'league': 'college-football', + 'logo_league': 'ncaa_fb', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaa_fb_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaa_fb_scoreboard', {}).get('enabled', False) @@ -145,6 +160,7 @@ class OddsTickerManager: 'milb': { 'sport': 'baseball', 'league': 'milb', + 'logo_league': 'milb', # ESPN API league identifier for logo downloading (if supported) 'logo_dir': 'assets/sports/milb_logos', 'favorite_teams': config.get('milb', {}).get('favorite_teams', []), 'enabled': config.get('milb', {}).get('enabled', False) @@ -152,6 +168,7 @@ class OddsTickerManager: 'nhl': { 'sport': 'hockey', 'league': 'nhl', + 'logo_league': 'nhl', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/nhl_logos', 'favorite_teams': config.get('nhl_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('nhl_scoreboard', {}).get('enabled', False) @@ -159,6 +176,7 @@ class OddsTickerManager: 'ncaam_basketball': { 'sport': 'basketball', 'league': 'mens-college-basketball', + 'logo_league': 'ncaam_basketball', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaam_basketball_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaam_basketball_scoreboard', {}).get('enabled', False) @@ -166,6 +184,7 @@ class OddsTickerManager: 'ncaa_baseball': { 'sport': 'baseball', 'league': 'college-baseball', + 'logo_league': 'ncaa_baseball', # ESPN API league identifier for logo downloading 'logo_dir': 'assets/sports/ncaa_fbs_logos', 'favorite_teams': config.get('ncaa_baseball_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('ncaa_baseball_scoreboard', {}).get('enabled', False) @@ -173,6 +192,7 @@ class OddsTickerManager: 'soccer': { 'sport': 'soccer', 'leagues': config.get('soccer_scoreboard', {}).get('leagues', []), + 'logo_league': None, # Soccer logos not supported by ESPN API 'logo_dir': 'assets/sports/soccer_logos', 'favorite_teams': config.get('soccer_scoreboard', {}).get('favorite_teams', []), 'enabled': config.get('soccer_scoreboard', {}).get('enabled', False) @@ -214,6 +234,9 @@ class OddsTickerManager: response.raise_for_status() data = response.json() + # Increment API counter for sports data + increment_api_counter('sports', 1) + # Different path for college sports records if league == 'college-football': record_items = data.get('team', {}).get('record', {}).get('items', []) @@ -229,8 +252,55 @@ class OddsTickerManager: logger.error(f"Error fetching record for {team_abbr} in league {league}: {e}") return "N/A" - def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory.""" + def _fetch_team_rankings(self) -> Dict[str, int]: + """Fetch current team rankings from ESPN API for NCAA football.""" + current_time = time.time() + + # Check if we have cached rankings that are still valid + if (hasattr(self, '_team_rankings_cache') and + hasattr(self, '_rankings_cache_timestamp') and + self._team_rankings_cache and + current_time - self._rankings_cache_timestamp < 3600): # Cache for 1 hour + return self._team_rankings_cache + + try: + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings" + response = requests.get(rankings_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + + rankings = {} + rankings_data = data.get('rankings', []) + + if rankings_data: + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + teams = first_ranking.get('ranks', []) + + for team_data in teams: + team_info = team_data.get('team', {}) + team_abbr = team_info.get('abbreviation', '') + current_rank = team_data.get('current', 0) + + if team_abbr and current_rank > 0: + rankings[team_abbr] = current_rank + + # Cache the results + self._team_rankings_cache = rankings + self._rankings_cache_timestamp = current_time + + logger.debug(f"Fetched rankings for {len(rankings)} teams") + return rankings + + except Exception as e: + logger.error(f"Error fetching team rankings: {e}") + return {} + + def _get_team_logo(self, team_abbr: str, logo_dir: str, league: str = None, team_name: str = None) -> Optional[Image.Image]: + """Get team logo from the configured directory, downloading if missing.""" if not team_abbr or not logo_dir: logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -239,10 +309,28 @@ class OddsTickerManager: logger.debug(f"Attempting to load logo from path: {logo_path}") if os.path.exists(logo_path): logo = Image.open(logo_path) + # Convert palette images with transparency to RGBA to avoid PIL warnings + if logo.mode == 'P' and 'transparency' in logo.info: + logo = logo.convert('RGBA') logger.debug(f"Successfully loaded logo for {team_abbr} from {logo_path}") return logo else: logger.warning(f"Logo not found at path: {logo_path}") + + # Try to download the missing logo if we have league information + if league: + logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") + success = download_missing_logo(team_abbr, league, team_name) + if success: + # Try to load the downloaded logo + if os.path.exists(logo_path): + logo = Image.open(logo_path) + # Convert palette images with transparency to RGBA to avoid PIL warnings + if logo.mode == 'P' and 'transparency' in logo.info: + logo = logo.convert('RGBA') + logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") + return logo + return None except Exception as e: logger.error(f"Error loading logo for {team_abbr} from {logo_dir}: {e}") @@ -254,6 +342,9 @@ class OddsTickerManager: now = datetime.now(timezone.utc) logger.debug(f"Fetching upcoming games for {len(self.enabled_leagues)} enabled leagues") + logger.debug(f"Enabled leagues: {self.enabled_leagues}") + logger.debug(f"Show favorite teams only: {self.show_favorite_teams_only}") + logger.debug(f"Show odds only: {self.show_odds_only}") for league_key in self.enabled_leagues: if league_key not in self.league_configs: @@ -272,15 +363,18 @@ class OddsTickerManager: if self.show_favorite_teams_only: # For each favorite team, find their next N games favorite_teams = league_config.get('favorite_teams', []) + logger.debug(f"Favorite teams for {league_key}: {favorite_teams}") seen_game_ids = set() for team in favorite_teams: # Find games where this team is home or away team_games = [g for g in all_games if (g['home_team'] == team or g['away_team'] == team)] + logger.debug(f"Found {len(team_games)} games for team {team}") # Sort by start_time team_games.sort(key=lambda x: x.get('start_time', datetime.max)) # Only keep games with odds if show_odds_only is set if self.show_odds_only: team_games = [g for g in team_games if g.get('odds')] + logger.debug(f"After odds filter: {len(team_games)} games for team {team}") # Take the next N games for this team for g in team_games[:self.games_per_favorite_team]: if g['id'] not in seen_game_ids: @@ -303,11 +397,14 @@ class OddsTickerManager: # (Other sort options can be added here) games_data.extend(league_games) + logger.debug(f"Added {len(league_games)} games from {league_key}") except Exception as e: logger.error(f"Error fetching games for {league_key}: {e}") logger.debug(f"Total games found: {len(games_data)}") + if games_data: + logger.debug(f"Sample game data keys: {list(games_data[0].keys())}") return games_data def _fetch_league_games(self, league_config: Dict[str, Any], now: datetime) -> List[Dict[str, Any]]: @@ -359,7 +456,7 @@ class OddsTickerManager: if request_date_obj < current_date_obj: ttl = 86400 * 30 # 30 days for past dates elif request_date_obj == current_date_obj: - ttl = 3600 # 1 hour for today + ttl = 300 # 5 minutes for today (shorter to catch live games) else: ttl = 43200 # 12 hours for future dates @@ -371,6 +468,10 @@ class OddsTickerManager: response = requests.get(url, timeout=self.request_timeout) response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + self.cache_manager.set(cache_key, data) logger.debug(f"Cached scoreboard for {league} on {date} with a TTL of {ttl} seconds.") else: @@ -382,9 +483,15 @@ class OddsTickerManager: break game_id = event['id'] status = event['status']['type']['name'].lower() - if status in ['scheduled', 'pre-game', 'status_scheduled']: + status_state = event['status']['type']['state'].lower() + + # Include both scheduled and live games + if status in ['scheduled', 'pre-game', 'status_scheduled'] or status_state == 'in': game_time = datetime.fromisoformat(event['date'].replace('Z', '+00:00')) - if now <= game_time <= future_window: + + # For live games, include them regardless of time window + # For scheduled games, check if they're within the future window + if status_state == 'in' or (now <= game_time <= future_window): competitors = event['competitions'][0]['competitors'] home_team = next(c for c in competitors if c['homeAway'] == 'home') away_team = next(c for c in competitors if c['homeAway'] == 'away') @@ -437,19 +544,59 @@ class OddsTickerManager: # Dynamically set update interval based on game start time time_until_game = game_time - now - if time_until_game > timedelta(hours=48): + if status_state == 'in': + # Live games need more frequent updates + update_interval_seconds = 300 # 5 minutes for live games + elif time_until_game > timedelta(hours=48): update_interval_seconds = 86400 # 24 hours else: update_interval_seconds = 3600 # 1 hour logger.debug(f"Game {game_id} starts in {time_until_game}. Setting odds update interval to {update_interval_seconds}s.") - odds_data = self.odds_manager.get_odds( - sport=sport, - league=league, - event_id=game_id, - update_interval_seconds=update_interval_seconds - ) + # Fetch odds with timeout protection to prevent freezing (if enabled) + if self.fetch_odds: + try: + import threading + import queue + + result_queue = queue.Queue() + + def fetch_odds(): + try: + odds_result = self.odds_manager.get_odds( + sport=sport, + league=league, + event_id=game_id, + update_interval_seconds=update_interval_seconds + ) + result_queue.put(('success', odds_result)) + except Exception as e: + result_queue.put(('error', e)) + + # Start odds fetch in a separate thread + odds_thread = threading.Thread(target=fetch_odds) + odds_thread.daemon = True + odds_thread.start() + + # Wait for result with 3-second timeout + try: + result_type, result_data = result_queue.get(timeout=3) + if result_type == 'success': + odds_data = result_data + else: + logger.warning(f"Odds fetch failed for game {game_id}: {result_data}") + odds_data = None + except queue.Empty: + logger.warning(f"Odds fetch timed out for game {game_id}") + odds_data = None + + except Exception as e: + logger.warning(f"Odds fetch failed for game {game_id}: {e}") + odds_data = None + else: + # Odds fetching is disabled + odds_data = None has_odds = False if odds_data and not odds_data.get('no_odds'): @@ -461,6 +608,12 @@ class OddsTickerManager: has_odds = True if odds_data.get('over_under') is not None: has_odds = True + + # Extract live game information if the game is in progress + live_info = None + if status_state == 'in': + live_info = self._extract_live_game_info(event, sport) + game = { 'id': game_id, 'home_team': home_abbr, @@ -472,7 +625,11 @@ class OddsTickerManager: 'away_record': away_record, 'odds': odds_data if has_odds else None, 'broadcast_info': broadcast_info, - 'logo_dir': league_config.get('logo_dir', f'assets/sports/{league.lower()}_logos') + 'logo_dir': league_config.get('logo_dir', f'assets/sports/{league.lower()}_logos'), + 'league': league_config.get('logo_league', league), # Use logo_league for downloading + 'status': status, + 'status_state': status_state, + 'live_info': live_info } all_games.append(game) games_found += 1 @@ -492,8 +649,170 @@ class OddsTickerManager: break return all_games + def _extract_live_game_info(self, event: Dict[str, Any], sport: str) -> Dict[str, Any]: + """Extract live game information from ESPN API event data.""" + try: + status = event['status'] + competitions = event['competitions'][0] + competitors = competitions['competitors'] + + # Get scores + home_score = next(c['score'] for c in competitors if c['homeAway'] == 'home') + away_score = next(c['score'] for c in competitors if c['homeAway'] == 'away') + + live_info = { + 'home_score': home_score, + 'away_score': away_score, + 'period': status.get('period', 1), + 'clock': status.get('displayClock', ''), + 'detail': status['type'].get('detail', ''), + 'short_detail': status['type'].get('shortDetail', '') + } + + # Sport-specific information + if sport == 'baseball': + # Extract inning information + situation = competitions.get('situation', {}) + count = situation.get('count', {}) + + live_info.update({ + 'inning': status.get('period', 1), + 'inning_half': 'top', # Default + 'balls': count.get('balls', 0), + 'strikes': count.get('strikes', 0), + 'outs': situation.get('outs', 0), + 'bases_occupied': [ + situation.get('onFirst', False), + situation.get('onSecond', False), + situation.get('onThird', False) + ] + }) + + # Determine inning half from status detail + status_detail = status['type'].get('detail', '').lower() + status_short = status['type'].get('shortDetail', '').lower() + + if 'bottom' in status_detail or 'bot' in status_detail or 'bottom' in status_short or 'bot' in status_short: + live_info['inning_half'] = 'bottom' + elif 'top' in status_detail or 'mid' in status_detail or 'top' in status_short or 'mid' in status_short: + live_info['inning_half'] = 'top' + + elif sport == 'football': + # Extract football-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'quarter': status.get('period', 1), + 'down': situation.get('down', 0), + 'distance': situation.get('distance', 0), + 'yard_line': situation.get('yardLine', 0), + 'possession': situation.get('possession', '') + }) + + elif sport == 'basketball': + # Extract basketball-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'quarter': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'possession': situation.get('possession', '') + }) + + elif sport == 'hockey': + # Extract hockey-specific information + situation = competitions.get('situation', {}) + + live_info.update({ + 'period': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'power_play': situation.get('powerPlay', False) + }) + + elif sport == 'soccer': + # Extract soccer-specific information + live_info.update({ + 'period': status.get('period', 1), + 'time_remaining': status.get('displayClock', ''), + 'extra_time': status.get('displayClock', '').endswith('+') + }) + + return live_info + + except Exception as e: + logger.error(f"Error extracting live game info: {e}") + return None + def _format_odds_text(self, game: Dict[str, Any]) -> str: """Format the odds text for display.""" + # Check if this is a live game + is_live = game.get('status_state') == 'in' + live_info = game.get('live_info') + + if is_live and live_info: + # Format live game information + home_score = live_info.get('home_score', 0) + away_score = live_info.get('away_score', 0) + + # Determine sport for sport-specific formatting + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + # Get team names with rankings for NCAA football + away_team_name = game.get('away_team_name', game['away_team']) + home_team_name = game.get('home_team_name', game['home_team']) + away_team_abbr = game.get('away_team', '') + home_team_abbr = game.get('home_team', '') + + # Check if this is NCAA football and add rankings + league_key = None + for key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + league_key = key + break + + if league_key == 'ncaa_fb': + rankings = self._fetch_team_rankings() + + # Add ranking to away team name if ranked + if away_team_abbr in rankings and rankings[away_team_abbr] > 0: + away_team_name = f"{rankings[away_team_abbr]}. {away_team_name}" + + # Add ranking to home team name if ranked + if home_team_abbr in rankings and rankings[home_team_abbr] > 0: + home_team_name = f"{rankings[home_team_abbr]}. {home_team_name}" + + if sport == 'baseball': + inning_half_indicator = "▲" if live_info.get('inning_half') == 'top' else "▼" + inning_text = f"{inning_half_indicator}{live_info.get('inning', 1)}" + count_text = f"{live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + outs_count = live_info.get('outs', 0) + outs_text = f"{outs_count} out" if outs_count == 1 else f"{outs_count} outs" + return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {inning_text} {count_text} {outs_text}" + + elif sport == 'football': + quarter_text = f"Q{live_info.get('quarter', 1)}" + down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}" + clock_text = live_info.get('clock', '') + return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {quarter_text} {down_text} {clock_text}" + + elif sport == 'basketball': + quarter_text = f"Q{live_info.get('quarter', 1)}" + clock_text = live_info.get('time_remaining', '') + return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {quarter_text} {clock_text}" + + elif sport == 'hockey': + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score} - {period_text} {clock_text}" + + else: + return f"[LIVE] {away_team_name} {away_score} vs {home_team_name} {home_score}" + + # Original odds formatting for non-live games odds = game.get('odds', {}) if not odds: # Show just the game info without odds @@ -509,7 +828,31 @@ class OddsTickerManager: local_time = game_time.astimezone(tz) time_str = local_time.strftime("%I:%M%p").lstrip('0') - return f"[{time_str}] {game.get('away_team_name', game['away_team'])} vs {game.get('home_team_name', game['home_team'])} (No odds)" + # Get team names with rankings for NCAA football + away_team_name = game.get('away_team_name', game['away_team']) + home_team_name = game.get('home_team_name', game['home_team']) + away_team_abbr = game.get('away_team', '') + home_team_abbr = game.get('home_team', '') + + # Check if this is NCAA football and add rankings + league_key = None + for key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + league_key = key + break + + if league_key == 'ncaa_fb': + rankings = self._fetch_team_rankings() + + # Add ranking to away team name if ranked + if away_team_abbr in rankings and rankings[away_team_abbr] > 0: + away_team_name = f"{rankings[away_team_abbr]}. {away_team_name}" + + # Add ranking to home team name if ranked + if home_team_abbr in rankings and rankings[home_team_abbr] > 0: + home_team_name = f"{rankings[home_team_abbr]}. {home_team_name}" + + return f"[{time_str}] {away_team_name} vs {home_team_name} (No odds)" # Extract odds data home_team_odds = odds.get('home_team_odds', {}) @@ -537,8 +880,32 @@ class OddsTickerManager: # Build odds string odds_parts = [f"[{time_str}]"] + # Get team names with rankings for NCAA football + away_team_name = game.get('away_team_name', game['away_team']) + home_team_name = game.get('home_team_name', game['home_team']) + away_team_abbr = game.get('away_team', '') + home_team_abbr = game.get('home_team', '') + + # Check if this is NCAA football and add rankings + league_key = None + for key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + league_key = key + break + + if league_key == 'ncaa_fb': + rankings = self._fetch_team_rankings() + + # Add ranking to away team name if ranked + if away_team_abbr in rankings and rankings[away_team_abbr] > 0: + away_team_name = f"{rankings[away_team_abbr]}. {away_team_name}" + + # Add ranking to home team name if ranked + if home_team_abbr in rankings and rankings[home_team_abbr] > 0: + home_team_name = f"{rankings[home_team_abbr]}. {home_team_name}" + # Add away team and odds - odds_parts.append(game.get('away_team_name', game['away_team'])) + odds_parts.append(away_team_name) if away_spread is not None: spread_str = f"{away_spread:+.1f}" if away_spread > 0 else f"{away_spread:.1f}" odds_parts.append(spread_str) @@ -549,7 +916,7 @@ class OddsTickerManager: odds_parts.append("vs") # Add home team and odds - odds_parts.append(game.get('home_team_name', game['home_team'])) + odds_parts.append(home_team_name) if home_spread is not None: spread_str = f"{home_spread:+.1f}" if home_spread > 0 else f"{home_spread:.1f}" odds_parts.append(spread_str) @@ -563,6 +930,52 @@ class OddsTickerManager: return " ".join(odds_parts) + def _draw_base_indicators(self, draw: ImageDraw.Draw, bases_occupied: List[bool], center_x: int, y: int) -> None: + """Draw base indicators on the display similar to MLB manager.""" + base_diamond_size = 8 # Match MLB manager size + base_horiz_spacing = 8 # Reduced from 10 to 8 for tighter spacing + base_vert_spacing = 6 # Reduced from 8 to 6 for tighter vertical spacing + base_cluster_width = base_diamond_size + base_horiz_spacing + base_diamond_size + base_cluster_height = base_diamond_size + base_vert_spacing + base_diamond_size + + # Calculate cluster dimensions and positioning + bases_origin_x = center_x - (base_cluster_width // 2) + overall_start_y = y - (base_cluster_height // 2) + + # Draw diamond-shaped bases like MLB manager + base_color_occupied = (255, 255, 255) + base_color_empty = (255, 255, 255) # Outline color + h_d = base_diamond_size // 2 + + # 2nd Base (Top center) + c2x = bases_origin_x + base_cluster_width // 2 + c2y = overall_start_y + h_d + poly2 = [(c2x, overall_start_y), (c2x + h_d, c2y), (c2x, c2y + h_d), (c2x - h_d, c2y)] + if bases_occupied[1]: + draw.polygon(poly2, fill=base_color_occupied) + else: + draw.polygon(poly2, outline=base_color_empty) + + base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond + + # 3rd Base (Bottom left) + c3x = bases_origin_x + h_d + c3y = base_bottom_y + base_vert_spacing + h_d + poly3 = [(c3x, base_bottom_y + base_vert_spacing), (c3x + h_d, c3y), (c3x, c3y + h_d), (c3x - h_d, c3y)] + if bases_occupied[2]: + draw.polygon(poly3, fill=base_color_occupied) + else: + draw.polygon(poly3, outline=base_color_empty) + + # 1st Base (Bottom right) + c1x = bases_origin_x + base_cluster_width - h_d + c1y = base_bottom_y + base_vert_spacing + h_d + poly1 = [(c1x, base_bottom_y + base_vert_spacing), (c1x + h_d, c1y), (c1x, c1y + h_d), (c1x - h_d, c1y)] + if bases_occupied[0]: + draw.polygon(poly1, fill=base_color_occupied) + else: + draw.polygon(poly1, outline=base_color_empty) + def _create_game_display(self, game: Dict[str, Any]) -> Image.Image: """Create a display image for a game in the new format.""" width = self.display_manager.matrix.width @@ -578,9 +991,11 @@ class OddsTickerManager: vs_font = self.fonts['medium'] datetime_font = self.fonts['medium'] # Use large font for date/time - # Get team logos - home_logo = self._get_team_logo(game['home_team'], game['logo_dir']) - away_logo = self._get_team_logo(game['away_team'], game['logo_dir']) + # Get team logos (with automatic download if missing) + home_logo = self._get_team_logo(game['home_team'], game['logo_dir'], + league=game.get('league'), team_name=game.get('home_team_name')) + away_logo = self._get_team_logo(game['away_team'], game['logo_dir'], + league=game.get('league'), team_name=game.get('away_team_name')) broadcast_logo = None # Enhanced broadcast logo debugging @@ -657,10 +1072,88 @@ class OddsTickerManager: game_time = game_time.replace(tzinfo=pytz.UTC) local_time = game_time.astimezone(tz) - # Capitalize full day name, e.g., 'Tuesday' - day_text = local_time.strftime("%A") - date_text = local_time.strftime("%-m/%d") - time_text = local_time.strftime("%I:%M%p").lstrip('0') + # Check if this is a live game + is_live = game.get('status_state') == 'in' + live_info = game.get('live_info') + + if is_live and live_info: + # Show live game information instead of date/time + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + # For baseball, we'll use graphical base indicators instead of text + # Don't show any text for bases - the graphical display will replace this section + away_odds_text = "" + home_odds_text = "" + + # Store bases data for later drawing + self._bases_data = live_info.get('bases_occupied', [False, False, False]) + + # Set datetime text for baseball live games + inning_half_indicator = "▲" if live_info.get('inning_half') == 'top' else "▼" + inning_text = f"{inning_half_indicator}{live_info.get('inning', 1)}" + count_text = f"{live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + outs_count = live_info.get('outs', 0) + outs_text = f"{outs_count} out" if outs_count == 1 else f"{outs_count} outs" + + day_text = inning_text + date_text = count_text + time_text = outs_text + elif sport == 'football': + # Football: Show quarter and down/distance + quarter_text = f"Q{live_info.get('quarter', 1)}" + down_text = f"{live_info.get('down', 0)}&{live_info.get('distance', 0)}" + clock_text = live_info.get('clock', '') + + day_text = quarter_text + date_text = down_text + time_text = clock_text + + elif sport == 'basketball': + # Basketball: Show quarter and time remaining + quarter_text = f"Q{live_info.get('quarter', 1)}" + clock_text = live_info.get('time_remaining', '') + possession_text = live_info.get('possession', '') + + day_text = quarter_text + date_text = clock_text + time_text = possession_text + + elif sport == 'hockey': + # Hockey: Show period and time remaining + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + power_play_text = "PP" if live_info.get('power_play') else "" + + day_text = period_text + date_text = clock_text + time_text = power_play_text + + elif sport == 'soccer': + # Soccer: Show period and time remaining + period_text = f"P{live_info.get('period', 1)}" + clock_text = live_info.get('time_remaining', '') + extra_time_text = "+" if live_info.get('extra_time') else "" + + day_text = period_text + date_text = clock_text + time_text = extra_time_text + + else: + # Fallback: Show generic live info + day_text = "LIVE" + date_text = f"{live_info.get('home_score', 0)}-{live_info.get('away_score', 0)}" + time_text = live_info.get('clock', '') + else: + # Show regular date/time for non-live games + # Capitalize full day name, e.g., 'Tuesday' + day_text = local_time.strftime("%A") + date_text = local_time.strftime("%-m/%d") + time_text = local_time.strftime("%I:%M%p").lstrip('0') # Datetime column width temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1))) @@ -673,9 +1166,40 @@ class OddsTickerManager: vs_text = "vs." vs_width = int(temp_draw.textlength(vs_text, font=vs_font)) - # Team and record text - away_team_text = f"{game.get('away_team_name', game.get('away_team', 'N/A'))} ({game.get('away_record', '') or 'N/A'})" - home_team_text = f"{game.get('home_team_name', game.get('home_team', 'N/A'))} ({game.get('home_record', '') or 'N/A'})" + # Team and record text with rankings + away_team_name = game.get('away_team_name', game.get('away_team', 'N/A')) + home_team_name = game.get('home_team_name', game.get('home_team', 'N/A')) + away_team_abbr = game.get('away_team', '') + home_team_abbr = game.get('home_team', '') + + # Check if this is NCAA football and fetch rankings + league_key = None + for key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + league_key = key + break + + # Add ranking prefix for NCAA football teams + if league_key == 'ncaa_fb': + rankings = self._fetch_team_rankings() + + # Add ranking to away team name if ranked + if away_team_abbr in rankings and rankings[away_team_abbr] > 0: + away_team_name = f"{rankings[away_team_abbr]}. {away_team_name}" + + # Add ranking to home team name if ranked + if home_team_abbr in rankings and rankings[home_team_abbr] > 0: + home_team_name = f"{rankings[home_team_abbr]}. {home_team_name}" + + away_team_text = f"{away_team_name} ({game.get('away_record', '') or 'N/A'})" + home_team_text = f"{home_team_name} ({game.get('home_record', '') or 'N/A'})" + + # For live games, show scores instead of records + if is_live and live_info: + away_score = live_info.get('away_score', 0) + home_score = live_info.get('home_score', 0) + away_team_text = f"{away_team_name}:{away_score} " + home_team_text = f"{home_team_name}:{home_score} " away_team_width = int(temp_draw.textlength(away_team_text, font=team_font)) home_team_width = int(temp_draw.textlength(home_team_text, font=team_font)) @@ -707,21 +1231,85 @@ class OddsTickerManager: away_odds_text = "" home_odds_text = "" - # Simplified odds placement logic - if home_favored: - home_odds_text = f"{home_spread}" - if over_under: - away_odds_text = f"O/U {over_under}" - elif away_favored: - away_odds_text = f"{away_spread}" - if over_under: + # For live games, show live status instead of odds + if is_live and live_info: + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + # Show bases occupied for baseball + bases = live_info.get('bases_occupied', [False, False, False]) + bases_text = "" + if bases[0]: bases_text += "1B" + if bases[1]: bases_text += "2B" + if bases[2]: bases_text += "3B" + if not bases_text: bases_text = "Empty" + + away_odds_text = f"Bases: {bases_text}" + home_odds_text = f"Count: {live_info.get('balls', 0)}-{live_info.get('strikes', 0)}" + + elif sport == 'football': + # Show possession and yard line for football + possession = live_info.get('possession', '') + yard_line = live_info.get('yard_line', 0) + + away_odds_text = f"Ball: {possession}" + home_odds_text = f"Yard: {yard_line}" + + elif sport == 'basketball': + # Show possession for basketball + possession = live_info.get('possession', '') + + away_odds_text = f"Ball: {possession}" + home_odds_text = f"Time: {live_info.get('time_remaining', '')}" + + elif sport == 'hockey': + # Show power play status for hockey + power_play = live_info.get('power_play', False) + + away_odds_text = "Power Play" if power_play else "Even" + home_odds_text = f"Time: {live_info.get('time_remaining', '')}" + + else: + # Generic live status + away_odds_text = "LIVE" + home_odds_text = live_info.get('clock', '') + else: + # Show odds for non-live games + # Simplified odds placement logic + if home_favored: + home_odds_text = f"{home_spread}" + if over_under: + away_odds_text = f"O/U {over_under}" + elif away_favored: + away_odds_text = f"{away_spread}" + if over_under: + home_odds_text = f"O/U {over_under}" + elif over_under: home_odds_text = f"O/U {over_under}" - elif over_under: - home_odds_text = f"O/U {over_under}" away_odds_width = int(temp_draw.textlength(away_odds_text, font=odds_font)) home_odds_width = int(temp_draw.textlength(home_odds_text, font=odds_font)) odds_width = max(away_odds_width, home_odds_width) + + # For baseball live games, optimize width for graphical bases + is_baseball_live = False + if is_live and live_info and hasattr(self, '_bases_data'): + sport = None + for league_key, config in self.league_configs.items(): + if config.get('logo_dir') == game.get('logo_dir'): + sport = config.get('sport') + break + + if sport == 'baseball': + is_baseball_live = True + # Use a more compact width for baseball games to minimize dead space + # The bases graphic only needs about 24px width, so we can be more efficient + min_bases_width = 24 # Reduced from 30 to minimize dead space + odds_width = max(odds_width, min_bases_width) # --- Calculate total width --- # Start with the sum of all visible components and consistent padding @@ -753,7 +1341,13 @@ class OddsTickerManager: # "vs." y_pos = (height - vs_font.size) // 2 if hasattr(vs_font, 'size') else (height - 8) // 2 # Added fallback for default font - draw.text((current_x, y_pos), vs_text, font=vs_font, fill=(255, 255, 255)) + + # Use red color for live game "vs." text to make it stand out + vs_color = (255, 255, 255) # White for regular games + if is_live and live_info: + vs_color = (255, 0, 0) # Red for live games + + draw.text((current_x, y_pos), vs_text, font=vs_font, fill=vs_color) current_x += vs_width + h_padding # Home Logo @@ -766,21 +1360,56 @@ class OddsTickerManager: team_font_height = team_font.size if hasattr(team_font, 'size') else 8 away_y = 2 home_y = height - team_font_height - 2 - draw.text((current_x, away_y), away_team_text, font=team_font, fill=(255, 255, 255)) - draw.text((current_x, home_y), home_team_text, font=team_font, fill=(255, 255, 255)) + + # Use red color for live game scores to make them stand out + team_color = (255, 255, 255) # White for regular team info + if is_live and live_info: + team_color = (255, 0, 0) # Red for live games + + draw.text((current_x, away_y), away_team_text, font=team_font, fill=team_color) + draw.text((current_x, home_y), home_team_text, font=team_font, fill=team_color) current_x += team_info_width + h_padding - # Odds (stacked) + # Odds (stacked) - Skip text for baseball live games, draw bases instead odds_font_height = odds_font.size if hasattr(odds_font, 'size') else 8 odds_y_away = 2 odds_y_home = height - odds_font_height - 2 # Use a consistent color for all odds text odds_color = (0, 255, 0) # Green + + # Use red color for live game information to make it stand out + if is_live and live_info: + odds_color = (255, 0, 0) # Red for live games - draw.text((current_x, odds_y_away), away_odds_text, font=odds_font, fill=odds_color) - draw.text((current_x, odds_y_home), home_odds_text, font=odds_font, fill=odds_color) - current_x += odds_width + h_padding + # Draw odds content based on game type + if is_baseball_live: + # Draw graphical bases instead of text + # Position bases closer to team names (left side of odds column) for better spacing + bases_x = current_x + 12 # Position at left side, offset by half cluster width (24/2 = 12) + # Shift bases down a bit more for better positioning + bases_y = (height // 2) + 2 # Move down 2 pixels from center + + # Ensure the bases don't go off the edge of the image + base_diamond_size = 8 # Total size of the diamond + base_cluster_width = 24 # Width of the base cluster (8 + 8 + 8) with tighter spacing + if bases_x - (base_cluster_width // 2) >= 0 and bases_x + (base_cluster_width // 2) <= image.width: + # Draw the base indicators + self._draw_base_indicators(draw, self._bases_data, bases_x, bases_y) + + # Clear the bases data after drawing + delattr(self, '_bases_data') + else: + # Draw regular odds text for non-baseball games + draw.text((current_x, odds_y_away), away_odds_text, font=odds_font, fill=odds_color) + draw.text((current_x, odds_y_home), home_odds_text, font=odds_font, fill=odds_color) + + # Dynamic spacing: Use reduced padding for baseball games to minimize dead space + if is_baseball_live: + # Use minimal padding since bases are positioned at left of column + current_x += odds_width + (h_padding // 3) # Use 1/3 padding for baseball games + else: + current_x += odds_width + h_padding # Datetime (stacked, 3 rows) - Center justified datetime_font_height = datetime_font.size if hasattr(datetime_font, 'size') else 6 @@ -804,9 +1433,14 @@ class OddsTickerManager: date_x = current_x + (datetime_col_width - date_text_width) // 2 time_x = current_x + (datetime_col_width - time_text_width) // 2 - draw.text((day_x, day_y), day_text, font=datetime_font, fill=(255, 255, 255)) - draw.text((date_x, date_y), date_text, font=datetime_font, fill=(255, 255, 255)) - draw.text((time_x, time_y), time_text, font=datetime_font, fill=(255, 255, 255)) + # Use red color for live game information to make it stand out + datetime_color = (255, 255, 255) # White for regular date/time + if is_live and live_info: + datetime_color = (255, 0, 0) # Red for live games + + draw.text((day_x, day_y), day_text, font=datetime_font, fill=datetime_color) + draw.text((date_x, date_y), date_text, font=datetime_font, fill=datetime_color) + draw.text((time_x, time_y), time_text, font=datetime_font, fill=datetime_color) current_x += datetime_col_width + h_padding # Add padding after datetime if broadcast_logo: @@ -825,6 +1459,8 @@ class OddsTickerManager: def _create_ticker_image(self): """Create a single wide image containing all game tickers.""" logger.debug("Entering _create_ticker_image method") + logger.debug(f"Number of games in games_data: {len(self.games_data) if self.games_data else 0}") + if not self.games_data: logger.warning("No games data available, cannot create ticker image.") self.ticker_image = None @@ -832,6 +1468,8 @@ class OddsTickerManager: logger.debug(f"Creating ticker image for {len(self.games_data)} games.") game_images = [self._create_game_display(game) for game in self.games_data] + logger.debug(f"Created {len(game_images)} game images") + if not game_images: logger.warning("Failed to create any game images.") self.ticker_image = None @@ -839,9 +1477,17 @@ class OddsTickerManager: gap_width = 24 # Reduced gap between games display_width = self.display_manager.matrix.width # Add display width of black space at start - total_width = display_width + sum(img.width for img in game_images) + gap_width * (len(game_images)) + content_width = sum(img.width for img in game_images) + gap_width * (len(game_images)) + total_width = display_width + content_width height = self.display_manager.matrix.height + logger.debug(f"Image creation details:") + logger.debug(f" Display width: {display_width}px") + logger.debug(f" Content width: {content_width}px") + logger.debug(f" Total image width: {total_width}px") + logger.debug(f" Number of games: {len(game_images)}") + logger.debug(f" Gap width: {gap_width}px") + self.ticker_image = Image.new('RGB', (total_width, height), color=(0, 0, 0)) current_x = display_width # Start after the black space @@ -855,8 +1501,15 @@ class OddsTickerManager: self.ticker_image.putpixel((bar_x, y), (255, 255, 255)) current_x += gap_width - # Calculate total scroll width for dynamic duration - self.total_scroll_width = total_width + # Calculate total scroll width for dynamic duration (only the content width, not including display width) + self.total_scroll_width = content_width + logger.debug(f"Odds ticker image creation:") + logger.debug(f" Display width: {display_width}px") + logger.debug(f" Content width: {content_width}px") + logger.debug(f" Total image width: {total_width}px") + logger.debug(f" Number of games: {len(game_images)}") + logger.debug(f" Gap width: {gap_width}px") + logger.debug(f" Set total_scroll_width to: {self.total_scroll_width}px") self.calculate_dynamic_duration() def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font: ImageFont.FreeTypeFont, @@ -871,6 +1524,8 @@ class OddsTickerManager: def calculate_dynamic_duration(self): """Calculate the exact time needed to display all odds ticker content""" + logger.debug(f"calculate_dynamic_duration called - dynamic_duration_enabled: {self.dynamic_duration_enabled}, total_scroll_width: {self.total_scroll_width}") + # If dynamic duration is disabled, use fixed duration from config if not self.dynamic_duration_enabled: self.dynamic_duration = self.odds_ticker_config.get('display_duration', 60) @@ -879,6 +1534,7 @@ class OddsTickerManager: if not self.total_scroll_width: self.dynamic_duration = self.min_duration # Use configured minimum + logger.debug(f"total_scroll_width is 0, using minimum duration: {self.min_duration}s") return try: @@ -890,17 +1546,38 @@ class OddsTickerManager: display_width = 128 # Default to 128 if not available # Calculate total scroll distance needed - # Text needs to scroll from right edge to completely off left edge - total_scroll_distance = display_width + self.total_scroll_width + # For looping content, we need to scroll the entire content width + # For non-looping content, we need content width minus display width (since last part shows fully) + if self.loop: + total_scroll_distance = self.total_scroll_width + else: + # For single pass, we need to scroll until the last content is fully visible + total_scroll_distance = max(0, self.total_scroll_width - display_width) # Calculate time based on scroll speed and delay # scroll_speed = pixels per frame, scroll_delay = seconds per frame - frames_needed = total_scroll_distance / self.scroll_speed - total_time = frames_needed * self.scroll_delay + # However, actual observed speed is slower than theoretical calculation + # Based on log analysis: 1950px in 36s = 54.2 px/s actual speed + # vs theoretical: 1px/0.01s = 100 px/s + # Use actual observed speed for more accurate timing + actual_scroll_speed = 54.2 # pixels per second (calculated from logs) + total_time = total_scroll_distance / actual_scroll_speed # Add buffer time for smooth cycling (configurable %) buffer_time = total_time * self.duration_buffer - calculated_duration = int(total_time + buffer_time) + + # Calculate duration for single complete pass + if self.loop: + # For looping: add 5-second buffer to ensure complete scroll before switching + fixed_buffer = 5 # 5 seconds of additional buffer + calculated_duration = int(total_time + fixed_buffer) + logger.debug(f"Looping enabled, duration set to one loop cycle plus 5s buffer: {calculated_duration}s") + else: + # For single pass: precise calculation to show content exactly once + # Add buffer to prevent cutting off the last content + completion_buffer = total_time * 0.05 # 5% extra to ensure complete display + calculated_duration = int(total_time + buffer_time + completion_buffer) + logger.debug(f"Single pass mode, added {completion_buffer:.2f}s completion buffer for precise timing") # Apply configured min/max limits if calculated_duration < self.min_duration: @@ -911,16 +1588,33 @@ class OddsTickerManager: logger.debug(f"Duration capped to maximum: {self.max_duration}s") else: self.dynamic_duration = calculated_duration + + # Additional safety check: if the calculated duration seems too short for the content, + # ensure we have enough time to display all content properly + if self.dynamic_duration < 45 and self.total_scroll_width > 200: + # If we have content but short duration, increase it + # Use a more generous calculation: at least 45s or 1s per 20px + self.dynamic_duration = max(45, int(self.total_scroll_width / 20)) + logger.debug(f"Adjusted duration for content: {self.dynamic_duration}s (content width: {self.total_scroll_width}px)") - logger.debug(f"Odds ticker dynamic duration calculation:") - logger.debug(f" Display width: {display_width}px") - logger.debug(f" Text width: {self.total_scroll_width}px") - logger.debug(f" Total scroll distance: {total_scroll_distance}px") - logger.debug(f" Frames needed: {frames_needed:.1f}") - logger.debug(f" Base time: {total_time:.2f}s") - logger.debug(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)") - logger.debug(f" Calculated duration: {calculated_duration}s") - logger.debug(f" Final duration: {self.dynamic_duration}s") + logger.info(f"Odds ticker dynamic duration calculation:") + logger.info(f" Display width: {display_width}px") + logger.info(f" Content width: {self.total_scroll_width}px") + logger.info(f" Total scroll distance: {total_scroll_distance}px") + logger.info(f" Configured scroll speed: {self.scroll_speed}px/frame") + logger.info(f" Configured scroll delay: {self.scroll_delay}s/frame") + logger.info(f" Actual observed scroll speed: {actual_scroll_speed}px/s (from log analysis)") + logger.info(f" Base time: {total_time:.2f}s") + logger.info(f" Buffer time: {buffer_time:.2f}s ({self.duration_buffer*100}%)") + logger.info(f" Looping enabled: {self.loop}") + if self.loop: + logger.info(f" Fixed buffer added: 5s") + logger.info(f" Calculated duration: {calculated_duration}s") + logger.info(f"Final calculated duration: {self.dynamic_duration}s") + + # Verify the duration makes sense for the content + expected_scroll_time = self.total_scroll_width / actual_scroll_speed + logger.info(f" Verification - Time to scroll content: {expected_scroll_time:.1f}s") except Exception as e: logger.error(f"Error calculating dynamic duration: {e}") @@ -928,6 +1622,22 @@ class OddsTickerManager: def get_dynamic_duration(self) -> int: """Get the calculated dynamic duration for display""" + # If we don't have a valid dynamic duration yet (total_scroll_width is 0), + # try to update the data first + if self.total_scroll_width == 0 and self.is_enabled: + logger.debug("get_dynamic_duration called but total_scroll_width is 0, attempting update...") + try: + # Force an update to get the data and calculate proper duration + # Bypass the update interval check for duration calculation + self.games_data = self._fetch_upcoming_games() + self.scroll_position = 0 + self.current_game_index = 0 + self._create_ticker_image() # Create the composite image + logger.debug(f"Force update completed, total_scroll_width: {self.total_scroll_width}px") + except Exception as e: + logger.error(f"Error updating odds ticker for dynamic duration: {e}") + + logger.debug(f"get_dynamic_duration called, returning: {self.dynamic_duration}s") return self.dynamic_duration def update(self): @@ -937,6 +1647,16 @@ class OddsTickerManager: logger.debug("Odds ticker is disabled, skipping update") return + # Check if we're currently scrolling and defer the update if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Odds ticker is currently scrolling, deferring update") + self.display_manager.defer_update(self._perform_update, priority=1) + return + + self._perform_update() + + def _perform_update(self): + """Internal method to perform the actual update.""" current_time = time.time() if current_time - self.last_update < self.update_interval: logger.debug(f"Odds ticker update interval not reached. Next update in {self.update_interval - (current_time - self.last_update)} seconds") @@ -967,15 +1687,60 @@ class OddsTickerManager: """Display the odds ticker.""" logger.debug("Entering display method") logger.debug(f"Odds ticker enabled: {self.is_enabled}") + logger.debug(f"Current scroll position: {self.scroll_position}") + logger.debug(f"Ticker image width: {self.ticker_image.width if self.ticker_image else 'None'}") + logger.debug(f"Dynamic duration: {self.dynamic_duration}s") if not self.is_enabled: logger.debug("Odds ticker is disabled, exiting display method.") return + # Reset display start time when force_clear is True or when starting fresh + if force_clear or not hasattr(self, '_display_start_time'): + self._display_start_time = time.time() + logger.debug(f"Reset/initialized display start time: {self._display_start_time}") + # Also reset scroll position for clean start + self.scroll_position = 0 + else: + # Check if the display start time is too old (more than 2x the dynamic duration) + current_time = time.time() + elapsed_time = current_time - self._display_start_time + if elapsed_time > (self.dynamic_duration * 2): + logger.debug(f"Display start time is too old ({elapsed_time:.1f}s), resetting") + self._display_start_time = current_time + self.scroll_position = 0 + logger.debug(f"Number of games in data at start of display method: {len(self.games_data)}") if not self.games_data: logger.warning("Odds ticker has no games data. Attempting to update...") - self.update() + try: + import threading + import queue + + update_queue = queue.Queue() + + def perform_update(): + try: + self.update() + update_queue.put(('success', None)) + except Exception as e: + update_queue.put(('error', e)) + + # Start update in a separate thread with 10-second timeout + update_thread = threading.Thread(target=perform_update) + update_thread.daemon = True + update_thread.start() + + try: + result_type, result_data = update_queue.get(timeout=10) + if result_type == 'error': + logger.error(f"Update failed: {result_data}") + except queue.Empty: + logger.warning("Update timed out after 10 seconds, using fallback") + + except Exception as e: + logger.error(f"Error during update: {e}") + if not self.games_data: logger.warning("Still no games data after update. Displaying fallback message.") self._display_fallback_message() @@ -983,7 +1748,34 @@ class OddsTickerManager: if self.ticker_image is None: logger.warning("Ticker image is not available. Attempting to create it.") - self._create_ticker_image() + try: + import threading + import queue + + image_queue = queue.Queue() + + def create_image(): + try: + self._create_ticker_image() + image_queue.put(('success', None)) + except Exception as e: + image_queue.put(('error', e)) + + # Start image creation in a separate thread with 5-second timeout + image_thread = threading.Thread(target=create_image) + image_thread.daemon = True + image_thread.start() + + try: + result_type, result_data = image_queue.get(timeout=5) + if result_type == 'error': + logger.error(f"Image creation failed: {result_data}") + except queue.Empty: + logger.warning("Image creation timed out after 5 seconds") + + except Exception as e: + logger.error(f"Error during image creation: {e}") + if self.ticker_image is None: logger.error("Failed to create ticker image.") self._display_fallback_message() @@ -992,8 +1784,18 @@ class OddsTickerManager: try: current_time = time.time() + # Check if we should be scrolling + should_scroll = current_time - self.last_scroll_time >= self.scroll_delay + + # Signal scrolling state to display manager + if should_scroll: + self.display_manager.set_scrolling_state(True) + else: + # If we're not scrolling, check if we should process deferred updates + self.display_manager.process_deferred_updates() + # Scroll the image - if current_time - self.last_scroll_time >= self.scroll_delay: + if should_scroll: self.scroll_position += self.scroll_speed self.last_scroll_time = current_time @@ -1005,11 +1807,51 @@ class OddsTickerManager: if self.loop: # Reset position when we've scrolled past the end for a continuous loop if self.scroll_position >= self.ticker_image.width: + logger.debug(f"Odds ticker loop reset: scroll_position {self.scroll_position} >= image width {self.ticker_image.width}") self.scroll_position = 0 else: # Stop scrolling when we reach the end if self.scroll_position >= self.ticker_image.width - width: + logger.info(f"Odds ticker reached end: scroll_position {self.scroll_position} >= {self.ticker_image.width - width}") self.scroll_position = self.ticker_image.width - width + # Signal that scrolling has stopped + self.display_manager.set_scrolling_state(False) + logger.info("Odds ticker scrolling stopped - reached end of content") + + # Check if we're at a natural break point for mode switching + # If we're near the end of the display duration and not at a clean break point, + # adjust the scroll position to complete the current game display + elapsed_time = current_time - self._display_start_time + remaining_time = self.dynamic_duration - elapsed_time + + # Log scroll progress every 50 pixels to help debug (less verbose) + if self.scroll_position % 50 == 0 and self.scroll_position > 0: + logger.info(f"Odds ticker progress: elapsed={elapsed_time:.1f}s, remaining={remaining_time:.1f}s, scroll_pos={self.scroll_position}/{self.ticker_image.width}px") + + # If we have less than 2 seconds remaining, check if we can complete the content display + if remaining_time < 2.0 and self.scroll_position > 0: + # Calculate how much time we need to complete the current scroll position + # Use actual observed scroll speed (54.2 px/s) instead of theoretical calculation + actual_scroll_speed = 54.2 # pixels per second (calculated from logs) + + if self.loop: + # For looping, we need to complete one full cycle + distance_to_complete = self.ticker_image.width - self.scroll_position + else: + # For single pass, we need to reach the end (content width minus display width) + end_position = max(0, self.ticker_image.width - width) + distance_to_complete = end_position - self.scroll_position + + time_to_complete = distance_to_complete / actual_scroll_speed + + if time_to_complete <= remaining_time: + # We have enough time to complete the scroll, continue normally + logger.debug(f"Sufficient time remaining ({remaining_time:.1f}s) to complete scroll ({time_to_complete:.1f}s)") + else: + # Not enough time, reset to beginning for clean transition + logger.warning(f"Not enough time to complete content display - remaining: {remaining_time:.1f}s, needed: {time_to_complete:.1f}s") + logger.debug(f"Resetting scroll position for clean transition") + self.scroll_position = 0 # Create the visible part of the image by pasting from the ticker_image visible_image = Image.new('RGB', (width, height)) @@ -1026,7 +1868,35 @@ class OddsTickerManager: # Display the cropped image self.display_manager.image = visible_image self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) - self.display_manager.update_display() + + # Add timeout protection for display update to prevent hanging + try: + import threading + import queue + + display_queue = queue.Queue() + + def update_display(): + try: + self.display_manager.update_display() + display_queue.put(('success', None)) + except Exception as e: + display_queue.put(('error', e)) + + # Start display update in a separate thread with 1-second timeout + display_thread = threading.Thread(target=update_display) + display_thread.daemon = True + display_thread.start() + + try: + result_type, result_data = display_queue.get(timeout=1) + if result_type == 'error': + logger.error(f"Display update failed: {result_data}") + except queue.Empty: + logger.warning("Display update timed out after 1 second") + + except Exception as e: + logger.error(f"Error during display update: {e}") except Exception as e: logger.error(f"Error displaying odds ticker: {e}", exc_info=True) diff --git a/src/of_the_day_manager.py b/src/of_the_day_manager.py index 8ede25cd..197bd55a 100644 --- a/src/of_the_day_manager.py +++ b/src/of_the_day_manager.py @@ -108,6 +108,8 @@ class OfTheDayManager: return logger.info(f"Loading data files for {len(self.categories)} categories") + logger.info(f"Current working directory: {os.getcwd()}") + logger.info(f"Script directory: {os.path.dirname(__file__)}") for category_name, category_config in self.categories.items(): logger.debug(f"Processing category: {category_name}") @@ -130,16 +132,49 @@ class OfTheDayManager: else: file_path = os.path.join(os.path.dirname(__file__), '..', 'of_the_day', data_file) + # Convert to absolute path for better logging + file_path = os.path.abspath(file_path) + logger.debug(f"Attempting to load {category_name} from: {file_path}") + if os.path.exists(file_path): + logger.debug(f"File exists, checking permissions...") + if not os.access(file_path, os.R_OK): + logger.error(f"File exists but is not readable: {file_path}") + self.data_files[category_name] = {} + continue + + # Get file size for debugging + file_size = os.path.getsize(file_path) + logger.debug(f"File size: {file_size} bytes") + with open(file_path, 'r', encoding='utf-8') as f: self.data_files[category_name] = json.load(f) + logger.info(f"Loaded data file for {category_name}: {len(self.data_files[category_name])} items") logger.debug(f"Sample keys from {category_name}: {list(self.data_files[category_name].keys())[:5]}") + + # Validate that we have data + if not self.data_files[category_name]: + logger.warning(f"Loaded data file for {category_name} but it's empty!") + else: logger.error(f"Data file not found for {category_name}: {file_path}") + logger.error(f"Directory contents: {os.listdir(os.path.dirname(file_path)) if os.path.exists(os.path.dirname(file_path)) else 'Parent directory does not exist'}") self.data_files[category_name] = {} + + except json.JSONDecodeError as e: + logger.error(f"JSON decode error loading data file for {category_name}: {e}") + logger.error(f"File path: {file_path}") + self.data_files[category_name] = {} + except UnicodeDecodeError as e: + logger.error(f"Unicode decode error loading data file for {category_name}: {e}") + logger.error(f"File path: {file_path}") + self.data_files[category_name] = {} except Exception as e: - logger.error(f"Error loading data file for {category_name}: {e}") + logger.error(f"Unexpected error loading data file for {category_name}: {e}") + logger.error(f"File path: {file_path}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") self.data_files[category_name] = {} def _load_todays_items(self): @@ -150,6 +185,7 @@ class OfTheDayManager: today = date.today() day_of_year = today.timetuple().tm_yday logger.info(f"Loading items for day {day_of_year} of the year") + logger.debug(f"Available data files: {list(self.data_files.keys())}") self.current_items = {} @@ -161,9 +197,13 @@ class OfTheDayManager: data = self.data_files.get(category_name, {}) if not data: logger.warning(f"No data loaded for category: {category_name}") + logger.debug(f"Data files available: {list(self.data_files.keys())}") + logger.debug(f"Category config: {category_config}") continue logger.debug(f"Checking category {category_name} for day {day_of_year}") + logger.debug(f"Data file contains {len(data)} items") + # Get item for today (day of year) item = data.get(str(day_of_year)) if item: @@ -171,7 +211,11 @@ class OfTheDayManager: logger.info(f"Loaded {category_name} item for day {day_of_year}: {item.get('title', 'No title')}") else: logger.warning(f"No item found for {category_name} on day {day_of_year}") - logger.debug(f"Available days in {category_name}: {list(data.keys())[:10]}...") + # Show more detailed information about available days + available_days = [k for k in data.keys() if k.isdigit()] + nearby_days = [k for k in available_days if abs(int(k) - day_of_year) <= 5] + logger.debug(f"Available days in {category_name}: {sorted(available_days)[:10]}...") + logger.debug(f"Days near {day_of_year}: {sorted(nearby_days)}") self.current_day = today self.current_category_index = 0 diff --git a/src/soccer_managers.py b/src/soccer_managers.py index 186177fe..a0b27fc9 100644 --- a/src/soccer_managers.py +++ b/src/soccer_managers.py @@ -14,6 +14,14 @@ from src.config_manager import ConfigManager from src.odds_manager import OddsManager import pytz +# 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_SOCCER_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/soccer/scoreboards" # Old URL ESPN_SOCCER_LEAGUE_SCOREBOARD_URL_FORMAT = "http://site.api.espn.com/apis/site/v2/sports/soccer/{}/scoreboard" # New format string @@ -53,11 +61,13 @@ class BaseSoccerManager: 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.soccer_config = config.get("soccer_scoreboard", {}) # Use 'soccer_scoreboard' config BaseSoccerManager._soccer_config_shared = self.soccer_config # Store for class methods self.cache_manager = cache_manager - self.odds_manager = OddsManager(self.cache_manager, self.config) + self.odds_manager = OddsManager(self.cache_manager, None) self.is_enabled = self.soccer_config.get("enabled", False) self.show_odds = self.soccer_config.get("show_odds", False) self.test_mode = self.soccer_config.get("test_mode", False) @@ -75,12 +85,8 @@ class BaseSoccerManager: self.team_map_file = self.soccer_config.get("team_map_file", "assets/data/team_league_map.json") self.team_map_update_days = self.soccer_config.get("team_map_update_days", 7) # How often to update the map - display_config = config.get("display", {}) - hardware_config = display_config.get("hardware", {}) - cols = hardware_config.get("cols", 64) - chain = hardware_config.get("chain_length", 1) - self.display_width = int(cols * chain) - self.display_height = hardware_config.get("rows", 32) + self.display_width = self.display_manager.matrix.width + self.display_height = self.display_manager.matrix.height self._logo_cache = {} @@ -192,6 +198,9 @@ class BaseSoccerManager: response = requests.get(url, params=params, timeout=10) # Add timeout response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) cls.logger.debug(f"[Soccer Map Build] Fetched data for {league_slug}") for event in data.get("events", []): @@ -264,6 +273,10 @@ class BaseSoccerManager: response = requests.get(url, params=params) response.raise_for_status() data = response.json() + + # Increment API counter for sports data + increment_api_counter('sports', 1) + self.logger.info(f"[Soccer] Fetched data from ESPN API for {league_slug} on {fetch_date}") if use_cache: diff --git a/src/stock_manager.py b/src/stock_manager.py index fb9833d5..2160323d 100644 --- a/src/stock_manager.py +++ b/src/stock_manager.py @@ -366,32 +366,52 @@ class StockManager: logger.info("Stock display settings changed, clearing cache") def update_stock_data(self): - """Update stock and crypto data for all configured symbols.""" + """Update stock data from API.""" current_time = time.time() - update_interval = self.stocks_config.get('update_interval', 300) + update_interval = self.stocks_config.get('update_interval', 600) - # Check if we need to update based on time - if current_time - self.last_update > update_interval: - stock_symbols = self.stocks_config.get('symbols', []) - crypto_symbols = self.crypto_config.get('symbols', []) if self.crypto_config.get('enabled', False) else [] + # Check if we're currently scrolling and defer the update if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Stock display is currently scrolling, deferring update") + self.display_manager.defer_update(self._perform_stock_update, priority=2) + return - if not stock_symbols and not crypto_symbols: - logger.warning("No stock or crypto symbols configured") + self._perform_stock_update() + + def _perform_stock_update(self): + """Internal method to perform the actual stock update.""" + current_time = time.time() + update_interval = self.stocks_config.get('update_interval', 600) + + if current_time - self.last_update < update_interval: + return + + try: + logger.debug("Updating stock data") + symbols = self.stocks_config.get('symbols', []) + + if not symbols: + logger.warning("No stock symbols configured") return - - # Update stocks - for symbol in stock_symbols: - data = self._fetch_stock_data(symbol, is_crypto=False) - if data: - self.stock_data[symbol] = data - - # Update crypto - for symbol in crypto_symbols: - data = self._fetch_stock_data(symbol, is_crypto=True) - if data: - self.stock_data[symbol] = data - + + # Fetch stock data + for symbol in symbols: + try: + data = self._fetch_stock_data(symbol) + if data: + self.stock_data[symbol] = data + logger.debug(f"Updated data for {symbol}: {data}") + except Exception as e: + logger.error(f"Error fetching data for {symbol}: {e}") + self.last_update = current_time + + # Clear cached text to force regeneration + self.cached_text = None + self.cached_text_image = None + + except Exception as e: + logger.error(f"Error updating stock data: {e}") def _get_stock_logo(self, symbol: str, is_crypto: bool = False) -> Image.Image: """Get stock or crypto logo image from local directory.""" @@ -696,6 +716,15 @@ class StockManager: width = self.display_manager.matrix.width total_width = self.cached_text_image.width + # Check if we should be scrolling + should_scroll = True # Stock display always scrolls continuously + + # Signal scrolling state to display manager + self.display_manager.set_scrolling_state(True) + + # Process any deferred updates (though stocks are always scrolling) + self.display_manager.process_deferred_updates() + # Update scroll position with small increments self.scroll_position = (self.scroll_position + self.scroll_speed) % total_width diff --git a/src/stock_news_manager.py b/src/stock_news_manager.py index ff6c9c7f..6ea64299 100644 --- a/src/stock_news_manager.py +++ b/src/stock_news_manager.py @@ -29,7 +29,8 @@ logger = logging.getLogger(__name__) class StockNewsManager: def __init__(self, config: Dict[str, Any], display_manager): self.config = config - self.config_manager = ConfigManager() + # Store reference to config instead of creating new ConfigManager + self.config_manager = None # Not used in this class self.display_manager = display_manager self.stocks_config = config.get('stocks', {}) self.stock_news_config = config.get('stock_news', {}) @@ -169,50 +170,52 @@ class StockNewsManager: return [] def update_news_data(self): - """Update news data for all configured stock symbols.""" + """Update news data from API.""" current_time = time.time() - update_interval = self.stock_news_config.get('update_interval', 300) + update_interval = self.stock_news_config.get('update_interval', 3600) - # Check if we need to update based on time - if current_time - self.last_update > update_interval: + # Check if we're currently scrolling and defer the update if so + if self.display_manager.is_currently_scrolling(): + logger.debug("Stock news display is currently scrolling, deferring update") + self.display_manager.defer_update(self._perform_news_update, priority=2) + return + + self._perform_news_update() + + def _perform_news_update(self): + """Internal method to perform the actual news update.""" + current_time = time.time() + update_interval = self.stock_news_config.get('update_interval', 3600) + + if current_time - self.last_update < update_interval: + return + + try: + logger.debug("Updating stock news data") symbols = self.stocks_config.get('symbols', []) + if not symbols: logger.warning("No stock symbols configured for news") return - - # Get cached data - cached_data = self.cache_manager.get('stock_news') - - # Update each symbol - new_data = {} - success = False - + + # Fetch news for each symbol for symbol in symbols: - # Check if data has changed before fetching - if cached_data and symbol in cached_data: - current_state = cached_data[symbol] - if not self.cache_manager.has_data_changed('stock_news', current_state): - logger.info(f"News data hasn't changed for {symbol}, using existing data") - new_data[symbol] = current_state - success = True - continue - - # Add a longer delay between requests to avoid rate limiting - time.sleep(random.uniform(1.0, 2.0)) # increased delay between requests - news_items = self._fetch_news(symbol) - if news_items: - new_data[symbol] = news_items - success = True + try: + news = self._fetch_news(symbol) + if news: + self.news_data[symbol] = news + logger.debug(f"Updated news for {symbol}: {len(news)} headlines") + except Exception as e: + logger.error(f"Error fetching news for {symbol}: {e}") - if success: - # Cache the new data - self.cache_manager.update_cache('stock_news', new_data) - # Only update the displayed data when we have new data - self.news_data = new_data - self.last_update = current_time - logger.info(f"Updated news data for {len(new_data)} symbols") - else: - logger.error("Failed to fetch news for any configured stocks") + self.last_update = current_time + + # Clear cached text to force regeneration + self.cached_text = None + self.cached_text_image = None + + except Exception as e: + logger.error(f"Error updating stock news data: {e}") def _create_text_image(self, text: str, color: Tuple[int, int, int] = (255, 255, 255)) -> Image.Image: """Create an image containing the text for efficient scrolling.""" @@ -405,12 +408,23 @@ class StockNewsManager: # If total_width is somehow less than screen width, don't scroll if total_width <= width: + # Signal that we're not scrolling for this frame + self.display_manager.set_scrolling_state(False) + # Process any deferred updates + self.display_manager.process_deferred_updates() + self.display_manager.image.paste(self.cached_text_image, (0, 0)) self.display_manager.update_display() time.sleep(self.stock_news_config.get('item_display_duration', 5)) # Hold static image self.cached_text_image = None # Force recreation next cycle return True + # Signal that we're scrolling + self.display_manager.set_scrolling_state(True) + + # Process any deferred updates (though news is usually always scrolling) + self.display_manager.process_deferred_updates() + # Update scroll position self.scroll_position += self.scroll_speed if self.scroll_position >= total_width: diff --git a/src/youtube_display.py b/src/youtube_display.py index d827c970..3299b499 100644 --- a/src/youtube_display.py +++ b/src/youtube_display.py @@ -8,6 +8,14 @@ from rgbmatrix import RGBMatrix, RGBMatrixOptions import os from typing import Dict, Any +# 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 + # Get logger without configuring logger = logging.getLogger(__name__) @@ -57,6 +65,10 @@ class YouTubeDisplay: try: response = requests.get(url) data = response.json() + + # Increment API counter for YouTube data + increment_api_counter('youtube', 1) + if data['items']: channel = data['items'][0] return { diff --git a/templates/index_v2.html b/templates/index_v2.html index 60276621..92a62766 100644 --- a/templates/index_v2.html +++ b/templates/index_v2.html @@ -818,12 +818,18 @@ + + @@ -1408,6 +1414,147 @@ + +
+
+
+

Leaderboard Configuration

+
+ + +
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +

Enabled Sports

+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+ + +
+
+
+
@@ -1448,6 +1595,84 @@
+ +
+
+
+

Of The Day Configuration

+
+ +
+
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
Comma-separated list of category keys in display order
+
+ +

Categories

+
+
Word of the Day
+
+ +
+
+ + +
+
+ + +
+
+ +
+
Slovenian Word of the Day
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+
@@ -1996,6 +2221,9 @@
Stocks: ${u.stocks || 0} used / ${f.stocks || 0} forecast
Sports: ${u.sports || 0} used / ${f.sports || 0} forecast
News: ${u.news || 0} used / ${f.news || 0} forecast
+
Odds: ${u.odds || 0} used / ${f.odds || 0} forecast
+
Music: ${u.music || 0} used / ${f.music || 0} forecast
+
YouTube: ${u.youtube || 0} used / ${f.youtube || 0} forecast
`; } catch (e) { // ignore @@ -2686,6 +2914,88 @@ }); })(); + // Leaderboard form submit + (function augmentLeaderboardForm(){ + const form = document.getElementById('leaderboard-form'); + form.addEventListener('submit', async function(e){ + e.preventDefault(); + const payload = { + leaderboard: { + enabled: document.getElementById('leaderboard_enabled').checked, + update_interval: parseInt(document.getElementById('leaderboard_update_interval').value), + scroll_speed: parseFloat(document.getElementById('leaderboard_scroll_speed').value), + scroll_delay: parseFloat(document.getElementById('leaderboard_scroll_delay').value), + display_duration: parseInt(document.getElementById('leaderboard_display_duration').value), + loop: document.getElementById('leaderboard_loop').checked, + request_timeout: parseInt(document.getElementById('leaderboard_request_timeout').value), + dynamic_duration: document.getElementById('leaderboard_dynamic_duration').checked, + min_duration: parseInt(document.getElementById('leaderboard_min_duration').value), + max_duration: parseInt(document.getElementById('leaderboard_max_duration').value), + duration_buffer: parseFloat(document.getElementById('leaderboard_duration_buffer').value), + enabled_sports: { + nfl: { + enabled: document.getElementById('leaderboard_nfl_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_nfl_top_teams').value) + }, + nba: { + enabled: document.getElementById('leaderboard_nba_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_nba_top_teams').value) + }, + mlb: { + enabled: document.getElementById('leaderboard_mlb_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_mlb_top_teams').value) + }, + ncaa_fb: { + enabled: document.getElementById('leaderboard_ncaa_fb_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_ncaa_fb_top_teams').value), + show_ranking: document.getElementById('leaderboard_ncaa_fb_show_ranking').checked + }, + nhl: { + enabled: document.getElementById('leaderboard_nhl_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_nhl_top_teams').value) + }, + ncaam_basketball: { + enabled: document.getElementById('leaderboard_ncaam_basketball_enabled').checked, + top_teams: parseInt(document.getElementById('leaderboard_ncaam_basketball_top_teams').value) + } + } + } + }; + await saveConfigJson(payload); + }); + })(); + + // Of The Day form submit + (function augmentOfTheDayForm(){ + const form = document.getElementById('of_the_day-form'); + form.addEventListener('submit', async function(e){ + e.preventDefault(); + const categoryOrder = document.getElementById('of_the_day_category_order').value.split(',').map(s => s.trim()).filter(Boolean); + const payload = { + of_the_day: { + enabled: document.getElementById('of_the_day_enabled').checked, + update_interval: parseInt(document.getElementById('of_the_day_update_interval').value), + display_rotate_interval: parseInt(document.getElementById('of_the_day_display_rotate_interval').value), + subtitle_rotate_interval: parseInt(document.getElementById('of_the_day_subtitle_rotate_interval').value), + category_order: categoryOrder, + categories: { + word_of_the_day: { + enabled: document.getElementById('of_the_day_word_enabled').checked, + data_file: document.getElementById('of_the_day_word_data_file').value, + display_name: document.getElementById('of_the_day_word_display_name').value + }, + slovenian_word_of_the_day: { + enabled: document.getElementById('of_the_day_slovenian_enabled').checked, + data_file: document.getElementById('of_the_day_slovenian_data_file').value, + display_name: document.getElementById('of_the_day_slovenian_display_name').value + } + } + } + }; + await saveConfigJson(payload); + }); + })(); + // Text form submit (function augmentTextForm(){ const form = document.getElementById('text-form'); diff --git a/test/README_broadcast_logo_analyzer.md b/test/README_broadcast_logo_analyzer.md new file mode 100644 index 00000000..d23f74c0 --- /dev/null +++ b/test/README_broadcast_logo_analyzer.md @@ -0,0 +1,103 @@ +# Broadcast Logo Analyzer + +This script analyzes broadcast channel logos to ensure we have proper logos for every game and identifies missing or problematic logos that might show as white boxes. + +## Important Notes + +**This script must be run on the Raspberry Pi** where the LEDMatrix project is located, as it needs to access the actual logo files in the `assets/broadcast_logos/` directory. + +## Usage + +### On Raspberry Pi (Recommended) + +```bash +# SSH into your Raspberry Pi +ssh pi@your-pi-ip + +# Navigate to the LEDMatrix project directory +cd /path/to/LEDMatrix + +# Run the analyzer +python test/analyze_broadcast_logos.py +``` + +### Local Testing (Optional) + +If you want to test the script logic locally, you can: + +1. Copy some logo files from your Pi to your local machine +2. Place them in `assets/broadcast_logos/` directory +3. Run the script locally + +## What the Script Does + +1. **Checks Logo Mappings**: Verifies all broadcast channel names in `BROADCAST_LOGO_MAP` have corresponding logo files +2. **Validates File Existence**: Ensures all referenced logo files actually exist +3. **Analyzes Logo Quality**: + - Checks dimensions (too small/large) + - Analyzes transparency handling + - Detects potential white box issues + - Measures content density +4. **Identifies Issues**: + - Missing logos + - Problematic logos (corrupted, too transparent, etc.) + - Orphaned logo files (exist but not mapped) +5. **Generates Report**: Creates both console output and JSON report + +## Output + +The script generates: +- **Console Report**: Detailed analysis with recommendations +- **JSON Report**: `test/broadcast_logo_analysis.json` with structured data + +## Common Issues Found + +- **White Boxes**: Usually caused by: + - Missing logo files + - Corrupted image files + - Images that are mostly transparent + - Images with very low content density +- **Missing Logos**: Broadcast channels that don't have corresponding logo files +- **Orphaned Logos**: Logo files that exist but aren't mapped to any broadcast channel + +## Recommendations + +The script provides specific recommendations for each issue found, such as: +- Adding missing logo files +- Fixing problematic logos +- Optimizing logo dimensions +- Ensuring proper transparency handling + +## Example Output + +``` +BROADCAST LOGO ANALYSIS REPORT +================================================================================ + +SUMMARY: + Total broadcast mappings: 44 + Existing logos: 40 + Missing logos: 2 + Problematic logos: 2 + Orphaned logos: 1 + +MISSING LOGOS (2): +-------------------------------------------------- + New Channel -> newchannel.png + Expected: /path/to/LEDMatrix/assets/broadcast_logos/newchannel.png + +PROBLEMATIC LOGOS (2): +-------------------------------------------------- + ESPN -> espn + Issue: Very low content density: 2.1% + Recommendation: Logo may appear as a white box - check content +``` + +## Troubleshooting + +If you see errors about missing dependencies: +```bash +pip install Pillow +``` + +If the script can't find the broadcast logos directory, ensure you're running it from the LEDMatrix project root directory. diff --git a/test/analyze_broadcast_logos.py b/test/analyze_broadcast_logos.py new file mode 100644 index 00000000..b95454ab --- /dev/null +++ b/test/analyze_broadcast_logos.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +""" +Broadcast Logo Analyzer + +This script analyzes broadcast channel logos to ensure we have proper logos +for every game and identifies missing or problematic logos that might show +as white boxes. + +IMPORTANT: This script must be run on the Raspberry Pi where the LEDMatrix +project is located, as it needs to access the actual logo files in the +assets/broadcast_logos/ directory. + +Usage (on Raspberry Pi): + python test/analyze_broadcast_logos.py + +Features: +- Checks all broadcast logos referenced in BROADCAST_LOGO_MAP +- Validates logo file existence and integrity +- Analyzes logo dimensions and transparency +- Identifies potential white box issues +- Provides recommendations for missing logos +- Generates a detailed report +""" + +import os +import sys +import json +from pathlib import Path +from typing import Dict, List, Set, Tuple, Optional +from PIL import Image, ImageStat +import logging + +# Add the project root to the path so we can import from src +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +# Define the broadcast logo map directly (copied from odds_ticker_manager.py) +BROADCAST_LOGO_MAP = { + "ACC Network": "accn", + "ACCN": "accn", + "ABC": "abc", + "BTN": "btn", + "CBS": "cbs", + "CBSSN": "cbssn", + "CBS Sports Network": "cbssn", + "ESPN": "espn", + "ESPN2": "espn2", + "ESPN3": "espn3", + "ESPNU": "espnu", + "ESPNEWS": "espn", + "ESPN+": "espn", + "ESPN Plus": "espn", + "FOX": "fox", + "FS1": "fs1", + "FS2": "fs2", + "MLBN": "mlbn", + "MLB Network": "mlbn", + "MLB.TV": "mlbn", + "NBC": "nbc", + "NFLN": "nfln", + "NFL Network": "nfln", + "PAC12": "pac12n", + "Pac-12 Network": "pac12n", + "SECN": "espn-sec-us", + "TBS": "tbs", + "TNT": "tnt", + "truTV": "tru", + "Peacock": "nbc", + "Paramount+": "paramount-plus", + "Hulu": "espn", + "Disney+": "espn", + "Apple TV+": "nbc", + # Regional sports networks + "MASN": "cbs", + "MASN2": "cbs", + "MAS+": "cbs", + "SportsNet": "nbc", + "FanDuel SN": "fox", + "FanDuel SN DET": "fox", + "FanDuel SN FL": "fox", + "SportsNet PIT": "nbc", + "Padres.TV": "espn", + "CLEGuardians.TV": "espn" +} + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class BroadcastLogoAnalyzer: + """Analyzes broadcast channel logos for completeness and quality.""" + + def __init__(self, project_root: Path): + self.project_root = project_root + self.broadcast_logos_dir = project_root / "assets" / "broadcast_logos" + self.results = { + 'total_mappings': len(BROADCAST_LOGO_MAP), + 'existing_logos': [], + 'missing_logos': [], + 'problematic_logos': [], + 'recommendations': [] + } + + def analyze_all_logos(self) -> Dict: + """Perform comprehensive analysis of all broadcast logos.""" + logger.info("Starting broadcast logo analysis...") + + # Get all logo files that exist + existing_files = self._get_existing_logo_files() + logger.info(f"Found {len(existing_files)} existing logo files") + + # Check each mapping in BROADCAST_LOGO_MAP + for broadcast_name, logo_filename in BROADCAST_LOGO_MAP.items(): + self._analyze_logo_mapping(broadcast_name, logo_filename, existing_files) + + # Check for orphaned logo files (files that exist but aren't mapped) + self._check_orphaned_logos(existing_files) + + # Generate recommendations + self._generate_recommendations() + + return self.results + + def _get_existing_logo_files(self) -> Set[str]: + """Get all existing logo files in the broadcast_logos directory.""" + existing_files = set() + + if not self.broadcast_logos_dir.exists(): + logger.warning(f"Broadcast logos directory does not exist: {self.broadcast_logos_dir}") + return existing_files + + for file_path in self.broadcast_logos_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in ['.png', '.jpg', '.jpeg']: + existing_files.add(file_path.stem) # filename without extension + + return existing_files + + def _analyze_logo_mapping(self, broadcast_name: str, logo_filename: str, existing_files: Set[str]): + """Analyze a single logo mapping.""" + logo_path = self.broadcast_logos_dir / f"{logo_filename}.png" + + if logo_filename not in existing_files: + self.results['missing_logos'].append({ + 'broadcast_name': broadcast_name, + 'logo_filename': logo_filename, + 'expected_path': str(logo_path) + }) + logger.warning(f"Missing logo: {broadcast_name} -> {logo_filename}.png") + return + + # Logo exists, analyze its quality + try: + analysis = self._analyze_logo_quality(logo_path, broadcast_name, logo_filename) + if analysis['is_problematic']: + self.results['problematic_logos'].append(analysis) + else: + self.results['existing_logos'].append(analysis) + except Exception as e: + logger.error(f"Error analyzing logo {logo_path}: {e}") + self.results['problematic_logos'].append({ + 'broadcast_name': broadcast_name, + 'logo_filename': logo_filename, + 'path': str(logo_path), + 'error': str(e), + 'is_problematic': True + }) + + def _analyze_logo_quality(self, logo_path: Path, broadcast_name: str, logo_filename: str) -> Dict: + """Analyze the quality of a logo file.""" + try: + with Image.open(logo_path) as img: + # Basic image info + width, height = img.size + mode = img.mode + + # Convert to RGBA for analysis if needed + if mode != 'RGBA': + img_rgba = img.convert('RGBA') + else: + img_rgba = img + + # Analyze for potential white box issues + analysis = { + 'broadcast_name': broadcast_name, + 'logo_filename': logo_filename, + 'path': str(logo_path), + 'dimensions': (width, height), + 'mode': mode, + 'file_size': logo_path.stat().st_size, + 'is_problematic': False, + 'issues': [], + 'recommendations': [] + } + + # Check for white box issues + self._check_white_box_issues(img_rgba, analysis) + + # Check dimensions + self._check_dimensions(width, height, analysis) + + # Check transparency + self._check_transparency(img_rgba, analysis) + + # Check if image is mostly empty/white + self._check_content_density(img_rgba, analysis) + + return analysis + + except Exception as e: + raise Exception(f"Failed to analyze image: {e}") + + def _check_white_box_issues(self, img: Image.Image, analysis: Dict): + """Check for potential white box issues.""" + # Get image statistics + stat = ImageStat.Stat(img) + + # Check if image is mostly white + if img.mode == 'RGBA': + # For RGBA, check RGB channels + r_mean, g_mean, b_mean = stat.mean[:3] + if r_mean > 240 and g_mean > 240 and b_mean > 240: + analysis['issues'].append("Image appears to be mostly white") + analysis['is_problematic'] = True + + # Check for completely transparent images + if img.mode == 'RGBA': + alpha_channel = img.split()[3] + alpha_stat = ImageStat.Stat(alpha_channel) + if alpha_stat.mean[0] < 10: # Very low alpha + analysis['issues'].append("Image is mostly transparent") + analysis['is_problematic'] = True + + def _check_dimensions(self, width: int, height: int, analysis: Dict): + """Check if dimensions are reasonable.""" + if width < 16 or height < 16: + analysis['issues'].append(f"Very small dimensions: {width}x{height}") + analysis['is_problematic'] = True + analysis['recommendations'].append("Consider using a higher resolution logo") + + if width > 512 or height > 512: + analysis['issues'].append(f"Very large dimensions: {width}x{height}") + analysis['recommendations'].append("Consider optimizing logo size for better performance") + + # Check aspect ratio + aspect_ratio = width / height + if aspect_ratio > 4 or aspect_ratio < 0.25: + analysis['issues'].append(f"Extreme aspect ratio: {aspect_ratio:.2f}") + analysis['recommendations'].append("Consider using a more square logo") + + def _check_transparency(self, img: Image.Image, analysis: Dict): + """Check transparency handling.""" + if img.mode == 'RGBA': + # Check if there's any transparency + alpha_channel = img.split()[3] + alpha_data = list(alpha_channel.getdata()) + min_alpha = min(alpha_data) + max_alpha = max(alpha_data) + + if min_alpha < 255: + analysis['recommendations'].append("Logo has transparency - ensure proper background handling") + + if max_alpha < 128: + analysis['issues'].append("Logo is very transparent") + analysis['is_problematic'] = True + + def _check_content_density(self, img: Image.Image, analysis: Dict): + """Check if the image has sufficient content.""" + # Convert to grayscale for analysis + gray = img.convert('L') + + # Count non-white pixels (assuming white background) + pixels = list(gray.getdata()) + non_white_pixels = sum(1 for p in pixels if p < 240) + total_pixels = len(pixels) + content_ratio = non_white_pixels / total_pixels + + if content_ratio < 0.05: # Less than 5% content + analysis['issues'].append(f"Very low content density: {content_ratio:.1%}") + analysis['is_problematic'] = True + analysis['recommendations'].append("Logo may appear as a white box - check content") + + def _check_orphaned_logos(self, existing_files: Set[str]): + """Check for logo files that exist but aren't mapped.""" + mapped_filenames = set(BROADCAST_LOGO_MAP.values()) + orphaned_files = existing_files - mapped_filenames + + if orphaned_files: + self.results['orphaned_logos'] = list(orphaned_files) + logger.info(f"Found {len(orphaned_files)} orphaned logo files: {orphaned_files}") + + def _generate_recommendations(self): + """Generate overall recommendations.""" + recommendations = [] + + if self.results['missing_logos']: + recommendations.append(f"Add {len(self.results['missing_logos'])} missing logo files") + + if self.results['problematic_logos']: + recommendations.append(f"Fix {len(self.results['problematic_logos'])} problematic logos") + + if 'orphaned_logos' in self.results: + recommendations.append(f"Consider mapping {len(self.results['orphaned_logos'])} orphaned logo files") + + # General recommendations + recommendations.extend([ + "Ensure all logos are PNG format with transparency support", + "Use consistent dimensions (preferably 64x64 or 128x128 pixels)", + "Test logos on the actual LED matrix display", + "Consider creating fallback logos for missing channels" + ]) + + self.results['recommendations'] = recommendations + + def print_report(self): + """Print a detailed analysis report.""" + print("\n" + "="*80) + print("BROADCAST LOGO ANALYSIS REPORT") + print("="*80) + + print(f"\nSUMMARY:") + print(f" Total broadcast mappings: {self.results['total_mappings']}") + print(f" Existing logos: {len(self.results['existing_logos'])}") + print(f" Missing logos: {len(self.results['missing_logos'])}") + print(f" Problematic logos: {len(self.results['problematic_logos'])}") + + if 'orphaned_logos' in self.results: + print(f" Orphaned logos: {len(self.results['orphaned_logos'])}") + + # Missing logos + if self.results['missing_logos']: + print(f"\nMISSING LOGOS ({len(self.results['missing_logos'])}):") + print("-" * 50) + for missing in self.results['missing_logos']: + print(f" {missing['broadcast_name']} -> {missing['logo_filename']}.png") + print(f" Expected: {missing['expected_path']}") + + # Problematic logos + if self.results['problematic_logos']: + print(f"\nPROBLEMATIC LOGOS ({len(self.results['problematic_logos'])}):") + print("-" * 50) + for problematic in self.results['problematic_logos']: + print(f" {problematic['broadcast_name']} -> {problematic['logo_filename']}") + if 'error' in problematic: + print(f" Error: {problematic['error']}") + if 'issues' in problematic: + for issue in problematic['issues']: + print(f" Issue: {issue}") + if 'recommendations' in problematic: + for rec in problematic['recommendations']: + print(f" Recommendation: {rec}") + + # Orphaned logos + if 'orphaned_logos' in self.results and self.results['orphaned_logos']: + print(f"\nORPHANED LOGOS ({len(self.results['orphaned_logos'])}):") + print("-" * 50) + for orphaned in self.results['orphaned_logos']: + print(f" {orphaned}.png (not mapped in BROADCAST_LOGO_MAP)") + + # Recommendations + if self.results['recommendations']: + print(f"\nRECOMMENDATIONS:") + print("-" * 50) + for i, rec in enumerate(self.results['recommendations'], 1): + print(f" {i}. {rec}") + + print("\n" + "="*80) + + def save_report(self, output_file: str = "broadcast_logo_analysis.json"): + """Save the analysis results to a JSON file.""" + output_path = self.project_root / "test" / output_file + with open(output_path, 'w') as f: + json.dump(self.results, f, indent=2) + logger.info(f"Analysis report saved to: {output_path}") + +def main(): + """Main function to run the broadcast logo analysis.""" + print("Broadcast Logo Analyzer") + print("=" * 50) + + # Check if we're in the right directory structure + if not (project_root / "assets" / "broadcast_logos").exists(): + print("ERROR: This script must be run from the LEDMatrix project root directory") + print(f"Expected directory structure: {project_root}/assets/broadcast_logos/") + print("Please run this script on the Raspberry Pi where the LEDMatrix project is located.") + print("\nTo test the script logic locally, you can copy some logo files to the expected location.") + return 1 + + # Initialize analyzer + analyzer = BroadcastLogoAnalyzer(project_root) + + # Run analysis + try: + results = analyzer.analyze_all_logos() + + # Print report + analyzer.print_report() + + # Save report + analyzer.save_report() + + # Return exit code based on issues found + total_issues = len(results['missing_logos']) + len(results['problematic_logos']) + if total_issues > 0: + print(f"\n⚠️ Found {total_issues} issues that need attention!") + return 1 + else: + print(f"\n✅ All broadcast logos are in good condition!") + return 0 + + except Exception as e: + logger.error(f"Analysis failed: {e}") + return 1 + +if __name__ == "__main__": + exit(main()) diff --git a/test/broadcast_logo_analysis.json b/test/broadcast_logo_analysis.json new file mode 100644 index 00000000..01768bb0 --- /dev/null +++ b/test/broadcast_logo_analysis.json @@ -0,0 +1,757 @@ +{ + "total_mappings": 44, + "existing_logos": [ + { + "broadcast_name": "ACC Network", + "logo_filename": "accn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\accn.png", + "dimensions": [ + 512, + 150 + ], + "mode": "RGBA", + "file_size": 6772, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ACCN", + "logo_filename": "accn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\accn.png", + "dimensions": [ + 512, + 150 + ], + "mode": "RGBA", + "file_size": 6772, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ABC", + "logo_filename": "abc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\abc.png", + "dimensions": [ + 512, + 511 + ], + "mode": "P", + "file_size": 21748, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "BTN", + "logo_filename": "btn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\btn.png", + "dimensions": [ + 512, + 309 + ], + "mode": "P", + "file_size": 4281, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "CBS", + "logo_filename": "cbs", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbs.png", + "dimensions": [ + 330, + 96 + ], + "mode": "RGBA", + "file_size": 10111, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "CBSSN", + "logo_filename": "cbssn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbssn.png", + "dimensions": [ + 512, + 111 + ], + "mode": "RGBA", + "file_size": 16230, + "is_problematic": false, + "issues": [ + "Extreme aspect ratio: 4.61" + ], + "recommendations": [ + "Consider using a more square logo", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "CBS Sports Network", + "logo_filename": "cbssn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbssn.png", + "dimensions": [ + 512, + 111 + ], + "mode": "RGBA", + "file_size": 16230, + "is_problematic": false, + "issues": [ + "Extreme aspect ratio: 4.61" + ], + "recommendations": [ + "Consider using a more square logo", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPN", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPN2", + "logo_filename": "espn2", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn2.png", + "dimensions": [ + 512, + 97 + ], + "mode": "P", + "file_size": 3996, + "is_problematic": false, + "issues": [ + "Extreme aspect ratio: 5.28" + ], + "recommendations": [ + "Consider using a more square logo", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPN3", + "logo_filename": "espn3", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn3.png", + "dimensions": [ + 512, + 101 + ], + "mode": "P", + "file_size": 4221, + "is_problematic": false, + "issues": [ + "Extreme aspect ratio: 5.07" + ], + "recommendations": [ + "Consider using a more square logo", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPNU", + "logo_filename": "espnu", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espnu.png", + "dimensions": [ + 512, + 147 + ], + "mode": "RGBA", + "file_size": 6621, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPNEWS", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPN+", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "ESPN Plus", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FOX", + "logo_filename": "fox", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fox.png", + "dimensions": [ + 512, + 307 + ], + "mode": "RGBA", + "file_size": 94499, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FS1", + "logo_filename": "fs1", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fs1.png", + "dimensions": [ + 512, + 257 + ], + "mode": "RGBA", + "file_size": 8139, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FS2", + "logo_filename": "fs2", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fs2.png", + "dimensions": [ + 512, + 256 + ], + "mode": "RGBA", + "file_size": 8204, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MLBN", + "logo_filename": "mlbn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\mlbn.png", + "dimensions": [ + 512, + 528 + ], + "mode": "RGBA", + "file_size": 42129, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x528" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MLB Network", + "logo_filename": "mlbn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\mlbn.png", + "dimensions": [ + 512, + 528 + ], + "mode": "RGBA", + "file_size": 42129, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x528" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MLB.TV", + "logo_filename": "mlbn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\mlbn.png", + "dimensions": [ + 512, + 528 + ], + "mode": "RGBA", + "file_size": 42129, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x528" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "NBC", + "logo_filename": "nbc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nbc.png", + "dimensions": [ + 512, + 479 + ], + "mode": "RGBA", + "file_size": 15720, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "NFLN", + "logo_filename": "nfln", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nfln.png", + "dimensions": [ + 330, + 130 + ], + "mode": "RGBA", + "file_size": 10944, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "NFL Network", + "logo_filename": "nfln", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nfln.png", + "dimensions": [ + 330, + 130 + ], + "mode": "RGBA", + "file_size": 10944, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "PAC12", + "logo_filename": "pac12n", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\pac12n.png", + "dimensions": [ + 512, + 645 + ], + "mode": "RGBA", + "file_size": 84038, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x645" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Pac-12 Network", + "logo_filename": "pac12n", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\pac12n.png", + "dimensions": [ + 512, + 645 + ], + "mode": "RGBA", + "file_size": 84038, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x645" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "SECN", + "logo_filename": "espn-sec-us", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn-sec-us.png", + "dimensions": [ + 512, + 718 + ], + "mode": "RGBA", + "file_size": 87531, + "is_problematic": false, + "issues": [ + "Very large dimensions: 512x718" + ], + "recommendations": [ + "Consider optimizing logo size for better performance", + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "TBS", + "logo_filename": "tbs", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\tbs.png", + "dimensions": [ + 512, + 276 + ], + "mode": "RGBA", + "file_size": 61816, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "truTV", + "logo_filename": "tru", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\tru.png", + "dimensions": [ + 512, + 198 + ], + "mode": "RGBA", + "file_size": 11223, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Peacock", + "logo_filename": "nbc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nbc.png", + "dimensions": [ + 512, + 479 + ], + "mode": "RGBA", + "file_size": 15720, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Paramount+", + "logo_filename": "paramount-plus", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\paramount-plus.png", + "dimensions": [ + 330, + 205 + ], + "mode": "RGBA", + "file_size": 17617, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Hulu", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Disney+", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Apple TV+", + "logo_filename": "nbc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nbc.png", + "dimensions": [ + 512, + 479 + ], + "mode": "RGBA", + "file_size": 15720, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MASN", + "logo_filename": "cbs", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbs.png", + "dimensions": [ + 330, + 96 + ], + "mode": "RGBA", + "file_size": 10111, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MASN2", + "logo_filename": "cbs", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbs.png", + "dimensions": [ + 330, + 96 + ], + "mode": "RGBA", + "file_size": 10111, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "MAS+", + "logo_filename": "cbs", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\cbs.png", + "dimensions": [ + 330, + 96 + ], + "mode": "RGBA", + "file_size": 10111, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "SportsNet", + "logo_filename": "nbc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nbc.png", + "dimensions": [ + 512, + 479 + ], + "mode": "RGBA", + "file_size": 15720, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FanDuel SN", + "logo_filename": "fox", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fox.png", + "dimensions": [ + 512, + 307 + ], + "mode": "RGBA", + "file_size": 94499, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FanDuel SN DET", + "logo_filename": "fox", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fox.png", + "dimensions": [ + 512, + 307 + ], + "mode": "RGBA", + "file_size": 94499, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "FanDuel SN FL", + "logo_filename": "fox", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\fox.png", + "dimensions": [ + 512, + 307 + ], + "mode": "RGBA", + "file_size": 94499, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "SportsNet PIT", + "logo_filename": "nbc", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\nbc.png", + "dimensions": [ + 512, + 479 + ], + "mode": "RGBA", + "file_size": 15720, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "Padres.TV", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + }, + { + "broadcast_name": "CLEGuardians.TV", + "logo_filename": "espn", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\espn.png", + "dimensions": [ + 512, + 512 + ], + "mode": "RGBA", + "file_size": 7391, + "is_problematic": false, + "issues": [], + "recommendations": [ + "Logo has transparency - ensure proper background handling" + ] + } + ], + "missing_logos": [], + "problematic_logos": [ + { + "broadcast_name": "TNT", + "logo_filename": "tnt", + "path": "C:\\Users\\Charles\\Documents\\GitHub\\LEDMatrix\\assets\\broadcast_logos\\tnt.png", + "dimensions": [ + 512, + 512 + ], + "mode": "P", + "file_size": 6131, + "is_problematic": true, + "issues": [ + "Image appears to be mostly white", + "Very low content density: 0.0%" + ], + "recommendations": [ + "Logo has transparency - ensure proper background handling", + "Logo may appear as a white box - check content" + ] + } + ], + "recommendations": [ + "Fix 1 problematic logos", + "Consider mapping 1 orphaned logo files", + "Ensure all logos are PNG format with transparency support", + "Use consistent dimensions (preferably 64x64 or 128x128 pixels)", + "Test logos on the actual LED matrix display", + "Consider creating fallback logos for missing channels" + ], + "orphaned_logos": [ + "prime" + ] +} \ No newline at end of file diff --git a/test/debug_espn_api.py b/test/debug_espn_api.py new file mode 100644 index 00000000..510e0444 --- /dev/null +++ b/test/debug_espn_api.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +""" +Debug script to examine ESPN API response structure +""" + +import requests +import json + +def debug_espn_api(): + """Debug ESPN API responses.""" + + # Test different endpoints + test_endpoints = [ + { + 'name': 'NFL Standings', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings' + }, + { + 'name': 'NFL Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams' + }, + { + 'name': 'NFL Scoreboard', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard' + }, + { + 'name': 'NBA Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams' + }, + { + 'name': 'MLB Teams', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/teams' + } + ] + + for endpoint in test_endpoints: + print(f"\n{'='*50}") + print(f"Testing {endpoint['name']}") + print(f"URL: {endpoint['url']}") + print('='*50) + + try: + response = requests.get(endpoint['url'], timeout=30) + response.raise_for_status() + data = response.json() + + print(f"Response status: {response.status_code}") + print(f"Response keys: {list(data.keys())}") + + # Print a sample of the response + if 'sports' in data: + sports = data['sports'] + print(f"Sports found: {len(sports)}") + if sports: + leagues = sports[0].get('leagues', []) + print(f"Leagues found: {len(leagues)}") + if leagues: + teams = leagues[0].get('teams', []) + print(f"Teams found: {len(teams)}") + if teams: + print("Sample team data:") + sample_team = teams[0] + print(f" Team: {sample_team.get('team', {}).get('name', 'Unknown')}") + print(f" Abbreviation: {sample_team.get('team', {}).get('abbreviation', 'Unknown')}") + stats = sample_team.get('stats', []) + print(f" Stats found: {len(stats)}") + for stat in stats[:3]: # Show first 3 stats + print(f" {stat.get('name', 'Unknown')}: {stat.get('value', 'Unknown')}") + + elif 'groups' in data: + groups = data['groups'] + print(f"Groups found: {len(groups)}") + if groups: + print("Sample group data:") + print(json.dumps(groups[0], indent=2)[:500] + "...") + + else: + print("Sample response data:") + print(json.dumps(data, indent=2)[:500] + "...") + + except Exception as e: + print(f"Error: {e}") + +if __name__ == "__main__": + debug_espn_api() diff --git a/test/download_espn_ncaa_fb_logos.py b/test/download_espn_ncaa_fb_logos.py new file mode 100644 index 00000000..54afc90c --- /dev/null +++ b/test/download_espn_ncaa_fb_logos.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +Script to download all NCAA Football team logos from ESPN API +and update the all_team_abbreviations.txt file with current ESPN abbreviations. +""" + +import os +import requests +import json +from pathlib import Path +import time + +def create_logo_directory(): + """Create the ncaaFBlogos directory if it doesn't exist.""" + logo_dir = Path("test/ncaaFBlogos") + logo_dir.mkdir(parents=True, exist_ok=True) + return logo_dir + +def fetch_teams_data(): + """Fetch team data from ESPN API.""" + url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error fetching teams data: {e}") + return None + +def download_logo(url, filepath, team_name): + """Download a logo from URL and save to filepath.""" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + with open(filepath, 'wb') as f: + f.write(response.content) + + print(f"✓ Downloaded: {team_name} -> {filepath.name}") + return True + + except requests.exceptions.RequestException as e: + print(f"✗ Failed to download {team_name}: {e}") + return False + +def normalize_abbreviation(abbreviation): + """Normalize team abbreviation to lowercase for filename.""" + return abbreviation.lower() + +def update_abbreviations_file(teams_data, abbreviations_file_path): + """Update the all_team_abbreviations.txt file with current ESPN abbreviations.""" + print(f"\nUpdating abbreviations file: {abbreviations_file_path}") + + # Read existing file + existing_content = [] + if os.path.exists(abbreviations_file_path): + with open(abbreviations_file_path, 'r', encoding='utf-8') as f: + existing_content = f.readlines() + + # Find the NCAAF section + ncaaf_start = -1 + ncaaf_end = -1 + + for i, line in enumerate(existing_content): + if line.strip() == "NCAAF": + ncaaf_start = i + elif ncaaf_start != -1 and line.strip() and not line.startswith(" "): + ncaaf_end = i + break + + if ncaaf_start == -1: + print("Warning: Could not find NCAAF section in abbreviations file") + return + + if ncaaf_end == -1: + ncaaf_end = len(existing_content) + + # Extract teams from ESPN data + espn_teams = [] + for team_data in teams_data: + team = team_data.get('team', {}) + abbreviation = team.get('abbreviation', '') + display_name = team.get('displayName', '') + + if abbreviation and display_name: + espn_teams.append((abbreviation, display_name)) + + # Sort teams by abbreviation + espn_teams.sort(key=lambda x: x[0]) + + # Create new NCAAF section + new_ncaaf_section = ["NCAAF\n"] + for abbreviation, display_name in espn_teams: + new_ncaaf_section.append(f" {abbreviation} => {display_name}\n") + new_ncaaf_section.append("\n") + + # Reconstruct the file + new_content = ( + existing_content[:ncaaf_start] + + new_ncaaf_section + + existing_content[ncaaf_end:] + ) + + # Write updated file + with open(abbreviations_file_path, 'w', encoding='utf-8') as f: + f.writelines(new_content) + + print(f"✓ Updated abbreviations file with {len(espn_teams)} NCAAF teams") + +def main(): + """Main function to download all NCAA FB team logos and update abbreviations.""" + print("Starting NCAA Football logo download and abbreviations update...") + + # Create directory + logo_dir = create_logo_directory() + print(f"Created/verified directory: {logo_dir}") + + # Fetch teams data + print("Fetching teams data from ESPN API...") + data = fetch_teams_data() + + if not data: + print("Failed to fetch teams data. Exiting.") + return + + # Extract teams + teams = [] + try: + sports = data.get('sports', []) + for sport in sports: + leagues = sport.get('leagues', []) + for league in leagues: + teams = league.get('teams', []) + break + except (KeyError, IndexError) as e: + print(f"Error parsing teams data: {e}") + return + + print(f"Found {len(teams)} teams") + + # Download logos + downloaded_count = 0 + failed_count = 0 + + for team_data in teams: + team = team_data.get('team', {}) + + # Extract team information + abbreviation = team.get('abbreviation', '') + display_name = team.get('displayName', 'Unknown') + logos = team.get('logos', []) + + if not abbreviation or not logos: + print(f"⚠ Skipping {display_name}: missing abbreviation or logos") + continue + + # Get the default logo (first one is usually default) + logo_url = logos[0].get('href', '') + if not logo_url: + print(f"⚠ Skipping {display_name}: no logo URL") + continue + + # Create filename + filename = f"{normalize_abbreviation(abbreviation)}.png" + filepath = logo_dir / filename + + # Skip if already exists + if filepath.exists(): + print(f"⏭ Skipping {display_name}: {filename} already exists") + continue + + # Download logo + if download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + else: + failed_count += 1 + + # Small delay to be respectful to the API + time.sleep(0.1) + + print(f"\nDownload complete!") + print(f"✓ Successfully downloaded: {downloaded_count} logos") + print(f"✗ Failed downloads: {failed_count}") + print(f"📁 Logos saved in: {logo_dir}") + + # Update abbreviations file + abbreviations_file_path = "assets/sports/all_team_abbreviations.txt" + update_abbreviations_file(teams, abbreviations_file_path) + +if __name__ == "__main__": + main() diff --git a/test/download_ncaa_fb_logos.py b/test/download_ncaa_fb_logos.py new file mode 100644 index 00000000..f3cf4915 --- /dev/null +++ b/test/download_ncaa_fb_logos.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +Script to download all NCAA Football team logos from ESPN API +and save them with team abbreviations as filenames. +""" + +import os +import requests +import json +from pathlib import Path +import time + +def create_logo_directory(): + """Create the ncaaFBlogos directory if it doesn't exist.""" + logo_dir = Path("test/ncaaFBlogos") + logo_dir.mkdir(parents=True, exist_ok=True) + return logo_dir + +def fetch_teams_data(): + """Fetch team data from ESPN API.""" + url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams" + + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + print(f"Error fetching teams data: {e}") + return None + +def download_logo(url, filepath, team_name): + """Download a logo from URL and save to filepath.""" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + with open(filepath, 'wb') as f: + f.write(response.content) + + print(f"✓ Downloaded: {team_name} -> {filepath.name}") + return True + + except requests.exceptions.RequestException as e: + print(f"✗ Failed to download {team_name}: {e}") + return False + +def normalize_abbreviation(abbreviation): + """Normalize team abbreviation to lowercase for filename.""" + return abbreviation.lower() + +def main(): + """Main function to download all NCAA FB team logos.""" + print("Starting NCAA Football logo download...") + + # Create directory + logo_dir = create_logo_directory() + print(f"Created/verified directory: {logo_dir}") + + # Fetch teams data + print("Fetching teams data from ESPN API...") + data = fetch_teams_data() + + if not data: + print("Failed to fetch teams data. Exiting.") + return + + # Extract teams + teams = [] + try: + sports = data.get('sports', []) + for sport in sports: + leagues = sport.get('leagues', []) + for league in leagues: + teams = league.get('teams', []) + break + except (KeyError, IndexError) as e: + print(f"Error parsing teams data: {e}") + return + + print(f"Found {len(teams)} teams") + + # Download logos + downloaded_count = 0 + failed_count = 0 + + for team_data in teams: + team = team_data.get('team', {}) + + # Extract team information + abbreviation = team.get('abbreviation', '') + display_name = team.get('displayName', 'Unknown') + logos = team.get('logos', []) + + if not abbreviation or not logos: + print(f"⚠ Skipping {display_name}: missing abbreviation or logos") + continue + + # Get the default logo (first one is usually default) + logo_url = logos[0].get('href', '') + if not logo_url: + print(f"⚠ Skipping {display_name}: no logo URL") + continue + + # Create filename + filename = f"{normalize_abbreviation(abbreviation)}.png" + filepath = logo_dir / filename + + # Skip if already exists + if filepath.exists(): + print(f"⏭ Skipping {display_name}: {filename} already exists") + continue + + # Download logo + if download_logo(logo_url, filepath, display_name): + downloaded_count += 1 + else: + failed_count += 1 + + # Small delay to be respectful to the API + time.sleep(0.1) + + print(f"\nDownload complete!") + print(f"✓ Successfully downloaded: {downloaded_count} logos") + print(f"✗ Failed downloads: {failed_count}") + print(f"📁 Logos saved in: {logo_dir}") + +if __name__ == "__main__": + main() diff --git a/test/ncaaFBlogos/amh.png b/test/ncaaFBlogos/amh.png new file mode 100644 index 00000000..c9f3ca86 Binary files /dev/null and b/test/ncaaFBlogos/amh.png differ diff --git a/test/ncaaFBlogos/ann.png b/test/ncaaFBlogos/ann.png new file mode 100644 index 00000000..6c61e18b Binary files /dev/null and b/test/ncaaFBlogos/ann.png differ diff --git a/test/ncaaFBlogos/ariz.png b/test/ncaaFBlogos/ariz.png new file mode 100644 index 00000000..bd17bbc3 Binary files /dev/null and b/test/ncaaFBlogos/ariz.png differ diff --git a/test/ncaaFBlogos/ark.png b/test/ncaaFBlogos/ark.png new file mode 100644 index 00000000..870c0e1c Binary files /dev/null and b/test/ncaaFBlogos/ark.png differ diff --git a/test/ncaaFBlogos/asu.png b/test/ncaaFBlogos/asu.png new file mode 100644 index 00000000..ec9e1162 Binary files /dev/null and b/test/ncaaFBlogos/asu.png differ diff --git a/test/ncaaFBlogos/aub.png b/test/ncaaFBlogos/aub.png new file mode 100644 index 00000000..fcd97fab Binary files /dev/null and b/test/ncaaFBlogos/aub.png differ diff --git a/test/ncaaFBlogos/bois.png b/test/ncaaFBlogos/bois.png new file mode 100644 index 00000000..ccbd604e Binary files /dev/null and b/test/ncaaFBlogos/bois.png differ diff --git a/test/ncaaFBlogos/brst.png b/test/ncaaFBlogos/brst.png new file mode 100644 index 00000000..e6bba244 Binary files /dev/null and b/test/ncaaFBlogos/brst.png differ diff --git a/test/ncaaFBlogos/buena.png b/test/ncaaFBlogos/buena.png new file mode 100644 index 00000000..88c443b3 Binary files /dev/null and b/test/ncaaFBlogos/buena.png differ diff --git a/test/ncaaFBlogos/cal.png b/test/ncaaFBlogos/cal.png new file mode 100644 index 00000000..ebc43156 Binary files /dev/null and b/test/ncaaFBlogos/cal.png differ diff --git a/test/ncaaFBlogos/car.png b/test/ncaaFBlogos/car.png new file mode 100644 index 00000000..dff64804 Binary files /dev/null and b/test/ncaaFBlogos/car.png differ diff --git a/test/ncaaFBlogos/cla.png b/test/ncaaFBlogos/cla.png new file mode 100644 index 00000000..30eefb63 Binary files /dev/null and b/test/ncaaFBlogos/cla.png differ diff --git a/test/ncaaFBlogos/colby.png b/test/ncaaFBlogos/colby.png new file mode 100644 index 00000000..5df6982f Binary files /dev/null and b/test/ncaaFBlogos/colby.png differ diff --git a/test/ncaaFBlogos/colo.png b/test/ncaaFBlogos/colo.png new file mode 100644 index 00000000..42ba2d55 Binary files /dev/null and b/test/ncaaFBlogos/colo.png differ diff --git a/test/ncaaFBlogos/conn.png b/test/ncaaFBlogos/conn.png new file mode 100644 index 00000000..1114466e Binary files /dev/null and b/test/ncaaFBlogos/conn.png differ diff --git a/test/ncaaFBlogos/cp.png b/test/ncaaFBlogos/cp.png new file mode 100644 index 00000000..80ac8ba5 Binary files /dev/null and b/test/ncaaFBlogos/cp.png differ diff --git a/test/ncaaFBlogos/csu.png b/test/ncaaFBlogos/csu.png new file mode 100644 index 00000000..5ade1fc8 Binary files /dev/null and b/test/ncaaFBlogos/csu.png differ diff --git a/test/ncaaFBlogos/cur.png b/test/ncaaFBlogos/cur.png new file mode 100644 index 00000000..4c71f0be Binary files /dev/null and b/test/ncaaFBlogos/cur.png differ diff --git a/test/ncaaFBlogos/del.png b/test/ncaaFBlogos/del.png new file mode 100644 index 00000000..52d230ba Binary files /dev/null and b/test/ncaaFBlogos/del.png differ diff --git a/test/ncaaFBlogos/dub.png b/test/ncaaFBlogos/dub.png new file mode 100644 index 00000000..cefe34e5 Binary files /dev/null and b/test/ncaaFBlogos/dub.png differ diff --git a/test/ncaaFBlogos/elm.png b/test/ncaaFBlogos/elm.png new file mode 100644 index 00000000..19601cf7 Binary files /dev/null and b/test/ncaaFBlogos/elm.png differ diff --git a/test/ncaaFBlogos/famu.png b/test/ncaaFBlogos/famu.png new file mode 100644 index 00000000..cbd35117 Binary files /dev/null and b/test/ncaaFBlogos/famu.png differ diff --git a/test/ncaaFBlogos/fla.png b/test/ncaaFBlogos/fla.png new file mode 100644 index 00000000..2383ea78 Binary files /dev/null and b/test/ncaaFBlogos/fla.png differ diff --git a/test/ncaaFBlogos/fsu.png b/test/ncaaFBlogos/fsu.png new file mode 100644 index 00000000..9a3be669 Binary files /dev/null and b/test/ncaaFBlogos/fsu.png differ diff --git a/test/ncaaFBlogos/gri.png b/test/ncaaFBlogos/gri.png new file mode 100644 index 00000000..1924fa7a Binary files /dev/null and b/test/ncaaFBlogos/gri.png differ diff --git a/test/ncaaFBlogos/gt.png b/test/ncaaFBlogos/gt.png new file mode 100644 index 00000000..228b8fdb Binary files /dev/null and b/test/ncaaFBlogos/gt.png differ diff --git a/test/ncaaFBlogos/gtwn.png b/test/ncaaFBlogos/gtwn.png new file mode 100644 index 00000000..9990feee Binary files /dev/null and b/test/ncaaFBlogos/gtwn.png differ diff --git a/test/ncaaFBlogos/haw.png b/test/ncaaFBlogos/haw.png new file mode 100644 index 00000000..cd5856dc Binary files /dev/null and b/test/ncaaFBlogos/haw.png differ diff --git a/test/ncaaFBlogos/how.png b/test/ncaaFBlogos/how.png new file mode 100644 index 00000000..a4b902a9 Binary files /dev/null and b/test/ncaaFBlogos/how.png differ diff --git a/test/ncaaFBlogos/idho.png b/test/ncaaFBlogos/idho.png new file mode 100644 index 00000000..1cfcdfff Binary files /dev/null and b/test/ncaaFBlogos/idho.png differ diff --git a/test/ncaaFBlogos/isu.png b/test/ncaaFBlogos/isu.png new file mode 100644 index 00000000..c59e3753 Binary files /dev/null and b/test/ncaaFBlogos/isu.png differ diff --git a/test/ncaaFBlogos/jxst.png b/test/ncaaFBlogos/jxst.png new file mode 100644 index 00000000..d507ee89 Binary files /dev/null and b/test/ncaaFBlogos/jxst.png differ diff --git a/test/ncaaFBlogos/lut.png b/test/ncaaFBlogos/lut.png new file mode 100644 index 00000000..684e10f2 Binary files /dev/null and b/test/ncaaFBlogos/lut.png differ diff --git a/test/ncaaFBlogos/mesa.png b/test/ncaaFBlogos/mesa.png new file mode 100644 index 00000000..66159848 Binary files /dev/null and b/test/ncaaFBlogos/mesa.png differ diff --git a/test/ncaaFBlogos/mil.png b/test/ncaaFBlogos/mil.png new file mode 100644 index 00000000..adc29c39 Binary files /dev/null and b/test/ncaaFBlogos/mil.png differ diff --git a/test/ncaaFBlogos/mor.png b/test/ncaaFBlogos/mor.png new file mode 100644 index 00000000..b701025e Binary files /dev/null and b/test/ncaaFBlogos/mor.png differ diff --git a/test/ncaaFBlogos/nor.png b/test/ncaaFBlogos/nor.png new file mode 100644 index 00000000..2b62fead Binary files /dev/null and b/test/ncaaFBlogos/nor.png differ diff --git a/test/ncaaFBlogos/red.png b/test/ncaaFBlogos/red.png new file mode 100644 index 00000000..196b387d Binary files /dev/null and b/test/ncaaFBlogos/red.png differ diff --git a/test/ncaaFBlogos/sac.png b/test/ncaaFBlogos/sac.png new file mode 100644 index 00000000..5ef0343f Binary files /dev/null and b/test/ncaaFBlogos/sac.png differ diff --git a/test/ncaaFBlogos/sdsu.png b/test/ncaaFBlogos/sdsu.png new file mode 100644 index 00000000..f73ee988 Binary files /dev/null and b/test/ncaaFBlogos/sdsu.png differ diff --git a/test/ncaaFBlogos/sjsu.png b/test/ncaaFBlogos/sjsu.png new file mode 100644 index 00000000..dbffbcc7 Binary files /dev/null and b/test/ncaaFBlogos/sjsu.png differ diff --git a/test/ncaaFBlogos/stan.png b/test/ncaaFBlogos/stan.png new file mode 100644 index 00000000..36dc600f Binary files /dev/null and b/test/ncaaFBlogos/stan.png differ diff --git a/test/ncaaFBlogos/stet.png b/test/ncaaFBlogos/stet.png new file mode 100644 index 00000000..d31e923b Binary files /dev/null and b/test/ncaaFBlogos/stet.png differ diff --git a/test/ncaaFBlogos/uab.png b/test/ncaaFBlogos/uab.png new file mode 100644 index 00000000..854220a7 Binary files /dev/null and b/test/ncaaFBlogos/uab.png differ diff --git a/test/ncaaFBlogos/ucla.png b/test/ncaaFBlogos/ucla.png new file mode 100644 index 00000000..216f392c Binary files /dev/null and b/test/ncaaFBlogos/ucla.png differ diff --git a/test/ncaaFBlogos/uga.png b/test/ncaaFBlogos/uga.png new file mode 100644 index 00000000..53175cff Binary files /dev/null and b/test/ncaaFBlogos/uga.png differ diff --git a/test/ncaaFBlogos/usa.png b/test/ncaaFBlogos/usa.png new file mode 100644 index 00000000..3083699d Binary files /dev/null and b/test/ncaaFBlogos/usa.png differ diff --git a/test/ncaaFBlogos/usc.png b/test/ncaaFBlogos/usc.png new file mode 100644 index 00000000..84c2711e Binary files /dev/null and b/test/ncaaFBlogos/usc.png differ diff --git a/test/ncaaFBlogos/usf.png b/test/ncaaFBlogos/usf.png new file mode 100644 index 00000000..c1b4c5b6 Binary files /dev/null and b/test/ncaaFBlogos/usf.png differ diff --git a/test/ncaaFBlogos/yale.png b/test/ncaaFBlogos/yale.png new file mode 100644 index 00000000..fa375049 Binary files /dev/null and b/test/ncaaFBlogos/yale.png differ diff --git a/test/test_leaderboard.py b/test/test_leaderboard.py new file mode 100644 index 00000000..54efa66e --- /dev/null +++ b/test/test_leaderboard.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +""" +Test script for the LeaderboardManager +""" + +import sys +import os +import json +import logging + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from leaderboard_manager import LeaderboardManager +from display_manager import DisplayManager +from config_manager import ConfigManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def test_leaderboard_manager(): + """Test the leaderboard manager functionality.""" + + # Load configuration + config_manager = ConfigManager() + config = config_manager.load_config() + + # Enable leaderboard and some sports for testing + config['leaderboard'] = { + 'enabled': True, + 'enabled_sports': { + 'nfl': { + 'enabled': True, + 'top_teams': 5 + }, + 'nba': { + 'enabled': True, + 'top_teams': 5 + }, + 'mlb': { + 'enabled': True, + 'top_teams': 5 + } + }, + 'update_interval': 3600, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 60, + 'loop': True, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1 + } + + # Initialize display manager (this will be a mock for testing) + display_manager = DisplayManager(config) + + # Initialize leaderboard manager + leaderboard_manager = LeaderboardManager(config, display_manager) + + print("Testing LeaderboardManager...") + print(f"Enabled: {leaderboard_manager.is_enabled}") + print(f"Enabled sports: {[k for k, v in leaderboard_manager.league_configs.items() if v['enabled']]}") + + # Test fetching standings + print("\nFetching standings...") + leaderboard_manager.update() + + print(f"Number of leagues with data: {len(leaderboard_manager.leaderboard_data)}") + + for league_data in leaderboard_manager.leaderboard_data: + league = league_data['league'] + teams = league_data['teams'] + print(f"\n{league.upper()}:") + for i, team in enumerate(teams[:5]): # Show top 5 + record = f"{team['wins']}-{team['losses']}" + if 'ties' in team: + record += f"-{team['ties']}" + print(f" {i+1}. {team['abbreviation']} {record}") + + # Test image creation + print("\nCreating leaderboard image...") + if leaderboard_manager.leaderboard_data: + leaderboard_manager._create_leaderboard_image() + if leaderboard_manager.leaderboard_image: + print(f"Image created successfully: {leaderboard_manager.leaderboard_image.size}") + print(f"Dynamic duration: {leaderboard_manager.dynamic_duration:.1f}s") + else: + print("Failed to create image") + else: + print("No data available to create image") + +if __name__ == "__main__": + test_leaderboard_manager() diff --git a/test/test_leaderboard_simple.py b/test/test_leaderboard_simple.py new file mode 100644 index 00000000..f65fb081 --- /dev/null +++ b/test/test_leaderboard_simple.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Simple test script for the LeaderboardManager (without display dependencies) +""" + +import sys +import os +import json +import logging +import requests +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta, timezone +from PIL import Image, ImageDraw, ImageFont + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +def test_espn_api(): + """Test ESPN API endpoints for standings.""" + + # Test different league endpoints + test_leagues = [ + { + 'name': 'NFL', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/standings' + }, + { + 'name': 'NBA', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/standings' + }, + { + 'name': 'MLB', + 'url': 'https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/standings' + } + ] + + for league in test_leagues: + print(f"\nTesting {league['name']} API...") + try: + response = requests.get(league['url'], timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ {league['name']} API response successful") + + # Check if we have groups data + groups = data.get('groups', []) + print(f" Groups found: {len(groups)}") + + # Try to extract some team data + total_teams = 0 + for group in groups: + if 'standings' in group: + total_teams += len(group['standings']) + elif 'groups' in group: + # Handle nested groups (like NFL conferences/divisions) + for sub_group in group['groups']: + if 'standings' in sub_group: + total_teams += len(sub_group['standings']) + elif 'groups' in sub_group: + for sub_sub_group in sub_group['groups']: + if 'standings' in sub_sub_group: + total_teams += len(sub_sub_group['standings']) + + print(f" Total teams found: {total_teams}") + + except Exception as e: + print(f"✗ {league['name']} API failed: {e}") + +def test_standings_parsing(): + """Test parsing standings data.""" + + # Test NFL standings parsing using teams endpoint + print("\nTesting NFL standings parsing...") + try: + # First get all teams + teams_url = 'https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams' + response = requests.get(teams_url, timeout=30) + response.raise_for_status() + data = response.json() + + sports = data.get('sports', []) + if not sports: + print("✗ No sports data found") + return + + leagues = sports[0].get('leagues', []) + if not leagues: + print("✗ No leagues data found") + return + + teams = leagues[0].get('teams', []) + if not teams: + print("✗ No teams data found") + return + + print(f"Found {len(teams)} NFL teams") + + # Test fetching individual team records + standings = [] + test_teams = teams[:5] # Test first 5 teams to avoid too many API calls + + for team_data in test_teams: + team = team_data.get('team', {}) + team_abbr = team.get('abbreviation') + team_name = team.get('name', 'Unknown') + + if not team_abbr: + continue + + print(f" Fetching record for {team_abbr}...") + + # Fetch individual team record + team_url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams/{team_abbr}" + team_response = requests.get(team_url, timeout=30) + team_response.raise_for_status() + team_data = team_response.json() + + team_info = team_data.get('team', {}) + stats = team_info.get('stats', []) + + # Find wins and losses + wins = 0 + losses = 0 + ties = 0 + + for stat in stats: + if stat.get('name') == 'wins': + wins = stat.get('value', 0) + elif stat.get('name') == 'losses': + losses = stat.get('value', 0) + elif stat.get('name') == 'ties': + ties = stat.get('value', 0) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage + }) + + # Sort by win percentage and show results + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + + print("NFL team records:") + for i, team in enumerate(standings): + record = f"{team['wins']}-{team['losses']}" + if team['ties'] > 0: + record += f"-{team['ties']}" + print(f" {i+1}. {team['abbreviation']} {record} ({team['win_percentage']:.3f})") + + except Exception as e: + print(f"✗ NFL standings parsing failed: {e}") + +def test_logo_loading(): + """Test logo loading functionality.""" + + print("\nTesting logo loading...") + + # Test team logo loading + logo_dir = "assets/sports/nfl_logos" + test_teams = ["TB", "DAL", "NE"] + + for team in test_teams: + logo_path = os.path.join(logo_dir, f"{team}.png") + if os.path.exists(logo_path): + print(f"✓ {team} logo found: {logo_path}") + else: + print(f"✗ {team} logo not found: {logo_path}") + + # Test league logo loading + league_logos = [ + "assets/sports/nfl_logos/nfl.png", + "assets/sports/nba_logos/nba.png", + "assets/sports/mlb_logos/mlb.png", + "assets/sports/nhl_logos/nhl.png", + "assets/sports/ncaa_fbs_logos/ncaa_fb.png", + "assets/sports/ncaa_fbs_logos/ncaam.png" + ] + + for logo_path in league_logos: + if os.path.exists(logo_path): + print(f"✓ League logo found: {logo_path}") + else: + print(f"✗ League logo not found: {logo_path}") + +if __name__ == "__main__": + print("Testing LeaderboardManager components...") + + test_espn_api() + test_standings_parsing() + test_logo_loading() + + print("\nTest completed!") diff --git a/test/test_ncaa_fb_leaderboard.py b/test/test_ncaa_fb_leaderboard.py new file mode 100644 index 00000000..36a1f8ee --- /dev/null +++ b/test/test_ncaa_fb_leaderboard.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate NCAA Football leaderboard data gathering. +Shows the top 10 NCAA Football teams ranked by win percentage. +This script examines the actual ESPN API response structure to understand +how team records are provided in the teams endpoint. +""" + +import sys +import os +import json +import time +import requests +from typing import Dict, Any, List, Optional + +# Add the src directory to the path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from cache_manager import CacheManager +from config_manager import ConfigManager + +class NCAAFBLeaderboardTester: + """Test class to demonstrate NCAA Football leaderboard data gathering.""" + + def __init__(self): + self.cache_manager = CacheManager() + self.config_manager = ConfigManager() + self.request_timeout = 30 + + # NCAA Football configuration (matching the leaderboard manager) + self.ncaa_fb_config = { + 'sport': 'football', + 'league': 'college-football', + 'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', + 'top_teams': 10 # Show top 10 for this test + } + + def examine_api_structure(self) -> None: + """Examine the ESPN API response structure to understand available data.""" + print("Examining ESPN API response structure...") + print("=" * 60) + + try: + response = requests.get(self.ncaa_fb_config['teams_url'], timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + print(f"API Response Status: {response.status_code}") + print(f"Response Keys: {list(data.keys())}") + + sports = data.get('sports', []) + if sports: + print(f"Sports found: {len(sports)}") + sport = sports[0] + print(f"Sport keys: {list(sport.keys())}") + print(f"Sport name: {sport.get('name', 'Unknown')}") + + leagues = sport.get('leagues', []) + if leagues: + print(f"Leagues found: {len(leagues)}") + league = leagues[0] + print(f"League keys: {list(league.keys())}") + print(f"League name: {league.get('name', 'Unknown')}") + + teams = league.get('teams', []) + if teams: + print(f"Teams found: {len(teams)}") + + # Examine first team structure + first_team = teams[0] + print(f"\nFirst team structure:") + print(f"Team keys: {list(first_team.keys())}") + + team_info = first_team.get('team', {}) + print(f"Team info keys: {list(team_info.keys())}") + print(f"Team name: {team_info.get('name', 'Unknown')}") + print(f"Team abbreviation: {team_info.get('abbreviation', 'Unknown')}") + + # Check for record data + record = team_info.get('record', {}) + print(f"Record keys: {list(record.keys())}") + + if record: + items = record.get('items', []) + print(f"Record items: {len(items)}") + if items: + print(f"First record item: {items[0]}") + + # Check for stats data + stats = team_info.get('stats', []) + print(f"Stats found: {len(stats)}") + if stats: + print("Available stats:") + for stat in stats[:5]: # Show first 5 stats + print(f" {stat.get('name', 'Unknown')}: {stat.get('value', 'Unknown')}") + + # Check for standings data + standings = first_team.get('standings', {}) + print(f"Standings keys: {list(standings.keys())}") + + print(f"\nSample team data structure:") + print(json.dumps(first_team, indent=2)[:1000] + "...") + + except Exception as e: + print(f"Error examining API structure: {e}") + + def fetch_ncaa_fb_rankings_correct(self) -> List[Dict[str, Any]]: + """Fetch NCAA Football rankings from ESPN API using the correct approach.""" + cache_key = "leaderboard_college-football-rankings" + + # Try to get cached data first + cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard') + if cached_data: + print("Using cached rankings data for NCAA Football") + return cached_data.get('rankings', []) + + try: + print("Fetching fresh rankings data for NCAA Football") + rankings_url = "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings" + print(f"Rankings URL: {rankings_url}") + + # Get rankings data + response = requests.get(rankings_url, timeout=self.request_timeout) + response.raise_for_status() + data = response.json() + + print(f"Available rankings: {[rank['name'] for rank in data.get('availableRankings', [])]}") + print(f"Latest season: {data.get('latestSeason', {})}") + print(f"Latest week: {data.get('latestWeek', {})}") + + rankings_data = data.get('rankings', []) + if not rankings_data: + print("No rankings data found") + return [] + + # Use the first ranking (usually AP Top 25) + first_ranking = rankings_data[0] + ranking_name = first_ranking.get('name', 'Unknown') + ranking_type = first_ranking.get('type', 'Unknown') + teams = first_ranking.get('ranks', []) + + print(f"Using ranking: {ranking_name} ({ranking_type})") + print(f"Found {len(teams)} teams in ranking") + + standings = [] + + # Process each team in the ranking + for i, team_data in enumerate(teams): + team_info = team_data.get('team', {}) + team_name = team_info.get('name', 'Unknown') + team_abbr = team_info.get('abbreviation', 'Unknown') + current_rank = team_data.get('current', 0) + record_summary = team_data.get('recordSummary', '0-0') + + print(f" {current_rank}. {team_name} ({team_abbr}): {record_summary}") + + # Parse the record string (e.g., "12-1", "8-4", "10-2-1") + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0 + + try: + parts = record_summary.split('-') + if len(parts) >= 2: + wins = int(parts[0]) + losses = int(parts[1]) + if len(parts) == 3: + ties = int(parts[2]) + + # Calculate win percentage + total_games = wins + losses + ties + win_percentage = wins / total_games if total_games > 0 else 0 + except (ValueError, IndexError): + print(f" Could not parse record: {record_summary}") + continue + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'rank': current_rank, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'ranking_name': ranking_name + }) + + # Limit to top teams (they're already ranked) + top_teams = standings[:self.ncaa_fb_config['top_teams']] + + # Cache the results + cache_data = { + 'rankings': top_teams, + 'timestamp': time.time(), + 'league': 'college-football', + 'ranking_name': ranking_name + } + self.cache_manager.save_cache(cache_key, cache_data) + + print(f"Fetched and cached {len(top_teams)} teams for college-football") + return top_teams + + except Exception as e: + print(f"Error fetching rankings for college-football: {e}") + return [] + + def display_standings(self, standings: List[Dict[str, Any]]) -> None: + """Display the standings in a formatted way.""" + if not standings: + print("No standings data available") + return + + ranking_name = standings[0].get('ranking_name', 'Unknown Ranking') if standings else 'Unknown' + + print("\n" + "="*80) + print(f"NCAA FOOTBALL LEADERBOARD - TOP 10 TEAMS ({ranking_name})") + print("="*80) + print(f"{'Rank':<4} {'Team':<25} {'Abbr':<6} {'Record':<12} {'Win %':<8}") + print("-"*80) + + for team in standings: + record_str = f"{team['wins']}-{team['losses']}" + if team['ties'] > 0: + record_str += f"-{team['ties']}" + + win_pct = team['win_percentage'] + win_pct_str = f"{win_pct:.3f}" if win_pct > 0 else "0.000" + + print(f"{team['rank']:<4} {team['name']:<25} {team['abbreviation']:<6} {record_str:<12} {win_pct_str:<8}") + + print("="*80) + print(f"Total teams processed: {len(standings)}") + print(f"Data fetched at: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + def run_test(self) -> None: + """Run the complete test.""" + print("NCAA Football Leaderboard Data Gathering Test") + print("=" * 50) + print("This test demonstrates how the leaderboard manager should gather data:") + print("1. Fetches rankings from ESPN API rankings endpoint") + print("2. Uses poll-based rankings (AP, Coaches, etc.) not win percentage") + print("3. Gets team records from the ranking data") + print("4. Displays top 10 teams with their poll rankings") + print() + + print("\n" + "="*60) + print("FETCHING RANKINGS DATA") + print("="*60) + + # Fetch the rankings using the correct approach + standings = self.fetch_ncaa_fb_rankings_correct() + + # Display the results + self.display_standings(standings) + + # Show some additional info + if standings: + ranking_name = standings[0].get('ranking_name', 'Unknown') + print(f"\nAdditional Information:") + print(f"- API Endpoint: https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings") + print(f"- Single API call fetches poll-based rankings") + print(f"- Rankings are based on polls, not just win percentage") + print(f"- Data is cached to avoid excessive API calls") + print(f"- Using ranking: {ranking_name}") + + # Show the best team + best_team = standings[0] + print(f"\nCurrent #1 Team: {best_team['name']} ({best_team['abbreviation']})") + print(f"Record: {best_team['wins']}-{best_team['losses']}{f'-{best_team['ties']}' if best_team['ties'] > 0 else ''}") + print(f"Win Percentage: {best_team['win_percentage']:.3f}") + print(f"Poll Ranking: #{best_team['rank']}") + +def main(): + """Main function to run the test.""" + try: + tester = NCAAFBLeaderboardTester() + tester.run_test() + except KeyboardInterrupt: + print("\nTest interrupted by user") + except Exception as e: + print(f"Error running test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/test/test_odds_ticker_dynamic_duration.py b/test/test_odds_ticker_dynamic_duration.py new file mode 100644 index 00000000..f78daf58 --- /dev/null +++ b/test/test_odds_ticker_dynamic_duration.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Test script for debugging OddsTickerManager dynamic duration calculation +""" + +import sys +import os +import time +import logging + +# Add the parent directory to the Python path so we can import from src +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from src.display_manager import DisplayManager +from src.config_manager import ConfigManager +from src.odds_ticker_manager import OddsTickerManager + +# Configure logging to show debug information +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', + datefmt='%H:%M:%S' +) + +def test_dynamic_duration(): + """Test the dynamic duration calculation for odds ticker.""" + print("Testing OddsTickerManager Dynamic Duration...") + + try: + # Load configuration + config_manager = ConfigManager() + config = config_manager.load_config() + + # Initialize display manager + display_manager = DisplayManager(config) + + # Initialize odds ticker + odds_ticker = OddsTickerManager(config, display_manager) + + print(f"Odds ticker enabled: {odds_ticker.is_enabled}") + print(f"Dynamic duration enabled: {odds_ticker.dynamic_duration_enabled}") + print(f"Min duration: {odds_ticker.min_duration}s") + print(f"Max duration: {odds_ticker.max_duration}s") + print(f"Duration buffer: {odds_ticker.duration_buffer}") + print(f"Scroll speed: {odds_ticker.scroll_speed}") + print(f"Scroll delay: {odds_ticker.scroll_delay}") + print(f"Display width: {display_manager.matrix.width}") + + if not odds_ticker.is_enabled: + print("Odds ticker is disabled in config. Enabling for test...") + odds_ticker.is_enabled = True + + # Temporarily disable favorite teams filter for testing + print("Temporarily disabling favorite teams filter to test display...") + original_show_favorite = odds_ticker.show_favorite_teams_only + odds_ticker.show_favorite_teams_only = False + + # Update odds ticker data + print("\nUpdating odds ticker data...") + odds_ticker.update() + + print(f"Found {len(odds_ticker.games_data)} games") + + if odds_ticker.games_data: + print("\nSample game data:") + for i, game in enumerate(odds_ticker.games_data[:3]): # Show first 3 games + print(f" Game {i+1}: {game.get('away_team', 'Unknown')} @ {game.get('home_team', 'Unknown')}") + print(f" Time: {game.get('start_time', 'Unknown')}") + print(f" League: {game.get('league', 'Unknown')}") + print(f" Sport: {game.get('sport', 'Unknown')}") + if game.get('odds'): + print(f" Has odds: Yes") + else: + print(f" Has odds: No") + print(f" Available keys: {list(game.keys())}") + print() + + # Check dynamic duration calculation + print("\nDynamic Duration Analysis:") + print(f"Total scroll width: {odds_ticker.total_scroll_width}px") + print(f"Calculated dynamic duration: {odds_ticker.dynamic_duration}s") + + # Calculate expected duration manually + display_width = display_manager.matrix.width + total_scroll_distance = display_width + odds_ticker.total_scroll_width + frames_needed = total_scroll_distance / odds_ticker.scroll_speed + total_time = frames_needed * odds_ticker.scroll_delay + buffer_time = total_time * odds_ticker.duration_buffer + calculated_duration = int(total_time + buffer_time) + + print(f"\nManual calculation:") + print(f" Display width: {display_width}px") + print(f" Content width: {odds_ticker.total_scroll_width}px") + print(f" Total scroll distance: {total_scroll_distance}px") + print(f" Frames needed: {frames_needed:.1f}") + print(f" Base time: {total_time:.2f}s") + print(f" Buffer time: {buffer_time:.2f}s ({odds_ticker.duration_buffer*100}%)") + print(f" Calculated duration: {calculated_duration}s") + + # Test display for a few iterations + print(f"\nTesting display for 10 iterations...") + for i in range(10): + print(f" Display iteration {i+1} starting...") + odds_ticker.display() + print(f" Display iteration {i+1} complete - scroll position: {odds_ticker.scroll_position}") + time.sleep(1) + + else: + print("No games found even with favorite teams filter disabled.") + + # Restore original setting + odds_ticker.show_favorite_teams_only = original_show_favorite + + # Cleanup + display_manager.cleanup() + print("\nTest completed successfully!") + + except Exception as e: + print(f"Error during test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_dynamic_duration() diff --git a/test/test_odds_ticker_live.py b/test/test_odds_ticker_live.py new file mode 100644 index 00000000..b65ed0e2 --- /dev/null +++ b/test/test_odds_ticker_live.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Test script to verify odds ticker live game functionality. +""" + +import sys +import os +import json +import requests +from datetime import datetime, timezone + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from odds_ticker_manager import OddsTickerManager +from display_manager import DisplayManager +from cache_manager import CacheManager +from config_manager import ConfigManager + +def test_live_game_detection(): + """Test that the odds ticker can detect live games.""" + print("Testing live game detection in odds ticker...") + + # Create a minimal config for testing + config = { + 'odds_ticker': { + 'enabled': True, + 'enabled_leagues': ['mlb', 'nfl', 'nba'], + 'show_favorite_teams_only': False, + 'max_games_per_league': 3, + 'show_odds_only': False, + 'update_interval': 300, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 30, + 'future_fetch_days': 1, + 'loop': True, + 'show_channel_logos': True, + 'broadcast_logo_height_ratio': 0.8, + 'broadcast_logo_max_width_ratio': 0.8, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1 + }, + 'timezone': 'UTC', + 'mlb': { + 'enabled': True, + 'favorite_teams': [] + }, + 'nfl_scoreboard': { + 'enabled': True, + 'favorite_teams': [] + }, + 'nba_scoreboard': { + 'enabled': True, + 'favorite_teams': [] + } + } + + # Create mock display manager + class MockDisplayManager: + def __init__(self): + self.matrix = MockMatrix() + self.image = None + self.draw = None + + def update_display(self): + pass + + def is_currently_scrolling(self): + return False + + def set_scrolling_state(self, state): + pass + + def defer_update(self, func, priority=0): + pass + + def process_deferred_updates(self): + pass + + class MockMatrix: + def __init__(self): + self.width = 128 + self.height = 32 + + # Create managers + display_manager = MockDisplayManager() + cache_manager = CacheManager() + config_manager = ConfigManager() + + # Create odds ticker manager + odds_ticker = OddsTickerManager(config, display_manager) + + # Test fetching games + print("Fetching games...") + games = odds_ticker._fetch_upcoming_games() + + print(f"Found {len(games)} total games") + + # Check for live games + live_games = [game for game in games if game.get('status_state') == 'in'] + scheduled_games = [game for game in games if game.get('status_state') != 'in'] + + print(f"Live games: {len(live_games)}") + print(f"Scheduled games: {len(scheduled_games)}") + + # Display live games + for i, game in enumerate(live_games[:3]): # Show first 3 live games + print(f"\nLive Game {i+1}:") + print(f" Teams: {game['away_team']} @ {game['home_team']}") + print(f" Status: {game.get('status')} (State: {game.get('status_state')})") + + live_info = game.get('live_info') + if live_info: + print(f" Score: {live_info.get('away_score', 0)} - {live_info.get('home_score', 0)}") + print(f" Period: {live_info.get('period', 'N/A')}") + print(f" Clock: {live_info.get('clock', 'N/A')}") + print(f" Detail: {live_info.get('detail', 'N/A')}") + + # Sport-specific info + sport = None + for league_key, league_config in odds_ticker.league_configs.items(): + if league_config.get('logo_dir') == game.get('logo_dir'): + sport = league_config.get('sport') + break + + if sport == 'baseball': + print(f" Inning: {live_info.get('inning_half', 'N/A')} {live_info.get('inning', 'N/A')}") + print(f" Count: {live_info.get('balls', 0)}-{live_info.get('strikes', 0)}") + print(f" Outs: {live_info.get('outs', 0)}") + print(f" Bases: {live_info.get('bases_occupied', [])}") + elif sport == 'football': + print(f" Quarter: {live_info.get('quarter', 'N/A')}") + print(f" Down: {live_info.get('down', 'N/A')} & {live_info.get('distance', 'N/A')}") + print(f" Yard Line: {live_info.get('yard_line', 'N/A')}") + print(f" Possession: {live_info.get('possession', 'N/A')}") + elif sport == 'basketball': + print(f" Quarter: {live_info.get('quarter', 'N/A')}") + print(f" Time: {live_info.get('time_remaining', 'N/A')}") + print(f" Possession: {live_info.get('possession', 'N/A')}") + elif sport == 'hockey': + print(f" Period: {live_info.get('period', 'N/A')}") + print(f" Time: {live_info.get('time_remaining', 'N/A')}") + print(f" Power Play: {live_info.get('power_play', False)}") + else: + print(" No live info available") + + # Test formatting + print("\nTesting text formatting...") + for game in live_games[:2]: # Test first 2 live games + formatted_text = odds_ticker._format_odds_text(game) + print(f"Formatted text: {formatted_text}") + + # Test image creation + print("\nTesting image creation...") + if games: + try: + odds_ticker.games_data = games[:3] # Use first 3 games + odds_ticker._create_ticker_image() + if odds_ticker.ticker_image: + print(f"Successfully created ticker image: {odds_ticker.ticker_image.size}") + else: + print("Failed to create ticker image") + except Exception as e: + print(f"Error creating ticker image: {e}") + + print("\nTest completed!") + +if __name__ == "__main__": + test_live_game_detection() diff --git a/test/test_ranking_toggle.py b/test/test_ranking_toggle.py new file mode 100644 index 00000000..703e32e7 --- /dev/null +++ b/test/test_ranking_toggle.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate the new ranking/record toggle functionality +for both the leaderboard manager and NCAA FB managers. +""" + +import sys +import os +import json +import time +from typing import Dict, Any + +# Add the src directory to the path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from leaderboard_manager import LeaderboardManager +from ncaa_fb_managers import BaseNCAAFBManager +from cache_manager import CacheManager +from config_manager import ConfigManager + +def test_leaderboard_ranking_toggle(): + """Test the leaderboard manager ranking toggle functionality.""" + + print("Testing Leaderboard Manager Ranking Toggle") + print("=" * 50) + + # Create a mock display manager + class MockDisplayManager: + def __init__(self): + self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() + self.image = None + self.draw = None + + def update_display(self): + pass + + def set_scrolling_state(self, scrolling): + pass + + def process_deferred_updates(self): + pass + + # Test configuration with show_ranking enabled + config_ranking_enabled = { + 'leaderboard': { + 'enabled': True, + 'enabled_sports': { + 'ncaa_fb': { + 'enabled': True, + 'top_teams': 10, + 'show_ranking': True # Show rankings + } + }, + 'update_interval': 3600, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 60, + 'loop': True, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1, + 'time_per_team': 2.0, + 'time_per_league': 3.0 + } + } + + # Test configuration with show_ranking disabled + config_ranking_disabled = { + 'leaderboard': { + 'enabled': True, + 'enabled_sports': { + 'ncaa_fb': { + 'enabled': True, + 'top_teams': 10, + 'show_ranking': False # Show records + } + }, + 'update_interval': 3600, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 60, + 'loop': True, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1, + 'time_per_team': 2.0, + 'time_per_league': 3.0 + } + } + + try: + display_manager = MockDisplayManager() + + # Test with ranking enabled + print("1. Testing with show_ranking = True") + leaderboard_manager = LeaderboardManager(config_ranking_enabled, display_manager) + ncaa_fb_config = leaderboard_manager.league_configs['ncaa_fb'] + print(f" show_ranking config: {ncaa_fb_config.get('show_ranking', 'Not set')}") + + standings = leaderboard_manager._fetch_standings(ncaa_fb_config) + if standings: + print(f" Fetched {len(standings)} teams") + print(" Top 5 teams with rankings:") + for i, team in enumerate(standings[:5]): + rank = team.get('rank', 'N/A') + record = team.get('record_summary', 'N/A') + print(f" {i+1}. {team['name']} ({team['abbreviation']}) - Rank: #{rank}, Record: {record}") + + print("\n2. Testing with show_ranking = False") + leaderboard_manager = LeaderboardManager(config_ranking_disabled, display_manager) + ncaa_fb_config = leaderboard_manager.league_configs['ncaa_fb'] + print(f" show_ranking config: {ncaa_fb_config.get('show_ranking', 'Not set')}") + + standings = leaderboard_manager._fetch_standings(ncaa_fb_config) + if standings: + print(f" Fetched {len(standings)} teams") + print(" Top 5 teams with records:") + for i, team in enumerate(standings[:5]): + rank = team.get('rank', 'N/A') + record = team.get('record_summary', 'N/A') + print(f" {i+1}. {team['name']} ({team['abbreviation']}) - Rank: #{rank}, Record: {record}") + + print("\n✓ Leaderboard ranking toggle test completed!") + return True + + except Exception as e: + print(f"✗ Error testing leaderboard ranking toggle: {e}") + import traceback + traceback.print_exc() + return False + +def test_ncaa_fb_ranking_toggle(): + """Test the NCAA FB manager ranking toggle functionality.""" + + print("\nTesting NCAA FB Manager Ranking Toggle") + print("=" * 50) + + # Create a mock display manager + class MockDisplayManager: + def __init__(self): + self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() + self.image = None + self.draw = None + + def update_display(self): + pass + + def set_scrolling_state(self, scrolling): + pass + + def process_deferred_updates(self): + pass + + # Test configurations + configs = [ + { + 'name': 'show_ranking=true, show_records=true', + 'config': { + 'ncaa_fb_scoreboard': { + 'enabled': True, + 'show_records': True, + 'show_ranking': True, + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'display_modes': { + 'ncaa_fb_live': True, + 'ncaa_fb_recent': True, + 'ncaa_fb_upcoming': True + } + } + } + }, + { + 'name': 'show_ranking=true, show_records=false', + 'config': { + 'ncaa_fb_scoreboard': { + 'enabled': True, + 'show_records': False, + 'show_ranking': True, + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'display_modes': { + 'ncaa_fb_live': True, + 'ncaa_fb_recent': True, + 'ncaa_fb_upcoming': True + } + } + } + }, + { + 'name': 'show_ranking=false, show_records=true', + 'config': { + 'ncaa_fb_scoreboard': { + 'enabled': True, + 'show_records': True, + 'show_ranking': False, + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'display_modes': { + 'ncaa_fb_live': True, + 'ncaa_fb_recent': True, + 'ncaa_fb_upcoming': True + } + } + } + }, + { + 'name': 'show_ranking=false, show_records=false', + 'config': { + 'ncaa_fb_scoreboard': { + 'enabled': True, + 'show_records': False, + 'show_ranking': False, + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'display_modes': { + 'ncaa_fb_live': True, + 'ncaa_fb_recent': True, + 'ncaa_fb_upcoming': True + } + } + } + } + ] + + try: + display_manager = MockDisplayManager() + cache_manager = CacheManager() + + for i, test_config in enumerate(configs, 1): + print(f"{i}. Testing: {test_config['name']}") + ncaa_fb_manager = BaseNCAAFBManager(test_config['config'], display_manager, cache_manager) + print(f" show_records: {ncaa_fb_manager.show_records}") + print(f" show_ranking: {ncaa_fb_manager.show_ranking}") + + # Test fetching rankings + rankings = ncaa_fb_manager._fetch_team_rankings() + if rankings: + print(f" Fetched rankings for {len(rankings)} teams") + print(" Sample rankings:") + for j, (team_abbr, rank) in enumerate(list(rankings.items())[:3]): + print(f" {team_abbr}: #{rank}") + print() + + print("✓ NCAA FB ranking toggle test completed!") + print("\nLogic Summary:") + print("- show_ranking=true, show_records=true: Shows #5 if ranked, 2-0 if unranked") + print("- show_ranking=true, show_records=false: Shows #5 if ranked, nothing if unranked") + print("- show_ranking=false, show_records=true: Shows 2-0 (record)") + print("- show_ranking=false, show_records=false: Shows nothing") + return True + + except Exception as e: + print(f"✗ Error testing NCAA FB ranking toggle: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + """Main function to run all tests.""" + print("NCAA Football Ranking/Record Toggle Test") + print("=" * 60) + print("This test demonstrates the new functionality:") + print("- Leaderboard manager can show poll rankings (#5) or records (2-0)") + print("- NCAA FB managers can show poll rankings (#5) or records (2-0)") + print("- Configuration controls which is displayed") + print() + + try: + success1 = test_leaderboard_ranking_toggle() + success2 = test_ncaa_fb_ranking_toggle() + + if success1 and success2: + print("\n🎉 All tests passed! The ranking/record toggle is working correctly.") + print("\nConfiguration Summary:") + print("- Set 'show_ranking': true in config to show poll rankings (#5)") + print("- Set 'show_ranking': false in config to show season records (2-0)") + print("- Works in both leaderboard and NCAA FB scoreboard managers") + else: + print("\n❌ Some tests failed. Please check the errors above.") + except KeyboardInterrupt: + print("\nTest interrupted by user") + except Exception as e: + print(f"Error running tests: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/test/test_standings_fetch.py b/test/test_standings_fetch.py new file mode 100644 index 00000000..3b5d7b80 --- /dev/null +++ b/test/test_standings_fetch.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +""" +Test script to verify the standings fetching logic works correctly. +This tests the core functionality without requiring the full LED matrix setup. +""" + +import requests +import json +import time +from typing import Dict, Any, List + +def fetch_standings_data(league_config: Dict[str, Any]) -> List[Dict[str, Any]]: + """Fetch standings data from ESPN API using the standings endpoint.""" + league_key = league_config['league'] + + try: + print(f"Fetching fresh standings data for {league_key}") + + # Build the standings URL with query parameters + standings_url = league_config['standings_url'] + params = { + 'season': league_config.get('season', 2024), + 'level': league_config.get('level', 1), + 'sort': league_config.get('sort', 'winpercent:desc,gamesbehind:asc') + } + + print(f"Fetching standings from: {standings_url} with params: {params}") + + response = requests.get(standings_url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + standings = [] + + # Parse the standings data structure + # Check if we have direct standings data or children (divisions/conferences) + if 'standings' in data and 'entries' in data['standings']: + # Direct standings data (e.g., NFL overall standings) + standings_data = data['standings']['entries'] + print(f"Processing direct standings data with {len(standings_data)} teams") + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': 'Overall' + }) + + elif 'children' in data: + # Children structure (divisions/conferences) + children = data.get('children', []) + print(f"Processing {len(children)} divisions/conferences") + + for child in children: + child_name = child.get('displayName', 'Unknown') + print(f"Processing {child_name}") + + standings_data = child.get('standings', {}).get('entries', []) + + for entry in standings_data: + team_data = entry.get('team', {}) + stats = entry.get('stats', []) + + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Extract record from stats + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in stats: + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + # Create record summary + if ties > 0: + record_summary = f"{wins}-{losses}-{ties}" + else: + record_summary = f"{wins}-{losses}" + + standings.append({ + 'name': team_name, + 'abbreviation': team_abbr, + 'wins': wins, + 'losses': losses, + 'ties': ties, + 'win_percentage': win_percentage, + 'record_summary': record_summary, + 'division': child_name + }) + else: + print(f"No standings or children data found for {league_key}") + return [] + + # Sort by win percentage (descending) and limit to top teams + standings.sort(key=lambda x: x['win_percentage'], reverse=True) + top_teams = standings[:league_config['top_teams']] + + print(f"Fetched and processed {len(top_teams)} teams for {league_key} standings") + return top_teams + + except Exception as e: + print(f"Error fetching standings for {league_key}: {e}") + return [] + +def test_standings_fetch(): + """Test the standings fetching functionality.""" + print("Testing Standings Fetching Logic") + print("=" * 50) + + # Test configurations + test_configs = [ + { + 'name': 'NFL', + 'config': { + 'league': 'nfl', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/football/nfl/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'MLB', + 'config': { + 'league': 'mlb', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'NHL', + 'config': { + 'league': 'nhl', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + }, + { + 'name': 'NCAA Baseball', + 'config': { + 'league': 'college-baseball', + 'standings_url': 'https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings', + 'top_teams': 5, + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + } + ] + + results = [] + + for test_config in test_configs: + print(f"\n--- Testing {test_config['name']} ---") + + standings = fetch_standings_data(test_config['config']) + + if standings: + print(f"✓ Successfully fetched {len(standings)} teams") + print(f"Top {len(standings)} teams:") + for i, team in enumerate(standings): + print(f" {i+1}. {team['name']} ({team['abbreviation']}): {team['record_summary']} ({team['win_percentage']:.3f})") + results.append(True) + else: + print(f"✗ Failed to fetch standings for {test_config['name']}") + results.append(False) + + # Summary + passed = sum(results) + total = len(results) + + print(f"\n=== Test Results ===") + print(f"Passed: {passed}/{total}") + + if passed == total: + print("✓ All standings fetch tests passed!") + return True + else: + print("✗ Some tests failed!") + return False + +if __name__ == "__main__": + success = test_standings_fetch() + exit(0 if success else 1) diff --git a/test/test_standings_simple.py b/test/test_standings_simple.py new file mode 100644 index 00000000..bde287de --- /dev/null +++ b/test/test_standings_simple.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +""" +Simple test script to verify the ESPN standings endpoints work correctly. +""" + +import requests +import json + +def test_nfl_standings(): + """Test NFL standings endpoint with corrected parsing.""" + print("\n=== Testing NFL Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/football/nfl/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NFL standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NFL standings: {e}") + return False + +def test_mlb_standings(): + """Test MLB standings endpoint with corrected parsing.""" + print("\n=== Testing MLB Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/baseball/mlb/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched MLB standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing MLB standings: {e}") + return False + +def test_nhl_standings(): + """Test NHL standings endpoint with corrected parsing.""" + print("\n=== Testing NHL Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/hockey/nhl/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NHL standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record with NHL-specific parsing + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + games_played = 0 + + # First pass: collect all stat values + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + # NHL specific stats + elif stat_type == 'overtimelosses': + ties = int(stat_value) # NHL uses overtime losses as ties + elif stat_type == 'gamesplayed': + games_played = float(stat_value) + + # Second pass: calculate win percentage for NHL if not already set + if win_percentage == 0.0 and games_played > 0: + win_percentage = wins / games_played + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NHL standings: {e}") + return False + +def test_ncaa_baseball_standings(): + """Test NCAA Baseball standings endpoint with corrected parsing.""" + print("\n=== Testing NCAA Baseball Standings ===") + + url = "https://site.api.espn.com/apis/v2/sports/baseball/college-baseball/standings" + params = { + 'season': 2025, + 'level': 1, + 'sort': 'winpercent:desc,gamesbehind:asc' + } + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + print(f"✓ Successfully fetched NCAA Baseball standings") + + # Check for direct standings data + if 'standings' in data and 'entries' in data['standings']: + standings_data = data['standings']['entries'] + print(f" Found {len(standings_data)} teams in direct standings") + + # Show top 5 teams + print(f" Top 5 teams:") + for i, entry in enumerate(standings_data[:5]): + team_data = entry.get('team', {}) + team_name = team_data.get('displayName', 'Unknown') + team_abbr = team_data.get('abbreviation', 'Unknown') + + # Get record + wins = 0 + losses = 0 + ties = 0 + win_percentage = 0.0 + + for stat in entry.get('stats', []): + stat_type = stat.get('type', '') + stat_value = stat.get('value', 0) + + if stat_type == 'wins': + wins = int(stat_value) + elif stat_type == 'losses': + losses = int(stat_value) + elif stat_type == 'ties': + ties = int(stat_value) + elif stat_type == 'winpercent': + win_percentage = float(stat_value) + + record = f"{wins}-{losses}" if ties == 0 else f"{wins}-{losses}-{ties}" + print(f" {i+1}. {team_name} ({team_abbr}): {record} ({win_percentage:.3f})") + + return True + else: + print(" ✗ No direct standings data found") + return False + + except Exception as e: + print(f"✗ Error testing NCAA Baseball standings: {e}") + return False + +def main(): + """Main function to run all tests.""" + print("ESPN Standings Endpoints Test (Corrected)") + print("=" * 50) + + results = [] + + # Test individual endpoints + results.append(test_nfl_standings()) + results.append(test_mlb_standings()) + results.append(test_nhl_standings()) + results.append(test_ncaa_baseball_standings()) + + # Summary + passed = sum(results) + total = len(results) + + print(f"\n=== Test Results ===") + print(f"Passed: {passed}/{total}") + + if passed == total: + print("✓ All tests passed!") + return True + else: + print("✗ Some tests failed!") + return False + +if __name__ == "__main__": + success = main() + exit(0 if success else 1) diff --git a/test/test_updated_leaderboard_manager.py b/test/test_updated_leaderboard_manager.py new file mode 100644 index 00000000..b09a9e39 --- /dev/null +++ b/test/test_updated_leaderboard_manager.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +Test script to verify the updated leaderboard manager works correctly +with the new NCAA Football rankings endpoint. +""" + +import sys +import os +import json +import time +from typing import Dict, Any + +# Add the src directory to the path so we can import our modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from leaderboard_manager import LeaderboardManager +from cache_manager import CacheManager +from config_manager import ConfigManager + +def test_updated_leaderboard_manager(): + """Test the updated leaderboard manager with NCAA Football rankings.""" + + print("Testing Updated Leaderboard Manager") + print("=" * 50) + + # Create a mock display manager (we don't need the actual hardware for this test) + class MockDisplayManager: + def __init__(self): + self.matrix = type('Matrix', (), {'width': 64, 'height': 32})() + self.image = None + self.draw = None + + def update_display(self): + pass + + def set_scrolling_state(self, scrolling): + pass + + def process_deferred_updates(self): + pass + + # Create test configuration + test_config = { + 'leaderboard': { + 'enabled': True, + 'enabled_sports': { + 'ncaa_fb': { + 'enabled': True, + 'top_teams': 10 + } + }, + 'update_interval': 3600, + 'scroll_speed': 2, + 'scroll_delay': 0.05, + 'display_duration': 60, + 'loop': True, + 'request_timeout': 30, + 'dynamic_duration': True, + 'min_duration': 30, + 'max_duration': 300, + 'duration_buffer': 0.1, + 'time_per_team': 2.0, + 'time_per_league': 3.0 + } + } + + try: + # Initialize the leaderboard manager + print("Initializing LeaderboardManager...") + display_manager = MockDisplayManager() + leaderboard_manager = LeaderboardManager(test_config, display_manager) + + print(f"Leaderboard enabled: {leaderboard_manager.is_enabled}") + print(f"Enabled sports: {[k for k, v in leaderboard_manager.enabled_sports.items() if v.get('enabled', False)]}") + + # Test the NCAA Football rankings fetch + print("\nTesting NCAA Football rankings fetch...") + ncaa_fb_config = leaderboard_manager.league_configs['ncaa_fb'] + print(f"NCAA FB config: {ncaa_fb_config}") + + # Fetch standings using the new method + standings = leaderboard_manager._fetch_standings(ncaa_fb_config) + + if standings: + print(f"\nSuccessfully fetched {len(standings)} teams") + print("\nTop 10 NCAA Football Teams (from rankings):") + print("-" * 60) + print(f"{'Rank':<4} {'Team':<25} {'Abbr':<6} {'Record':<12} {'Win %':<8}") + print("-" * 60) + + for team in standings: + record_str = f"{team['wins']}-{team['losses']}" + if team['ties'] > 0: + record_str += f"-{team['ties']}" + + win_pct = team['win_percentage'] + win_pct_str = f"{win_pct:.3f}" if win_pct > 0 else "0.000" + + print(f"{team.get('rank', 'N/A'):<4} {team['name']:<25} {team['abbreviation']:<6} {record_str:<12} {win_pct_str:<8}") + + print("-" * 60) + + # Show additional info + ranking_name = standings[0].get('ranking_name', 'Unknown') if standings else 'Unknown' + print(f"Ranking system used: {ranking_name}") + print(f"Data fetched at: {time.strftime('%Y-%m-%d %H:%M:%S')}") + + # Test caching + print(f"\nTesting caching...") + cached_standings = leaderboard_manager._fetch_standings(ncaa_fb_config) + if cached_standings: + print("✓ Caching works correctly - data retrieved from cache") + else: + print("✗ Caching issue - no data retrieved from cache") + + else: + print("✗ No standings data retrieved") + return False + + print("\n✓ Leaderboard manager test completed successfully!") + return True + + except Exception as e: + print(f"✗ Error testing leaderboard manager: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + """Main function to run the test.""" + try: + success = test_updated_leaderboard_manager() + if success: + print("\n🎉 All tests passed! The updated leaderboard manager is working correctly.") + else: + print("\n❌ Tests failed. Please check the errors above.") + except KeyboardInterrupt: + print("\nTest interrupted by user") + except Exception as e: + print(f"Error running test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/test_graceful_updates.py b/test_graceful_updates.py new file mode 100644 index 00000000..3014bb45 --- /dev/null +++ b/test_graceful_updates.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +""" +Test script to demonstrate the graceful update system for scrolling displays. +This script shows how updates are deferred during scrolling periods to prevent lag. +""" + +import time +import logging +import sys +import os + +# Add the project root directory to Python path +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# Configure logging first +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', + datefmt='%H:%M:%S', + stream=sys.stdout +) + +logger = logging.getLogger(__name__) + +# Mock rgbmatrix module for testing on non-Raspberry Pi systems +try: + from rgbmatrix import RGBMatrix, RGBMatrixOptions +except ImportError: + logger.info("rgbmatrix module not available, using mock for testing") + + class MockRGBMatrixOptions: + def __init__(self): + self.rows = 32 + self.cols = 64 + self.chain_length = 2 + self.parallel = 1 + self.hardware_mapping = 'adafruit-hat-pwm' + self.brightness = 90 + self.pwm_bits = 10 + self.pwm_lsb_nanoseconds = 150 + self.led_rgb_sequence = 'RGB' + self.pixel_mapper_config = '' + self.row_address_type = 0 + self.multiplexing = 0 + self.disable_hardware_pulsing = False + self.show_refresh_rate = False + self.limit_refresh_rate_hz = 90 + self.gpio_slowdown = 2 + + class MockRGBMatrix: + def __init__(self, options=None): + self.width = 128 # 64 * 2 chain length + self.height = 32 + + def CreateFrameCanvas(self): + return MockCanvas() + + def SwapOnVSync(self, canvas, dont_wait=False): + pass + + def Clear(self): + pass + + class MockCanvas: + def __init__(self): + self.width = 128 + self.height = 32 + + def SetImage(self, image): + pass + + def Clear(self): + pass + + RGBMatrix = MockRGBMatrix + RGBMatrixOptions = MockRGBMatrixOptions + +from src.display_manager import DisplayManager +from src.config_manager import ConfigManager + +def simulate_scrolling_display(display_manager, duration=10): + """Simulate a scrolling display for testing.""" + logger.info(f"Starting scrolling simulation for {duration} seconds") + + start_time = time.time() + while time.time() - start_time < duration: + # Signal that we're scrolling + display_manager.set_scrolling_state(True) + + # Simulate some scrolling work + time.sleep(0.1) + + # Every 2 seconds, try to defer an update + if int(time.time() - start_time) % 2 == 0: + logger.info("Attempting to defer an update during scrolling") + display_manager.defer_update( + lambda: logger.info("This update was deferred and executed later!"), + priority=1 + ) + + # Signal that scrolling has stopped + display_manager.set_scrolling_state(False) + logger.info("Scrolling simulation completed") + +def test_graceful_updates(): + """Test the graceful update system.""" + logger.info("Testing graceful update system") + + # Load config + config_manager = ConfigManager() + config = config_manager.load_config() + + # Initialize display manager + display_manager = DisplayManager(config, force_fallback=True) + + try: + # Test 1: Defer updates during scrolling + logger.info("=== Test 1: Defer updates during scrolling ===") + + # Add some deferred updates + display_manager.defer_update( + lambda: logger.info("Update 1: High priority update"), + priority=1 + ) + display_manager.defer_update( + lambda: logger.info("Update 2: Medium priority update"), + priority=2 + ) + display_manager.defer_update( + lambda: logger.info("Update 3: Low priority update"), + priority=3 + ) + + # Start scrolling simulation + simulate_scrolling_display(display_manager, duration=5) + + # Check scrolling stats + stats = display_manager.get_scrolling_stats() + logger.info(f"Scrolling stats: {stats}") + + # Test 2: Process deferred updates when not scrolling + logger.info("=== Test 2: Process deferred updates when not scrolling ===") + + # Process any remaining deferred updates + display_manager.process_deferred_updates() + + # Test 3: Test inactivity threshold + logger.info("=== Test 3: Test inactivity threshold ===") + + # Signal scrolling started + display_manager.set_scrolling_state(True) + logger.info(f"Is scrolling: {display_manager.is_currently_scrolling()}") + + # Wait longer than the inactivity threshold + time.sleep(3) + logger.info(f"Is scrolling after inactivity: {display_manager.is_currently_scrolling()}") + + # Test 4: Test priority ordering + logger.info("=== Test 4: Test priority ordering ===") + + # Add updates in reverse priority order + display_manager.defer_update( + lambda: logger.info("Priority 3 update"), + priority=3 + ) + display_manager.defer_update( + lambda: logger.info("Priority 1 update"), + priority=1 + ) + display_manager.defer_update( + lambda: logger.info("Priority 2 update"), + priority=2 + ) + + # Process them (should execute in priority order: 1, 2, 3) + display_manager.process_deferred_updates() + + logger.info("All tests completed successfully!") + + except Exception as e: + logger.error(f"Test failed: {e}", exc_info=True) + finally: + # Cleanup + display_manager.cleanup() + +if __name__ == "__main__": + test_graceful_updates() diff --git a/web_interface_v2.py b/web_interface_v2.py index 53b88951..d255547a 100644 --- a/web_interface_v2.py +++ b/web_interface_v2.py @@ -815,6 +815,9 @@ api_counters = { 'stocks': {'used': 0}, 'sports': {'used': 0}, 'news': {'used': 0}, + 'odds': {'used': 0}, + 'music': {'used': 0}, + 'youtube': {'used': 0}, } api_window_start = time.time() api_window_seconds = 24 * 3600 @@ -875,6 +878,27 @@ def get_metrics(): except Exception: forecast['news'] = 0 + # Odds ticker + try: + o_int = int(config.get('odds_ticker', {}).get('update_interval', 3600)) + forecast['odds'] = max(1, int(api_window_seconds / max(1, o_int))) + except Exception: + forecast['odds'] = 0 + + # Music manager (image downloads) + try: + m_int = int(config.get('music', {}).get('POLLING_INTERVAL_SECONDS', 5)) + forecast['music'] = max(1, int(api_window_seconds / max(1, m_int))) + except Exception: + forecast['music'] = 0 + + # YouTube display + try: + y_int = int(config.get('youtube', {}).get('update_interval', 300)) + forecast['youtube'] = max(1, int(api_window_seconds / max(1, y_int))) + except Exception: + forecast['youtube'] = 0 + return jsonify({ 'status': 'success', 'window_seconds': api_window_seconds, diff --git a/wiki/GRACEFUL_UPDATES.md b/wiki/GRACEFUL_UPDATES.md new file mode 100644 index 00000000..ec16d06f --- /dev/null +++ b/wiki/GRACEFUL_UPDATES.md @@ -0,0 +1,146 @@ +# Graceful Update System + +The LED Matrix project now includes a graceful update system that prevents lag during scrolling displays by deferring updates until the display is not actively scrolling. + +## Overview + +When displays like the odds ticker, stock ticker, or news ticker are actively scrolling, performing API updates or data fetching can cause visual lag or stuttering. The graceful update system solves this by: + +1. **Tracking scrolling state** - The system monitors when displays are actively scrolling +2. **Deferring updates** - Updates that might cause lag are deferred during scrolling periods +3. **Processing when safe** - Deferred updates are processed when scrolling stops or during non-scrolling periods +4. **Priority-based execution** - Updates are executed in priority order when processed + +## How It Works + +### Scrolling State Tracking + +The `DisplayManager` class now includes scrolling state tracking: + +```python +# Signal when scrolling starts +display_manager.set_scrolling_state(True) + +# Signal when scrolling stops +display_manager.set_scrolling_state(False) + +# Check if currently scrolling +if display_manager.is_currently_scrolling(): + # Defer updates + pass +``` + +### Deferred Updates + +Updates can be deferred using the `defer_update` method: + +```python +# Defer an update with priority +display_manager.defer_update( + lambda: self._perform_update(), + priority=1 # Lower numbers = higher priority +) +``` + +### Automatic Processing + +Deferred updates are automatically processed when: +- A display signals it's not scrolling +- The main loop processes updates during non-scrolling periods +- The inactivity threshold is reached (default: 2 seconds) + +## Implementation Details + +### Display Manager Changes + +The `DisplayManager` class now includes: + +- `set_scrolling_state(is_scrolling)` - Signal scrolling state changes +- `is_currently_scrolling()` - Check if display is currently scrolling +- `defer_update(update_func, priority)` - Defer an update function +- `process_deferred_updates()` - Process all pending deferred updates +- `get_scrolling_stats()` - Get current scrolling statistics + +### Manager Updates + +The following managers have been updated to use the graceful update system: + +#### Odds Ticker Manager +- Defers API updates during scrolling +- Signals scrolling state during display +- Processes deferred updates when not scrolling + +#### Stock Manager +- Defers stock data updates during scrolling +- Always signals scrolling state (continuous scrolling) +- Priority 2 for stock updates + +#### Stock News Manager +- Defers news data updates during scrolling +- Signals scrolling state during display +- Priority 2 for news updates + +### Display Controller Changes + +The main display controller now: +- Checks scrolling state before updating modules +- Defers scrolling-sensitive updates during scrolling periods +- Processes deferred updates in the main loop +- Continues non-scrolling-sensitive updates normally + +## Configuration + +The system uses these default settings: + +- **Inactivity threshold**: 2.0 seconds +- **Update priorities**: + - Priority 1: Odds ticker updates + - Priority 2: Stock and news updates + - Priority 3+: Other updates + +## Benefits + +1. **Smoother Scrolling** - No more lag during ticker scrolling +2. **Better User Experience** - Displays remain responsive during updates +3. **Efficient Resource Usage** - Updates happen when the system is idle +4. **Priority-Based** - Important updates are processed first +5. **Automatic** - No manual intervention required + +## Testing + +You can test the graceful update system using the provided test script: + +```bash +python test_graceful_updates.py +``` + +This script demonstrates: +- Deferring updates during scrolling +- Processing updates when not scrolling +- Priority-based execution +- Inactivity threshold behavior + +## Debugging + +To debug the graceful update system, enable debug logging: + +```python +import logging +logging.getLogger('src.display_manager').setLevel(logging.DEBUG) +``` + +The system will log: +- When scrolling state changes +- When updates are deferred +- When deferred updates are processed +- Current scrolling statistics + +## Future Enhancements + +Potential improvements to the system: + +1. **Configurable thresholds** - Allow users to adjust inactivity thresholds +2. **More granular priorities** - Add more priority levels for different update types +3. **Update batching** - Group similar updates to reduce processing overhead +4. **Performance metrics** - Track and report update deferral statistics +5. **Web interface integration** - Show deferred update status in the web UI