This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "master" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Gitea automatically sets GITEA_REPOSITORY to "owner/repo"
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
# Gitea's built-in container registry is usually on the same host
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.home.piesweb.co.uk
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.home.piesweb.co.uk/${{ env.IMAGE_NAME }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir streamlit requests
|
||||||
|
|
||||||
|
# Copy app
|
||||||
|
COPY crunchyroll_watchlist.py .
|
||||||
|
|
||||||
|
# Streamlit config — disable telemetry, set port
|
||||||
|
ENV STREAMLIT_SERVER_PORT=8501 \
|
||||||
|
STREAMLIT_SERVER_ADDRESS=0.0.0.0 \
|
||||||
|
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false \
|
||||||
|
STREAMLIT_SERVER_HEADLESS=true
|
||||||
|
|
||||||
|
EXPOSE 8501
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request,sys; \
|
||||||
|
sys.exit(0 if urllib.request.urlopen('http://localhost:8501/_stcore/health').status==200 else 1)"
|
||||||
|
|
||||||
|
ENTRYPOINT ["streamlit", "run", "crunchyroll_watchlist.py", "--server.port=8501", "--server.address=0.0.0.0"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Crunchyroll Watchlist
|
||||||
|
|
||||||
|
A polished, Streamlit-based dashboard for viewing and managing your Crunchyroll watchlist. This project provides a custom-styled interface with category-based grouping and interactive carousels.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
- **Purpose:** Provide an enhanced visual interface for the Crunchyroll watchlist using the Crunchyroll API.
|
||||||
|
- **Main Technologies:**
|
||||||
|
- [Python](https://www.python.org/)
|
||||||
|
- [Streamlit](https://streamlit.io/) (Web Framework)
|
||||||
|
- [Requests](https://requests.readthedocs.io/) (API communication)
|
||||||
|
- **Key Features:**
|
||||||
|
- OAuth2 Authentication with Crunchyroll.
|
||||||
|
- Custom CSS for a dark-themed, "Netflix-style" UI.
|
||||||
|
- Category-based sorting (Action, Adventure, Comedy, etc.).
|
||||||
|
- Interactive row carousels for series navigation.
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
|
||||||
|
### Using Docker (Recommended)
|
||||||
|
|
||||||
|
The project is fully containerized for easy deployment.
|
||||||
|
|
||||||
|
1. **Build the image:**
|
||||||
|
```bash
|
||||||
|
docker build -t crunchyroll-watchlist .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the container:**
|
||||||
|
```bash
|
||||||
|
docker run -p 8501:8501 \
|
||||||
|
-e CR_EMAIL="your-email@example.com" \
|
||||||
|
-e CR_PASSWORD="your-password" \
|
||||||
|
crunchyroll-watchlist
|
||||||
|
```
|
||||||
|
Access the app at `http://localhost:8501`. If environment variables are provided, the app will attempt to log in automatically.
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install streamlit requests
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the application:**
|
||||||
|
```bash
|
||||||
|
streamlit run crunchyroll_watchlist.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Conventions
|
||||||
|
|
||||||
|
- **Frontend:** Custom styling is handled via `st.markdown` with `unsafe_allow_html=True`.
|
||||||
|
- **Components:** Complex interactive elements (like the carousel) are implemented using Streamlit HTML components (`components.html`).
|
||||||
|
- **State Management:** Uses `st.session_state` to manage authentication tokens and account data.
|
||||||
|
- **Caching:** API calls are cached using `st.cache_data` to improve performance and reduce API load.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `crunchyroll_watchlist.py`: The main application logic, including authentication, API interaction, and UI rendering.
|
||||||
|
- `Dockerfile`: Configuration for building the project as a container.
|
||||||
@@ -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": "▶",
|
||||||
|
"Up Next": "⏭",
|
||||||
|
"Start Watching":"✦",
|
||||||
|
"Watch Again": "↺",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 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">‹</button>
|
||||||
|
<div class="row" id="row">
|
||||||
|
CARDS_PLACEHOLDER
|
||||||
|
</div>
|
||||||
|
<button class="arrow next" id="next">›</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("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||||
|
|
||||||
|
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">🍊 Watchlist</div>
|
||||||
|
<div class="hero-sub">Crunchyroll — 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 · fetched {} · 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()
|
||||||
Reference in New Issue
Block a user