From a0fd62b9d25a61025edb9ef94e38707fd1b510cb Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 27 Jan 2025 18:39:35 +0100
Subject: [PATCH] Deduplicate minifigures

---
 .env.sample                                   |  11 +-
 bricktracker/config.py                        |   4 +-
 bricktracker/minifigure.py                    | 161 ++++++------------
 bricktracker/minifigure_list.py               |  66 +++++--
 bricktracker/part.py                          |   8 +-
 bricktracker/part_list.py                     |   2 +-
 bricktracker/rebrickable_image.py             |  14 +-
 bricktracker/rebrickable_minifigure.py        | 130 ++++++++++++++
 bricktracker/rebrickable_minifigures.py       |  85 ---------
 bricktracker/rebrickable_parts.py             |   2 +-
 bricktracker/set.py                           |   5 +-
 bricktracker/set_list.py                      |  16 +-
 bricktracker/sql/migrations/0007.sql          |  30 ++++
 bricktracker/sql/migrations/0008.sql          |  32 ++++
 .../minifigure/base/{select.sql => base.sql}  |  17 +-
 bricktracker/sql/minifigure/insert.sql        |  20 +--
 bricktracker/sql/minifigure/list/all.sql      |  12 +-
 bricktracker/sql/minifigure/list/from_set.sql |   5 +-
 bricktracker/sql/minifigure/list/last.sql     |  10 +-
 .../sql/minifigure/list/missing_part.sql      |  10 +-
 .../sql/minifigure/list/using_part.sql        |   8 +-
 .../sql/minifigure/select/generic.sql         |  28 +--
 .../sql/minifigure/select/specific.sql        |   7 +-
 bricktracker/sql/part/list/all.sql            |  15 +-
 bricktracker/sql/part/list/missing.sql        |  10 +-
 bricktracker/sql/part/select/generic.sql      |  10 +-
 .../sql/rebrickable/minifigure/insert.sql     |  11 ++
 .../sql/rebrickable/minifigure/list.sql       |   6 +
 .../sql/rebrickable/minifigure/select.sql     |   8 +
 bricktracker/sql/schema/drop.sql              |   3 +
 bricktracker/sql/set/base/full.sql            |  10 +-
 bricktracker/sql/set/delete/set.sql           |   4 +-
 .../sql/set/list/missing_minifigure.sql       |   2 +-
 .../sql/set/list/using_minifigure.sql         |   2 +-
 bricktracker/sql/set/select/full.sql          |   2 +-
 bricktracker/sql_counter.py                   |   4 +-
 bricktracker/views/set.py                     |   2 +-
 templates/minifigure/card.html                |   8 +-
 templates/minifigure/table.html               |   2 +-
 templates/set/card.html                       |   2 +-
 40 files changed, 441 insertions(+), 343 deletions(-)
 create mode 100644 bricktracker/rebrickable_minifigure.py
 delete mode 100644 bricktracker/rebrickable_minifigures.py
 create mode 100644 bricktracker/sql/migrations/0007.sql
 create mode 100644 bricktracker/sql/migrations/0008.sql
 rename bricktracker/sql/minifigure/base/{select.sql => base.sql} (56%)
 create mode 100644 bricktracker/sql/rebrickable/minifigure/insert.sql
 create mode 100644 bricktracker/sql/rebrickable/minifigure/list.sql
 create mode 100644 bricktracker/sql/rebrickable/minifigure/select.sql

diff --git a/.env.sample b/.env.sample
index 06584db..91caf76 100644
--- a/.env.sample
+++ b/.env.sample
@@ -121,10 +121,11 @@
 
 # Optional: Change the default order of minifigures. By default ordered by insertion order.
 # Useful column names for this option are:
-# - "minifigures"."fig_num": minifigure ID (fig-xxxxx)
-# - "minifigures"."name": minifigure name
-# Default: "minifigures"."name" ASC
-# BK_MINIFIGURES_DEFAULT_ORDER="minifigures"."name" ASC
+# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx)
+# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx)
+# - "rebrickable_minifigures"."name": minifigure name
+# Default: "rebrickable_minifigures"."name" ASC
+# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC
 
 # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder
 # Default: minifigs
@@ -175,7 +176,7 @@
 # BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE=
 
 # Optional: Pattern of the link to Rebrickable for a minifigure. Will be passed to Python .format()
-# Default: https://rebrickable.com/minifigs/{number}
+# Default: https://rebrickable.com/minifigs/{figure}
 # BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN=
 
 # Optional: Pattern of the link to Rebrickable for a part. Will be passed to Python .format()
diff --git a/bricktracker/config.py b/bricktracker/config.py
index f9d8f2b..236eb54 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -32,7 +32,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'HIDE_MISSING_PARTS', 'c': bool},
     {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
     {'n': 'HIDE_WISHES', 'c': bool},
-    {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"minifigures"."name" ASC'},
+    {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'},  # noqa: E501
     {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
     {'n': 'NO_THREADED_SOCKET', 'c': bool},
     {'n': 'PARTS_DEFAULT_ORDER', 'd': '"inventory"."name" ASC, "inventory"."color_name" ASC, "inventory"."is_spare" ASC'},  # noqa: E501
@@ -42,7 +42,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
     {'n': 'REBRICKABLE_API_KEY', 'e': 'REBRICKABLE_API_KEY', 'd': ''},
     {'n': 'REBRICKABLE_IMAGE_NIL', 'd': 'https://rebrickable.com/static/img/nil.png'},  # noqa: E501
     {'n': 'REBRICKABLE_IMAGE_NIL_MINIFIGURE', 'd': 'https://rebrickable.com/static/img/nil_mf.jpg'},  # noqa: E501
-    {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{number}'},  # noqa: E501
+    {'n': 'REBRICKABLE_LINK_MINIFIGURE_PATTERN', 'd': 'https://rebrickable.com/minifigs/{figure}'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{number}/_/{color}'},  # noqa: E501
     {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'},  # noqa: E501
     {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'},  # noqa: E501
diff --git a/bricktracker/minifigure.py b/bricktracker/minifigure.py
index 0ad55b1..76a482e 100644
--- a/bricktracker/minifigure.py
+++ b/bricktracker/minifigure.py
@@ -1,48 +1,61 @@
-from sqlite3 import Row
-from typing import Any, Self, TYPE_CHECKING
-
-from flask import current_app, url_for
+import logging
+import traceback
+from typing import Self, TYPE_CHECKING
 
 from .exceptions import ErrorException, NotFoundException
 from .part_list import BrickPartList
-from .rebrickable_image import RebrickableImage
-from .record import BrickRecord
+from .rebrickable_parts import RebrickableParts
+from .rebrickable_minifigure import RebrickableMinifigure
 if TYPE_CHECKING:
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego minifigure
-class BrickMinifigure(BrickRecord):
-    brickset: 'BrickSet | None'
-
+class BrickMinifigure(RebrickableMinifigure):
     # Queries
     insert_query: str = 'minifigure/insert'
     generic_query: str = 'minifigure/select/generic'
     select_query: str = 'minifigure/select/specific'
 
-    def __init__(
-        self,
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        record: Row | dict[str, Any] | None = None,
-    ):
-        super().__init__()
+    def download(self, socket: 'BrickSocket'):
+        if self.brickset is None:
+            raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
 
-        # Save the brickset
-        self.brickset = brickset
+        try:
+            # Insert into the database
+            socket.auto_progress(
+                message='Set {set}: inserting minifigure {figure} into database'.format(  # noqa: E501
+                    set=self.brickset.fields.set,
+                    figure=self.fields.figure
+                )
+            )
 
-        # Ingest the record if it has one
-        if record is not None:
-            self.ingest(record)
+            # Insert into database
+            self.insert(commit=False)
 
-    # Return the number just in digits format
-    def clean_number(self, /) -> str:
-        number: str = self.fields.fig_num
-        number = number.removeprefix('fig-')
-        number = number.lstrip('0')
+            # Insert the rebrickable set into database
+            self.insert_rebrickable()
 
-        return number
+            # Load the inventory
+            RebrickableParts(
+                socket,
+                self.brickset,
+                minifigure=self,
+            ).download()
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing minifigure {figure} from {set}: {error}'.format(  # noqa: E501
+                    figure=self.fields.figure,
+                    set=self.brickset.fields.set,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
 
     # Parts
     def generic_parts(self, /) -> BrickPartList:
@@ -51,108 +64,38 @@ class BrickMinifigure(BrickRecord):
     # Parts
     def parts(self, /) -> BrickPartList:
         if self.brickset is None:
-            raise ErrorException('Part list for minifigure {number} requires a brickset'.format(  # noqa: E501
-                number=self.fields.fig_num,
+            raise ErrorException('Part list for minifigure {figure} requires a brickset'.format(  # noqa: E501
+                figure=self.fields.figure,
             ))
 
         return BrickPartList().load(self.brickset, minifigure=self)
 
     # Select a generic minifigure
-    def select_generic(self, fig_num: str, /) -> Self:
+    def select_generic(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         if not self.select(override_query=self.generic_query):
             raise NotFoundException(
-                'Minifigure with number {number} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.fig_num,
+                'Minifigure with figure {figure} was not found in the database'.format(  # noqa: E501
+                    figure=self.fields.figure,
                 ),
             )
 
         return self
 
-    # Select a specific minifigure (with a set and an number)
-    def select_specific(self, brickset: 'BrickSet', fig_num: str, /) -> Self:
+    # Select a specific minifigure (with a set and a figure)
+    def select_specific(self, brickset: 'BrickSet', figure: str, /) -> Self:
         # Save the parameters to the fields
         self.brickset = brickset
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         if not self.select():
             raise NotFoundException(
-                'Minifigure with number {number} from set {set} was not found in the database'.format(  # noqa: E501
-                    number=self.fields.fig_num,
+                'Minifigure with figure {figure} from set {set} was not found in the database'.format(  # noqa: E501
+                    figure=self.fields.figure,
                     set=self.brickset.fields.set,
                 ),
             )
 
         return self
-
-    # Return a dict with common SQL parameters for a minifigure
-    def sql_parameters(self, /) -> dict[str, Any]:
-        parameters = super().sql_parameters()
-
-        # Supplement from the brickset
-        if self.brickset is not None:
-            if 'u_id' not in parameters:
-                parameters['u_id'] = self.brickset.fields.id
-
-            if 'set_num' not in parameters:
-                parameters['set_num'] = self.brickset.fields.set
-
-        return parameters
-
-    # Self url
-    def url(self, /) -> str:
-        return url_for(
-            'minifigure.details',
-            number=self.fields.fig_num,
-        )
-
-    # Compute the url for minifigure part image
-    def url_for_image(self, /) -> str:
-        if not current_app.config['USE_REMOTE_IMAGES']:
-            if self.fields.set_img_url is None:
-                file = RebrickableImage.nil_minifigure_name()
-            else:
-                file = self.fields.fig_num
-
-            return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER')
-        else:
-            if self.fields.set_img_url is None:
-                return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
-            else:
-                return self.fields.set_img_url
-
-    # Compute the url for the rebrickable page
-    def url_for_rebrickable(self, /) -> str:
-        if current_app.config['REBRICKABLE_LINKS']:
-            try:
-                return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format(  # noqa: E501
-                    number=self.fields.fig_num.lower(),
-                )
-            except Exception:
-                pass
-
-        return ''
-
-    # Normalize from Rebrickable
-    @staticmethod
-    def from_rebrickable(
-        data: dict[str, Any],
-        /,
-        *,
-        brickset: 'BrickSet | None' = None,
-        **_,
-    ) -> dict[str, Any]:
-        record = {
-            'fig_num': data['set_num'],
-            'name': data['set_name'],
-            'quantity': data['quantity'],
-            'set_img_url': data['set_img_url'],
-        }
-
-        if brickset is not None:
-            record['set_num'] = brickset.fields.set
-            record['u_id'] = brickset.fields.id
-
-        return record
diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py
index 4b5f183..81affa6 100644
--- a/bricktracker/minifigure_list.py
+++ b/bricktracker/minifigure_list.py
@@ -1,11 +1,17 @@
+import logging
+import traceback
 from typing import Any, Self, TYPE_CHECKING
 
 from flask import current_app
 
 from .minifigure import BrickMinifigure
+from .rebrickable import Rebrickable
 from .record_list import BrickRecordList
 if TYPE_CHECKING:
     from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
 
 
 # Lego minifigures
@@ -47,7 +53,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
         if current_app.config['RANDOM']:
             order = 'RANDOM()'
         else:
-            order = 'minifigures.rowid DESC'
+            order = '"bricktracker_minifigures"."rowid" DESC'
 
         for record in self.select(
             override_query=self.last_query,
@@ -73,16 +79,6 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
 
         return self
 
-    # Return a dict with common SQL parameters for a minifigures list
-    def sql_parameters(self, /) -> dict[str, Any]:
-        parameters: dict[str, Any] = super().sql_parameters()
-
-        if self.brickset is not None:
-            parameters['u_id'] = self.brickset.fields.id
-            parameters['set_num'] = self.brickset.fields.set
-
-        return parameters
-
     # Minifigures missing a part
     def missing_part(
         self,
@@ -132,3 +128,51 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
             self.records.append(minifigure)
 
         return self
+
+    # Return a dict with common SQL parameters for a minifigures list
+    def sql_parameters(self, /) -> dict[str, Any]:
+        parameters: dict[str, Any] = super().sql_parameters()
+
+        if self.brickset is not None:
+            parameters['bricktracker_set_id'] = self.brickset.fields.id
+
+        return parameters
+
+    # Import the minifigures from Rebrickable
+    @staticmethod
+    def download(socket: 'BrickSocket', brickset: 'BrickSet', /) -> None:
+        try:
+            socket.auto_progress(
+                message='Set {set}: loading minifigures from Rebrickable'.format(  # noqa: E501
+                    set=brickset.fields.set,
+                ),
+                increment_total=True,
+            )
+
+            logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format(
+                set=brickset.fields.set,
+            ))
+
+            minifigures = Rebrickable[BrickMinifigure](
+                'get_set_minifigs',
+                brickset.fields.set,
+                BrickMinifigure,
+                socket=socket,
+                brickset=brickset,
+            ).list()
+
+            # Process each minifigure
+            socket.update_total(len(minifigures), add=True)
+
+            for minifigure in minifigures:
+                minifigure.download(socket)
+
+        except Exception as e:
+            socket.fail(
+                message='Error while importing set {set} minifigure list: {error}'.format(  # noqa: E501
+                    set=brickset.fields.set,
+                    error=e,
+                )
+            )
+
+            logger.debug(traceback.format_exc())
diff --git a/bricktracker/part.py b/bricktracker/part.py
index 80a51bd..b6dc153 100644
--- a/bricktracker/part.py
+++ b/bricktracker/part.py
@@ -137,7 +137,7 @@ class BrickPart(BrickRecord):
 
         if 'set_num' not in parameters:
             if self.minifigure is not None:
-                parameters['set_num'] = self.minifigure.fields.fig_num
+                parameters['set_num'] = self.minifigure.fields.figure
 
             elif self.brickset is not None:
                 parameters['set_num'] = self.brickset.fields.set
@@ -215,14 +215,14 @@ class BrickPart(BrickRecord):
             return url_for(
                 'set.missing_minifigure_part',
                 id=self.fields.u_id,
-                minifigure_id=self.minifigure.fields.fig_num,
-                part_id=self.fields.id,
+                figure=self.minifigure.fields.figure,
+                part=self.fields.id,
             )
 
         return url_for(
             'set.missing_part',
             id=self.fields.u_id,
-            part_id=self.fields.id
+            part=self.fields.id
         )
 
     # Compute the url for the rebrickable page
diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py
index 93897f8..7805d57 100644
--- a/bricktracker/part_list.py
+++ b/bricktracker/part_list.py
@@ -120,7 +120,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
         # Use the minifigure number if present,
         # otherwise use the set number
         if self.minifigure is not None:
-            parameters['set_num'] = self.minifigure.fields.fig_num
+            parameters['set_num'] = self.minifigure.fields.figure
         elif self.brickset is not None:
             parameters['set_num'] = self.brickset.fields.set
 
diff --git a/bricktracker/rebrickable_image.py b/bricktracker/rebrickable_image.py
index 0a0d9f4..f15a9b4 100644
--- a/bricktracker/rebrickable_image.py
+++ b/bricktracker/rebrickable_image.py
@@ -8,7 +8,7 @@ from shutil import copyfileobj
 
 from .exceptions import DownloadException
 if TYPE_CHECKING:
-    from .minifigure import BrickMinifigure
+    from .rebrickable_minifigure import RebrickableMinifigure
     from .part import BrickPart
     from .rebrickable_set import RebrickableSet
 
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
 # A set, part or minifigure image from Rebrickable
 class RebrickableImage(object):
     set: 'RebrickableSet'
-    minifigure: 'BrickMinifigure | None'
+    minifigure: 'RebrickableMinifigure | None'
     part: 'BrickPart | None'
 
     extension: str | None
@@ -26,7 +26,7 @@ class RebrickableImage(object):
         set: 'RebrickableSet',
         /,
         *,
-        minifigure: 'BrickMinifigure | None' = None,
+        minifigure: 'RebrickableMinifigure | None' = None,
         part: 'BrickPart | None' = None,
     ):
         # Save all objects
@@ -87,10 +87,10 @@ class RebrickableImage(object):
                 return self.part.fields.part_img_url_id
 
         if self.minifigure is not None:
-            if self.minifigure.fields.set_img_url is None:
+            if self.minifigure.fields.image is None:
                 return RebrickableImage.nil_minifigure_name()
             else:
-                return self.minifigure.fields.fig_num
+                return self.minifigure.fields.figure
 
         return self.set.fields.set
 
@@ -111,10 +111,10 @@ class RebrickableImage(object):
                 return self.part.fields.part_img_url
 
         if self.minifigure is not None:
-            if self.minifigure.fields.set_img_url is None:
+            if self.minifigure.fields.image is None:
                 return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
             else:
-                return self.minifigure.fields.set_img_url
+                return self.minifigure.fields.image
 
         return self.set.fields.image
 
diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py
new file mode 100644
index 0000000..28d3d75
--- /dev/null
+++ b/bricktracker/rebrickable_minifigure.py
@@ -0,0 +1,130 @@
+import logging
+from sqlite3 import Row
+from typing import Any, TYPE_CHECKING
+
+from flask import current_app, url_for
+
+from .exceptions import ErrorException
+from .rebrickable_image import RebrickableImage
+from .record import BrickRecord
+if TYPE_CHECKING:
+    from .set import BrickSet
+    from .socket import BrickSocket
+
+logger = logging.getLogger(__name__)
+
+
+# A minifigure from Rebrickable
+class RebrickableMinifigure(BrickRecord):
+    socket: 'BrickSocket'
+    brickset: 'BrickSet | None'
+
+    # Queries
+    select_query: str = 'rebrickable/minifigure/select'
+    insert_query: str = 'rebrickable/minifigure/insert'
+
+    def __init__(
+        self,
+        /,
+        *,
+        brickset: 'BrickSet | None' = None,
+        socket: 'BrickSocket | None' = None,
+        record: Row | dict[str, Any] | None = None
+    ):
+        super().__init__()
+
+        # Placeholders
+        self.instructions = []
+
+        # Save the brickset
+        self.brickset = brickset
+
+        # Save the socket
+        if socket is not None:
+            self.socket = socket
+
+        # Ingest the record if it has one
+        if record is not None:
+            self.ingest(record)
+
+    # Insert the minifigure from Rebrickable
+    def insert_rebrickable(self, /) -> bool:
+        if self.brickset is None:
+            raise ErrorException('Importing a minifigure from Rebrickable outside of a set is not supported')  # noqa: E501
+
+        # Insert the Rebrickable minifigure to the database
+        rows, _ = self.insert(
+            commit=False,
+            no_defer=True,
+            override_query=RebrickableMinifigure.insert_query
+        )
+
+        inserted = rows > 0
+
+        if inserted:
+            if not current_app.config['USE_REMOTE_IMAGES']:
+                RebrickableImage(
+                    self.brickset,
+                    minifigure=self,
+                ).download()
+
+        return inserted
+
+    # Return a dict with common SQL parameters for a minifigure
+    def sql_parameters(self, /) -> dict[str, Any]:
+        parameters = super().sql_parameters()
+
+        # Supplement from the brickset
+        if self.brickset is not None:
+            if 'bricktracker_set_id' not in parameters:
+                parameters['bricktracker_set_id'] = self.brickset.fields.id
+
+        return parameters
+
+    # Self url
+    def url(self, /) -> str:
+        return url_for(
+            'minifigure.details',
+            figure=self.fields.figure,
+        )
+
+    # Compute the url for minifigure image
+    def url_for_image(self, /) -> str:
+        if not current_app.config['USE_REMOTE_IMAGES']:
+            if self.fields.image is None:
+                file = RebrickableImage.nil_minifigure_name()
+            else:
+                file = self.fields.figure
+
+            return RebrickableImage.static_url(file, 'MINIFIGURES_FOLDER')
+        else:
+            if self.fields.image is None:
+                return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
+            else:
+                return self.fields.image
+
+    # Compute the url for the rebrickable page
+    def url_for_rebrickable(self, /) -> str:
+        if current_app.config['REBRICKABLE_LINKS']:
+            try:
+                return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format(  # noqa: E501
+                    number=self.fields.figure,
+                )
+            except Exception:
+                pass
+
+        return ''
+
+    # Normalize from Rebrickable
+    @staticmethod
+    def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
+        # Extracting  number
+        number = int(str(data['set_num'])[5:])
+
+        return {
+            'figure': str(data['set_num']),
+            'number': int(number),
+            'name': str(data['set_name']),
+            'quantity': int(data['quantity']),
+            'image': data['set_img_url'],
+        }
diff --git a/bricktracker/rebrickable_minifigures.py b/bricktracker/rebrickable_minifigures.py
deleted file mode 100644
index eb72e06..0000000
--- a/bricktracker/rebrickable_minifigures.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import logging
-from typing import TYPE_CHECKING
-
-from flask import current_app
-
-from .minifigure import BrickMinifigure
-from .rebrickable import Rebrickable
-from .rebrickable_image import RebrickableImage
-from .rebrickable_parts import RebrickableParts
-if TYPE_CHECKING:
-    from .set import BrickSet
-    from .socket import BrickSocket
-
-logger = logging.getLogger(__name__)
-
-
-# Minifigures from Rebrickable
-class RebrickableMinifigures(object):
-    socket: 'BrickSocket'
-    brickset: 'BrickSet'
-
-    def __init__(self, socket: 'BrickSocket', brickset: 'BrickSet', /):
-        # Save the socket
-        self.socket = socket
-
-        # Save the objects
-        self.brickset = brickset
-
-    # Import the minifigures from Rebrickable
-    def download(self, /) -> None:
-        self.socket.auto_progress(
-            message='Set {number}: loading minifigures from Rebrickable'.format(  # noqa: E501
-                number=self.brickset.fields.set,
-            ),
-            increment_total=True,
-        )
-
-        logger.debug('rebrick.lego.get_set_minifigs("{set}")'.format(
-            set=self.brickset.fields.set,
-        ))
-
-        minifigures = Rebrickable[BrickMinifigure](
-            'get_set_minifigs',
-            self.brickset.fields.set,
-            BrickMinifigure,
-            socket=self.socket,
-            brickset=self.brickset,
-        ).list()
-
-        # Process each minifigure
-        total = len(minifigures)
-        for index, minifigure in enumerate(minifigures):
-            # Insert into the database
-            self.socket.auto_progress(
-                message='Set {number}: inserting minifigure {current}/{total} into database'.format(  # noqa: E501
-                    number=self.brickset.fields.set,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            # Insert into database
-            minifigure.insert(commit=False)
-
-            # Grab the image
-            self.socket.progress(
-                message='Set {number}: downloading minifigure {current}/{total} image'.format(  # noqa: E501
-                    number=self.brickset.fields.set,
-                    current=index+1,
-                    total=total,
-                )
-            )
-
-            if not current_app.config['USE_REMOTE_IMAGES']:
-                RebrickableImage(
-                    self.brickset,
-                    minifigure=minifigure
-                ).download()
-
-            # Load the inventory
-            RebrickableParts(
-                self.socket,
-                self.brickset,
-                minifigure=minifigure,
-            ).download()
diff --git a/bricktracker/rebrickable_parts.py b/bricktracker/rebrickable_parts.py
index 69c42dc..9fd2341 100644
--- a/bricktracker/rebrickable_parts.py
+++ b/bricktracker/rebrickable_parts.py
@@ -40,7 +40,7 @@ class RebrickableParts(object):
         self.minifigure = minifigure
 
         if self.minifigure is not None:
-            self.number = self.minifigure.fields.fig_num
+            self.number = self.minifigure.fields.figure
             self.kind = 'Minifigure'
             self.method = 'get_minifig_elements'
         else:
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 17f71b8..52a2ed9 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -8,7 +8,6 @@ from flask import current_app, url_for
 from .exceptions import DatabaseException, NotFoundException
 from .minifigure_list import BrickMinifigureList
 from .part_list import BrickPartList
-from .rebrickable_minifigures import RebrickableMinifigures
 from .rebrickable_parts import RebrickableParts
 from .rebrickable_set import RebrickableSet
 from .set_checkbox import BrickSetCheckbox
@@ -55,14 +54,14 @@ class BrickSet(RebrickableSet):
             # Insert into database
             self.insert(commit=False)
 
-            # Execute the parent download method
+            # Insert the rebrickable set into database
             self.insert_rebrickable()
 
             # Load the inventory
             RebrickableParts(socket, self).download()
 
             # Load the minifigures
-            RebrickableMinifigureList(socket, self).download()
+            BrickMinifigureList.download(socket, self)
 
             # Commit the transaction to the database
             socket.auto_progress(
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index 3b229e8..58ae8ec 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -82,13 +82,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets missing a minifigure
-    def missing_minifigure(
-        self,
-        fig_num: str,
-        /
-    ) -> Self:
+    def missing_minifigure(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         # Load the sets from the database
         for record in self.select(
@@ -127,13 +123,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
         return self
 
     # Sets using a minifigure
-    def using_minifigure(
-        self,
-        fig_num: str,
-        /
-    ) -> Self:
+    def using_minifigure(self, figure: str, /) -> Self:
         # Save the parameters to the fields
-        self.fields.fig_num = fig_num
+        self.fields.figure = figure
 
         # Load the sets from the database
         for record in self.select(
diff --git a/bricktracker/sql/migrations/0007.sql b/bricktracker/sql/migrations/0007.sql
new file mode 100644
index 0000000..09830c4
--- /dev/null
+++ b/bricktracker/sql/migrations/0007.sql
@@ -0,0 +1,30 @@
+-- description: Creation of the deduplicated table of Rebrickable minifigures
+
+BEGIN TRANSACTION;
+
+-- Create a Rebrickable minifigures table: each unique minifigure imported from Rebrickable
+CREATE TABLE "rebrickable_minifigures" (
+    "figure" TEXT NOT NULL,
+    "number" INTEGER NOT NULL,
+    "name" TEXT NOT NULL,
+    "image" TEXT,
+    PRIMARY KEY("figure")
+);
+
+-- Insert existing sets into the new table
+INSERT INTO "rebrickable_minifigures" (
+    "figure",
+    "number",
+    "name",
+    "image"
+)
+SELECT
+    "minifigures"."fig_num",
+    CAST(SUBSTR("minifigures"."fig_num", 5) AS INTEGER),
+    "minifigures"."name",
+    "minifigures"."set_img_url"
+FROM "minifigures"
+GROUP BY
+    "minifigures"."fig_num";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/migrations/0008.sql b/bricktracker/sql/migrations/0008.sql
new file mode 100644
index 0000000..48b905a
--- /dev/null
+++ b/bricktracker/sql/migrations/0008.sql
@@ -0,0 +1,32 @@
+-- description: Migrate the Bricktracker minifigures
+
+PRAGMA foreign_keys = ON;
+
+BEGIN TRANSACTION;
+
+-- Create a Bricktable minifigures table: an amount of minifigures linked to a Bricktracker set
+CREATE TABLE "bricktracker_minifigures" (
+    "bricktracker_set_id" TEXT NOT NULL,
+    "rebrickable_figure" TEXT NOT NULL,
+    "quantity" INTEGER NOT NULL,
+    PRIMARY KEY("bricktracker_set_id", "rebrickable_figure"),
+    FOREIGN KEY("bricktracker_set_id") REFERENCES "bricktracker_sets"("id"),
+    FOREIGN KEY("rebrickable_figure") REFERENCES "rebrickable_minifigures"("figure")
+);
+
+-- Insert existing sets into the new table
+INSERT INTO "bricktracker_minifigures" (
+    "bricktracker_set_id",
+    "rebrickable_figure",
+    "quantity"
+)
+SELECT
+    "minifigures"."u_id",
+    "minifigures"."fig_num",
+    "minifigures"."quantity"
+FROM "minifigures";
+
+-- Rename the original table (don't delete it yet?)
+ALTER TABLE "minifigures" RENAME TO "minifigures_old";
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/minifigure/base/select.sql b/bricktracker/sql/minifigure/base/base.sql
similarity index 56%
rename from bricktracker/sql/minifigure/base/select.sql
rename to bricktracker/sql/minifigure/base/base.sql
index 8182998..bfaf10d 100644
--- a/bricktracker/sql/minifigure/base/select.sql
+++ b/bricktracker/sql/minifigure/base/base.sql
@@ -1,10 +1,10 @@
 SELECT
-    "minifigures"."fig_num",
-    "minifigures"."set_num",
-    "minifigures"."name",
-    "minifigures"."quantity",
-    "minifigures"."set_img_url",
-    "minifigures"."u_id",
+    {% block set %}{% endblock %}
+    "bricktracker_minifigures"."quantity",
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image",
     {% block total_missing %}
     NULL AS "total_missing", -- dummy for order: total_missing
     {% endblock %}
@@ -14,7 +14,10 @@ SELECT
     {% block total_sets %}
     NULL AS "total_sets" -- dummy for order: total_sets
     {% endblock %}
-FROM "minifigures"
+FROM "bricktracker_minifigures"
+
+INNER JOIN "rebrickable_minifigures"
+ON "bricktracker_minifigures"."rebrickable_figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
 
 {% block join %}{% endblock %}
 
diff --git a/bricktracker/sql/minifigure/insert.sql b/bricktracker/sql/minifigure/insert.sql
index d72a2a3..cd7c413 100644
--- a/bricktracker/sql/minifigure/insert.sql
+++ b/bricktracker/sql/minifigure/insert.sql
@@ -1,15 +1,9 @@
-INSERT INTO "minifigures" (
-    "fig_num",
-    "set_num",
-    "name",
-    "quantity",
-    "set_img_url",
-    "u_id"
+INSERT INTO "bricktracker_minifigures" (
+    "bricktracker_set_id",
+    "rebrickable_figure",
+    "quantity"
 ) VALUES (
-    :fig_num,
-    :set_num,
-    :name,
-    :quantity,
-    :set_img_url,
-    :u_id
+    :bricktracker_set_id,
+    :figure,
+    :quantity
 )
diff --git a/bricktracker/sql/minifigure/list/all.sql b/bricktracker/sql/minifigure/list/all.sql
index a00f474..82e61a2 100644
--- a/bricktracker/sql/minifigure/list/all.sql
+++ b/bricktracker/sql/minifigure/list/all.sql
@@ -1,15 +1,15 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("minifigures"."set_num") AS "total_sets"
+COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
@@ -24,11 +24,11 @@ LEFT JOIN (
         "missing"."set_num",
         "missing"."u_id"
 ) missing_join
-ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
-AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
+ON "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing_join"."u_id"
+AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing_join"."set_num"
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/from_set.sql b/bricktracker/sql/minifigure/list/from_set.sql
index ea2dcbe..65b4e69 100644
--- a/bricktracker/sql/minifigure/list/from_set.sql
+++ b/bricktracker/sql/minifigure/list/from_set.sql
@@ -1,6 +1,5 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
-AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/last.sql b/bricktracker/sql/minifigure/list/last.sql
index faf3f40..266b7c0 100644
--- a/bricktracker/sql/minifigure/list/last.sql
+++ b/bricktracker/sql/minifigure/list/last.sql
@@ -1,4 +1,4 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
@@ -6,12 +6,12 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 
 {% block join %}
 LEFT JOIN "missing"
-ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num",
-    "minifigures"."u_id"
+    "rebrickable_minifigures"."figure",
+    "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/missing_part.sql b/bricktracker/sql/minifigure/list/missing_part.sql
index e0bc54d..660da6d 100644
--- a/bricktracker/sql/minifigure/list/missing_part.sql
+++ b/bricktracker/sql/minifigure/list/missing_part.sql
@@ -1,4 +1,4 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
 SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
@@ -6,12 +6,12 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 
 {% block join %}
 LEFT JOIN "missing"
-ON "minifigures"."fig_num" IS NOT DISTINCT FROM "missing"."set_num"
-AND "minifigures"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IN (
+WHERE "rebrickable_minifigures"."figure" IN (
     SELECT
         "missing"."set_num"
     FROM "missing"
@@ -26,5 +26,5 @@ WHERE "minifigures"."fig_num" IN (
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/list/using_part.sql b/bricktracker/sql/minifigure/list/using_part.sql
index c40d379..e701d8d 100644
--- a/bricktracker/sql/minifigure/list/using_part.sql
+++ b/bricktracker/sql/minifigure/list/using_part.sql
@@ -1,11 +1,11 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_quantity %}
-SUM("minifigures"."quantity") AS "total_quantity",
+SUM("bricktracker_minifigures"."quantity") AS "total_quantity",
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IN (
+WHERE "rebrickable_minifigures"."figure" IN (
     SELECT
         "inventory"."set_num"
     FROM "inventory"
@@ -20,5 +20,5 @@ WHERE "minifigures"."fig_num" IN (
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql
index 114810d..966c022 100644
--- a/bricktracker/sql/minifigure/select/generic.sql
+++ b/bricktracker/sql/minifigure/select/generic.sql
@@ -1,38 +1,28 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block total_missing %}
-SUM(IFNULL("missing_join"."total", 0)) AS "total_missing",
+SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_quantity",
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("minifigures"."set_num") AS "total_sets"
+COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets"
 {% endblock %}
 
 {% block join %}
--- LEFT JOIN + SELECT to avoid messing the total
-LEFT JOIN (
-    SELECT
-        "missing"."set_num",
-        "missing"."u_id",
-        SUM("missing"."quantity") AS "total"
-    FROM "missing"
-    GROUP BY
-        "missing"."set_num",
-        "missing"."u_id"
-) "missing_join"
-ON "minifigures"."u_id" IS NOT DISTINCT FROM "missing_join"."u_id"
-AND "minifigures"."fig_num" IS NOT DISTINCT FROM "missing_join"."set_num"
+LEFT JOIN "missing"
+ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "missing"."set_num"
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM "missing"."u_id"
 {% endblock %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
 {% endblock %}
 
 {% block group %}
 GROUP BY
-    "minifigures"."fig_num"
+    "rebrickable_minifigures"."figure"
 {% endblock %}
diff --git a/bricktracker/sql/minifigure/select/specific.sql b/bricktracker/sql/minifigure/select/specific.sql
index 34a8b3d..479c9e5 100644
--- a/bricktracker/sql/minifigure/select/specific.sql
+++ b/bricktracker/sql/minifigure/select/specific.sql
@@ -1,7 +1,6 @@
-{% extends 'minifigure/base/select.sql' %}
+{% extends 'minifigure/base/base.sql' %}
 
 {% block where %}
-WHERE "minifigures"."fig_num" IS NOT DISTINCT FROM :fig_num
-AND "minifigures"."u_id" IS NOT DISTINCT FROM :u_id
-AND "minifigures"."set_num" IS NOT DISTINCT FROM :set_num
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
+AND "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :bricktracker_set_id
 {% endblock %}
diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql
index b1ff2ac..9d73fcc 100644
--- a/bricktracker/sql/part/list/all.sql
+++ b/bricktracker/sql/part/list/all.sql
@@ -5,15 +5,15 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM("inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity",
+SUM("inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_sets %}
-COUNT(DISTINCT "bricktracker_sets"."id") AS "total_sets",
+COUNT(DISTINCT "bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
@@ -25,12 +25,9 @@ AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
 AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
-LEFT JOIN "minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
-
-LEFT JOIN "bricktracker_sets"
-ON "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/list/missing.sql b/bricktracker/sql/part/list/missing.sql
index 555916f..fc64e25 100644
--- a/bricktracker/sql/part/list/missing.sql
+++ b/bricktracker/sql/part/list/missing.sql
@@ -5,11 +5,11 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_sets %}
-COUNT("inventory"."u_id")  - COUNT("minifigures"."u_id") AS "total_sets",
+COUNT("inventory"."u_id")  - COUNT("bricktracker_minifigures"."bricktracker_set_id") AS "total_sets",
 {% endblock %}
 
 {% block total_minifigures %}
-SUM(IFNULL("minifigures"."quantity", 0)) AS "total_minifigures"
+SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
 {% endblock %}
 
 {% block join %}
@@ -21,9 +21,9 @@ AND "missing"."color_id" IS NOT DISTINCT FROM "inventory"."color_id"
 AND "missing"."element_id" IS NOT DISTINCT FROM "inventory"."element_id"
 AND "missing"."u_id" IS NOT DISTINCT FROM "inventory"."u_id"
 
-LEFT JOIN "minifigures"
-ON "missing"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "missing"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block group %}
diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql
index 4a75b4c..28b32a9 100644
--- a/bricktracker/sql/part/select/generic.sql
+++ b/bricktracker/sql/part/select/generic.sql
@@ -5,11 +5,11 @@ SUM(IFNULL("missing"."quantity", 0)) AS "total_missing",
 {% endblock %}
 
 {% block total_quantity %}
-SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_quantity",
+SUM((NOT "inventory"."is_spare") * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
 {% endblock %}
 
 {% block total_spare %}
-SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("minifigures"."quantity", 1)) AS "total_spare",
+SUM("inventory"."is_spare" * "inventory"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare",
 {% endblock %}
 
 {% block join %}
@@ -21,9 +21,9 @@ AND "inventory"."color_id" IS NOT DISTINCT FROM "missing"."color_id"
 AND "inventory"."element_id" IS NOT DISTINCT FROM "missing"."element_id"
 AND "inventory"."u_id" IS NOT DISTINCT FROM "missing"."u_id"
 
-LEFT JOIN "minifigures"
-ON "inventory"."set_num" IS NOT DISTINCT FROM "minifigures"."fig_num"
-AND "inventory"."u_id" IS NOT DISTINCT FROM "minifigures"."u_id"
+LEFT JOIN "bricktracker_minifigures"
+ON "inventory"."set_num" IS NOT DISTINCT FROM "bricktracker_minifigures"."rebrickable_figure"
+AND "inventory"."u_id" IS NOT DISTINCT FROM "bricktracker_minifigures"."bricktracker_set_id"
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql/rebrickable/minifigure/insert.sql b/bricktracker/sql/rebrickable/minifigure/insert.sql
new file mode 100644
index 0000000..0671925
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/insert.sql
@@ -0,0 +1,11 @@
+INSERT OR IGNORE INTO "rebrickable_minifigures" (
+    "figure",
+    "number",
+    "name",
+    "image"
+) VALUES (
+    :figure,
+    :number,
+    :name,
+    :image
+)
diff --git a/bricktracker/sql/rebrickable/minifigure/list.sql b/bricktracker/sql/rebrickable/minifigure/list.sql
new file mode 100644
index 0000000..ec379d8
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/list.sql
@@ -0,0 +1,6 @@
+SELECT
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image"
+FROM "rebrickable_minifigures"
diff --git a/bricktracker/sql/rebrickable/minifigure/select.sql b/bricktracker/sql/rebrickable/minifigure/select.sql
new file mode 100644
index 0000000..f1c68c1
--- /dev/null
+++ b/bricktracker/sql/rebrickable/minifigure/select.sql
@@ -0,0 +1,8 @@
+SELECT
+    "rebrickable_minifigures"."figure",
+    "rebrickable_minifigures"."number",
+    "rebrickable_minifigures"."name",
+    "rebrickable_minifigures"."image"
+FROM "rebrickable_minifigures"
+
+WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql
index b961b28..1d39d99 100644
--- a/bricktracker/sql/schema/drop.sql
+++ b/bricktracker/sql/schema/drop.sql
@@ -1,12 +1,15 @@
 BEGIN transaction;
 
+DROP TABLE IF EXISTS "bricktracker_minifigures";
 DROP TABLE IF EXISTS "bricktracker_sets";
 DROP TABLE IF EXISTS "bricktracker_set_checkboxes";
 DROP TABLE IF EXISTS "bricktracker_set_statuses";
 DROP TABLE IF EXISTS "bricktracker_wishes";
 DROP TABLE IF EXISTS "inventory";
 DROP TABLE IF EXISTS "minifigures";
+DROP TABLE IF EXISTS "minifigures_old";
 DROP TABLE IF EXISTS "missing";
+DROP TABLE IF EXISTS "rebrickable_minifigures";
 DROP TABLE IF EXISTS "rebrickable_sets";
 DROP TABLE IF EXISTS "sets";
 DROP TABLE IF EXISTS "sets_old";
diff --git a/bricktracker/sql/set/base/full.sql b/bricktracker/sql/set/base/full.sql
index c169c7a..68333c2 100644
--- a/bricktracker/sql/set/base/full.sql
+++ b/bricktracker/sql/set/base/full.sql
@@ -32,11 +32,11 @@ ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "missing_join"."u_id"
 -- LEFT JOIN + SELECT to avoid messing the total
 LEFT JOIN (
     SELECT
-       "minifigures"."u_id",
-       SUM("minifigures"."quantity") AS "total"
-    FROM "minifigures"
+       "bricktracker_minifigures"."bricktracker_set_id",
+       SUM("bricktracker_minifigures"."quantity") AS "total"
+    FROM "bricktracker_minifigures"
     {% block where_minifigures %}{% endblock %}
-    GROUP BY "u_id"
+    GROUP BY "bricktracker_minifigures"."bricktracker_set_id"
 ) "minifigures_join"
-ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."u_id"
+ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "minifigures_join"."bricktracker_set_id"
 {% endblock %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/delete/set.sql b/bricktracker/sql/set/delete/set.sql
index dd2c856..93a51df 100644
--- a/bricktracker/sql/set/delete/set.sql
+++ b/bricktracker/sql/set/delete/set.sql
@@ -9,8 +9,8 @@ WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM '{{ id }}';
 DELETE FROM "bricktracker_set_statuses"
 WHERE "bricktracker_set_statuses"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
 
-DELETE FROM "minifigures"
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM '{{ id }}';
+DELETE FROM "bricktracker_minifigures"
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM '{{ id }}';
 
 DELETE FROM "missing"
 WHERE "missing"."u_id" IS NOT DISTINCT FROM '{{ id }}';
diff --git a/bricktracker/sql/set/list/missing_minifigure.sql b/bricktracker/sql/set/list/missing_minifigure.sql
index 5f27088..2f19bfe 100644
--- a/bricktracker/sql/set/list/missing_minifigure.sql
+++ b/bricktracker/sql/set/list/missing_minifigure.sql
@@ -6,7 +6,7 @@ WHERE "bricktracker_sets"."id" IN (
         "missing"."u_id"
     FROM "missing"
 
-    WHERE "missing"."set_num" IS NOT DISTINCT FROM :fig_num
+    WHERE "missing"."set_num" IS NOT DISTINCT FROM :figure
 
     GROUP BY "missing"."u_id"
 )
diff --git a/bricktracker/sql/set/list/using_minifigure.sql b/bricktracker/sql/set/list/using_minifigure.sql
index f08a5d7..711866b 100644
--- a/bricktracker/sql/set/list/using_minifigure.sql
+++ b/bricktracker/sql/set/list/using_minifigure.sql
@@ -6,7 +6,7 @@ WHERE "bricktracker_sets"."id" IN (
         "inventory"."u_id"
     FROM "inventory"
 
-    WHERE "inventory"."set_num" IS NOT DISTINCT FROM :fig_num
+    WHERE "inventory"."set_num" IS NOT DISTINCT FROM :figure
 
     GROUP BY "inventory"."u_id"
 )
diff --git a/bricktracker/sql/set/select/full.sql b/bricktracker/sql/set/select/full.sql
index 4b19136..a89d2c9 100644
--- a/bricktracker/sql/set/select/full.sql
+++ b/bricktracker/sql/set/select/full.sql
@@ -5,7 +5,7 @@ WHERE "missing"."u_id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where_minifigures %}
-WHERE "minifigures"."u_id" IS NOT DISTINCT FROM :id
+WHERE "bricktracker_minifigures"."bricktracker_set_id" IS NOT DISTINCT FROM :id
 {% endblock %}
 
 {% block where %}
diff --git a/bricktracker/sql_counter.py b/bricktracker/sql_counter.py
index 7175494..d01546f 100644
--- a/bricktracker/sql_counter.py
+++ b/bricktracker/sql_counter.py
@@ -2,13 +2,15 @@ from typing import Tuple
 
 # Some table aliases to make it look cleaner (id: (name, icon))
 ALIASES: dict[str, Tuple[str, str]] = {
-    'bricktracker_set_checkboxes': ('Checkboxes', 'checkbox-line'),
+    'bricktracker_minifigures': ('Bricktracker minifigures', 'group-line'),
     'bricktracker_set_statuses': ('Bricktracker sets status', 'checkbox-line'),
     'bricktracker_sets': ('Bricktracker sets', 'hashtag'),
     'bricktracker_wishes': ('Bricktracker wishes', 'gift-line'),
     'inventory': ('Parts', 'shapes-line'),
     'minifigures': ('Minifigures', 'group-line'),
+    'minifigures_old': ('Minifigures (legacy)', 'group-line'),
     'missing': ('Missing', 'error-warning-line'),
+    'rebrickable_minifigures': ('Rebrickable minifigures', 'group-line'),
     'rebrickable_sets': ('Rebrickable sets', 'hashtag'),
     'sets': ('Sets', 'hashtag'),
     'sets_old': ('Sets (legacy)', 'hashtag'),
diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py
index 02353c1..1683175 100644
--- a/bricktracker/views/set.py
+++ b/bricktracker/views/set.py
@@ -128,7 +128,7 @@ def missing_minifigure_part(*, id: str, figure: str, part: str) -> Response:
     logger.info('Set {number} ({id}): updated minifigure ({figure}) part ({part}) missing count to {missing}'.format(  # noqa: E501
         number=brickset.fields.set,
         id=brickset.fields.id,
-        figure=brickminifigure.fields.fig_num,
+        figure=brickminifigure.fields.figure,
         part=brickpart.fields.id,
         missing=missing,
     ))
diff --git a/templates/minifigure/card.html b/templates/minifigure/card.html
index 79ed414..b1949ef 100644
--- a/templates/minifigure/card.html
+++ b/templates/minifigure/card.html
@@ -3,11 +3,11 @@
 {% import 'macro/card.html' as card %}
 
 <div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
-  {{ card.header(item, item.fields.name, solo=solo, number=item.clean_number(), icon='user-line') }}
-  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.fig_num, medium=true) }}
+  {{ card.header(item, item.fields.name, solo=solo, number=item.fields.number, icon='user-line') }}
+  {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.figure, medium=true) }}
   <div class="card-body border-bottom {% if not solo %}p-1{% endif %}">
     {% if last %}
-      {{ badge.set(item.fields.set_num, solo=solo, last=last, id=item.fields.u_id) }}
+      {{ badge.set(item.fields.set, solo=solo, last=last, id=item.fields.rebrickable_set_id) }}
       {{ badge.quantity(item.fields.quantity, solo=solo, last=last) }}
     {% endif %}
     {{ badge.quantity(item.fields.total_quantity, solo=solo, last=last) }}
@@ -19,7 +19,7 @@
   </div>
   {% if solo %}
     <div class="accordion accordion-flush" id="minifigure-details">
-      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.fig_num, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.fig_num, read_only_missing=read_only_missing)}}
+      {{ accordion.table(item.generic_parts(), 'Parts', item.fields.figure, 'minifigure-details', 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only_missing=read_only_missing)}}
       {{ accordion.cards(using, 'Sets using this minifigure', 'using-inventory', 'minifigure-details', 'set/card.html', icon='hashtag') }}
       {{ accordion.cards(missing, 'Sets missing parts of this minifigure', 'missing-inventory', 'minifigure-details', 'set/card.html', icon='error-warning-line') }}
     </div>
diff --git a/templates/minifigure/table.html b/templates/minifigure/table.html
index 94ccef7..66ece79 100644
--- a/templates/minifigure/table.html
+++ b/templates/minifigure/table.html
@@ -6,7 +6,7 @@
     <tbody>
       {% for item in table_collection %}
       <tr>
-        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.fig_num) }}
+        {{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.figure) }}
         <td >
           <a class="text-reset" href="{{ item.url() }}" style="max-width:auto">{{ item.fields.name }}</a>
           {% if all %}
diff --git a/templates/set/card.html b/templates/set/card.html
index 1308c71..d6729ee 100644
--- a/templates/set/card.html
+++ b/templates/set/card.html
@@ -57,7 +57,7 @@
         {{ accordion.footer() }}
         {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line')}}
         {% for minifigure in item.minifigures() %}
-          {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.fig_num, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.fig_num, details=minifigure.url())}}
+          {{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url())}}
         {% endfor %}
       {% endif %}
       {% if g.login.is_authenticated() %}