Add stats page, SEO improvements, and auto-sitemap updates
- Add podcast_stats.py with --json/--upload flags for BunnyCDN - Add website/stats.html fetching stats from CDN - Add stats CSS styles - SEO: shorten title/description, add og:site_name, twitter cards, theme-color, image dimensions, consistent favicons and cache-busting - Add all episode transcript pages to sitemap.xml with lastmod - Auto-add new episodes to sitemap in publish_episode.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
412
podcast_stats.py
Normal file
412
podcast_stats.py
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Podcast Stats — Aggregate reviews, comments, likes, and analytics from all platforms.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python podcast_stats.py # All platforms
|
||||||
|
python podcast_stats.py --youtube # YouTube only
|
||||||
|
python podcast_stats.py --apple # Apple Podcasts only
|
||||||
|
python podcast_stats.py --spotify # Spotify only
|
||||||
|
python podcast_stats.py --castopod # Castopod downloads only
|
||||||
|
python podcast_stats.py --comments # Include full YouTube comments
|
||||||
|
python podcast_stats.py --json # Output as JSON
|
||||||
|
python podcast_stats.py --json --upload # Output JSON and upload to BunnyCDN
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
YOUTUBE_PLAYLIST = "PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-"
|
||||||
|
APPLE_PODCAST_ID = "1875205848"
|
||||||
|
APPLE_STOREFRONTS = ["us", "gb", "ca", "au"]
|
||||||
|
SPOTIFY_SHOW_ID = "0ZrpMigG1fo0CCN7F4YmuF"
|
||||||
|
NAS_SSH = "luke@mmgnas-10g"
|
||||||
|
NAS_SSH_PORT = "8001"
|
||||||
|
DOCKER_BIN = "/share/CACHEDEV1_DATA/.qpkg/container-station/bin/docker"
|
||||||
|
CASTOPOD_DB_CONTAINER = "castopod-mariadb-1"
|
||||||
|
|
||||||
|
BUNNY_STORAGE_ZONE = "lukeattheroost"
|
||||||
|
BUNNY_STORAGE_KEY = "92749cd3-85df-4cff-938fe35eb994-30f8-4cf2"
|
||||||
|
BUNNY_STORAGE_REGION = "la"
|
||||||
|
BUNNY_ACCOUNT_KEY = "2865f279-297b-431a-ad18-0ccf1f8e4fa8cf636cea-3222-415a-84ed-56ee195c0530"
|
||||||
|
|
||||||
|
|
||||||
|
def gather_apple_reviews():
|
||||||
|
all_reviews = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for storefront in APPLE_STOREFRONTS:
|
||||||
|
url = f"https://itunes.apple.com/{storefront}/rss/customerreviews/id={APPLE_PODCAST_ID}/sortby=mostrecent/json"
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=15)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
data = resp.json()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
feed = data.get("feed", {})
|
||||||
|
entries = feed.get("entry", [])
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
if "im:name" in entry and "im:rating" not in entry:
|
||||||
|
continue
|
||||||
|
|
||||||
|
review_id = entry.get("id", {}).get("label", "")
|
||||||
|
if review_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(review_id)
|
||||||
|
|
||||||
|
author = entry.get("author", {}).get("name", {}).get("label", "Unknown")
|
||||||
|
title = entry.get("title", {}).get("label", "")
|
||||||
|
content = entry.get("content", {}).get("label", "")
|
||||||
|
rating = int(entry.get("im:rating", {}).get("label", "0"))
|
||||||
|
updated = entry.get("updated", {}).get("label", "")
|
||||||
|
date_str = updated[:10] if updated else ""
|
||||||
|
|
||||||
|
all_reviews.append({
|
||||||
|
"author": author,
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"rating": rating,
|
||||||
|
"date": date_str,
|
||||||
|
"storefront": storefront.upper(),
|
||||||
|
})
|
||||||
|
|
||||||
|
avg_rating = round(sum(r["rating"] for r in all_reviews) / len(all_reviews), 1) if all_reviews else None
|
||||||
|
return {
|
||||||
|
"avg_rating": avg_rating,
|
||||||
|
"review_count": len(all_reviews),
|
||||||
|
"reviews": all_reviews[:10],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def gather_spotify():
|
||||||
|
result = {"show_title": None, "rating": None, "url": f"https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
oembed_url = f"https://open.spotify.com/oembed?url=https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"
|
||||||
|
resp = requests.get(oembed_url, timeout=15)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
result["show_title"] = data.get("title")
|
||||||
|
|
||||||
|
show_url = f"https://open.spotify.com/show/{SPOTIFY_SHOW_ID}"
|
||||||
|
resp = requests.get(show_url, timeout=15, headers={
|
||||||
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"
|
||||||
|
})
|
||||||
|
|
||||||
|
rating_match = re.search(r'"ratingValue"\s*:\s*"?([\d.]+)"?', resp.text)
|
||||||
|
if rating_match:
|
||||||
|
result["rating"] = float(rating_match.group(1))
|
||||||
|
else:
|
||||||
|
rating_match2 = re.search(r'rating["\s:]*(\d+\.?\d*)\s*/\s*5', resp.text, re.IGNORECASE)
|
||||||
|
if rating_match2:
|
||||||
|
result["rating"] = float(rating_match2.group(1))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def gather_youtube(include_comments=False):
|
||||||
|
result = {
|
||||||
|
"total_views": 0,
|
||||||
|
"total_likes": 0,
|
||||||
|
"total_comments": 0,
|
||||||
|
"subscribers": None,
|
||||||
|
"videos": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["yt-dlp", "--dump-json", "--flat-playlist",
|
||||||
|
f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"],
|
||||||
|
capture_output=True, text=True, timeout=60
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return result
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return result
|
||||||
|
|
||||||
|
video_ids = []
|
||||||
|
for line in proc.stdout.strip().split("\n"):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
entry = json.loads(line)
|
||||||
|
vid = entry.get("id") or entry.get("url", "").split("=")[-1]
|
||||||
|
if vid:
|
||||||
|
video_ids.append(vid)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not video_ids:
|
||||||
|
return result
|
||||||
|
|
||||||
|
total_views = 0
|
||||||
|
total_likes = 0
|
||||||
|
total_comments = 0
|
||||||
|
videos = []
|
||||||
|
|
||||||
|
for vid in video_ids:
|
||||||
|
try:
|
||||||
|
cmd = ["yt-dlp", "--dump-json", "--no-download", f"https://www.youtube.com/watch?v={vid}"]
|
||||||
|
if include_comments:
|
||||||
|
cmd.insert(2, "--write-comments")
|
||||||
|
vr = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||||
|
if vr.returncode != 0:
|
||||||
|
continue
|
||||||
|
vdata = json.loads(vr.stdout)
|
||||||
|
|
||||||
|
title = vdata.get("title", "Unknown")
|
||||||
|
views = vdata.get("view_count", 0) or 0
|
||||||
|
likes = vdata.get("like_count", 0) or 0
|
||||||
|
comment_count = vdata.get("comment_count", 0) or 0
|
||||||
|
upload_date = vdata.get("upload_date", "")
|
||||||
|
if upload_date:
|
||||||
|
upload_date = f"{upload_date[:4]}-{upload_date[4:6]}-{upload_date[6:]}"
|
||||||
|
|
||||||
|
comments_list = []
|
||||||
|
if include_comments:
|
||||||
|
for c in (vdata.get("comments") or [])[:5]:
|
||||||
|
comments_list.append({
|
||||||
|
"author": c.get("author", "Unknown"),
|
||||||
|
"text": c.get("text", "")[:200],
|
||||||
|
"time": c.get("time_text", ""),
|
||||||
|
"likes": c.get("like_count", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
total_views += views
|
||||||
|
total_likes += likes
|
||||||
|
total_comments += comment_count
|
||||||
|
|
||||||
|
videos.append({
|
||||||
|
"title": title,
|
||||||
|
"views": views,
|
||||||
|
"likes": likes,
|
||||||
|
"comments": comment_count,
|
||||||
|
"date": upload_date,
|
||||||
|
})
|
||||||
|
except (subprocess.TimeoutExpired, json.JSONDecodeError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get subscriber count
|
||||||
|
if videos:
|
||||||
|
try:
|
||||||
|
vr = subprocess.run(
|
||||||
|
["yt-dlp", "--dump-json", "--no-download", "--playlist-items", "1",
|
||||||
|
f"https://www.youtube.com/playlist?list={YOUTUBE_PLAYLIST}"],
|
||||||
|
capture_output=True, text=True, timeout=30
|
||||||
|
)
|
||||||
|
if vr.returncode == 0:
|
||||||
|
ch_data = json.loads(vr.stdout)
|
||||||
|
sub = ch_data.get("channel_follower_count")
|
||||||
|
if sub is not None:
|
||||||
|
result["subscribers"] = sub
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result["total_views"] = total_views
|
||||||
|
result["total_likes"] = total_likes
|
||||||
|
result["total_comments"] = total_comments
|
||||||
|
result["videos"] = videos
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _run_db_query(sql):
|
||||||
|
cmd = [
|
||||||
|
"ssh", "-p", NAS_SSH_PORT, NAS_SSH,
|
||||||
|
f"{DOCKER_BIN} exec -i {CASTOPOD_DB_CONTAINER} mysql -u castopod -pBYtbFfk3ndeVabb26xb0UyKU castopod -N"
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, input=sql, capture_output=True, text=True, timeout=30)
|
||||||
|
stderr = proc.stderr.strip()
|
||||||
|
stdout = proc.stdout.strip()
|
||||||
|
if proc.returncode != 0 and not stdout:
|
||||||
|
return None, stderr
|
||||||
|
return stdout, None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return None, "SSH timeout"
|
||||||
|
except Exception as e:
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def gather_castopod():
|
||||||
|
result = {"total_downloads": 0, "unique_listeners": 0, "episodes": []}
|
||||||
|
|
||||||
|
query = (
|
||||||
|
"SELECT p.title, "
|
||||||
|
"(SELECT SUM(hits) FROM cp_analytics_podcasts WHERE podcast_id = p.id), "
|
||||||
|
"(SELECT SUM(unique_listeners) FROM cp_analytics_podcasts WHERE podcast_id = p.id) "
|
||||||
|
"FROM cp_podcasts p WHERE p.handle = 'LukeAtTheRoost' LIMIT 1;"
|
||||||
|
)
|
||||||
|
episode_query = (
|
||||||
|
"SELECT e.title, e.slug, COALESCE(SUM(ae.hits), 0), e.published_at "
|
||||||
|
"FROM cp_episodes e LEFT JOIN cp_analytics_podcasts_by_episode ae ON ae.episode_id = e.id "
|
||||||
|
"WHERE e.podcast_id = (SELECT id FROM cp_podcasts WHERE handle = 'LukeAtTheRoost') "
|
||||||
|
"GROUP BY e.id ORDER BY e.published_at DESC;"
|
||||||
|
)
|
||||||
|
|
||||||
|
out, err = _run_db_query(query)
|
||||||
|
if err or not out:
|
||||||
|
return result
|
||||||
|
|
||||||
|
parts = out.split("\t")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
result["total_downloads"] = int(parts[1]) if parts[1] and parts[1] != "NULL" else 0
|
||||||
|
result["unique_listeners"] = int(parts[2]) if parts[2] and parts[2] != "NULL" else 0
|
||||||
|
elif len(parts) >= 2:
|
||||||
|
result["total_downloads"] = int(parts[1]) if parts[1] and parts[1] != "NULL" else 0
|
||||||
|
|
||||||
|
out, err = _run_db_query(episode_query)
|
||||||
|
if err or not out:
|
||||||
|
return result
|
||||||
|
|
||||||
|
for line in out.strip().split("\n"):
|
||||||
|
cols = line.split("\t")
|
||||||
|
if len(cols) >= 4:
|
||||||
|
result["episodes"].append({
|
||||||
|
"title": cols[0],
|
||||||
|
"downloads": int(cols[2]) if cols[2] else 0,
|
||||||
|
"date": cols[3][:10] if cols[3] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def print_apple(data):
|
||||||
|
print("\n⭐ APPLE PODCASTS")
|
||||||
|
print("─" * 40)
|
||||||
|
if data["reviews"]:
|
||||||
|
print(f" Rating: {data['avg_rating']}/5 ({data['review_count']} reviews)")
|
||||||
|
print()
|
||||||
|
for r in data["reviews"]:
|
||||||
|
stars = "★" * r["rating"] + "☆" * (5 - r["rating"])
|
||||||
|
print(f" {stars} \"{r['title']}\" — {r['author']} ({r['date']}, {r['storefront']})")
|
||||||
|
if r["content"] and r["content"] != r["title"]:
|
||||||
|
content_preview = r["content"][:120]
|
||||||
|
if len(r["content"]) > 120:
|
||||||
|
content_preview += "..."
|
||||||
|
print(f" {content_preview}")
|
||||||
|
else:
|
||||||
|
print(" No reviews found")
|
||||||
|
|
||||||
|
|
||||||
|
def print_spotify(data):
|
||||||
|
print("\n🎵 SPOTIFY")
|
||||||
|
print("─" * 40)
|
||||||
|
if data["show_title"]:
|
||||||
|
print(f" Show: {data['show_title']}")
|
||||||
|
if data["rating"]:
|
||||||
|
print(f" Rating: {data['rating']}/5")
|
||||||
|
else:
|
||||||
|
print(" Rating: Not publicly available (Spotify hides ratings from web)")
|
||||||
|
print(f" Link: {data['url']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_youtube(data):
|
||||||
|
print("\n📺 YOUTUBE")
|
||||||
|
print("─" * 40)
|
||||||
|
sub_str = f" | Subscribers: {data['subscribers']:,}" if data["subscribers"] else ""
|
||||||
|
print(f" Total views: {data['total_views']:,} | Likes: {data['total_likes']:,} | Comments: {data['total_comments']:,}{sub_str}")
|
||||||
|
print()
|
||||||
|
for v in data["videos"]:
|
||||||
|
print(f" {v['title']}")
|
||||||
|
print(f" {v['views']:,} views, {v['likes']:,} likes, {v['comments']:,} comments — {v['date']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_castopod(data):
|
||||||
|
print("\n📊 DOWNLOADS (Castopod)")
|
||||||
|
print("─" * 40)
|
||||||
|
print(f" Total downloads: {data['total_downloads']:,} | Unique listeners: {data['unique_listeners']:,}")
|
||||||
|
if data["episodes"]:
|
||||||
|
print()
|
||||||
|
for ep in data["episodes"]:
|
||||||
|
print(f" {ep['title']} — {ep['downloads']:,} downloads ({ep['date']})")
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_bunnycdn(json_data):
|
||||||
|
storage_url = f"https://{BUNNY_STORAGE_REGION}.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE}/stats.json"
|
||||||
|
resp = requests.put(
|
||||||
|
storage_url,
|
||||||
|
data=json_data,
|
||||||
|
headers={
|
||||||
|
"AccessKey": BUNNY_STORAGE_KEY,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
purge_url = "https://api.bunny.net/purge"
|
||||||
|
requests.post(
|
||||||
|
purge_url,
|
||||||
|
params={"url": "https://cdn.lukeattheroost.com/stats.json"},
|
||||||
|
headers={"AccessKey": BUNNY_ACCOUNT_KEY},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
print("Uploaded stats.json to BunnyCDN and purged cache", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Podcast analytics aggregator")
|
||||||
|
parser.add_argument("--youtube", action="store_true", help="YouTube only")
|
||||||
|
parser.add_argument("--apple", action="store_true", help="Apple Podcasts only")
|
||||||
|
parser.add_argument("--spotify", action="store_true", help="Spotify only")
|
||||||
|
parser.add_argument("--castopod", action="store_true", help="Castopod only")
|
||||||
|
parser.add_argument("--comments", action="store_true", help="Include YouTube comments")
|
||||||
|
parser.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON")
|
||||||
|
parser.add_argument("--upload", action="store_true", help="Upload JSON to BunnyCDN (requires --json)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.upload and not args.json_output:
|
||||||
|
print("Error: --upload requires --json", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_all = not (args.youtube or args.apple or args.spotify or args.castopod)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
if run_all or args.castopod:
|
||||||
|
results["castopod"] = gather_castopod()
|
||||||
|
if run_all or args.apple:
|
||||||
|
results["apple"] = gather_apple_reviews()
|
||||||
|
if run_all or args.spotify:
|
||||||
|
results["spotify"] = gather_spotify()
|
||||||
|
if run_all or args.youtube:
|
||||||
|
results["youtube"] = gather_youtube(include_comments=args.comments or args.youtube)
|
||||||
|
|
||||||
|
if args.json_output:
|
||||||
|
output = {
|
||||||
|
"updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
**results,
|
||||||
|
}
|
||||||
|
json_str = json.dumps(output, indent=2, ensure_ascii=False)
|
||||||
|
print(json_str)
|
||||||
|
if args.upload:
|
||||||
|
upload_to_bunnycdn(json_str)
|
||||||
|
else:
|
||||||
|
print("=" * 45)
|
||||||
|
print(" PODCAST STATS: Luke at the Roost")
|
||||||
|
print("=" * 45)
|
||||||
|
if "castopod" in results:
|
||||||
|
print_castopod(results["castopod"])
|
||||||
|
if "apple" in results:
|
||||||
|
print_apple(results["apple"])
|
||||||
|
if "spotify" in results:
|
||||||
|
print_spotify(results["spotify"])
|
||||||
|
if "youtube" in results:
|
||||||
|
print_youtube(results["youtube"])
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -17,6 +17,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import base64
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import ssl
|
import ssl
|
||||||
@@ -515,6 +516,33 @@ def sync_episode_media_to_bunny(episode_id: int, already_uploaded: set):
|
|||||||
Path(tmp_path).unlink(missing_ok=True)
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_episode_to_sitemap(slug: str):
|
||||||
|
"""Add episode transcript page to sitemap.xml."""
|
||||||
|
sitemap_path = Path(__file__).parent / "website" / "sitemap.xml"
|
||||||
|
if not sitemap_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"https://lukeattheroost.com/episode.html?slug={slug}"
|
||||||
|
content = sitemap_path.read_text()
|
||||||
|
|
||||||
|
if url in content:
|
||||||
|
print(f" Episode already in sitemap")
|
||||||
|
return
|
||||||
|
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
new_entry = f""" <url>
|
||||||
|
<loc>{url}</loc>
|
||||||
|
<lastmod>{today}</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>"""
|
||||||
|
|
||||||
|
content = content.replace("</urlset>", new_entry)
|
||||||
|
sitemap_path.write_text(content)
|
||||||
|
print(f" Added episode to sitemap.xml")
|
||||||
|
|
||||||
|
|
||||||
def get_next_episode_number() -> int:
|
def get_next_episode_number() -> int:
|
||||||
"""Get the next episode number from Castopod."""
|
"""Get the next episode number from Castopod."""
|
||||||
headers = get_auth_header()
|
headers = get_auth_header()
|
||||||
@@ -665,6 +693,9 @@ def main():
|
|||||||
shutil.copy2(str(transcript_path), str(website_transcript_path))
|
shutil.copy2(str(transcript_path), str(website_transcript_path))
|
||||||
print(f" Transcript copied to website/transcripts/")
|
print(f" Transcript copied to website/transcripts/")
|
||||||
|
|
||||||
|
# Add to sitemap
|
||||||
|
add_episode_to_sitemap(episode["slug"])
|
||||||
|
|
||||||
# Step 4: Publish
|
# Step 4: Publish
|
||||||
episode = publish_episode(episode["id"])
|
episode = publish_episode(episode["id"])
|
||||||
|
|
||||||
|
|||||||
@@ -992,6 +992,154 @@ a:hover {
|
|||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats Page */
|
||||||
|
.stats-updated {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-loading, .stats-error {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid #2a2015;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-big {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-item {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-list-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-reviews {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-card {
|
||||||
|
background: var(--bg-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.15rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-stars {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-link {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-link a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-link a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* Desktop */
|
/* Desktop */
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.hero {
|
.hero {
|
||||||
@@ -1036,4 +1184,8 @@ a:hover {
|
|||||||
.diagram-row-split {
|
.diagram-row-split {
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-container {
|
||||||
|
padding: 0 2rem 3rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
<title id="page-title">Episode — Luke at the Roost</title>
|
<title id="page-title">Episode — Luke at the Roost</title>
|
||||||
<meta name="description" id="page-description" content="Full transcript of this episode of Luke at the Roost, the late-night call-in radio show.">
|
<meta name="description" id="page-description" content="Full transcript of this episode of Luke at the Roost, the late-night call-in radio show.">
|
||||||
<link rel="canonical" id="page-canonical" href="https://lukeattheroost.com/episode.html">
|
<link rel="canonical" id="page-canonical" href="https://lukeattheroost.com/episode.html">
|
||||||
|
|
||||||
<!-- OG / Social -->
|
<!-- OG / Social -->
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
<meta property="og:title" id="og-title" content="Episode — Luke at the Roost">
|
<meta property="og:title" id="og-title" content="Episode — Luke at the Roost">
|
||||||
<meta property="og:description" id="og-description" content="Full transcript of this episode of Luke at the Roost.">
|
<meta property="og:description" id="og-description" content="Full transcript of this episode of Luke at the Roost.">
|
||||||
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
<meta property="og:url" id="og-url" content="https://lukeattheroost.com/episode.html">
|
<meta property="og:url" id="og-url" content="https://lukeattheroost.com/episode.html">
|
||||||
<meta property="og:type" content="article">
|
<meta property="og:type" content="article">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" id="tw-title" content="Episode — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" id="tw-description" content="Full transcript of this episode of Luke at the Roost.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
@@ -21,7 +27,7 @@
|
|||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
<link rel="alternate" type="application/rss+xml" title="Luke at the Roost RSS Feed" href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,27 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>How It Works — Luke at the Roost</title>
|
<title>How It Works — Luke at the Roost</title>
|
||||||
<meta name="description" content="How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, multi-stem recording, automated post-production, and CDN-powered global distribution — all built from scratch.">
|
<meta name="description" content="How Luke at the Roost works: AI-generated callers with unique personalities, real phone calls, voice synthesis, multi-stem recording, and automated post-production.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
|
<link rel="canonical" href="https://lukeattheroost.com/how-it-works">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
<meta property="og:title" content="How It Works — Luke at the Roost">
|
<meta property="og:title" content="How It Works — Luke at the Roost">
|
||||||
<meta property="og:description" content="The tech behind a one-of-a-kind AI radio show: real-time caller generation, multi-stem recording, automated post-production, and global CDN distribution — all custom-built.">
|
<meta property="og:description" content="The tech behind a one-of-a-kind AI radio show: real-time caller generation, multi-stem recording, automated post-production, and global CDN distribution — all custom-built.">
|
||||||
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
<meta property="og:url" content="https://lukeattheroost.com/how-it-works">
|
<meta property="og:url" content="https://lukeattheroost.com/how-it-works">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="How It Works — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" content="The tech behind a one-of-a-kind AI radio show: real-time caller generation, multi-stem recording, automated post-production, and global CDN distribution.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpath d='M32 4c-2 0-4 2-4 5 0 1 .3 2 .8 3C26 13 24 16 24 20c0 2 .5 4 1.5 5.5L22 28c-2 1-4 3-5 6l-3 10c-.5 2 .5 3 2 3h4l1-4 2 4h6l-1-6 3 6h6l-1-6 3 6h4c1.5 0 2.5-1 2-3l-3-10c-1-3-3-5-5-6l-3.5-2.5C35.5 24 36 22 36 20c0-4-2-7-4.8-8 .5-1 .8-2 .8-3 0-3-2-5-4-5z' fill='%23e8791d'/%3E%3Ccircle cx='30' cy='17' r='1.5' fill='%231a1209'/%3E%3Cpath d='M36 15c1-1 3-1 4 0s1 3 0 4' fill='none' stroke='%23cc2222' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M28 22c2 1 4 1 6 0' fill='none' stroke='%23e8791d' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E">
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -483,6 +492,7 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
||||||
|
|||||||
@@ -3,20 +3,21 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Luke at the Roost — The call-in talk show where Luke gives life advice to biologically questionable organisms</title>
|
<title>Luke at the Roost — AI Call-In Comedy Podcast</title>
|
||||||
<meta name="description" content="Luke at the Roost is a late-night call-in radio show broadcast from a desert hermit's RV, featuring a mix of real callers and AI-generated callers talking to Luke about life, love, and everything in between. Call in live: 208-439-LUKE (208-439-5853). Listen on Spotify, Apple Podcasts, and YouTube.">
|
<meta name="description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI-generated characters talk to Luke about life, love, and everything in between. Call in: 208-439-LUKE.">
|
||||||
<meta name="keywords" content="Luke at the Roost, podcast, call-in radio show, AI radio, life advice, late night radio, comedy podcast, luke macneil">
|
<meta name="theme-color" content="#1a1209">
|
||||||
<link rel="canonical" href="https://lukeattheroost.com">
|
<link rel="canonical" href="https://lukeattheroost.com">
|
||||||
|
|
||||||
<!-- OG / Social -->
|
<!-- OG / Social -->
|
||||||
<meta property="og:title" content="Luke at the Roost — Life advice for biologically questionable organisms">
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
<meta property="og:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms — from a desert hermit's RV. Call in: 208-439-LUKE.">
|
<meta property="og:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
||||||
|
<meta property="og:description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI characters talk to Luke about life, love, and everything in between.">
|
||||||
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
<meta property="og:url" content="https://lukeattheroost.com">
|
<meta property="og:url" content="https://lukeattheroost.com">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="Luke at the Roost">
|
<meta name="twitter:title" content="Luke at the Roost — AI Call-In Comedy Podcast">
|
||||||
<meta name="twitter:description" content="The call-in talk show where Luke gives life advice to biologically questionable organisms. Call in: 208-439-LUKE">
|
<meta name="twitter:description" content="Late-night call-in radio from a desert hermit's RV. Real callers and AI characters talk to Luke about life, love, and everything in between.">
|
||||||
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
|
|
||||||
<!-- Banner -->
|
<!-- Banner -->
|
||||||
<div class="banner">
|
<div class="banner">
|
||||||
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img">
|
<img src="images/banner.png" alt="Luke at the Roost — ON AIR" class="banner-img" width="1500" height="500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
@@ -70,6 +71,8 @@
|
|||||||
class="cover-art"
|
class="cover-art"
|
||||||
src="images/cover.png"
|
src="images/cover.png"
|
||||||
alt="Luke at the Roost cover art"
|
alt="Luke at the Roost cover art"
|
||||||
|
width="1440"
|
||||||
|
height="1440"
|
||||||
>
|
>
|
||||||
<div class="hero-info">
|
<div class="hero-info">
|
||||||
<h1>Luke at the Roost</h1>
|
<h1>Luke at the Roost</h1>
|
||||||
@@ -198,6 +201,7 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/how-it-works">How It Works</a>
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="/stats">Stats</a>
|
||||||
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
||||||
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
||||||
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
||||||
|
|||||||
@@ -2,7 +2,68 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://lukeattheroost.com</loc>
|
<loc>https://lukeattheroost.com</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/how-it-works</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/stats</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-8-real-news-or-fake-news</loc>
|
||||||
|
<lastmod>2026-02-11</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-7-ai-takeover-and-honey-endurance</loc>
|
||||||
|
<lastmod>2026-02-10</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-6-late-night-woes-and-cosmic-contemplations</loc>
|
||||||
|
<lastmod>2026-02-09</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-5-cosmic-theories-and-calling-for-change</loc>
|
||||||
|
<lastmod>2026-02-08</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-4-navigating-life-s-challenges</loc>
|
||||||
|
<lastmod>2026-02-07</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-3-desire-burnout-and-friendship-woes</loc>
|
||||||
|
<lastmod>2026-02-06</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=episode-2-late-night-confessions</loc>
|
||||||
|
<lastmod>2026-02-05</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://lukeattheroost.com/episode.html?slug=quantum-physics-pluto-relationship-blunders</loc>
|
||||||
|
<lastmod>2026-02-04</lastmod>
|
||||||
|
<changefreq>never</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|||||||
201
website/stats.html
Normal file
201
website/stats.html
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podcast Stats & Downloads — Luke at the Roost</title>
|
||||||
|
<meta name="description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<meta name="theme-color" content="#1a1209">
|
||||||
|
<link rel="canonical" href="https://lukeattheroost.com/stats">
|
||||||
|
|
||||||
|
<meta property="og:site_name" content="Luke at the Roost">
|
||||||
|
<meta property="og:title" content="Podcast Stats & Downloads — Luke at the Roost">
|
||||||
|
<meta property="og:description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<meta property="og:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
<meta property="og:url" content="https://lukeattheroost.com/stats">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
<meta name="twitter:title" content="Podcast Stats & Downloads — Luke at the Roost">
|
||||||
|
<meta name="twitter:description" content="Podcast stats for Luke at the Roost — total downloads, unique listeners, Apple Podcasts reviews, YouTube views and subscribers, updated daily.">
|
||||||
|
<meta name="twitter:image" content="https://cdn.lukeattheroost.com/media/podcasts/LukeAtTheRoost/cover_feed.png?v=3">
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="css/style.css?v=2">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Nav -->
|
||||||
|
<nav class="page-nav">
|
||||||
|
<a href="/" class="nav-home">Luke at the Roost</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<section class="page-header">
|
||||||
|
<h1>Stats</h1>
|
||||||
|
<p class="page-subtitle">Downloads, reviews, and audience numbers across all platforms.</p>
|
||||||
|
<p class="stats-updated" id="stats-updated"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Stats Content -->
|
||||||
|
<div class="stats-container" id="stats-container">
|
||||||
|
<div class="stats-loading" id="stats-loading">Loading stats...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/how-it-works">How It Works</a>
|
||||||
|
<a href="https://open.spotify.com/show/0ZrpMigG1fo0CCN7F4YmuF?si=f990713adce84ba4" target="_blank" rel="noopener">Spotify</a>
|
||||||
|
<a href="https://www.youtube.com/watch?v=xryGLifMBTY&list=PLGq4uZyNV1yYH_rcitTTPVysPbC6-7pe-" target="_blank" rel="noopener">YouTube</a>
|
||||||
|
<a href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" target="_blank" rel="noopener">RSS</a>
|
||||||
|
</div>
|
||||||
|
<div class="footer-projects">
|
||||||
|
<span class="footer-projects-label">More from Luke</span>
|
||||||
|
<div class="footer-projects-links">
|
||||||
|
<a href="https://macneilmediagroup.com" target="_blank" rel="noopener">MacNeil Media Group</a>
|
||||||
|
<a href="https://prints.macneilmediagroup.com" target="_blank" rel="noopener">Photography Prints</a>
|
||||||
|
<a href="https://youtube.com/lukemacneil" target="_blank" rel="noopener">YouTube</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="footer-contact">Sales & Collaboration: <a href="mailto:luke@macneilmediagroup.com">luke@macneilmediagroup.com</a></p>
|
||||||
|
<p>© 2026 Luke at the Roost</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(async function() {
|
||||||
|
const container = document.getElementById('stats-container');
|
||||||
|
const loading = document.getElementById('stats-loading');
|
||||||
|
const updatedEl = document.getElementById('stats-updated');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://cdn.lukeattheroost.com/stats.json');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load stats');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.updated_at) {
|
||||||
|
const d = new Date(data.updated_at);
|
||||||
|
updatedEl.textContent = 'Last updated ' + d.toLocaleDateString('en-US', {
|
||||||
|
month: 'long', day: 'numeric', year: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit', timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Downloads (Castopod)
|
||||||
|
if (data.castopod) {
|
||||||
|
const c = data.castopod;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Downloads</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (c.total_downloads || 0).toLocaleString() + '</span><span class="stat-label">Total Downloads</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (c.unique_listeners || 0).toLocaleString() + '</span><span class="stat-label">Unique Listeners</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
if (c.episodes && c.episodes.length) {
|
||||||
|
html += '<div class="stats-list">';
|
||||||
|
c.episodes.forEach(function(ep) {
|
||||||
|
html += '<div class="stats-list-item">';
|
||||||
|
html += '<span class="stats-list-title">' + escapeHtml(ep.title) + '</span>';
|
||||||
|
html += '<span class="stats-list-meta">' + (ep.downloads || 0).toLocaleString() + ' downloads · ' + escapeHtml(ep.date) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple Reviews
|
||||||
|
if (data.apple) {
|
||||||
|
const a = data.apple;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Apple Podcasts</h2>';
|
||||||
|
if (a.review_count > 0) {
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (a.avg_rating || 0) + '/5</span><span class="stat-label">Average Rating</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + a.review_count + '</span><span class="stat-label">Reviews</span></div>';
|
||||||
|
html += '</div>';
|
||||||
|
if (a.reviews && a.reviews.length) {
|
||||||
|
html += '<div class="stats-reviews">';
|
||||||
|
a.reviews.forEach(function(r) {
|
||||||
|
const stars = '\u2605'.repeat(r.rating) + '\u2606'.repeat(5 - r.rating);
|
||||||
|
html += '<div class="review-card">';
|
||||||
|
html += '<div class="review-stars">' + stars + '</div>';
|
||||||
|
html += '<div class="review-title">' + escapeHtml(r.title) + '</div>';
|
||||||
|
if (r.content && r.content !== r.title) {
|
||||||
|
html += '<div class="review-content">' + escapeHtml(r.content) + '</div>';
|
||||||
|
}
|
||||||
|
html += '<div class="review-meta">' + escapeHtml(r.author) + ' · ' + escapeHtml(r.date) + ' · ' + escapeHtml(r.storefront) + '</div>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html += '<p class="stats-empty">No reviews yet</p>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spotify
|
||||||
|
if (data.spotify) {
|
||||||
|
const s = data.spotify;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>Spotify</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
if (s.rating) {
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + s.rating + '/5</span><span class="stat-label">Rating</span></div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">—</span><span class="stat-label">Rating (not public)</span></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
if (s.url) {
|
||||||
|
html += '<p class="stats-link"><a href="' + escapeHtml(s.url) + '" target="_blank" rel="noopener">Listen on Spotify</a></p>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube
|
||||||
|
if (data.youtube) {
|
||||||
|
const y = data.youtube;
|
||||||
|
html += '<section class="stats-section">';
|
||||||
|
html += '<h2>YouTube</h2>';
|
||||||
|
html += '<div class="stats-summary">';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_views || 0).toLocaleString() + '</span><span class="stat-label">Views</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_likes || 0).toLocaleString() + '</span><span class="stat-label">Likes</span></div>';
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + (y.total_comments || 0).toLocaleString() + '</span><span class="stat-label">Comments</span></div>';
|
||||||
|
if (y.subscribers != null) {
|
||||||
|
html += '<div class="stat-big"><span class="stat-number">' + y.subscribers.toLocaleString() + '</span><span class="stat-label">Subscribers</span></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
if (y.videos && y.videos.length) {
|
||||||
|
html += '<div class="stats-list">';
|
||||||
|
y.videos.forEach(function(v) {
|
||||||
|
html += '<div class="stats-list-item">';
|
||||||
|
html += '<span class="stats-list-title">' + escapeHtml(v.title) + '</span>';
|
||||||
|
html += '<span class="stats-list-meta">' + (v.views || 0).toLocaleString() + ' views · ' + (v.likes || 0).toLocaleString() + ' likes · ' + escapeHtml(v.date) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</section>';
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.style.display = 'none';
|
||||||
|
container.innerHTML = html;
|
||||||
|
} catch (e) {
|
||||||
|
loading.textContent = 'Unable to load stats. Try again later.';
|
||||||
|
loading.className = 'stats-error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user