Initial commit
Build and Push Docker Image / build-and-push-image (push) Failing after 14m8s

This commit is contained in:
Pie
2026-04-19 17:08:09 +01:00
commit 47e66478c4
5 changed files with 534 additions and 0 deletions
+405
View File
@@ -0,0 +1,405 @@
import streamlit as st
import streamlit.components.v1 as components
import requests
import os
from collections import defaultdict
from datetime import datetime
st.set_page_config(page_title="Crunchyroll Watchlist", page_icon="🍊", layout="wide")
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap');
html, body, [class*="css"] { font-family: 'DM Sans', sans-serif; background-color: #0d0d0d; color: #f0ede8; }
#MainMenu, footer, header { visibility: hidden; }
.block-container { padding: 2rem 2.5rem 4rem; max-width: 1400px; }
.hero { display: flex; align-items: flex-end; gap: 1.2rem; margin-bottom: 2.5rem; border-bottom: 1px solid #2a2a2a; padding-bottom: 1.5rem; }
.hero-logo { font-family: 'Bebas Neue', sans-serif; font-size: 3.2rem; letter-spacing: 0.04em; color: #f47521; line-height: 1; }
.hero-sub { font-size: 0.85rem; color: #666; font-weight: 300; padding-bottom: 0.4rem; }
.cat-header { display: flex; align-items: center; gap: 0.75rem; margin: 2rem 0 0.5rem; }
.cat-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.4rem; letter-spacing: 0.06em; color: #f47521; }
.cat-count { background: #1e1e1e; border: 1px solid #2e2e2e; color: #888; font-size: 0.7rem; font-weight: 500; padding: 0.15rem 0.55rem; border-radius: 20px; }
.cat-line { flex: 1; height: 1px; background: #1e1e1e; }
.stTextInput input { background: #141414 !important; border: 1px solid #2a2a2a !important; color: #f0ede8 !important; border-radius: 6px !important; }
.stTextInput input:focus { border-color: #f47521 !important; box-shadow: 0 0 0 2px rgba(244,117,33,0.15) !important; }
.stButton > button { background: #f47521 !important; color: #0d0d0d !important; font-family: 'Bebas Neue', sans-serif !important; font-size: 1rem !important; letter-spacing: 0.08em !important; border: none !important; border-radius: 6px !important; padding: 0.45rem 1.8rem !important; }
.stButton > button:hover { opacity: 0.85 !important; }
.cache-pill { display: inline-flex; align-items: center; gap: 0.4rem; background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 20px; padding: 0.25rem 0.75rem; font-size: 0.72rem; color: #555; margin-bottom: 1.5rem; }
.cache-dot { width: 6px; height: 6px; border-radius: 50%; background: #3a3; display: inline-block; }
.stats-row { display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap; }
.stat-box { background: #141414; border: 1px solid #222; border-radius: 8px; padding: 0.75rem 1.25rem; min-width: 120px; text-align: center; }
.stat-num { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; color: #f47521; line-height: 1; }
.stat-label { font-size: 0.68rem; color: #555; text-transform: uppercase; letter-spacing: 0.08em; margin-top: 0.2rem; }
</style>
""", unsafe_allow_html=True)
# ── Constants ────────────────────────────────────────────────────────────────
AUTH_URL = "https://www.crunchyroll.com"
API_URL = "https://beta-api.crunchyroll.com"
CR_CLIENT_ID = "t-kdgp2h8c3jub8fn0fq"
CR_CLIENT_SECRET = "yfLDfMfrYvKXh4JXS1LEI2cCqu1v5Wan"
HEADERS = {"User-Agent": "Crunchyroll/3.0.0 Android/5.1.1 okhttp/3.12.1"}
CATEGORY_ORDER = ["Continue", "Up Next", "Start Watching", "Watch Again"]
CATEGORY_ICONS = {
"Continue": "&#9654;",
"Up Next": "&#9197;",
"Start Watching":"&#10022;",
"Watch Again": "&#8634;",
}
# ── iframe HTML template ─────────────────────────────────────────────────────
# Built as a plain string (no f-string) to avoid brace-escaping headaches.
IFRAME_TEMPLATE = """\
<!DOCTYPE html>
<html>
<head>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: transparent; font-family: 'DM Sans', sans-serif; overflow: hidden; }
.outer { position: relative; }
/* soft edge fades to hint there is more content */
.outer::before, .outer::after {
content: '';
position: absolute;
top: 0; bottom: 16px;
width: 56px;
pointer-events: none;
z-index: 2;
}
.outer::before { left: 0; background: linear-gradient(to right, #0d0d0d, transparent); }
.outer::after { right: 0; background: linear-gradient(to left, #0d0d0d, transparent); }
.row {
display: flex;
gap: 12px;
padding: 4px 2px 16px;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
.row::-webkit-scrollbar { height: 4px; }
.row::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 2px; }
.row::-webkit-scrollbar-thumb { background: #f47521; border-radius: 2px; }
/* prev / next arrow buttons */
.arrow {
position: absolute;
top: 50%;
transform: translateY(calc(-50% - 8px));
z-index: 3;
width: 36px; height: 36px;
border-radius: 50%;
background: rgba(20,20,20,0.92);
border: 1px solid #3a3a3a;
color: #f0ede8;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s, border-color 0.15s, opacity 0.2s;
opacity: 0;
user-select: none;
}
.outer:hover .arrow { opacity: 0.75; }
.arrow:hover { background: #f47521 !important; border-color: #f47521 !important; color: #0d0d0d !important; opacity: 1 !important; }
.arrow.prev { left: 6px; }
.arrow.next { right: 6px; }
.arrow.hidden { opacity: 0 !important; pointer-events: none; }
/* cards */
.card {
flex: 0 0 240px;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 10px;
overflow: hidden;
scroll-snap-align: start;
text-decoration: none;
display: block;
transition: border-color 0.15s, transform 0.15s;
cursor: pointer;
}
.card:hover { border-color: #f47521; transform: translateY(-2px); }
.thumb-wrap { position: relative; width: 100%; aspect-ratio: 16 / 9; background: #1e1e1e; overflow: hidden; }
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.badge {
position: absolute; top: 7px; right: 7px;
background: rgba(0,0,0,0.78); border: 1px solid rgba(255,255,255,0.15);
color: #ccc; font-size: 0.6rem; font-weight: 600;
padding: 2px 6px; border-radius: 4px; letter-spacing: 0.05em; text-transform: uppercase;
}
.prog-track { position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: rgba(255,255,255,0.1); }
.prog-fill { height: 100%; background: #f47521; border-radius: 0 2px 2px 0; }
.info { padding: 10px 12px 12px; }
.series {
font-size: 0.7rem; color: #f47521; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 3px; letter-spacing: 0.02em;
}
.ep-title {
font-size: 0.85rem; font-weight: 500; color: #f0ede8;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 4px;
}
.meta { font-size: 0.67rem; color: #666; font-weight: 300; }
</style>
</head>
<body>
<div class="outer">
<button class="arrow prev hidden" id="prev">&#8249;</button>
<div class="row" id="row">
CARDS_PLACEHOLDER
</div>
<button class="arrow next" id="next">&#8250;</button>
</div>
<script>
(function() {
var row = document.getElementById('row');
var prev = document.getElementById('prev');
var next = document.getElementById('next');
var STEP = 252 * 3;
function update() {
var atStart = row.scrollLeft <= 4;
var atEnd = row.scrollLeft + row.clientWidth >= row.scrollWidth - 4;
prev.classList.toggle('hidden', atStart);
next.classList.toggle('hidden', atEnd);
}
prev.addEventListener('click', function() { row.scrollBy({ left: -STEP, behavior: 'smooth' }); });
next.addEventListener('click', function() { row.scrollBy({ left: STEP, behavior: 'smooth' }); });
row.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
update();
})();
</script>
</body>
</html>
"""
# ── API helpers (cached) ─────────────────────────────────────────────────────
@st.cache_data(ttl=300, show_spinner=False)
def fetch_token(email, password):
r = requests.post(
AUTH_URL + "/auth/v1/token",
data={"username": email, "password": password, "grant_type": "password", "scope": "offline_access"},
headers=HEADERS, auth=(CR_CLIENT_ID, CR_CLIENT_SECRET), timeout=10,
)
r.raise_for_status()
return r.json()["access_token"]
@st.cache_data(ttl=300, show_spinner=False)
def fetch_account_id(token):
r = requests.get(API_URL + "/accounts/v1/me",
headers={**HEADERS, "Authorization": "Bearer " + token}, timeout=10)
r.raise_for_status()
return r.json()["account_id"]
@st.cache_data(ttl=300, show_spinner=False)
def fetch_watchlist(token, account_id, n=200):
r = requests.get(
API_URL + "/content/v2/discover/" + account_id + "/watchlist",
params={"locale": "en-US", "n": n},
headers={**HEADERS, "Authorization": "Bearer " + token}, timeout=10,
)
r.raise_for_status()
return r.json().get("data", [])
# ── Helpers ──────────────────────────────────────────────────────────────────
def categorise(item):
if item.get("never_watched"): return "Start Watching"
if item.get("fully_watched"): return "Watch Again"
if item.get("playhead", 0) > 0: return "Continue"
return "Up Next"
def get_thumbnail(panel):
try:
sizes = panel["images"]["thumbnail"][0]
for s in sizes:
if s.get("width") == 640:
return s["source"]
return sizes[-1]["source"]
except (KeyError, IndexError):
return ""
def esc(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def build_card(item):
panel = item.get("panel", {})
meta = panel.get("episode_metadata", {})
series = meta.get("series_title", panel.get("title", "Unknown"))
ep_num = meta.get("episode_number", "")
season = meta.get("season_number", "")
ep_title = panel.get("title", "")
duration = meta.get("duration_ms", 0) // 1000
playhead = item.get("playhead", 0)
thumb = get_thumbnail(panel)
ep_label = "S{}E{}".format(season, ep_num) if season and ep_num else ""
ep_id = panel.get("id", "")
ep_slug = panel.get("slug_title", "")
url = "https://www.crunchyroll.com/watch/{}/{}".format(ep_id, ep_slug) if ep_id else "#"
prog = ""
if playhead > 0 and not item.get("fully_watched") and duration:
pct = min(int(playhead / duration * 100), 100)
prog = '<div class="prog-track"><div class="prog-fill" style="width:{}%"></div></div>'.format(pct)
img = '<img src="{}" alt="">'.format(esc(thumb)) if thumb else '<div style="width:100%;height:100%;background:#1e1e1e;"></div>'
badge = '<span class="badge">{}</span>'.format(esc(ep_label)) if ep_label else ""
return (
'<a class="card" href="{}" target="_blank" rel="noopener noreferrer">'
'<div class="thumb-wrap">{}{}{}</div>'
'<div class="info">'
'<div class="series">{}</div>'
'<div class="ep-title">{}</div>'
'<div class="meta">{}</div>'
'</div>'
'</a>'
).format(esc(url), img, badge, prog, esc(series), esc(ep_title), esc(ep_label))
def render_row(entries):
cards_html = "".join(build_card(i) for i in entries)
html = IFRAME_TEMPLATE.replace("CARDS_PLACEHOLDER", cards_html)
components.html(html, height=255, scrolling=False)
# ── Session state ────────────────────────────────────────────────────────────
for k in ("logged_in", "token", "account_id", "watchlist", "fetched_at"):
st.session_state.setdefault(k, None)
st.session_state.setdefault("logged_in", False)
# ── Auto-login ───────────────────────────────────────────────────────────────
if not st.session_state.logged_in:
env_email = os.environ.get("CR_EMAIL")
env_pass = os.environ.get("CR_PASSWORD")
if env_email and env_pass:
try:
tok = fetch_token(env_email, env_pass)
aid = fetch_account_id(tok)
wl = fetch_watchlist(tok, aid)
st.session_state.update(
logged_in=True, token=tok, account_id=aid,
watchlist=wl, fetched_at=datetime.now()
)
st.rerun()
except Exception:
# Fallback to manual login if auto-login fails
pass
# ── Header ───────────────────────────────────────────────────────────────────
st.markdown("""
<div class="hero">
<div class="hero-logo">&#127818; Watchlist</div>
<div class="hero-sub">Crunchyroll &mdash; personal queue dashboard</div>
</div>
""", unsafe_allow_html=True)
# ── Login ────────────────────────────────────────────────────────────────────
if not st.session_state.logged_in:
_, mid, _ = st.columns([1, 1.2, 1])
with mid:
st.markdown("#### Sign in to Crunchyroll")
email = st.text_input("Email", key="email_input")
password = st.text_input("Password", key="pass_input", type="password")
if st.button("Load Watchlist"):
if not email or not password:
st.error("Please enter your email and password.")
else:
with st.spinner("Signing in..."):
try:
tok = fetch_token(email, password)
aid = fetch_account_id(tok)
wl = fetch_watchlist(tok, aid)
st.session_state.update(
logged_in=True, token=tok, account_id=aid,
watchlist=wl, fetched_at=datetime.now()
)
st.rerun()
except requests.HTTPError as e:
st.error("Login failed: {}".format(e))
except Exception as e:
st.error("Error: {}".format(e))
# ── Dashboard ────────────────────────────────────────────────────────────────
else:
watchlist = st.session_state.watchlist
fetched_at = st.session_state.fetched_at
# Toolbar
c1, c2 = st.columns([6, 1])
with c1:
age = int((datetime.now() - fetched_at).total_seconds()) if fetched_at else 0
astr = "{}s ago".format(age) if age < 60 else "{}m ago".format(age // 60)
st.markdown(
'<div class="cache-pill"><span class="cache-dot"></span>'
'Cached &middot; fetched {} &middot; refreshes every 5 min</div>'.format(astr),
unsafe_allow_html=True,
)
with c2:
if st.button("Refresh"):
fetch_token.clear(); fetch_account_id.clear(); fetch_watchlist.clear()
with st.spinner("Refreshing..."):
try:
tok = fetch_token(
st.session_state.get("email_input", ""),
st.session_state.get("pass_input", ""),
)
wl = fetch_watchlist(tok, st.session_state.account_id)
st.session_state.update(watchlist=wl, fetched_at=datetime.now())
st.rerun()
except Exception as e:
st.error("Refresh failed: {}".format(e))
# Bucket items
buckets = defaultdict(list)
for item in watchlist:
buckets[categorise(item)].append(item)
# Stats
stat_boxes = ""
for c in CATEGORY_ORDER:
n = len(buckets.get(c, []))
stat_boxes += (
'<div class="stat-box">'
'<div class="stat-num">{}</div>'
'<div class="stat-label">{}</div>'
'</div>'
).format(n, c)
stat_boxes += (
'<div class="stat-box">'
'<div class="stat-num">{}</div>'
'<div class="stat-label">Total</div>'
'</div>'
).format(len(watchlist))
st.markdown('<div class="stats-row">{}</div>'.format(stat_boxes), unsafe_allow_html=True)
# Category rows
for cat in CATEGORY_ORDER:
entries = buckets.get(cat, [])
if not entries:
continue
icon = CATEGORY_ICONS[cat]
st.markdown(
'<div class="cat-header">'
'<span class="cat-title">{} {}</span>'
'<span class="cat-count">{}</span>'
'<div class="cat-line"></div>'
'</div>'.format(icon, cat, len(entries)),
unsafe_allow_html=True,
)
render_row(entries)
# Sign out
st.markdown("<br>", unsafe_allow_html=True)
if st.button("Sign out"):
for k in ("logged_in", "token", "account_id", "watchlist", "fetched_at"):
st.session_state[k] = None
st.session_state.logged_in = False
fetch_token.clear(); fetch_account_id.clear(); fetch_watchlist.clear()
st.rerun()