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 @@ + +