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()