diff --git a/calendar/calander.py b/calendar/calander.py
new file mode 100644
index 000000000..82688760a
--- /dev/null
+++ b/calendar/calander.py
@@ -0,0 +1,140 @@
+import json
+from datetime import datetime
+
+INPUT_FILE = "events.json"
+OUTPUT_FILE = "calendar.html"
+MAX_EVENTS = 5
+
+
+def load_events():
+ with open(INPUT_FILE, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def format_event_html(event):
+ title = event.get("title", "Untitled Event")
+ date = event.get("date", "")
+ start_time = event.get("start_time", "")
+ end_time = event.get("end_time", "")
+ location = event.get("location", "No location listed")
+ description = event.get("description", "")
+ link = event.get("event_link", "")
+
+ time_text = start_time
+ if end_time:
+ time_text += f" - {end_time}"
+
+ if len(description) > 180:
+ description = description[:177] + "..."
+
+ link_html = ""
+ if link:
+ link_html = f"
+
{status_label}
+
{event["title"]}
+
Date: {event["start_date"]}
+
Time: {time_line}
+
Location: {event["location"]}
+ {description_html}
+
+ """
+
+
+def format_sidebar_events(events):
+ if not events:
+ return "No additional upcoming events.
"
+
+ html_parts = ["Also Coming Up
"]
+ for event in events:
+ time_line = "All Day" if event["all_day"] else event["start_time"]
+ html_parts.append(f"""
+
+
{event["title"]}
+
{event["start_date"]}
+
{time_line}
+
{event["location"]}
+
+ """)
+ html_parts.append("
")
+ return "".join(html_parts)
+
+
+def build_html(status_label, main_event, sidebar_events):
+ updated_at = datetime.now().strftime("%m/%d/%Y %I:%M %p")
+ main_html = format_main_event(main_event, status_label)
+ sidebar_html = format_sidebar_events(sidebar_events)
+
+ html = f"""
+
+
+
+
+
+
+
RPI Events
+
Current and upcoming campus events
+
+
+
+
+ {main_html}
+ {sidebar_html}
+
+
+
+
+
+
+
+"""
+ return html
+
+
+def write_html(html):
+ with open(OUTPUT_HTML, "w", encoding="utf-8") as f:
+ f.write(html)
+
+
+def update_display():
+ events = fetch_events()
+ status_label, main_event, sidebar_events = choose_events(events)
+ html = build_html(status_label, main_event, sidebar_events)
+ write_html(html)
+ print(f"Updated {OUTPUT_HTML} at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
+
+
+def main():
+ while True:
+ try:
+ update_display()
+ except Exception as e:
+ print("Error updating display:", e)
+
+ time.sleep(REFRESH_SECONDS)
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/calendar/fetch_events.py b/calendar/fetch_events.py
new file mode 100644
index 000000000..de531fbd8
--- /dev/null
+++ b/calendar/fetch_events.py
@@ -0,0 +1,63 @@
+import requests
+import json
+import re
+from datetime import datetime
+
+FEED_URL = "https://events.rpi.edu/feeder/main/eventsFeed.do?f=y&sort=dtstart.utc:asc&fexpr=(categories.href!=%22/public/.bedework/categories/Ongoing%22)%20and%20(entity_type=%22event%22%20or%20entity_type=%22todo%22)&skinName=list-json&setappvar=objName(bwObject)&count=10"
+OUTPUT_FILE = "events.json"
+
+
+def extract_bwobject(text):
+ """
+ Extract the JavaScript object from:
+ var bwObject = {...}
+ """
+ match = re.search(r'var\s+bwObject\s*=\s*(\{.*\})\s*$', text, re.DOTALL)
+ if not match:
+ raise ValueError("Could not find bwObject in feed response.")
+ return match.group(1)
+
+
+def clean_event(event):
+ start = event.get("start", {})
+ end = event.get("end", {})
+ location = event.get("location", {})
+
+ return {
+ "title": event.get("summary", ""),
+ "formatted_date": event.get("formattedDate", ""),
+ "date": start.get("longdate", ""),
+ "start_time": start.get("time", ""),
+ "end_time": end.get("time", ""),
+ "all_day": str(start.get("allday", "false")).lower() == "true",
+ "location": location.get("address", ""),
+ "description": event.get("description", ""),
+ "event_link": event.get("eventlink", ""),
+ "categories": event.get("categories", [])
+ }
+
+
+def main():
+ response = requests.get(FEED_URL, timeout=15)
+ response.raise_for_status()
+
+ raw_text = response.text
+ bwobject_text = extract_bwobject(raw_text)
+ bwobject = json.loads(bwobject_text)
+
+ raw_events = bwobject.get("bwEventList", {}).get("events", [])
+
+ cleaned = {
+ "title": "Upcoming Events",
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
+ "events": [clean_event(event) for event in raw_events]
+ }
+
+ with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
+ json.dump(cleaned, f, indent=2, ensure_ascii=False)
+
+ print(f"Saved {len(cleaned['events'])} events to {OUTPUT_FILE}")
+
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/feeds/mlb-feed/__pycache__/main.cpython-314.pyc b/feeds/mlb-feed/__pycache__/main.cpython-314.pyc
new file mode 100644
index 000000000..ef0a03575
Binary files /dev/null and b/feeds/mlb-feed/__pycache__/main.cpython-314.pyc differ
diff --git a/feeds/mlb-feed/concerto-mlb.service.txt b/feeds/mlb-feed/concerto-mlb.service.txt
new file mode 100644
index 000000000..f4662cd7f
--- /dev/null
+++ b/feeds/mlb-feed/concerto-mlb.service.txt
@@ -0,0 +1,13 @@
+[Unit]
+Description=Concerto MLB Feed
+After=network.target
+
+[Service]
+Type=simple
+WorkingDirectory=/path/to/python-mlb-feed
+ExecStart=/path/to/python-mlb-feed/start.sh
+Restart=always
+User=YOUR_USERNAME
+
+[Install]
+WantedBy=multi-user.target
\ No newline at end of file
diff --git a/feeds/mlb-feed/main.py b/feeds/mlb-feed/main.py
new file mode 100644
index 000000000..520dcbae7
--- /dev/null
+++ b/feeds/mlb-feed/main.py
@@ -0,0 +1,91 @@
+from typing import Dict, List
+from fastapi import FastAPI
+import requests
+import datetime
+
+app = FastAPI()
+
+
+MLB_SCHEDULE_URL = "https://statsapi.mlb.com/api/v1/schedule"
+
+
+def format_game_line(game: dict) -> str:
+ teams = game.get("teams", {})
+ away = teams.get("away", {})
+ home = teams.get("home", {})
+
+ away_team = away.get("team", {}).get("name", "Away")
+ home_team = home.get("team", {}).get("name", "Home")
+
+ away_score = away.get("score")
+ home_score = home.get("score")
+
+ status = game.get("status", {})
+ detailed_state = status.get("detailedState", "Scheduled")
+ abstract_state = status.get("abstractGameState", "Preview")
+
+ game_datetime = game.get("gameDate")
+ display_time = "TBD"
+
+ if game_datetime:
+ try:
+ dt = datetime.datetime.fromisoformat(game_datetime.replace("Z", "+00:00"))
+ display_time = dt.astimezone().strftime("%I:%M %p").lstrip("0")
+ except ValueError:
+ pass
+
+ if abstract_state == "Final":
+ return f"FINAL: {away_team} {away_score}, {home_team} {home_score}"
+ elif abstract_state == "Live":
+ return f"LIVE: {away_team} {away_score}, {home_team} {home_score} ({detailed_state})"
+ else:
+ return f"{display_time}: {away_team} at {home_team}"
+
+
+def get_mlb_games() -> List[str]:
+ today = datetime.datetime.now().strftime("%Y-%m-%d")
+
+ response = requests.get(
+ MLB_SCHEDULE_URL,
+ params={
+ "sportId": 1,
+ "date": today,
+ "hydrate": "linescore,team,flags",
+ },
+ timeout=20,
+ )
+
+ data = response.json()
+ dates = data.get("dates", [])
+ if not dates:
+ return ["No MLB games scheduled today."]
+
+ games = dates[0].get("games", [])
+ if not games:
+ return ["No MLB games scheduled today."]
+
+ lines = [format_game_line(game) for game in games[:12]]
+ return lines
+
+
+@app.get("/mlb.json")
+def read_mlb_scores() -> List[Dict[str, str]] | Dict[str, str]:
+ now = datetime.datetime.now()
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0)
+ end = now.replace(hour=23, minute=59, second=59, microsecond=999999)
+
+ try:
+ game_lines = get_mlb_games()
+ except Exception as e:
+ return {"Error": "Response Failed", "info": str(e)}
+
+ html = " | ".join(game_lines)
+
+ return [{
+ "name": "MLB Scores",
+ "type": "RichText",
+ "render_as": "html",
+ "start_time": start.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "end_time": end.strftime("%Y-%m-%dT%H:%M:%SZ"),
+ "text": html
+ }]
\ No newline at end of file
diff --git a/feeds/mlb-feed/requirements.txt b/feeds/mlb-feed/requirements.txt
new file mode 100644
index 000000000..b07ade6ba
--- /dev/null
+++ b/feeds/mlb-feed/requirements.txt
@@ -0,0 +1,2 @@
+fastapi[standard]
+requests
\ No newline at end of file
diff --git a/feeds/mlb-feed/start.sh b/feeds/mlb-feed/start.sh
new file mode 100644
index 000000000..3d35bb6b9
--- /dev/null
+++ b/feeds/mlb-feed/start.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+. .venv/bin/activate
+fastapi run --host 0.0.0.0 --port 43679
+deactivate
\ No newline at end of file