262 lines
11 KiB
Python
262 lines
11 KiB
Python
import argparse
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from sqlalchemy.orm import Session
|
|
import aiohttp
|
|
import jinja2
|
|
from weasyprint import HTML
|
|
from app.services.base_service import BaseService
|
|
from app.models.tcgplayer_group import TCGPlayerGroup
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
ENV = jinja2.Environment(
|
|
loader=jinja2.FileSystemLoader("app/data/assets/templates"))
|
|
|
|
# Set types we are interested in
|
|
SET_TYPES = (
|
|
"core",
|
|
"expansion",
|
|
"starter", # Portal, P3k, welcome decks
|
|
"masters",
|
|
"commander",
|
|
"planechase",
|
|
"draft_innovation", # Battlebond, Conspiracy
|
|
"duel_deck", # Duel Deck Elves,
|
|
"premium_deck", # Premium Deck Series: Slivers, Premium Deck Series: Graveborn
|
|
"from_the_vault", # Make sure to adjust the MINIMUM_SET_SIZE if you want these
|
|
"archenemy",
|
|
"box",
|
|
"funny", # Unglued, Unhinged, Ponies: TG, etc.
|
|
# "memorabilia", # Commander's Arsenal, Celebration Cards, World Champ Decks
|
|
# "spellbook",
|
|
# These are relatively large groups of sets
|
|
# You almost certainly don't want these
|
|
# "token",
|
|
# "promo",
|
|
)
|
|
|
|
# Only include sets at least this size
|
|
# For reference, the smallest proper expansion is Arabian Nights with 78 cards
|
|
MINIMUM_SET_SIZE = 50
|
|
|
|
# Set codes you might want to ignore
|
|
IGNORED_SETS = (
|
|
"cmb1", # Mystery Booster Playtest Cards
|
|
"amh1", # Modern Horizon Art Series
|
|
"cmb2", # Mystery Booster Playtest Cards Part Deux
|
|
)
|
|
|
|
# Used to rename very long set names
|
|
RENAME_SETS = {
|
|
"Adventures in the Forgotten Realms": "Forgotten Realms",
|
|
"Adventures in the Forgotten Realms Minigames": "Forgotten Realms Minigames",
|
|
"Angels: They're Just Like Us but Cooler and with Wings": "Angels: Just Like Us",
|
|
"Archenemy: Nicol Bolas Schemes": "Archenemy: Bolas Schemes",
|
|
"Chronicles Foreign Black Border": "Chronicles FBB",
|
|
"Commander Anthology Volume II": "Commander Anthology II",
|
|
"Commander Legends: Battle for Baldur's Gate": "CMDR Legends: Baldur's Gate",
|
|
"Dominaria United Commander": "Dominaria United [C]",
|
|
"Duel Decks: Elves vs. Goblins": "DD: Elves vs. Goblins",
|
|
"Duel Decks: Jace vs. Chandra": "DD: Jace vs. Chandra",
|
|
"Duel Decks: Divine vs. Demonic": "DD: Divine vs. Demonic",
|
|
"Duel Decks: Garruk vs. Liliana": "DD: Garruk vs. Liliana",
|
|
"Duel Decks: Phyrexia vs. the Coalition": "DD: Phyrexia vs. Coalition",
|
|
"Duel Decks: Elspeth vs. Tezzeret": "DD: Elspeth vs. Tezzeret",
|
|
"Duel Decks: Knights vs. Dragons": "DD: Knights vs. Dragons",
|
|
"Duel Decks: Ajani vs. Nicol Bolas": "DD: Ajani vs. Nicol Bolas",
|
|
"Duel Decks: Heroes vs. Monsters": "DD: Heroes vs. Monsters",
|
|
"Duel Decks: Speed vs. Cunning": "DD: Speed vs. Cunning",
|
|
"Duel Decks Anthology: Elves vs. Goblins": "DDA: Elves vs. Goblins",
|
|
"Duel Decks Anthology: Jace vs. Chandra": "DDA: Jace vs. Chandra",
|
|
"Duel Decks Anthology: Divine vs. Demonic": "DDA: Divine vs. Demonic",
|
|
"Duel Decks Anthology: Garruk vs. Liliana": "DDA: Garruk vs. Liliana",
|
|
"Duel Decks: Elspeth vs. Kiora": "DD: Elspeth vs. Kiora",
|
|
"Duel Decks: Zendikar vs. Eldrazi": "DD: Zendikar vs. Eldrazi",
|
|
"Duel Decks: Blessed vs. Cursed": "DD: Blessed vs. Cursed",
|
|
"Duel Decks: Nissa vs. Ob Nixilis": "DD: Nissa vs. Ob Nixilis",
|
|
"Duel Decks: Merfolk vs. Goblins": "DD: Merfolk vs. Goblins",
|
|
"Duel Decks: Elves vs. Inventors": "DD: Elves vs. Inventors",
|
|
"Duel Decks: Mirrodin Pure vs. New Phyrexia": "DD: Mirrodin vs.New Phyrexia",
|
|
"Duel Decks: Izzet vs. Golgari": "Duel Decks: Izzet vs. Golgari",
|
|
"Fourth Edition Foreign Black Border": "Fourth Edition FBB",
|
|
"Global Series Jiang Yanggu & Mu Yanling": "Jiang Yanggu & Mu Yanling",
|
|
"Innistrad: Crimson Vow Minigames": "Crimson Vow Minigames",
|
|
"Introductory Two-Player Set": "Intro Two-Player Set",
|
|
"March of the Machine: The Aftermath": "MotM: The Aftermath",
|
|
"March of the Machine Commander": "March of the Machine [C]",
|
|
"Murders at Karlov Manor Commander": "Murders at Karlov Manor [C]",
|
|
"Mystery Booster Playtest Cards": "Mystery Booster Playtest",
|
|
"Mystery Booster Playtest Cards 2019": "MB Playtest Cards 2019",
|
|
"Mystery Booster Playtest Cards 2021": "MB Playtest Cards 2021",
|
|
"Mystery Booster Retail Edition Foils": "Mystery Booster Retail Foils",
|
|
"Outlaws of Thunder Junction Commander": "Outlaws of Thunder Junction [C]",
|
|
"Phyrexia: All Will Be One Commander": "Phyrexia: All Will Be One [C]",
|
|
"Planechase Anthology Planes": "Planechase Anth. Planes",
|
|
"Premium Deck Series: Slivers": "Premium Deck Slivers",
|
|
"Premium Deck Series: Graveborn": "Premium Deck Graveborn",
|
|
"Premium Deck Series: Fire and Lightning": "PD: Fire & Lightning",
|
|
"Shadows over Innistrad Remastered": "SOI Remastered",
|
|
"Strixhaven: School of Mages Minigames": "Strixhaven Minigames",
|
|
"Tales of Middle-earth Commander": "Tales of Middle-earth [C]",
|
|
"The Brothers' War Retro Artifacts": "Brothers' War Retro",
|
|
"The Brothers' War Commander": "Brothers' War Commander",
|
|
"The Lord of the Rings: Tales of Middle-earth": "LOTR: Tales of Middle-earth",
|
|
"The Lost Caverns of Ixalan Commander": "The Lost Caverns of Ixalan [C]",
|
|
"Warhammer 40,000 Commander": "Warhammer 40K [C]",
|
|
"Wilds of Eldraine Commander": "Wilds of Eldraine [C]",
|
|
"World Championship Decks 1997": "World Championship 1997",
|
|
"World Championship Decks 1998": "World Championship 1998",
|
|
"World Championship Decks 1999": "World Championship 1999",
|
|
"World Championship Decks 2000": "World Championship 2000",
|
|
"World Championship Decks 2001": "World Championship 2001",
|
|
"World Championship Decks 2002": "World Championship 2002",
|
|
"World Championship Decks 2003": "World Championship 2003",
|
|
"World Championship Decks 2004": "World Championship 2004",
|
|
}
|
|
|
|
|
|
class SetLabelService(BaseService):
|
|
DEFAULT_OUTPUT_DIR = "app/data/cache/set_labels"
|
|
os.makedirs(DEFAULT_OUTPUT_DIR, exist_ok=True)
|
|
|
|
def __init__(self, output_dir=DEFAULT_OUTPUT_DIR):
|
|
super().__init__(None) # BaseService doesn't need a model for this service
|
|
self.set_codes = []
|
|
self.ignored_sets = IGNORED_SETS
|
|
self.set_types = SET_TYPES
|
|
self.minimum_set_size = MINIMUM_SET_SIZE
|
|
self.output_dir = Path(output_dir)
|
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
async def get_set_data(self, session):
|
|
log.info("Getting set data and icons from Scryfall")
|
|
|
|
async with session.get("https://api.scryfall.com/sets") as resp:
|
|
resp.raise_for_status()
|
|
data = (await resp.json())["data"]
|
|
set_data = []
|
|
for exp in data:
|
|
if exp["code"] in self.ignored_sets:
|
|
continue
|
|
elif exp["card_count"] < self.minimum_set_size:
|
|
continue
|
|
elif self.set_types and exp["set_type"] not in self.set_types:
|
|
continue
|
|
elif self.set_codes and exp["code"].lower() not in self.set_codes:
|
|
continue
|
|
else:
|
|
set_data.append(exp)
|
|
|
|
if self.set_codes:
|
|
known_sets = set([exp["code"] for exp in data])
|
|
specified_sets = set([code.lower() for code in self.set_codes])
|
|
unknown_sets = specified_sets.difference(known_sets)
|
|
for set_code in unknown_sets:
|
|
log.warning("Unknown set '%s'", set_code)
|
|
|
|
set_data.reverse()
|
|
return set_data
|
|
|
|
async def get_set_icon(self, session, icon_url):
|
|
try:
|
|
async with session.get(icon_url) as resp:
|
|
if resp.status == 200:
|
|
icon_data = await resp.read()
|
|
return base64.b64encode(icon_data).decode('utf-8')
|
|
except Exception as e:
|
|
log.warning(f"Failed to fetch icon from {icon_url}: {e}")
|
|
return None
|
|
|
|
async def generate_label(self, session, set_data):
|
|
output_file = self.output_dir / f"{set_data['code']}.pdf"
|
|
|
|
# Check if file already exists
|
|
if output_file.exists():
|
|
log.info(f"Label already exists for {set_data['name']} ({set_data['code']})")
|
|
return output_file
|
|
|
|
name = RENAME_SETS.get(set_data["name"], set_data["name"])
|
|
icon_b64 = await self.get_set_icon(session, set_data["icon_svg_uri"])
|
|
|
|
template = ENV.get_template("set_label.html")
|
|
html_content = template.render(
|
|
name=name,
|
|
code=set_data["code"],
|
|
date=datetime.strptime(set_data["released_at"], "%Y-%m-%d").date(),
|
|
icon_b64=icon_b64,
|
|
)
|
|
|
|
HTML(string=html_content).write_pdf(output_file)
|
|
log.info(f"Generated label for {name} ({set_data['code']})")
|
|
return output_file
|
|
|
|
async def generate_labels(self, sets=None):
|
|
if sets:
|
|
self.ignored_sets = ()
|
|
self.minimum_set_size = 0
|
|
self.set_types = ()
|
|
self.set_codes = [exp.lower() for exp in sets]
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
set_data = await self.get_set_data(session)
|
|
tasks = [self.generate_label(session, exp) for exp in set_data]
|
|
return await asyncio.gather(*tasks)
|
|
|
|
async def get_available_sets(self, db: Session):
|
|
"""
|
|
Get a list of available MTG sets that can be used for label generation.
|
|
|
|
Returns:
|
|
List of set codes and their names
|
|
"""
|
|
try:
|
|
# Get all sets from the database
|
|
sets = db.query(TCGPlayerGroup).filter(
|
|
TCGPlayerGroup.category_id == 1,
|
|
TCGPlayerGroup.abbreviation.isnot(None),
|
|
TCGPlayerGroup.abbreviation != ""
|
|
).all()
|
|
if not sets:
|
|
log.warning("No sets found in database")
|
|
return []
|
|
return [{"code": set.abbreviation, "name": set.name} for set in sets]
|
|
except Exception as e:
|
|
log.error(f"Error getting available sets: {str(e)}")
|
|
raise
|
|
|
|
|
|
def main():
|
|
log_format = '[%(levelname)s] %(message)s'
|
|
logging.basicConfig(format=log_format, level=logging.INFO)
|
|
|
|
parser = argparse.ArgumentParser(description="Generate MTG labels")
|
|
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
default=SetLabelService.DEFAULT_OUTPUT_DIR,
|
|
help="Output labels to this directory",
|
|
)
|
|
parser.add_argument(
|
|
"sets",
|
|
nargs="*",
|
|
help=(
|
|
"Only output sets with the specified set code (eg. MH1, NEO). "
|
|
"This can be used multiple times."
|
|
),
|
|
metavar="SET",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
service = SetLabelService(args.output_dir)
|
|
asyncio.run(service.generate_labels(args.sets))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |