ai_giga_tcg/app/services/set_label_service.py

275 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 = "set_labels" # Changed to be relative to FileService's base_cache_dir
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 = output_dir
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, db: Session):
"""Generate a label for a set and save it using FileService"""
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,
)
# Generate PDF content
pdf_content = HTML(string=html_content).write_pdf()
# Save using FileService
filename = f"{set_data['code']}.pdf"
metadata = {
"set_name": name,
"set_code": set_data["code"],
"release_date": set_data["released_at"],
"card_count": set_data["card_count"]
}
file_record = await self.file_service.save_file(
db=db,
file_data=pdf_content,
filename=filename,
subdir=self.output_dir,
file_type="set_label",
metadata=metadata
)
log.info(f"Generated label for {name} ({set_data['code']})")
return file_record
async def generate_labels(self, db: Session, sets=None):
"""Generate labels for sets and return their file records"""
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, db) 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()