Repository: mindstorm38/portablemc

Issue:
Support for Babric
There is a beta fork of Fabric called [Babric](https://babric.github.io/). Its files are hosted on a [different URL](https://maven.glass-launcher.net/#/) than Fabric but the overall structure and functionality seem about the same. Because of this, it should just be a matter of making a copy of the PortableMC-Fabric module with the correct URL, but when I tried this I ran into URL redirect limit errors.

I know that this is quite a niche feature, but I would really appreciate it. Thanks!

Hints from issue comments:
@mindstorm38: This is definitely a good idea, as long as it is supported for some time. With the increasing number of loaders natively available, I really need to rework the prefix argument, because there are too much arguments like this and they are really not used that much. Maybe a generic argument for the version directory would be fine.@mindstorm38: The infinite redirect was an issue with PMC bulk download, fixed.@mindstorm38: Implemented in 4.4.0, if any issue with it please open a new issue

Solution patch:
diff --git a/README.md b/README.md
index 72788e1..4e1fa06 100644
--- a/README.md
+++ b/README.md
@@ -250,7 +250,7 @@ by the LWJGL-provided natives).
 #### Miscellaneous
 With `--dry`, the start command does not start the game, but simply installs it.
 
-With `--demo` you can enable the [demo mode](https://minecraft.gamepedia.com/Demo_mode) 
+With `--demo` you can enable the [demo mode](https://minecraft.wiki/w/Demo_mode) 
 of the game.  
 
 With `--resolution <width>x<height>` you can change the resolution of the game window.
diff --git a/portablemc/__init__.py b/portablemc/__init__.py
index b1f6883..35b3043 100644
--- a/portablemc/__init__.py
+++ b/portablemc/__init__.py
@@ -7,7 +7,7 @@ public API, and therefore stable across minor and patch version bumps.
 """
 
 LAUNCHER_NAME = "portablemc"
-LAUNCHER_VERSION = "4.3.0"
+LAUNCHER_VERSION = "4.4.0"
 LAUNCHER_AUTHORS = ["Théo Rozier <contact@theorozier.fr>", "Contributors"]
 LAUNCHER_COPYRIGHT = "PortableMC  Copyright (C) 2021-2024  Théo Rozier"
 LAUNCHER_URL = "https://github.com/mindstorm38/portablemc"
diff --git a/portablemc/cli/__init__.py b/portablemc/cli/__init__.py
index 8b86018..8382480 100644
--- a/portablemc/cli/__init__.py
+++ b/portablemc/cli/__init__.py
@@ -36,7 +36,7 @@ from portablemc.standard import Context, Version, VersionManifest, SimpleWatcher
 
 from portablemc.fabric import FabricVersion, FabricResolveEvent
 from portablemc.forge import ForgeVersion, ForgeResolveEvent, ForgePostProcessingEvent, \
-    ForgePostProcessedEvent, ForgeInstallError, _FORGE_REPO, _NEO_FORGE_REPO
+    ForgePostProcessedEvent, ForgeInstallError, _NeoForgeVersion
 
 from typing import cast, Optional, List, Union, Dict, Callable, Any, Tuple
 
@@ -281,9 +281,9 @@ def cmd_search_handler(ns: SearchNs, kind: str, table: OutputTable):
             if search is None or search in alias:
                 table.add(alias, version)
 
-    elif kind in ("fabric", "quilt", "legacyfabric"):
+    elif kind in ("fabric", "quilt", "legacyfabric", "babric"):
 
-        from ..fabric import FABRIC_API, QUILT_API, LEGACYFABRIC_API
+        from ..fabric import FABRIC_API, QUILT_API, LEGACYFABRIC_API, BABRIC_API
 
         table.add(_("search.loader_version"), _("search.flags"))
         table.separator()
@@ -292,8 +292,10 @@ def cmd_search_handler(ns: SearchNs, kind: str, table: OutputTable):
             api = FABRIC_API
         elif kind == "quilt":
             api = QUILT_API
-        else:
+        elif kind == "legacyfabric":
             api = LEGACYFABRIC_API
+        elif kind == "babric":
+            api = BABRIC_API
         
         for loader in api._request_loaders():
             if search is None or search in loader.version:
@@ -458,15 +460,18 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers
             return None
         return Version(version, context=ns.context)
     
-    elif kind in ("fabric", "quilt", "legacyfabric"):
+    elif kind in ("fabric", "quilt", "legacyfabric", "babric"):
 
         if len(parts) > 2:
             return None
         
         # Legacy fabric has a special case because it will never be supported for 
         # versions past 1.13.2, it is not made for latest release version.
-        if kind == "legacyfabric" and version == "release":
-            version = "1.13.2"
+        if version == "release":
+            if kind == "legacyfabric":
+                version = "1.13.2"
+            elif kind == "babric":
+                version = "b1.7.3"
         
         if kind == "fabric":
             constructor = FabricVersion.with_fabric
@@ -474,9 +479,12 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers
         elif kind == "quilt":
             constructor = FabricVersion.with_quilt
             prefix = ns.quilt_prefix
-        else:
+        elif kind == "legacyfabric":
             constructor = FabricVersion._with_legacyfabric
             prefix = ns.legacyfabric_prefix
+        elif kind == "babric":
+            constructor = FabricVersion._with_babric
+            prefix = ns.babric_prefix
         
         if len(parts) != 2:
             ns.socket_error_tips.append(f"{kind}_loader_version")
@@ -486,9 +494,9 @@ def cmd_start_handler(ns: StartNs, kind: str, parts: List[str]) -> Optional[Vers
     elif kind in ("forge", "neoforge"):
         if len(parts) != 1:
             return None
-        repo = _FORGE_REPO if kind == "forge" else _NEO_FORGE_REPO
+        constructor = ForgeVersion if kind == "forge" else _NeoForgeVersion
         prefix = ns.forge_prefix if kind == "forge" else ns.neoforge_prefix
-        return ForgeVersion(version, context=ns.context, prefix=prefix, _forge_repo=repo)
+        return constructor(version, context=ns.context, prefix=prefix)
     
     else:
         return None
@@ -822,11 +830,10 @@ class StartWatcher(SimpleWatcher):
                 ns.out.finish()
         
         def forge_resolve(e: ForgeResolveEvent) -> None:
-            api = "forge" if e._forge_repo == _FORGE_REPO else "neoforge"
             if e.alias:
-                ns.out.task("..", "start.forge.resolving", api=api, version=e.forge_version)
+                ns.out.task("..", "start.forge.resolving", api=e._api, version=e.forge_version)
             else:
-                ns.out.task("OK", "start.forge.resolved", api=api, version=e.forge_version)
+                ns.out.task("OK", "start.forge.resolved", api=e._api, version=e.forge_version)
                 ns.out.finish()
 
         super().__init__({
diff --git a/portablemc/cli/lang.py b/portablemc/cli/lang.py
index 62668a2..e873add 100644
--- a/portablemc/cli/lang.py
+++ b/portablemc/cli/lang.py
@@ -37,8 +37,8 @@ lang = {
     "args._": 
         "  A fast, reliable and cross-platform command-line Minecraft launcher and API\n"
         "  for developers. Including fast and easy installation of common mod loaders such\n"
-        "  as Fabric, LegacyFabric, Forge, NeoForge and Quilt. This launcher is compatible\n" 
-        "  with the standard Minecraft directories.\n\n",
+        "  as Fabric, LegacyFabric, Forge, NeoForge, Quilt and Babric. This launcher is\n" 
+        "  compatible with the standard Minecraft directories.\n\n",
     "args.main_dir": "Set the main directory where libraries, assets and versions.",
     "args.work_dir": "Set the working directory where the game run and place for examples "
         "saves, screenshots (and resources for legacy versions), it also store "
@@ -74,6 +74,7 @@ lang = {
     "args.search.kind.comp.fabric": "Search for Fabric versions.",
     "args.search.kind.comp.legacyfabric": "Search for LegacyFabric versions.",
     "args.search.kind.comp.quilt": "Search for Quilt versions.",
+    "args.search.kind.comp.babric": "Search for Babric versions.",
     "args.search.input": "Search input.",
     "args.search.input.comp.release": "Resolve version of the latest release.",
     "args.search.input.comp.snapshot": "Resolve version of the latest snapshot.",
@@ -84,6 +85,7 @@ lang = {
     "args.start.version.fabric": "fabric:[<vanilla-version>[:<loader-version>]]",
     "args.start.version.legacyfabric": "legacyfabric:[<vanilla-version>[:<loader-version>]]",
     "args.start.version.quilt": "quilt:[<vanilla-version>[:<loader-version>]]",
+    "args.start.version.babric": "babric::[<loader-version>]",
     "args.start.version.forge": "forge:[<forge-version>] (forge-version >= 1.5.2)",
     "args.start.version.neoforge": "neoforge:[<neoforge-version>] (neoforge-version >= 1.20.1)",
     "args.start.version.comp.release": "Start the latest release (default).",
@@ -91,6 +93,7 @@ lang = {
     "args.start.version.comp.fabric": "Start Fabric mod loader with latest release.",
     "args.start.version.comp.legacyfabric": "Start LegacyFabric mod loader with latest release.",
     "args.start.version.comp.quilt": "Start Quilt mod loader with latest release.",
+    "args.start.version.comp.babric": "Start Babric mod loader with beta 1.7.3.",
     "args.start.version.comp.forge": "Start Forge mod loader with latest release.",
     "args.start.version.comp.neoforge": "Start NeoForge mod loader with latest release.",
     "args.start.dry": "Simulate game starting.",
@@ -106,6 +109,7 @@ lang = {
         "enabled by default.",
     "args.start.fabric_prefix": "Change the prefix of the version ID when starting with Fabric (<prefix>-<vanilla-version>-<loader-version>).",
     "args.start.legacyfabric_prefix": "Change the prefix of the version ID when starting with LegacyFabric (<prefix>-<vanilla-version>-<loader-version>).",
+    "args.start.babric_prefix": "Change the prefix of the version ID when starting with Babric (<prefix>-<vanilla-version>-<loader-version>).",
     "args.start.quilt_prefix": "Change the prefix of the version ID when starting with Quilt (<prefix>-<vanilla-version>-<loader-version>).",
     "args.start.forge_prefix": "Change the prefix of the version ID when starting with Forge (<prefix>-<forge-version>).",
     "args.start.neoforge_prefix": "Change the prefix of the version ID when starting with NeoForge (<prefix>-<neoforge-version>).",
@@ -174,6 +178,7 @@ lang = {
     "error.socket.tip.fabric_loader_version": "Fabric loader version must be specified if network is not operational.",
     "error.socket.tip.legacyfabric_loader_version": "Fabric loader version must be specified if network is not operational.",
     "error.socket.tip.quilt_loader_version": "Quilt loader version must be specified if network is not operational.",
+    "error.socket.tip.babric_loader_version": "Babric loader version must be specified if network is not operational.",
     "error.cert": "Certificate verification failed, you can try installing 'certifi' package:",
     # Command search
     "search.type": "Type",
diff --git a/portablemc/cli/parse.py b/portablemc/cli/parse.py
index d4bb83e..a640b31 100644
--- a/portablemc/cli/parse.py
+++ b/portablemc/cli/parse.py
@@ -47,6 +47,7 @@ class StartNs(AuthBaseNs):
     jvm: Optional[Path]
     jvm_args: Optional[str]
     no_fix: bool
+    babric_prefix: str
     fabric_prefix: str
     legacyfabric_prefix: str
     quilt_prefix: str
@@ -147,6 +148,7 @@ def register_start_arguments(parser: ArgumentParser) -> None:
     parser.add_argument("--fabric-prefix", help=_("args.start.fabric_prefix"), default="fabric", metavar="PREFIX")
     parser.add_argument("--quilt-prefix", help=_("args.start.quilt_prefix"), default="quilt", metavar="PREFIX")
     parser.add_argument("--legacyfabric-prefix", help=_("args.start.legacyfabric_prefix"), default="legacyfabric", metavar="PREFIX")
+    parser.add_argument("--babric-prefix", help=_("args.start.babric_prefix"), default="babric", metavar="PREFIX")
     parser.add_argument("--forge-prefix", help=_("args.start.forge_prefix"), default="forge", metavar="PREFIX")
     parser.add_argument("--neoforge-prefix", help=_("args.start.neoforge_prefix"), default="neoforge", metavar="PREFIX")
     parser.add_argument("--lwjgl", help=_("args.start.lwjgl"))
@@ -161,10 +163,10 @@ def register_start_arguments(parser: ArgumentParser) -> None:
     parser.add_argument("-s", "--server", help=_("args.start.server"), type=type_host)
     parser.add_argument("-p", "--server-port", help=_("args.start.server_port"), metavar="PORT")
 
-    version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "legacyfabric", "forge", "neoforge")))))
+    version_arg = parser.add_argument("version", nargs="?", default="release", help=_("args.start.version", formats=", ".join(map(lambda s: _(f"args.start.version.{s}"), ("standard", "fabric", "quilt", "legacyfabric", "babric", "forge", "neoforge")))))
     for standard in ("release", "snapshot"):
         add_completion(version_arg, standard, _(f"args.start.version.comp.{standard}"))
-    for loader in ("fabric", "quilt", "legacyfabric", "forge", "neoforge"):
+    for loader in ("fabric", "quilt", "legacyfabric", "babric", "forge", "neoforge"):
         add_completion(version_arg, f"{loader}:", _(f"args.start.version.comp.{loader}"))
 
 
@@ -216,7 +218,7 @@ def get_outputs() -> List[str]:
     return ["human-color", "human", "machine"]
 
 def get_search_kinds() -> List[str]:
-    return ["mojang", "local", "forge", "fabric", "quilt", "legacyfabric"]
+    return ["mojang", "local", "forge", "fabric", "quilt", "legacyfabric", "babric"]
 
 def get_auth_services() -> List[str]:
     return ["microsoft", "yggdrasil"]
diff --git a/portablemc/download.py b/portablemc/download.py
index e6fdd84..776b0c2 100644
--- a/portablemc/download.py
+++ b/portablemc/download.py
@@ -35,7 +35,7 @@ class DownloadEntry:
         self.executable = executable
 
     def __repr__(self) -> str:
-        return f"<DownloadEntry {self.name}>"
+        return f"<DownloadEntry {self.name} @ {self.url}>"
 
     def __hash__(self) -> int:
         # Making size and sha1 in the hash is useful to make them, 
@@ -54,16 +54,18 @@ class _DownloadEntry:
     unsupported URL schemes.
     """
 
-    __slots__ = "https", "host", "port", "entry"
+    __slots__ = "https", "host", "port", "path", "entry", "redirect"
 
-    def __init__(self, https: bool, host: str, port: Optional[int], entry: DownloadEntry) -> None:
+    def __init__(self, https: bool, host: str, port: Optional[int], path: str, entry: DownloadEntry, *, redirect: int = 0) -> None:
         self.https = https
         self.host = host
         self.port = port
+        self.path = path
         self.entry = entry
+        self.redirect = redirect
     
     @classmethod
-    def from_entry(cls, entry: DownloadEntry) -> "_DownloadEntry":
+    def from_entry(cls, entry: DownloadEntry, *, redirect: int = 0) -> "_DownloadEntry":
 
         # We only support HTTP/HTTPS
         url_parsed = urllib.parse.urlparse(entry.url)
@@ -74,7 +76,9 @@ class _DownloadEntry:
             url_parsed.scheme == "https",
             url_parsed.netloc,
             url_parsed.port,
-            entry)
+            url_parsed.path,
+            entry,
+            redirect=redirect)
 
 
 class DownloadResult:
@@ -103,6 +107,7 @@ class DownloadResultError(DownloadResult):
     """
 
     CONNECTION = "connection"
+    TOO_MANY_REDIRECT = "too_many_redirect"
     NOT_FOUND = "not_found"
     INVALID_SIZE = "invalid_size"
     INVALID_SHA1 = "invalid_sha1"
@@ -120,10 +125,11 @@ class DownloadList:
     with multithreading.
     """
 
-    __slots__ = "entries", "count", "size"
+    __slots__ = "entries", "count", "size", "_dst_entries"
 
     def __init__(self):
         self.entries: List[_DownloadEntry] = []
+        self._dst_entries = {}
         self.count = 0
         self.size = 0
     
@@ -131,6 +137,7 @@ class DownloadList:
         """Clear the download entry, removing all entries and computed count/size.
         """
         self.entries.clear()
+        self._dst_entries.clear()
         self.count = 0
         self.size = 0
 
@@ -142,10 +149,14 @@ class DownloadList:
         size has the given entry, in such case the entry is not added.
         """
 
+        if entry.dst in self._dst_entries:
+            raise ValueError("duplicate entry destination", entry, self._dst_entries[entry.dst])
+
         if verify and entry.dst.is_file() and (entry.size is None or entry.size == entry.dst.stat().st_size):
             return
         
         self.entries.append(_DownloadEntry.from_entry(entry))
+        self._dst_entries[entry.dst] = entry
         self.count += 1
         if entry.size is not None:
             self.size += entry.size
@@ -271,6 +282,7 @@ def _download_thread(
     
     # Maximum tries count or a single entry.
     max_try_count = 3
+    max_redirect = 10
 
     # For speed calculation.
     speed_update_interval = 0.25
@@ -319,7 +331,7 @@ def _download_thread(
             # This try-except block is around all potential 
             try:
                 
-                conn.request("GET", entry.url)
+                conn.request("GET", raw_entry.path)
                 res = conn.getresponse()
 
                 if res.status != 200:
@@ -328,8 +340,13 @@ def _download_thread(
                     # and allow further request.
                     while res.readinto(buffer):
                         pass
-
+                    
                     if res.status == 301 or res.status == 302:
+                        
+                        if raw_entry.redirect >= max_redirect:
+                            last_error = DownloadResultError.TOO_MANY_REDIRECT
+                            continue
+
                         # If location header is absent, consider it not found.
                         redirect_url = res.headers.get("location")
                         if redirect_url is not None:
@@ -340,7 +357,7 @@ def _download_thread(
                                 sha1=entry.sha1, 
                                 name=entry.name)
                             
-                            entries_queue.put(_DownloadEntry.from_entry(redirect_entry))
+                            entries_queue.put(_DownloadEntry.from_entry(redirect_entry, redirect=raw_entry.redirect + 1))
                             break  # Abort on redirect
 
                     # Any other non-200 code is considered not found and we retry...
diff --git a/portablemc/fabric.py b/portablemc/fabric.py
index 976de25..0cde15a 100644
--- a/portablemc/fabric.py
+++ b/portablemc/fabric.py
@@ -74,6 +74,7 @@ class FabricApi:
 FABRIC_API = FabricApi("fabric", "https://meta.fabricmc.net/v2/")
 QUILT_API = FabricApi("quilt", "https://meta.quiltmc.org/v3/")
 LEGACYFABRIC_API = FabricApi("legacyfabric", "https://meta.legacyfabric.net/v2/")
+BABRIC_API = FabricApi("babric", "https://meta.babric.glass-launcher.net/v2/")
 
 
 class FabricVersion(Version):
@@ -116,6 +117,14 @@ class FabricVersion(Version):
         """Construct a root for resolving a LegacyFabric version"""
         return cls(LEGACYFABRIC_API, vanilla_version, loader_version, prefix, context=context)
 
+    @classmethod
+    def _with_babric(cls, vanilla_version: str = "release", loader_version: Optional[str] = None, *,
+        context: Optional[Context] = None,
+        prefix="babric"
+    ) -> "FabricVersion":
+        """Construct a root for resolving a LegacyFabric version"""
+        return cls(BABRIC_API, vanilla_version, loader_version, prefix, context=context)
+
     def _resolve_version(self, watcher: Watcher) -> None:
         
         # Vanilla version may be "release" or "snapshot"
diff --git a/portablemc/forge.py b/portablemc/forge.py
index a90fd38..b8889d5 100644
--- a/portablemc/forge.py
+++ b/portablemc/forge.py
@@ -18,11 +18,16 @@ from .standard import parse_download_entry, LIBRARIES_URL, \
 from .util import calc_input_sha1, LibrarySpecifier
 from .http import http_request, HttpError
 
-from typing import Dict, Optional, List
+from typing import Dict, Optional, List, Tuple
 
 
-_FORGE_REPO = "https://maven.minecraftforge.net/net/minecraftforge/forge"
-_NEO_FORGE_REPO = "https://maven.neoforged.net/releases/net/neoforged/forge"
+_FORGE_REPO_URL = "https://maven.minecraftforge.net/"
+_FORGE_GROUP = "net.minecraftforge"
+_FORGE_ARTIFACT = "forge"
+
+_NEO_FORGE_REPO_URL = "https://maven.neoforged.net/releases/"
+_NEO_FORGE_GROUP = "net.neoforged"
+_NEO_FORGE_ARTIFACT = "neoforge"
 
 
 class ForgeVersion(Version):
@@ -30,67 +35,56 @@ class ForgeVersion(Version):
     def __init__(self, forge_version: str = "release", *,
         context: Optional[Context] = None,
         prefix: str = "forge",
-        _forge_repo: str = _FORGE_REPO,
     ) -> None:
 
         super().__init__("", context=context)  # Do not give a root version for now.
 
         self.forge_version = forge_version
         self.prefix = prefix
-        self._forge_repo = _forge_repo
-        self._forge_post_info: Optional[ForgePostInfo] = None
+        
+        # This is set when version is resolved.
+        self._forge_repo_url: Optional[str] = None
+        self._forge_installer_spec: Optional[LibrarySpecifier] = None
+
+        self._forge_post_info: Optional[_ForgePostInfo] = None
     
     def _resolve_version(self, watcher: Watcher) -> None:
         
         # Maybe "release" or "snapshot", we process this first.
         self.forge_version = self.manifest.filter_latest(self.forge_version)[0]
 
-        if self._forge_repo == _FORGE_REPO:
+        # If no alias is specified, with add recommended.
+        if "-" not in self.forge_version:
+            self.forge_version = f"{self.forge_version}-recommended"
 
-            # If no alias is specified, with add recommended.
-            if "-" not in self.forge_version:
-                self.forge_version = f"{self.forge_version}-recommended"
+        # Now, if the specified version is an alias, we resolve it.
+        if self.forge_version.endswith(("-latest", "-recommended")):
 
-            # Now, if the specified version is an alias, we resolve it.
-            if self.forge_version.endswith(("-latest", "-recommended")):
+            # Split the version, used later.
+            alias_version, alias = self.forge_version.rsplit("-", maxsplit=1)
+            watcher.handle(ForgeResolveEvent(self.forge_version, True))
 
-                # Split the version, used later.
-                alias_version, alias = self.forge_version.rsplit("-", maxsplit=1)
-                watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO))
+            # Try to get loader from promo versions.
+            promo_versions = request_promo_versions()
+            loader_version = promo_versions.get(self.forge_version)
 
-                # Try to get loader from promo versions.
-                promo_versions = request_promo_versions()
+            # If we can't find the load version, just try to other alias (issue #189).
+            if loader_version is None:
+                alias = { "latest": "recommended", "recommended": "latest" }[alias]
+                self.forge_version = f"{alias_version}-{alias}"
+                watcher.handle(ForgeResolveEvent(self.forge_version, True))
                 loader_version = promo_versions.get(self.forge_version)
 
-                # If we can't find the load version, just try to other alias (issue #189).
-                if loader_version is None:
-                    alias = { "latest": "recommended", "recommended": "latest" }[alias]
-                    self.forge_version = f"{alias_version}-{alias}"
-                    watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_FORGE_REPO))
-                    loader_version = promo_versions.get(self.forge_version)
+            if loader_version is None:
+                raise VersionNotFoundError(f"{self.prefix}-{alias_version}-???")
 
-                if loader_version is None:
-                    raise VersionNotFoundError(f"{self.prefix}-{alias_version}-???")
-
-                self.forge_version = f"{alias_version}-{loader_version}"
-                watcher.handle(ForgeResolveEvent(self.forge_version, False, _forge_repo=_FORGE_REPO))
-        
-        elif self._forge_repo == _NEO_FORGE_REPO:
-
-            # The forge version is not fully specified.
-            if "-" not in self.forge_version:
-
-                watcher.handle(ForgeResolveEvent(self.forge_version, True, _forge_repo=_NEO_FORGE_REPO))
-                full_version = _request_neoforge_version(self.forge_version)
-
-                if full_version is None:
-                    raise VersionNotFoundError(f"{self.prefix}-{self.forge_version}-???")
-                
-                self.forge_version = full_version
-                watcher.handle(ForgeResolveEvent(self.forge_version, False, _forge_repo=_NEO_FORGE_REPO))
+            self.forge_version = f"{alias_version}-{loader_version}"
+            watcher.handle(ForgeResolveEvent(self.forge_version, False))
         
         # Finally define the full version id.
         self.version = f"{self.prefix}-{self.forge_version}"
+        self._forge_repo_url = _FORGE_REPO_URL
+        self._forge_installer_spec = LibrarySpecifier(_FORGE_GROUP, _FORGE_ARTIFACT, self.forge_version, classifier="installer")
 
     def _load_version(self, version: VersionHandle, watcher: Watcher) -> bool:
         if version.id == self.version:
@@ -103,6 +97,10 @@ class ForgeVersion(Version):
         if version.id != self.version:
             return super()._fetch_version(version, watcher)
         
+        # Must have been set in _resolve_version
+        assert self._forge_repo_url is not None
+        assert self._forge_installer_spec is not None
+
         # Extract the game version from the forge version, we'll use
         # it to add suffix to find the right forge version if needed.
         game_version = self.forge_version.split("-", 1)[0]
@@ -124,9 +122,14 @@ class ForgeVersion(Version):
 
         # Iterate suffix and find the first install JAR that works.
         install_jar = None
+        original_version = self._forge_installer_spec.version
         for suffix in suffixes:
             try:
-                install_jar = request_install_jar(f"{self.forge_version}{suffix}", _repo=self._forge_repo)
+                # Apply the suffix before request...
+                self._forge_installer_spec.version = f"{original_version}{suffix}"
+                install_jar_url = f"{self._forge_repo_url}{self._forge_installer_spec.file_path()}"
+                install_jar_res = res = http_request("GET", install_jar_url, accept="application/java-archive")
+                install_jar = ZipFile(BytesIO(install_jar_res.data))
                 break
             except HttpError as error:
                 if error.res.status != 404:
@@ -159,17 +162,16 @@ class ForgeVersion(Version):
             except KeyError:
                 raise ForgeInstallError(self.forge_version, ForgeInstallError.INSTALL_PROFILE_NOT_FOUND)
 
-            # print(f"{install_profile=}")
-
-            if "json" in install_profile:
+            # print(f"{json.dumps(install_profile, indent='  ')}")
 
-                # Forge versions since 1.12.2-14.23.5.2851
+            if "json" in install_profile:  # Forge versions since 1.12.2-14.23.5.2851
+                
                 info = install_jar.getinfo(install_profile["json"].lstrip("/"))
                 with install_jar.open(info) as fp:
                     version.metadata = json.load(fp)
 
                 # We use the bin directory if there is a need to extract temporary files.
-                post_info = ForgePostInfo(self.context.gen_bin_dir())
+                post_info = _ForgePostInfo(self.context.gen_bin_dir())
 
                 # Parse processors
                 for i, processor in enumerate(install_profile["processors"]):
@@ -185,9 +187,11 @@ class ForgeVersion(Version):
                     if not isinstance(processor_jar_name, str):
                         raise ValueError(f"forge profile: /json/processors/{i}/jar must be a string")
 
-                    post_info.processors.append(ForgePostProcessor(
-                        processor_jar_name,
-                        processor.get("classpath", []),
+                    processor_spec = LibrarySpecifier.from_str(processor_jar_name)
+
+                    post_info.processors.append(_ForgePostProcessor(
+                        processor_spec,
+                        [LibrarySpecifier.from_str(raw_spec) for raw_spec in processor.get("classpath", [])],
                         processor.get("args", []),
                         processor.get("outputs", {})
                     ))
@@ -210,7 +214,12 @@ class ForgeVersion(Version):
                     lib_artifact = install_lib["downloads"]["artifact"]
                     lib_path = self.context.libraries_dir / lib_spec.file_path()
 
-                    post_info.libraries[lib_name] = lib_path
+                    # Ignore the library if it has already been specified, has been seen
+                    # in neoforge installer...
+                    if lib_spec in post_info.libraries:
+                        continue
+                    post_info.libraries[lib_spec] = lib_path
+                    # print(lib_spec, lib_path)
                     
                     if len(lib_artifact["url"]):
                         self._dl.add(parse_download_entry(lib_artifact, lib_path, "forge profile: /json/libraries/"), verify=True)
@@ -235,9 +244,8 @@ class ForgeVersion(Version):
                 
                 self._forge_post_info = post_info
 
-            else: 
+            else:  # Forge versions before 1.12.2-14.23.5.2847
 
-                # Forge versions before 1.12.2-14.23.5.2847
                 version.metadata = install_profile.get("versionInfo")
                 if not isinstance(version.metadata, dict):
                     raise ForgeInstallError(self.forge_version, ForgeInstallError.VERSION_METADATA_NOT_FOUND)
@@ -250,7 +258,7 @@ class ForgeVersion(Version):
                         del version_lib["clientreq"]
                     if "checksums" in version_lib:
                         del version_lib["checksums"]
-                    # Older version uses to require libraries that are no longer installed
+                    # Older versions used to require libraries that are no longer installed
                     # by parent versions, therefore it's required to add url if not 
                     # provided, pointing to maven central repository, for downloading.
                     if not version_lib.get("url"):
@@ -323,7 +331,7 @@ class ForgeVersion(Version):
 
             # Extract the main-class from manifest. Required because we cannot use 
             # both -cp and -jar.
-            jar_path = info.libraries[processor.jar_name].absolute()
+            jar_path = info.libraries[processor.spec].absolute()
             main_class = None
             with ZipFile(jar_path) as jar_fp:
                 with jar_fp.open("META-INF/MANIFEST.MF") as manifest_fp:
@@ -338,21 +346,18 @@ class ForgeVersion(Version):
             # Try to find the task name in the arguments, just for information purpose.
             if len(processor.args) >= 2 and processor.args[0] == "--task":
                 task = processor.args[1].lower()
-            elif processor.jar_name.startswith("net.minecraftforge:jarsplitter:"):
-                task = "split_jar"
-            elif processor.jar_name.startswith("net.minecraftforge:ForgeAutoRenamingTool:"):
-                task = "forge_auto_renaming"
-            elif processor.jar_name.startswith("net.minecraftforge:binarypatcher:"):
-                task = "patch_binary"
-            elif processor.jar_name.startswith("net.md-5:SpecialSource:"):
-                task = "special_source_renaming"
             else:
-                task = "unknown"
+                task = {
+                    "jarsplitter": "split_jar",
+                    "ForgeAutoRenamingTool": "forge_auto_renaming",
+                    "binarypatcher": "patch_binary",
+                    "SpecialSource": "special_source_renaming",
+                }.get(processor.spec.artifact, f"unknown({processor.spec})")
 
             # Compute the full arguments list.
             args = [
                 str(self._jvm_path.absolute()),
-                "-cp", os.pathsep.join([str(jar_path), *(str(info.libraries[lib_name].absolute()) for lib_name in processor.class_path)]),
+                "-cp", os.pathsep.join([str(jar_path), *(str(info.libraries[lib_spec].absolute()) for lib_spec in processor.class_path)]),
                 main_class,
                 *(replace_install_args(arg) for arg in processor.args)
             ]
@@ -378,18 +383,53 @@ class ForgeVersion(Version):
         watcher.handle(ForgePostProcessedEvent())
 
 
-class ForgePostProcessor:
+class _NeoForgeVersion(ForgeVersion):
+
+    def __init__(self, neoforge_version: str = "release", *,
+        context: Optional[Context] = None,
+        prefix: str = "neoforge",
+    ) -> None:
+        super().__init__(neoforge_version, context=context, prefix=prefix)
+    
+    def _resolve_version(self, watcher: Watcher) -> None:
+        
+        # Maybe "release" or "snapshot", we process this first.
+        self.forge_version = self.manifest.filter_latest(self.forge_version)[0]
+
+        # The forge version is not fully specified.
+        if "-" not in self.forge_version and self.forge_version.startswith("1."):
+
+            watcher.handle(ForgeResolveEvent(self.forge_version, True, _api = "neoforge"))
+            full_version = _request_neoforge_version(self.forge_version)
+
+            if full_version is None:
+                raise VersionNotFoundError(f"{self.prefix}-{self.forge_version}-???")
+            
+            self.forge_version = full_version
+            watcher.handle(ForgeResolveEvent(self.forge_version, False, _api = "neoforge"))
+        
+        # Finally define the full version id.
+        self.version = f"{self.prefix}-{self.forge_version}"
+        
+        # This is using the legacy forge artifact for 1.20.1 only.
+        forge_artifact = _FORGE_ARTIFACT if self.forge_version.startswith("1.20.1-") else _NEO_FORGE_ARTIFACT
+
+        self._forge_repo_url = _NEO_FORGE_REPO_URL
+        self._forge_installer_spec = LibrarySpecifier(_NEO_FORGE_GROUP, forge_artifact, self.forge_version, classifier="installer")
+
+
+class _ForgePostProcessor:
     """Describe the execution model of a post process.
     """
-    __slots__ = "jar_name", "class_path", "args", "sha1"
-    def __init__(self, jar_name: str, class_path: List[str], args: List[str], sha1: Dict[str, str]) -> None:
-        self.jar_name = jar_name
+    __slots__ = "spec", "class_path", "args", "sha1"
+    def __init__(self, spec: LibrarySpecifier, class_path: List[LibrarySpecifier], args: List[str], sha1: Dict[str, str]) -> None:
+        self.spec = spec
         self.class_path = class_path
         self.args = args
         self.sha1 = sha1
 
 
-class ForgePostInfo:
+class _ForgePostInfo:
     """Internal state, used only when forge installer is "modern" (>= 1.12.2-14.23.5.2851)
     describing data and post processors.
     """
@@ -397,8 +437,8 @@ class ForgePostInfo:
     def __init__(self, tmp_dir: Path) -> None:
         self.tmp_dir = tmp_dir
         self.variables: Dict[str, str] = {}   # Data for variable replacements.
-        self.libraries: Dict[str, Path] = {}  # Install-time libraries.  FIXME: Get rid of this?
-        self.processors: List[ForgePostProcessor] = []
+        self.libraries: Dict[LibrarySpecifier, Path] = {}  # Install-time libraries.
+        self.processors: List[_ForgePostProcessor] = []
 
 
 class ForgeInstallError(Exception):
@@ -421,11 +461,12 @@ class ForgeResolveEvent:
     The 'alias' attribute specifies if an alias version is being resolved, if false the
     resolving has finished and we'll try to install the given version.
     """
-    __slots__ = "forge_version", "alias", "_forge_repo"
-    def __init__(self, forge_version: str, alias: bool, *, _forge_repo: str) -> None:
+    __slots__ = "forge_version", "alias", "_api"
+
+    def __init__(self, forge_version: str, alias: bool, *, _api = "forge") -> None:
         self.forge_version = forge_version
         self.alias = alias
-        self._forge_repo = _forge_repo
+        self._api = _api
 
 class ForgePostProcessingEvent:
     """Event triggered when a post processing task is starting.
@@ -442,7 +483,7 @@ class ForgePostProcessedEvent:
 
 def request_promo_versions() -> Dict[str, str]:
     """Request recommended and latest versions for each supported game release, this only
-    works.
+    works for forge version.
     """
     return http_request("GET", "https://files.minecraftforge.net/net/minecraftforge/forge/promotions_slim.json", 
         accept="application/json").json()["promos"]
@@ -453,26 +494,39 @@ def _request_neoforge_version(game_version: str) -> Optional[str]:
     returning any matching version number, this function will also check that the returned
     version is of the right game version.
     """
+
+    game_version_parts = game_version.split(".")
+    if len(game_version_parts) < 2 or len(game_version_parts) > 3:
+        return None
+    
+    # If the "super-major" version is not "1", abort...
+    if game_version_parts[0] != "1":
+        return None
+    
+    # Special case for the first version NeoForged was introduced.
+    if game_version_parts == ["1", "20", "1"]:
+        url = "https://maven.neoforged.net/api/maven/latest/version/releases/net%2Fneoforged%2Fforge?filter=1.20.1-"
+    else:
+        # Just keep major and minor version number and construct the neoforge version prefix.
+        filter_version = ".".join(game_version_parts[1:3])
+        url = f"https://maven.neoforged.net/api/maven/latest/version/releases/net%2Fneoforged%2Fneoforge?filter={url_parse.quote(filter_version)}"
+
     try:
-        # NOTE: For now we don't sanitize the parameter.
-        url = f"https://maven.neoforged.net/api/maven/latest/version/releases/net%2Fneoforged%2Fforge?filter={url_parse.quote(game_version)}"
         ret = http_request("GET", url, accept="application/json").json()
-        loader_version = ret.get("version", "")
-        if not loader_version.startswith(f"{game_version}-"):
-            return None
-        return loader_version
     except HttpError as err:
         if err.res.status != 404:
             raise
         return None
 
+    return ret.get("version")
 
-def request_maven_versions(*, _repo: str = _FORGE_REPO) -> List[str]:
+
+def request_maven_versions() -> List[str]:
     """Internal function that parses maven metadata of forge in order to get all 
     supported forge versions.
     """
 
-    text = http_request("GET", f"{_repo}/maven-metadata.xml", 
+    text = http_request("GET", f"{_FORGE_REPO_URL}/net/minecraftforge/forge/maven-metadata.xml", 
         accept="application/xml").text()
     
     versions = list()
@@ -494,10 +548,9 @@ def request_maven_versions(*, _repo: str = _FORGE_REPO) -> List[str]:
     return versions
 
 
-def request_install_jar(version: str, *, _repo: str = _FORGE_REPO) -> ZipFile:
-    """Internal function to request the installation JAR file.
-    """
-    res = http_request("GET", f"{_repo}/{version}/forge-{version}-installer.jar",
+def request_install_jar(version: str) -> ZipFile:
+    """deprecated"""
+    res = http_request("GET", f"{_FORGE_REPO_URL}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar",
         accept="application/java-archive")
     
     return ZipFile(BytesIO(res.data))
@@ -510,3 +563,27 @@ def zip_extract_file(zf: ZipFile, entry_path: str, dst_path: Path):
     dst_path.parent.mkdir(parents=True, exist_ok=True)
     with zf.open(entry_path) as src, dst_path.open("wb") as dst:
         shutil.copyfileobj(src, dst)
+
+#
+# Following classes are deprecated public class that should never have been private...
+#
+
+class ForgePostProcessor:
+    """deprecated: This class should have been private...
+    """
+    __slots__ = "jar_name", "class_path", "args", "sha1"
+    def __init__(self, jar_name: str, class_path: List[str], args: List[str], sha1: Dict[str, str]) -> None:
+        self.jar_name = jar_name
+        self.class_path = class_path
+        self.args = args
+        self.sha1 = sha1
+
+class ForgePostInfo:
+    """deprecated: This class should have been private..
+    """
+
+    def __init__(self, tmp_dir: Path) -> None:
+        self.tmp_dir = tmp_dir
+        self.variables: Dict[str, str] = {}   # Data for variable replacements.
+        self.libraries: Dict[str, Path] = {}  # Install-time libraries.
+        self.processors: List[ForgePostProcessor] = []
diff --git a/portablemc/standard.py b/portablemc/standard.py
index bac001f..7084096 100644
--- a/portablemc/standard.py
+++ b/portablemc/standard.py
@@ -49,7 +49,7 @@ class Context:
 
         :param main_dir: The main directory where versions, assets, libraries and 
         optionally JVM are installed. If not specified this path will be set the usual 
-        `.minecraft` (see https://minecraft.fandom.com/fr/wiki/.minecraft).
+        `.minecraft` (see https://minecraft.wiki/w/.minecraft).
         :param work_dir: The working directory from where the game is run, the game stores
         thing like saves, resource packs, options and mods if relevant. This defaults to
         `main_dir` if not specified.
@@ -534,6 +534,8 @@ class Version:
         if not isinstance(assets_objects, dict):
             raise ValueError("assets index: /objects must be an object")
 
+        assets_unique = set()
+
         for asset_id, asset_obj in assets_objects.items():
 
             if not isinstance(asset_obj, dict):
@@ -550,8 +552,15 @@ class Version:
             asset_hash_prefix = asset_hash[:2]
             asset_file = assets_objects_dir.joinpath(asset_hash_prefix, asset_hash)
 
-            asset_url = f"{RESOURCES_URL}{asset_hash_prefix}/{asset_hash}"
             self._assets[asset_id] = asset_file
+
+            # Some assets are represented with multiple files, but we don't want to 
+            # download a file multiple time so we abort here.
+            if asset_hash in assets_unique:
+                continue
+            assets_unique.add(asset_hash)
+
+            asset_url = f"{RESOURCES_URL}{asset_hash_prefix}/{asset_hash}"
             self._dl.add(DownloadEntry(asset_url, asset_file, size=asset_size, sha1=asset_hash, name=asset_id), verify=True)
         
         self._assets_index_version = assets_index_version
@@ -590,6 +599,10 @@ class Version:
 
         watcher.handle(LibrariesResolvingEvent())
 
+        # This set contains all unique library specifiers that have been found so far. 
+        # It also contains libs that have been filtered out by rules.
+        unique_specs = set()
+
         # Recursion order is important for libraries resolving, root libraries should
         # be placed first.
         for version in self._hierarchy[0].recurse():
@@ -612,15 +625,6 @@ class Version:
                 
                 spec = LibrarySpecifier.from_str(name)
 
-                rules = library.get("rules")
-                if rules is not None:
-
-                    if not isinstance(rules, list):
-                        raise ValueError(f"metadata: /libraries/{library_idx}/rules must be a list")
-                    
-                    if not interpret_rule(rules, self._features, f"metadata: /libraries/{library_idx}/rules"):
-                        continue
-
                 # Old metadata files provides a 'natives' mapping from OS to the classifier
                 # specific for this OS, this kind of libs are "native libs", we need to
                 # extract their dynamic libs into the "bin" directory before running.
@@ -639,6 +643,29 @@ class Version:
                     if minecraft_arch_bits is not None:
                         spec.classifier = spec.classifier.replace("${arch}", str(minecraft_arch_bits))
 
+                # Create a wildcard copy of the spec without version, because we don't
+                # want to match against version, regardless of its version, a library
+                # should not be added twice.
+                spec_wild = spec.copy()
+                spec_wild.version = "*"
+
+                # We just abort here if the lib name has already been specified. It is
+                # really important to do that here after natives resolution because it
+                # adds a classifier to the lib spec.
+                if spec_wild in unique_specs:
+                    continue
+                unique_specs.add(spec_wild)
+
+                # Changed in 4.3.1, rules are checked after natives
+                rules = library.get("rules")
+                if rules is not None:
+
+                    if not isinstance(rules, list):
+                        raise ValueError(f"metadata: /libraries/{library_idx}/rules must be a list")
+                    
+                    if not interpret_rule(rules, self._features, f"metadata: /libraries/{library_idx}/rules"):
+                        continue
+
                 lib_entry: Optional[DownloadEntry] = None
                 
                 # Avoids ready downloading if a fix is being used, in such case we'll use
@@ -1422,8 +1449,12 @@ class VersionManifest:
                 try:
                     with self.cache_file.open("rt") as cache_fp:
                         cache_data = json.load(cache_fp)
-                    if "last_modified" in cache_data:
-                        headers["If-Modified-Since"] = cache_data["last_modified"]
+                    if isinstance(cache_data, dict):
+                        if "last_modified" in cache_data:
+                            headers["If-Modified-Since"] = cache_data["last_modified"]
+                    else:
+                        # If the data isn't a dictionary, it's invalid.
+                        cache_data = None
                 except (OSError, json.JSONDecodeError):
                     pass
             
@@ -1434,6 +1465,9 @@ class VersionManifest:
                     accept="application/json")
                 
                 self.data = res.json()
+                if not isinstance(self.data, dict):
+                    # Raise error because the API is faulting.
+                    raise ValueError("manifest api data is not dict")
 
                 if "Last-Modified" in res.headers:
                     self.data["last_modified"] = res.headers["Last-Modified"]
@@ -1451,6 +1485,9 @@ class VersionManifest:
                 else:
                     raise
 
+        if self.data is None:
+            raise ValueError("manifest data should not be null at this point")
+        
         return self.data
 
     def is_alias(self, version: str) -> bool:
diff --git a/portablemc/util.py b/portablemc/util.py
index ff6b536..a91b7d4 100644
--- a/portablemc/util.py
+++ b/portablemc/util.py
@@ -132,3 +132,6 @@ class LibrarySpecifier:
             f".{self.extension}"
         
         return "/".join([*self.group.split("."), self.artifact, self.version, file_name])
+
+    def copy(self) -> "LibrarySpecifier":
+        return LibrarySpecifier(self.group, self.artifact, self.version, self.classifier, self.extension)
diff --git a/pyproject.toml b/pyproject.toml
index 2e54521..82f335e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
 
 [tool.poetry]
 name = "portablemc"
-version = "4.3.0"
+version = "4.4.0"
 description = "PortableMC is a module that provides both an API for development of your custom launcher and an executable script to run PortableMC CLI."
 authors = ["Théo Rozier <contact@theorozier.fr>"]
 license = "GPL-3.0-only"


Test patch:
diff --git a/test/test_download.py b/test/test_download.py
index f736a74..13ec4cf 100644
--- a/test/test_download.py
+++ b/test/test_download.py
@@ -47,7 +47,7 @@ def test_download(tmp_path):
         name="not_found")
 
     conn_err = DownloadEntry("https://rfdfdfesources.download.minecraft.net/bd/bdf48ef6b5d0d23bbb02e17d04865216",
-        assets_dir / "icons" / "icon_16x16_not_found.png",
+        assets_dir / "icons" / "icon_16x16_conn_err.png",
         sha1="bdf48ef6b5d0d23bbb02e17d04865216179f510a",
         size=3665,
         name="conn_err")
diff --git a/test/test_install.py b/test/test_install.py
index 7b9cfd8..33da15d 100644
--- a/test/test_install.py
+++ b/test/test_install.py
@@ -9,7 +9,7 @@ from portablemc.standard import Context, Version, VersionManifest, TooMuchParent
     JarNotFoundError
 from portablemc.download import DownloadList
 from portablemc.fabric import FabricVersion
-from portablemc.forge import ForgeVersion, _NEO_FORGE_REPO
+from portablemc.forge import ForgeVersion, _NeoForgeVersion
 
 from typing import Type, Optional
 
@@ -73,12 +73,12 @@ def test_install_forge(tmp_context: Context, test_version: str):
     version.install()
 
 
-@pytest.mark.parametrize("test_version", ["1.20.1", "1.20.1-47.1.79"])
+@pytest.mark.parametrize("test_version", ["1.20.1", "1.20.1-47.1.79", "1.21", "21.0.42-beta"])
 def test_install_neoforge(tmp_context: Context, test_version: str):
     """Testing neoforge install for both old an new formats.
     """
 
-    version = ForgeVersion(test_version, _forge_repo=_NEO_FORGE_REPO)
+    version = _NeoForgeVersion(test_version)
     version.manifest = VersionManifest(tmp_context.work_dir / "version_manifest.json")
     _remove_assets(version)
     version.install()