Initial commit: N64 Tracker source from server (excludes Coverart images)
This commit is contained in:
commit
ccf87df36d
14 changed files with 10105 additions and 0 deletions
464
app.py
Normal file
464
app.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
from flask import (Flask, jsonify, request, send_from_directory,
|
||||
send_file, abort, session, redirect, url_for)
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import hashlib
|
||||
import secrets
|
||||
import threading
|
||||
from PIL import Image
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
BASE_PREFIX = '/n64' # subpath for nginx reverse proxy
|
||||
DATA_FILE = '/app/data/games.json'
|
||||
SETTINGS_FILE= '/app/data/settings.json'
|
||||
ART_BASE = '/app/coverart'
|
||||
THUMB_DIR = '/app/data/thumbs' # persisted on the game-data volume
|
||||
THUMB_WIDTH = 300 # px — enough for the card grid
|
||||
|
||||
app = Flask(__name__, static_folder='static')
|
||||
app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
|
||||
|
||||
# ── Thumbnail generator ────────────────────────────────────────────────────
|
||||
def _make_thumb(src_path, thumb_path):
|
||||
"""Convert a large PNG to a small WebP thumbnail."""
|
||||
try:
|
||||
with Image.open(src_path) as img:
|
||||
# Maintain aspect ratio, limit width to THUMB_WIDTH
|
||||
w, h = img.size
|
||||
ratio = THUMB_WIDTH / w
|
||||
new_h = int(h * ratio)
|
||||
img = img.convert('RGB')
|
||||
img = img.resize((THUMB_WIDTH, new_h), Image.LANCZOS)
|
||||
img.save(thumb_path, 'WEBP', quality=82, method=4)
|
||||
except Exception as e:
|
||||
print(f'Thumb error {src_path}: {e}')
|
||||
|
||||
def generate_thumbnails():
|
||||
"""Walk ART_BASE and generate WebP thumbs for any PNG that needs one."""
|
||||
os.makedirs(THUMB_DIR, exist_ok=True)
|
||||
todo = []
|
||||
for fname in os.listdir(ART_BASE):
|
||||
if not fname.lower().endswith('.png'):
|
||||
continue
|
||||
src = os.path.join(ART_BASE, fname)
|
||||
thumb = os.path.join(THUMB_DIR, os.path.splitext(fname)[0] + '.webp')
|
||||
if not os.path.isfile(thumb):
|
||||
todo.append((src, thumb))
|
||||
if todo:
|
||||
print(f'Generating {len(todo)} thumbnails...')
|
||||
for src, thumb in todo:
|
||||
_make_thumb(src, thumb)
|
||||
print('Thumbnails done.')
|
||||
|
||||
# Run thumbnail generation in background so startup isn't blocked
|
||||
threading.Thread(target=generate_thumbnails, daemon=True).start()
|
||||
|
||||
# ── Settings helpers ───────────────────────────────────────────────────────
|
||||
DEFAULT_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'n64admin')
|
||||
|
||||
def _hash(pw):
|
||||
return hashlib.sha256(pw.encode()).hexdigest()
|
||||
|
||||
def load_settings():
|
||||
if os.path.isfile(SETTINGS_FILE):
|
||||
with open(SETTINGS_FILE) as f:
|
||||
return json.load(f)
|
||||
# First-run: persist hashed default
|
||||
s = {'password_hash': _hash(DEFAULT_PASSWORD)}
|
||||
save_settings(s)
|
||||
return s
|
||||
|
||||
def save_settings(s):
|
||||
with open(SETTINGS_FILE, 'w') as f:
|
||||
json.dump(s, f, indent=2)
|
||||
|
||||
def check_password(pw):
|
||||
return _hash(pw) == load_settings().get('password_hash', '')
|
||||
|
||||
def is_authed():
|
||||
return session.get('authed') is True
|
||||
|
||||
def require_auth(f):
|
||||
"""Decorator: returns 401 JSON if not authenticated."""
|
||||
from functools import wraps
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not is_authed():
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
# ── Game data helpers ──────────────────────────────────────────────────────
|
||||
def load_games():
|
||||
with open(DATA_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_games(games):
|
||||
with open(DATA_FILE, 'w') as f:
|
||||
json.dump(games, f, indent=2)
|
||||
|
||||
# ── Static / index ─────────────────────────────────────────────────────────
|
||||
@app.route(BASE_PREFIX + '/')
|
||||
@app.route(BASE_PREFIX)
|
||||
def index():
|
||||
return send_from_directory('static', 'index.html')
|
||||
|
||||
# ── Cover art ──────────────────────────────────────────────────────────────
|
||||
@app.route(BASE_PREFIX + '/art/<path:art_path>')
|
||||
def serve_art(art_path):
|
||||
full = os.path.join(ART_BASE, art_path)
|
||||
if not os.path.isfile(full):
|
||||
abort(404)
|
||||
|
||||
# Serve pre-generated WebP thumbnail if available (much smaller)
|
||||
base_name = os.path.splitext(art_path)[0]
|
||||
thumb_path = os.path.join(THUMB_DIR, base_name + '.webp')
|
||||
|
||||
if os.path.isfile(thumb_path):
|
||||
resp = send_file(thumb_path, mimetype='image/webp')
|
||||
else:
|
||||
# Thumb not ready yet — serve original and kick off generation
|
||||
resp = send_file(full, mimetype='image/png')
|
||||
threading.Thread(target=_make_thumb, args=(full, thumb_path), daemon=True).start()
|
||||
|
||||
# Cache aggressively — images never change
|
||||
resp.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
||||
return resp
|
||||
|
||||
# ── Auth endpoints ─────────────────────────────────────────────────────────
|
||||
@app.route(BASE_PREFIX + '/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json(silent=True) or {}
|
||||
pw = data.get('password', '')
|
||||
if check_password(pw):
|
||||
session['authed'] = True
|
||||
return jsonify({'ok': True})
|
||||
return jsonify({'ok': False, 'error': 'Wrong password'}), 401
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/auth/logout', methods=['POST'])
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/auth/status', methods=['GET'])
|
||||
def auth_status():
|
||||
return jsonify({'authed': is_authed()})
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/auth/change-password', methods=['POST'])
|
||||
@require_auth
|
||||
def change_password():
|
||||
data = request.get_json(silent=True) or {}
|
||||
current = data.get('current', '')
|
||||
new_pw = data.get('new', '')
|
||||
if not check_password(current):
|
||||
return jsonify({'ok': False, 'error': 'Current password incorrect'}), 400
|
||||
if len(new_pw) < 4:
|
||||
return jsonify({'ok': False, 'error': 'Password must be at least 4 characters'}), 400
|
||||
s = load_settings()
|
||||
s['password_hash'] = _hash(new_pw)
|
||||
save_settings(s)
|
||||
return jsonify({'ok': True})
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
@app.route(BASE_PREFIX + '/api/games', methods=['GET'])
|
||||
def get_games():
|
||||
games = load_games()
|
||||
query = request.args.get('q', '').lower().strip()
|
||||
if query:
|
||||
games = [g for g in games if query in g['title'].lower()]
|
||||
return jsonify(games)
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/games/owned', methods=['GET'])
|
||||
def get_owned():
|
||||
games = load_games()
|
||||
return jsonify([g for g in games if g.get('owned')])
|
||||
|
||||
CASE_COST = 7.00
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/stats', methods=['GET'])
|
||||
def get_stats():
|
||||
games = load_games()
|
||||
total = len(games)
|
||||
owned = sum(1 for g in games if g.get('owned'))
|
||||
collection_value = sum(
|
||||
g.get('loose_price') or 0
|
||||
for g in games if g.get('owned') and g.get('loose_price')
|
||||
)
|
||||
unowned_games = [g for g in games if not g.get('owned')]
|
||||
needs_cost = sum(
|
||||
(g.get('loose_price') or 0) + CASE_COST
|
||||
for g in unowned_games
|
||||
)
|
||||
return jsonify({
|
||||
'total': total,
|
||||
'owned': owned,
|
||||
'unowned': total - owned,
|
||||
'collection_value': round(collection_value, 2),
|
||||
'needs_cost': round(needs_cost, 2)
|
||||
})
|
||||
|
||||
# ── Protected write API ───────────────────────────────────────────────────
|
||||
@app.route(BASE_PREFIX + '/api/games/<int:game_id>/own', methods=['POST'])
|
||||
@require_auth
|
||||
def toggle_own(game_id):
|
||||
games = load_games()
|
||||
for g in games:
|
||||
if g['id'] == game_id:
|
||||
g['owned'] = not g.get('owned', False)
|
||||
save_games(games)
|
||||
if g['owned'] and not g.get('cover_art_local'):
|
||||
info = scrape_game_info(g['title'])
|
||||
if info:
|
||||
games = load_games()
|
||||
for gg in games:
|
||||
if gg['id'] == game_id:
|
||||
gg.update(info)
|
||||
break
|
||||
save_games(games)
|
||||
g.update(info)
|
||||
return jsonify(g)
|
||||
return jsonify({'error': 'Game not found'}), 404
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/games/<int:game_id>/flags', methods=['POST'])
|
||||
@require_auth
|
||||
def update_flags(game_id):
|
||||
games = load_games()
|
||||
data = request.get_json(silent=True) or {}
|
||||
for g in games:
|
||||
if g['id'] == game_id:
|
||||
for field in ('has_case', 'cleaned', 'battery_replaced'):
|
||||
if field in data:
|
||||
g[field] = bool(data[field])
|
||||
save_games(games)
|
||||
return jsonify(g)
|
||||
return jsonify({'error': 'Game not found'}), 404
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/games/<int:game_id>/info', methods=['GET'])
|
||||
def get_game_info(game_id):
|
||||
games = load_games()
|
||||
for g in games:
|
||||
if g['id'] == game_id:
|
||||
if not g.get('cover_art_local') and not g.get('cover_art_remote'):
|
||||
info = scrape_game_info(g['title'])
|
||||
if info:
|
||||
g.update(info)
|
||||
save_games(games)
|
||||
return jsonify(g)
|
||||
return jsonify({'error': 'Game not found'}), 404
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/rarity/refresh', methods=['POST'])
|
||||
@require_auth
|
||||
def refresh_rarity():
|
||||
try:
|
||||
rarity_data = scrape_rarityguide()
|
||||
if not rarity_data:
|
||||
return jsonify({'error': 'Scrape returned no data'}), 502
|
||||
games = load_games()
|
||||
updated = 0
|
||||
for g in games:
|
||||
match = _match_rarity(g['title'], rarity_data)
|
||||
if match:
|
||||
g['rarity_pct'] = match['rarity_pct']
|
||||
g['rarity_label'] = match['rarity_label']
|
||||
updated += 1
|
||||
elif 'rarity_pct' not in g:
|
||||
g['rarity_pct'] = None
|
||||
g['rarity_label'] = None
|
||||
save_games(games)
|
||||
return jsonify({'updated': updated, 'total': len(games)})
|
||||
except Exception as e:
|
||||
print(f'Rarity refresh error: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route(BASE_PREFIX + '/api/prices/refresh', methods=['POST'])
|
||||
@require_auth
|
||||
def refresh_prices():
|
||||
try:
|
||||
prices = scrape_pricecharting()
|
||||
if not prices:
|
||||
return jsonify({'error': 'Scrape returned no data'}), 502
|
||||
games = load_games()
|
||||
updated = 0
|
||||
for g in games:
|
||||
matched = _match_price(g['title'], prices)
|
||||
if matched is not None:
|
||||
g['loose_price'] = matched
|
||||
updated += 1
|
||||
elif 'loose_price' not in g:
|
||||
g['loose_price'] = None
|
||||
save_games(games)
|
||||
collection_value = round(sum(
|
||||
g.get('loose_price') or 0
|
||||
for g in games if g.get('owned') and g.get('loose_price')
|
||||
), 2)
|
||||
return jsonify({'updated': updated, 'total': len(games), 'collection_value': collection_value})
|
||||
except Exception as e:
|
||||
print(f'Price refresh error: {e}')
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# ── Scrapers ───────────────────────────────────────────────────────────────
|
||||
def scrape_rarityguide():
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'}
|
||||
base = 'https://www.rarityguide.com/n64_view.php'
|
||||
results = {}
|
||||
for first in [None, 101, 201]:
|
||||
params = {'SortDirection': 'desc', 'SortField': '5', 'recordsPerPage': '100'}
|
||||
if first:
|
||||
params['FirstRecord'] = str(first)
|
||||
try:
|
||||
resp = requests.get(base, params=params, headers=headers, timeout=20)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
for row in soup.find_all('tr'):
|
||||
tds = row.find_all('td')
|
||||
if len(tds) < 5:
|
||||
continue
|
||||
title = tds[1].get_text(strip=True)
|
||||
if not title:
|
||||
continue
|
||||
img = tds[4].find('img')
|
||||
if not img:
|
||||
continue
|
||||
alt = img.get('alt', '')
|
||||
m = re.match(r'(\d+)\s*percent\s*\(([^)]+)\)', alt)
|
||||
if not m:
|
||||
continue
|
||||
results[title] = {'rarity_pct': int(m.group(1)), 'rarity_label': m.group(2).strip()}
|
||||
return results if results else None
|
||||
|
||||
def _match_rarity(title, rarity_data):
|
||||
import difflib, unicodedata
|
||||
def norm(s):
|
||||
s = unicodedata.normalize('NFD', s)
|
||||
s = ''.join(c for c in s if unicodedata.category(c) != 'Mn')
|
||||
s = s.lower()
|
||||
s = re.sub(r'[^a-z0-9\s\-]', '', s)
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
for art in ('the ', 'a ', 'an '):
|
||||
if s.startswith(art): s = s[len(art):]
|
||||
return s
|
||||
tn = norm(title)
|
||||
for k, v in rarity_data.items():
|
||||
if norm(k) == tn: return v
|
||||
keys = list(rarity_data.keys())
|
||||
nkeys = [norm(k) for k in keys]
|
||||
matches = difflib.get_close_matches(tn, nkeys, n=1, cutoff=0.85)
|
||||
if matches:
|
||||
return rarity_data[keys[nkeys.index(matches[0])]]
|
||||
return None
|
||||
|
||||
def scrape_pricecharting():
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'}
|
||||
base_url = 'https://www.pricecharting.com/console/nintendo-64'
|
||||
prices = {}
|
||||
session = requests.Session()
|
||||
resp = session.get(base_url, headers=headers, timeout=20)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
def parse_page(html):
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
table = soup.find('table', id='games_table')
|
||||
if not table: return soup, False
|
||||
for row in table.find_all('tr'):
|
||||
title_td = row.find('td', class_='title')
|
||||
price_td = row.find('td', class_='used_price')
|
||||
if not title_td or not price_td: continue
|
||||
a = title_td.find('a')
|
||||
if not a: continue
|
||||
span = price_td.find('span', class_='js-price')
|
||||
price_text = span.get_text(strip=True) if span else 'N/A'
|
||||
try:
|
||||
prices[a.get_text(strip=True)] = float(price_text.replace('$','').replace(',',''))
|
||||
except (ValueError, AttributeError):
|
||||
prices[a.get_text(strip=True)] = None
|
||||
form = soup.find('form', {'id': re.compile(r'pagination')}) or soup.find('input', {'name': 'cursor'})
|
||||
if form:
|
||||
cursor_input = soup.find('input', {'name': 'cursor'})
|
||||
return soup, cursor_input
|
||||
return soup, None
|
||||
|
||||
soup, cursor_input = parse_page(resp.text)
|
||||
while cursor_input:
|
||||
cursor_val = cursor_input.get('value', '')
|
||||
try:
|
||||
resp = session.post(base_url, headers=headers, timeout=20,
|
||||
data={'cursor': cursor_val, 'q': '', 'sort': 'name'})
|
||||
if resp.status_code != 200: break
|
||||
except Exception: break
|
||||
_, cursor_input = parse_page(resp.text)
|
||||
return prices
|
||||
|
||||
def _normalize(s):
|
||||
s = s.lower()
|
||||
s = re.sub(r'[éèê]', 'e', s); s = re.sub(r'[óò]', 'o', s)
|
||||
s = re.sub(r'[úù]', 'u', s); s = re.sub(r'°', '', s)
|
||||
s = re.sub(r'[^a-z0-9\s]', '', s)
|
||||
s = re.sub(r'\s+', ' ', s).strip()
|
||||
for art in ('the ', 'a ', 'an '):
|
||||
if s.startswith(art): s = s[len(art):]
|
||||
return s
|
||||
|
||||
def _match_price(title, prices):
|
||||
if title in prices: return prices[title]
|
||||
tl = title.lower()
|
||||
for k, v in prices.items():
|
||||
if k.lower() == tl: return v
|
||||
tn = _normalize(title)
|
||||
for k, v in prices.items():
|
||||
if _normalize(k) == tn: return v
|
||||
return None
|
||||
|
||||
def scrape_game_info(title):
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||
resp = requests.get(f'https://gamesdb.launchbox-app.com/games/results/{quote(title)}',
|
||||
headers=headers, timeout=15)
|
||||
if resp.status_code != 200: return None
|
||||
soup = BeautifulSoup(resp.text, 'html.parser')
|
||||
title_lower = title.lower().strip()
|
||||
clean_title = re.sub(r'[^a-z0-9\s]', '', title_lower).strip()
|
||||
game_link = None
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link['href']
|
||||
if '/games/details/' not in href: continue
|
||||
link_text = link.get_text(strip=True)
|
||||
if 'Nintendo 64' not in link_text or 'Released' not in link_text: continue
|
||||
if re.sub(r'[^a-z0-9\s]', '', link_text.lower()).startswith(clean_title):
|
||||
game_link = href; break
|
||||
if not game_link: return None
|
||||
resp2 = requests.get(f'https://gamesdb.launchbox-app.com{game_link}', headers=headers, timeout=15)
|
||||
if resp2.status_code != 200: return None
|
||||
soup2 = BeautifulSoup(resp2.text, 'html.parser')
|
||||
info = {}
|
||||
for img in soup2.find_all('img'):
|
||||
if 'launchbox-app.com' in img.get('src','') and 'm-auto' in img.get('class',[]):
|
||||
info['cover_art_remote'] = img['src']; break
|
||||
for dt in soup2.find_all('dt'):
|
||||
dd = dt.find_next_sibling('dd')
|
||||
if not dd: continue
|
||||
label = dt.get_text(strip=True)
|
||||
value = dd.get_text(strip=True)
|
||||
if label == 'Developers': info['developer'] = value[:100]
|
||||
elif label == 'Genre': info['genres'] = value[:100]
|
||||
elif label == 'ESRB': info['rating'] = value[:50]
|
||||
elif label == 'Max Players': info['players'] = value[:50]
|
||||
if not info.get('description'):
|
||||
for p in soup2.find_all('p'):
|
||||
text = p.get_text(strip=True)
|
||||
if len(text) > 80:
|
||||
info['description'] = text[:500]; break
|
||||
return info if info else None
|
||||
except Exception as e:
|
||||
print(f"Scraping error for '{title}': {e}")
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue