├── .dockerignore ├── .github └── workflows │ ├── gitlab-sync.yml │ └── tests.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── app ├── __init__.py ├── config │ ├── __init__.py │ ├── constants.py │ ├── mcim.py │ ├── mongodb.py │ └── redis.py ├── controller │ ├── __init__.py │ ├── curseforge │ │ ├── __init__.py │ │ └── v1 │ │ │ ├── __init__.py │ │ │ └── game.py │ ├── file_cdn │ │ └── __init__.py │ ├── modrinth │ │ ├── __init__.py │ │ └── v2 │ │ │ └── __init__.py │ └── translate.py ├── database │ ├── __init__.py │ ├── _redis.py │ └── mongodb.py ├── exceptions │ └── __init__.py ├── models │ ├── database │ │ ├── curseforge.py │ │ ├── file_cdn.py │ │ ├── modrinth.py │ │ └── translate.py │ └── response │ │ ├── curseforge.py │ │ └── modrinth.py ├── sync_queue │ ├── __init__.py │ ├── curseforge.py │ └── modrinth.py └── utils │ ├── __init__.py │ ├── loger │ └── __init__.py │ ├── metric │ └── __init__.py │ ├── middleware │ ├── __init__.py │ ├── count_trustable.py │ ├── etag.py │ ├── timing.py │ └── uncache_post.py │ ├── network │ └── __init__.py │ ├── response │ └── __init__.py │ └── response_cache │ ├── __init__.py │ ├── key_builder.py │ └── resp_builder.py ├── config ├── mongod.conf └── redis.conf ├── data ├── curseforge_categories.json ├── curseforge_files.json ├── curseforge_fingerprints.json ├── curseforge_mods.json ├── curseforge_translated.json ├── file_cdn_files.json ├── modrinth_categories.json ├── modrinth_files.json ├── modrinth_game_versions.json ├── modrinth_loaders.json ├── modrinth_projects.json ├── modrinth_translated.json └── modrinth_versions.json ├── docker-compose.yml ├── docker ├── fastapi └── fastapi_gunicorn ├── justfile ├── pytest.ini ├── requirements.txt ├── scripts └── gunicorn_config.py ├── start.py ├── static ├── redoc.standalone.js ├── swagger-ui-bundle.js └── swagger-ui.css └── tests ├── __init__.py ├── conftest.py ├── test_curseforge.py ├── test_file_cdn.py ├── test_modrinth.py ├── test_root.py └── test_translate.py /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | **/.env 6 | **/.git 7 | **/.gitignore 8 | **/.project 9 | **/.settings 10 | **/.toolstarget 11 | **/.vs 12 | **/.vscode 13 | **/*.*proj.user 14 | **/*.dbmdl 15 | **/*.jfm 16 | **/bin 17 | **/charts 18 | **/docker-compose* 19 | **/compose* 20 | **/Dockerfile* 21 | **/node_modules 22 | **/npm-debug.log 23 | **/obj 24 | **/secrets.dev.yaml 25 | **/values.dev.yaml 26 | LICENSE 27 | README.md 28 | -------------------------------------------------------------------------------- /.github/workflows/gitlab-sync.yml: -------------------------------------------------------------------------------- 1 | # 通过 Github action, 在仓库的每一次 commit 后自动同步到 Gitee 上 2 | name: sync2gitlab 3 | on: 4 | push: 5 | # branches: 6 | # - main 7 | jobs: 8 | repo-sync: 9 | env: 10 | SSH_PRIVATE_KEY: ${{ secrets.GITLAB_PRIVATE_KEY }} 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | persist-credentials: false 16 | 17 | - name: Configure Git 18 | run: | 19 | git config --global --add safe.directory /github/workspace 20 | 21 | - name: sync github -> gitee 22 | uses: wearerequired/git-mirror-action@master 23 | if: env.SSH_PRIVATE_KEY 24 | with: 25 | source-repo: "git@github.com:${{ github.repository }}.git" 26 | destination-repo: "git@git.pysio.online:z0z0r4/mcim.git" 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Test 2 | 3 | on: 4 | push: 5 | # branches: [main] 6 | pull_request: 7 | merge_group: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | unit-test: 12 | runs-on: ubuntu-latest 13 | name: Unit test 14 | env: 15 | MCIM_CONFIG: ${{ secrets.MCIM_CONFIG }} # 配置文件 16 | REDIS_CONFIG: ${{ secrets.REDIS_CONFIG }} # 配置文件 17 | MONGODB_CONFIG: ${{ secrets.MONGODB_CONFIG }} # 配置文件 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | with: 22 | submodules: recursive 23 | 24 | - name: Install python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: 3.11 28 | cache: 'pip' 29 | 30 | - name: Install just 31 | uses: extractions/setup-just@v2 32 | 33 | - name: Start Redis 34 | uses: supercharge/redis-github-action@1.7.0 35 | 36 | - name: Start MongoDB 37 | uses: supercharge/mongodb-github-action@1.12.0 38 | 39 | - name: Install dependencies 40 | run: | 41 | just mongodb-tool-install 42 | just ci-install 43 | just import-data 44 | just ci-config 45 | 46 | - name: Run unit tests 47 | run: | 48 | just ci-test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Mac OS 3 | .DS_Store 4 | 5 | # Python 6 | __pycache__ 7 | 8 | # Logs 9 | /logs 10 | /webapi_logs 11 | 12 | # Pycharm 13 | .idea 14 | 15 | # Config? **Is TEST folder useless ?** 16 | /config 17 | 18 | /curseforge 19 | /modrinth 20 | test.py 21 | /aria2c 22 | .vscode/settings.json 23 | .coverage 24 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcmod-info-mirror/mcim-api/c0b424f72d39bd318565de95b7b505a0073da60c/.gitmodules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 mcmod-info-mirror 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcim-api 2 | 3 | ![mcim-api](https://socialify.git.ci/mcmod-info-mirror/mcim-api/image?description=1&font=Inter&issues=1&language=1&name=1&owner=1&pattern=Overlapping%20Hexagons&pulls=1&stargazers=1&theme=Auto) 4 | 5 | 为各平台的 Mod 的 API 缓存加速,由 [MCLF-CN #3](https://github.com/MCLF-CN/docs/issues/3) 提议,由[鸣谢列表](#鸣谢)内的各位提供支持~ 6 | 7 | > [!WARNING] 8 | > 由于高考,未来半年内也许无人维护。但镜像会继续运行。 9 | 10 | 已缓存 **绝大多数** 的 Modrinth 和 Curseforge 上的 Minecraft Mod 信息。缓存统计信息见 [mcim-statistics](https://mod.mcimirror.top/statistics)。 11 | 12 | > [!WARNING] 13 | > 由于多种原因,OpenMCIM 已暂停运行,MCIM API 不受影响。文件下载已自动重定向会源站。 14 | > 高考完后可能后可能重制后投入使用,敬请期待。 15 | 16 | 效仿 [BMCLAPI](https://bmclapidoc.bangbang93.com) ,当前 OpenMCIM 文件缓存在试运行。**急需节点加入 orz !详情见 [OpenMCIM 文件分发相关 #91](https://github.com/mcmod-info-mirror/mcim/issues/91)**。 17 | 18 | API 支持 [Curseforge](https://curseforge.com/) 和 [Modrinth](https://modrinth.com/)。 19 | 20 | - [API](https://mod.mcimirror.top) 21 | - [Docs](https://mod.mcimirror.top/docs) 22 | - [MCIM 同步进度](https://t.me/mcim_sync) 23 | - [Status](https://status.mcimirror.top) 24 | 25 | ## 接入 26 | 27 | 本镜像可能会添加 UA 白名单,请在使用前提交启动器的 UA [启动器信息](https://github.com/mcmod-info-mirror/mcim/issues/4)。 28 | 29 | ## 使用 30 | 31 | 不了解官方 API 的话请前往 [CFCore](https://docs.curseforge.com) 和 [Modrinth Docs](https://docs.modrinth.com) 参考。 32 | 33 | MCIM 几乎完美兼容官方的 API 结构,可以直接替换 URL,方便迁移,具体可以比对 [Docs](https://mod.mcimirror.top/docs),你可以在里面尝试。 34 | 35 | 部分接口已忽略未实现,如果官方 API 有更新或新增,未及时跟进,请联系。 36 | 37 | ### Modrinth 38 | 39 | - `api.modrinth.com` -> `mod.mcimirror.top/modrinth` 40 | - `cdn.modrinth.com` -> `mod.mcimirror.top` 41 | 42 | ### Curseforge 43 | 44 | - `api.curseforge.com` -> `mod.mcimirror.top/curseforge` 45 | - `edge.forgecdn.net` or `mediafilez.forgecdn.net` -> `mod.mcimirror.top` 46 | 47 | ## 缓存相关 48 | 49 | 关于缓存,详见 [mcim-sync](https://github.com/mcmod-info-mirror/mcim-sync) 50 | 51 | **MCIM 有可能随着风控策略的改变,无法及时更新缓存数据。如果有需要,启动器应该自行检查缓存日期并决定是否信任响应。** 52 | 53 | 每一个来自 MCIM 缓存的 API 响应,都会提供该响应对应的缓存日期,位于 Headers 的 `sync_at` 字段,格式为 `YYYY-MM-DDTHH:MM:SSZ`。同一个响应中,可能包含多个 `sync_at` 字段对应响应的不同部分。 54 | 55 | ### 简介翻译 56 | 57 | 简介原文来自 Modrinth Project 的 `description` 和 Curseforge Mod 的 `summary` 字段 58 | 59 | 详情见 [translate-mod-summary](https://github.com/mcmod-info-mirror/translate-mod-summary),API 详情见[接口文档](https://mod.mcimirror.top/docs#/translate) 60 | 61 | #### Modrinth 62 | 63 | POST `https://mod.mcimirror.top/translate/modrinth` 64 | 65 | URL 参数:`project_id` 66 | 67 | 例如 68 | 69 | ```json 70 | { 71 | "project_id": "P7dR8mSH", 72 | "translated": "轻量级且模块化的API,为使用Fabric工具链的模组提供了常见的钩子功能和互操作性措施。", 73 | "original": "Lightweight and modular API providing common hooks and intercompatibility measures utilized by mods using the Fabric toolchain.", 74 | "translated_at": "2025-02-02T08:53:28.638000" 75 | } 76 | ``` 77 | 78 | #### Curseforge 79 | 80 | POST `https://mod.mcimirror.top/translate/curseforge` 81 | 82 | URL 参数:`modId` 83 | 84 | 例如 85 | 86 | ```json 87 | { 88 | "modId": 238222, 89 | "translated": "查看物品和配方", 90 | "original": "View Items and Recipes", 91 | "translated_at": "2025-02-02T10:01:52.805000" 92 | } 93 | ``` 94 | 95 | ## OpenMCIM 96 | 97 | > [!WARNING] 98 | > 由于多种原因,OpenMCIM 已暂停运行,MCIM API 不受影响。文件下载已自动重定向会源站。 99 | > 高考完后可能后可能重制后投入使用,敬请期待。 100 | 101 | 和 [OpenBMCLAPI](https://github.com/bangbang93/openbmclapi) 需要节点分发文件,**急需节点加入 orz !** 见 [OpenMCIM 文件分发相关 #91](https://github.com/mcmod-info-mirror/mcim/issues/91) 102 | 103 | 对于启动器开发者,OpenMCIM 不会缓存**除 Mod 外**的整合包、资源包、材质包、地图等,以及文件大小大于 **20MB** 的文件,Curseforge 的类型限制为 `classId=6`,该限制会被可能根据需求更改。 104 | 105 | ## 注意事项 106 | 107 | **文件**下载可能存在一定的不稳定性,当前缺少多节点网盘的分流,建议启动器在未能成功下载的情况下才尝试使用镜像源。 108 | 109 | 该 API 只提供 Minecraft 相关内容,不支持 Curseforge 上的其他游戏例如 wow。 110 | 111 | 关于 Mod 开发者收益问题,由于 API 下载量并不计入收益,因此无论从启动器官方源下载还是镜像源下载都是无法为 Mod 开发者提供收益的,不接受影响 Mod 开发者收益的指责。 112 | 113 | **本镜像可能会在滥用或遭到攻击的情况下切换到 Cloudflare CDN 或开启 URL 鉴权,或者暂时关闭。** 114 | 115 | **这是一项公益服务,请不要攻击我们** 116 | 117 | ## 鸣谢 118 | 119 | - [Pysio](https://github.com/pysio2007) 提供 CDN 和域名 120 | - [BangBang93](https://blog.bangbang93.com/) 提供服务器 121 | - [SaltWood_233](https://github.com/SALTWOOD) 提供文件分发主控技术支持 122 | - [为 OpenMCIM 提供节点支持的各位](https://files.mcimirror.top/dashboard/rank) 123 | 124 | ## 联系 125 | 126 | - Email: z0z0r4@outlook.com 127 | - QQ: 3531890582 128 | - QQ 群聊 [OpenMCIM](https://qm.qq.com/q/ZSN6ilHEwC) 129 | 130 | ### 声明 131 | 132 | MCIM 是一个镜像服务平台,旨在为中国大陆用户提供稳定的 Mod 信息镜像服务。为维护 Mod 创作者及源站平台的合法权益,MCIM 制定以下协议及处理方式: 133 | 134 | 1. **文件归属** 135 | MCIM 平台镜像的所有文件,除 MCIM 本身的相关配置外,其所有权依据源站平台的协议进行归属。未经原始版权所有者授权,严禁通过 MCIM 进行任何形式的转发或二次发布。 136 | 137 | 2. **责任免责** 138 | MCIM 将尽力确保所镜像信息的完整性、有效性和实时性。然而,对于通过 MCIM 使用的引发的任何纠纷或责任,MCIM 不承担任何法律责任,所有风险由用户自行承担。 139 | 140 | 3. **禁止二次封装协议** 141 | 禁止在 MCIM 上对接口进行二次封装。 142 | 143 | 如有违反上述内容,MCIM 保留采取必要措施或终止服务的权利。 144 | 145 | NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT. 146 | 147 | 不是 Minecraft 官方服务。未经 Mojang 或 MICROSOFT 批准或与 MOJANG 或 MICROSOFT 相关。 148 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.responses import RedirectResponse 3 | from starlette.middleware.cors import CORSMiddleware 4 | from fastapi.middleware.gzip import GZipMiddleware 5 | from fastapi.exception_handlers import ( 6 | request_validation_exception_handler, 7 | ) 8 | from fastapi.exceptions import RequestValidationError 9 | from contextlib import asynccontextmanager 10 | from app.controller import controller_router 11 | from app.utils.loger import log 12 | from app.config import MCIMConfig 13 | from app.database.mongodb import setup_async_mongodb, init_mongodb_aioengine 14 | from app.database._redis import ( 15 | init_redis_aioengine, 16 | close_aio_redis_engine, 17 | init_sync_queue_redis_engine, 18 | close_sync_queue_redis_engine, 19 | ) 20 | from app.utils.response_cache import Cache 21 | from app.utils.response_cache import cache 22 | from app.utils.response import BaseResponse 23 | from app.utils.middleware import ( 24 | TimingMiddleware, 25 | CountTrustableMiddleware, 26 | UncachePOSTMiddleware, 27 | ) 28 | from app.utils.metric import init_prometheus_metrics 29 | 30 | mcim_config = MCIMConfig.load() 31 | 32 | 33 | @asynccontextmanager 34 | async def lifespan(app: FastAPI): 35 | app.state.aio_redis_engine = init_redis_aioengine() 36 | init_sync_queue_redis_engine() 37 | await app.state.aio_redis_engine.flushall() 38 | app.state.aio_mongo_engine = init_mongodb_aioengine() 39 | await setup_async_mongodb(app.state.aio_mongo_engine) 40 | 41 | if mcim_config.redis_cache: 42 | app.state.fastapi_cache = Cache.init(enabled=True) 43 | 44 | yield 45 | 46 | await close_aio_redis_engine() 47 | await close_sync_queue_redis_engine() 48 | 49 | 50 | APP = FastAPI( 51 | title="MCIM", 52 | # description="这是一个为 Mod 信息加速的 API
你不应该直接浏览器中测试接口,有 UA 限制", 53 | description="这是一个为 Mod 信息加速的 API", 54 | lifespan=lifespan, 55 | ) 56 | 57 | if mcim_config.prometheus: 58 | init_prometheus_metrics(APP) 59 | 60 | 61 | APP.include_router(controller_router) 62 | 63 | # Gzip 中间件 64 | APP.add_middleware(GZipMiddleware, minimum_size=1000) 65 | 66 | # 计时中间件 67 | APP.add_middleware(TimingMiddleware) 68 | 69 | # 统计 Trustable 请求 70 | APP.add_middleware(CountTrustableMiddleware) 71 | 72 | # 不缓存 POST 请求 73 | APP.add_middleware(UncachePOSTMiddleware) 74 | 75 | # 跨域中间件 76 | APP.add_middleware( 77 | CORSMiddleware, 78 | allow_origins=["*"], 79 | allow_credentials=True, 80 | allow_methods=["*"], 81 | allow_headers=["*"], 82 | ) 83 | 84 | 85 | @APP.exception_handler(RequestValidationError) 86 | async def validation_exception_handler(request: Request, exc: RequestValidationError): 87 | # Log the 422 error details, 分行输出避免 Request body 截断 88 | log.debug(f"Invalid request on {request.url} Detail: {exc}") 89 | log.debug(f"Invalid request on {request.url} Request body: {exc.body}") 90 | return await request_validation_exception_handler(request, exc) 91 | 92 | 93 | @APP.get("/favicon.ico") 94 | @cache(never_expire=True) 95 | async def favicon(): 96 | return RedirectResponse(url=mcim_config.favicon_url) 97 | 98 | 99 | WELCOME_MESSAGE = { 100 | "status": "success", 101 | "message": "mcimirror", 102 | "information": { 103 | "Status": "https://status.mcimirror.top", 104 | "Docs": [ 105 | "https://mod.mcimirror.top/docs", 106 | ], 107 | "Github": "https://github.com/mcmod-info-mirror", 108 | "contact": {"Email": "z0z0r4@outlook.com", "QQ": "3531890582"}, 109 | }, 110 | } 111 | 112 | 113 | @APP.get( 114 | "/", 115 | responses={ 116 | 200: { 117 | "description": "MCIM API", 118 | "content": { 119 | "APPlication/json": { 120 | "example": WELCOME_MESSAGE, 121 | } 122 | }, 123 | } 124 | }, 125 | description="MCIM API", 126 | ) 127 | @cache(never_expire=True) 128 | async def root(): 129 | return BaseResponse(content=WELCOME_MESSAGE) 130 | -------------------------------------------------------------------------------- /app/config/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from app.config.constants import CONFIG_PATH 3 | from app.config.mcim import MCIMConfig 4 | from app.config.mongodb import MongodbConfig 5 | from app.config.redis import RedisdbConfig 6 | 7 | __all__ = [ 8 | "MCIMConfig", 9 | "MongodbConfig", 10 | "RedisdbConfig", 11 | ] 12 | 13 | 14 | if not os.path.exists(CONFIG_PATH): 15 | os.makedirs(CONFIG_PATH) 16 | -------------------------------------------------------------------------------- /app/config/constants.py: -------------------------------------------------------------------------------- 1 | CONFIG_PATH = "./config/" 2 | -------------------------------------------------------------------------------- /app/config/mcim.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Optional 4 | from pydantic import BaseModel, ValidationError, validator 5 | from enum import Enum 6 | 7 | from .constants import CONFIG_PATH 8 | 9 | # MCIM config path 10 | MICM_CONFIG_PATH = os.path.join(CONFIG_PATH, "mcim.json") 11 | 12 | 13 | class Curseforge(BaseModel): 14 | mod: int = 86400 15 | file: int = 86400 16 | fingerprint: int = 86400 * 7 # 一般不刷新 17 | search: int = 7200 18 | categories: int = 86400 * 7 19 | 20 | 21 | class Modrinth(BaseModel): 22 | project: int = 86400 23 | version: int = 86400 24 | file: int = 86400 * 7 # 一般不刷新 25 | search: int = 7200 26 | category: int = 86400 * 7 27 | 28 | 29 | class ExpireSecond(BaseModel): 30 | curseforge: Curseforge = Curseforge() 31 | modrinth: Modrinth = Modrinth() 32 | 33 | class FileCDNRedirectMode(str, Enum): 34 | # 重定向到原始链接 35 | ORIGIN = "origin" 36 | # 重定向到 open93home 37 | OPEN93HOME = "open93home" 38 | # 重定向到 pysio 39 | PYSIO = "pysio" 40 | 41 | 42 | class MCIMConfigModel(BaseModel): 43 | host: str = "127.0.0.1" 44 | port: int = 8000 45 | debug: bool = False 46 | 47 | curseforge_api_key: str = "" 48 | curseforge_api: str = "https://api.curseforge.com" # 不然和api的拼接对不上 49 | modrinth_api: str = "https://api.modrinth.com" 50 | proxies: Optional[str] = None 51 | 52 | file_cdn: bool = False 53 | file_cdn_redirect_mode: FileCDNRedirectMode = FileCDNRedirectMode.ORIGIN 54 | file_cdn_secret: str = "secret" 55 | max_file_size: int = 1024 * 1024 * 20 56 | 57 | prometheus: bool = False 58 | 59 | redis_cache: bool = True 60 | open93home_endpoint: str = "http://open93home" 61 | 62 | # pysio 63 | pysio_endpoint: str = "https://pysio.online" 64 | 65 | expire_second: ExpireSecond = ExpireSecond() 66 | 67 | favicon_url: str = ( 68 | "https://thirdqq.qlogo.cn/g?b=sdk&k=ABmaVOlfKKPceB5qfiajxqg&s=640" 69 | ) 70 | 71 | 72 | class MCIMConfig: 73 | @staticmethod 74 | def save(model: MCIMConfigModel = MCIMConfigModel(), target=MICM_CONFIG_PATH): 75 | with open(target, "w") as fd: 76 | json.dump(model.model_dump(), fd, indent=4) 77 | 78 | @staticmethod 79 | def load(target=MICM_CONFIG_PATH) -> MCIMConfigModel: 80 | if not os.path.exists(target): 81 | MCIMConfig.save(target=target) 82 | return MCIMConfigModel() 83 | with open(target, "r") as fd: 84 | data = json.load(fd) 85 | return MCIMConfigModel(**data) 86 | -------------------------------------------------------------------------------- /app/config/mongodb.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from pydantic import BaseModel, ValidationError, validator 4 | 5 | from .constants import CONFIG_PATH 6 | 7 | # MONGODB config path 8 | MONGODB_CONFIG_PATH = os.path.join(CONFIG_PATH, "mongodb.json") 9 | 10 | 11 | class MongodbConfigModel(BaseModel): 12 | host: str = "mongodb" 13 | port: int = 27017 14 | auth: bool = True 15 | user: str = "username" 16 | password: str = "password" 17 | database: str = "database" 18 | 19 | 20 | class MongodbConfig: 21 | @staticmethod 22 | def save( 23 | model: MongodbConfigModel = MongodbConfigModel(), target=MONGODB_CONFIG_PATH 24 | ): 25 | with open(target, "w") as fd: 26 | json.dump(model.model_dump(), fd, indent=4) 27 | 28 | @staticmethod 29 | def load(target=MONGODB_CONFIG_PATH) -> MongodbConfigModel: 30 | if not os.path.exists(target): 31 | MongodbConfig.save(target=target) 32 | return MongodbConfigModel() 33 | with open(target, "r") as fd: 34 | data = json.load(fd) 35 | return MongodbConfigModel(**data) 36 | -------------------------------------------------------------------------------- /app/config/redis.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Union, Optional 3 | import os 4 | from pydantic import BaseModel, ValidationError, validator 5 | 6 | from .constants import CONFIG_PATH 7 | 8 | # REDIS config path 9 | REDIS_CONFIG_PATH = os.path.join(CONFIG_PATH, "redis.json") 10 | SYNC_REDIS_CONFIG_PATH = os.path.join(CONFIG_PATH, "sync_redis.json") 11 | 12 | 13 | class RedisDatabaseModel(BaseModel): 14 | tasks_queue: int = 0 # dramatiq tasks 15 | info_cache: int = 1 # response_cache and static info 16 | rate_limit: int = 3 # rate limit 17 | sync_queue: int = 4 # sync queue 18 | 19 | class RedisdbConfigModel(BaseModel): 20 | host: str = "redis" 21 | port: int = 6379 22 | user: Optional[str] = None 23 | password: Optional[str] = None 24 | database: RedisDatabaseModel = RedisDatabaseModel() 25 | 26 | class SyncRedisdbConfigModel(BaseModel): 27 | host: str = "sync_redis" 28 | port: int = 6379 29 | user: Optional[str] = None 30 | password: Optional[str] = None 31 | 32 | class RedisdbConfig: 33 | @staticmethod 34 | def save( 35 | model: RedisdbConfigModel = RedisdbConfigModel(), target=REDIS_CONFIG_PATH 36 | ): 37 | with open(target, "w") as fd: 38 | json.dump(model.model_dump(), fd, indent=4) 39 | 40 | @staticmethod 41 | def load(target=REDIS_CONFIG_PATH) -> RedisdbConfigModel: 42 | if not os.path.exists(target): 43 | RedisdbConfig.save(target=target) 44 | return RedisdbConfigModel() 45 | with open(target, "r") as fd: 46 | data = json.load(fd) 47 | return RedisdbConfigModel(**data) 48 | 49 | class SyncRedisdbConfig: 50 | @staticmethod 51 | def save( 52 | model: SyncRedisdbConfigModel = SyncRedisdbConfigModel(), target=SYNC_REDIS_CONFIG_PATH 53 | ): 54 | with open(target, "w") as fd: 55 | json.dump(model.model_dump(), fd, indent=4) 56 | 57 | @staticmethod 58 | def load(target=REDIS_CONFIG_PATH) -> SyncRedisdbConfigModel: 59 | if not os.path.exists(target): 60 | SyncRedisdbConfig.save(target=target) 61 | return SyncRedisdbConfigModel() 62 | with open(target, "r") as fd: 63 | data = json.load(fd) 64 | return SyncRedisdbConfigModel(**data) 65 | -------------------------------------------------------------------------------- /app/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from typing import Optional 3 | from app.controller.modrinth import modrinth_router 4 | from app.controller.curseforge import curseforge_router 5 | from app.controller.file_cdn import file_cdn_router 6 | from app.controller.translate import translate_router 7 | from app.config import MCIMConfig 8 | from app.utils.loger import log 9 | from app.models.database.modrinth import ( 10 | Project as ModrinthProject, 11 | Version as ModrinthVersion, 12 | File as ModrinthFile, 13 | ) 14 | from app.models.database.curseforge import ( 15 | Mod as CurseForgeMod, 16 | File as CurseForgeFile, 17 | Fingerprint as CurseForgeFingerprint, 18 | ) 19 | from app.models.database.file_cdn import File as FileCDNFile 20 | from app.utils.response import BaseResponse 21 | from app.utils.response_cache import cache 22 | 23 | mcim_config = MCIMConfig.load() 24 | 25 | controller_router = APIRouter() 26 | controller_router.include_router(curseforge_router) 27 | controller_router.include_router(modrinth_router) 28 | controller_router.include_router(file_cdn_router) 29 | controller_router.include_router(translate_router) 30 | 31 | 32 | @controller_router.get( 33 | "/statistics", description="MCIM 缓存统计信息,每小时更新", include_in_schema=True 34 | ) 35 | @cache(expire=3600) 36 | async def mcim_statistics( 37 | request: Request, 38 | modrinth: Optional[bool] = True, 39 | curseforge: Optional[bool] = True, 40 | file_cdn: Optional[bool] = True, 41 | ): 42 | """ 43 | 全部统计信息 44 | """ 45 | 46 | result = {} 47 | 48 | if curseforge: 49 | curseforge_mod_collection = request.app.state.aio_mongo_engine.get_collection( 50 | CurseForgeMod 51 | ) 52 | curseforge_file_collection = request.app.state.aio_mongo_engine.get_collection( 53 | CurseForgeFile 54 | ) 55 | curseforge_fingerprint_collection = ( 56 | request.app.state.aio_mongo_engine.get_collection(CurseForgeFingerprint) 57 | ) 58 | 59 | curseforge_mod_count = await curseforge_mod_collection.aggregate( 60 | [{"$collStats": {"count": {}}}] 61 | ).to_list(length=None) 62 | curseforge_file_count = await curseforge_file_collection.aggregate( 63 | [{"$collStats": {"count": {}}}] 64 | ).to_list(length=None) 65 | curseforge_fingerprint_count = ( 66 | await curseforge_fingerprint_collection.aggregate( 67 | [{"$collStats": {"count": {}}}] 68 | ).to_list(length=None) 69 | ) 70 | 71 | result["curseforge"] = { 72 | "mod": curseforge_mod_count[0]["count"], 73 | "file": curseforge_file_count[0]["count"], 74 | "fingerprint": curseforge_fingerprint_count[0]["count"], 75 | } 76 | 77 | if modrinth: 78 | modrinth_project_collection = request.app.state.aio_mongo_engine.get_collection( 79 | ModrinthProject 80 | ) 81 | modrinth_version_collection = request.app.state.aio_mongo_engine.get_collection( 82 | ModrinthVersion 83 | ) 84 | modrinth_file_collection = request.app.state.aio_mongo_engine.get_collection( 85 | ModrinthFile 86 | ) 87 | 88 | modrinth_project_count = await modrinth_project_collection.aggregate( 89 | [{"$collStats": {"count": {}}}] 90 | ).to_list(length=None) 91 | modrinth_version_count = await modrinth_version_collection.aggregate( 92 | [{"$collStats": {"count": {}}}] 93 | ).to_list(length=None) 94 | modrinth_file_count = await modrinth_file_collection.aggregate( 95 | [{"$collStats": {"count": {}}}] 96 | ).to_list(length=None) 97 | 98 | result["modrinth"] = { 99 | "project": modrinth_project_count[0]["count"], 100 | "version": modrinth_version_count[0]["count"], 101 | "file": modrinth_file_count[0]["count"], 102 | } 103 | 104 | if file_cdn and mcim_config.file_cdn: 105 | file_cdn_file_collection = request.app.state.aio_mongo_engine.get_collection( 106 | FileCDNFile 107 | ) 108 | 109 | file_cdn_file_count = await file_cdn_file_collection.aggregate( 110 | [{"$collStats": {"count": {}}}] 111 | ).to_list(length=None) 112 | 113 | result["file_cdn"] = { 114 | "file": file_cdn_file_count[0]["count"], 115 | } 116 | 117 | return BaseResponse( 118 | content=result, 119 | headers={"Cache-Control": f"max-age=3600"}, 120 | ) 121 | -------------------------------------------------------------------------------- /app/controller/curseforge/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from pydantic import BaseModel 3 | 4 | from app.controller.curseforge.v1 import v1_router 5 | from app.utils.response_cache import cache 6 | from app.utils.response import BaseResponse 7 | from app.models.database.curseforge import Mod, File, Fingerprint 8 | 9 | curseforge_router = APIRouter(prefix="/curseforge", tags=["curseforge"]) 10 | 11 | curseforge_router.include_router(v1_router) 12 | 13 | 14 | @curseforge_router.get("/") 15 | @cache(never_expire=True) 16 | async def get_curseforge(): 17 | return BaseResponse(content={"message": "CurseForge"}) 18 | 19 | 20 | class CurseforgeStatistics(BaseModel): 21 | mods: int 22 | files: int 23 | fingerprints: int 24 | 25 | 26 | # statistics 27 | @curseforge_router.get( 28 | "/statistics", 29 | description="Curseforge 缓存统计信息", 30 | response_model=CurseforgeStatistics, 31 | include_in_schema=False, 32 | ) 33 | # @cache(expire=3600) 34 | async def curseforge_statistics(request: Request): 35 | mod_collection = request.app.state.aio_mongo_engine.get_collection(Mod) 36 | file_collection = request.app.state.aio_mongo_engine.get_collection(File) 37 | fingerprint_collection = request.app.state.aio_mongo_engine.get_collection( 38 | Fingerprint 39 | ) 40 | 41 | mod_count = await mod_collection.aggregate([{"$collStats": {"count": {}}}]).to_list(length=None) 42 | file_count = await file_collection.aggregate([{"$collStats": {"count": {}}}]).to_list(length=None) 43 | fingerprint_count = await fingerprint_collection.aggregate( 44 | [{"$collStats": {"count": {}}}] 45 | ).to_list(length=None) 46 | 47 | return BaseResponse( 48 | content=CurseforgeStatistics( 49 | mods=mod_count[0]["count"], 50 | files=file_count[0]["count"], 51 | fingerprints=fingerprint_count[0]["count"], 52 | ) 53 | ) -------------------------------------------------------------------------------- /app/controller/curseforge/v1/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, BackgroundTasks 2 | from typing import List, Optional, Union, Annotated 3 | from pydantic import BaseModel, Field 4 | from odmantic import query 5 | from enum import Enum 6 | 7 | from app.sync_queue.curseforge import ( 8 | add_curseforge_modIds_to_queue, 9 | add_curseforge_fileIds_to_queue, 10 | add_curseforge_fingerprints_to_queue, 11 | ) 12 | from app.models.database.curseforge import Mod, File, Fingerprint, Category 13 | from app.models.response.curseforge import ( 14 | _FingerprintResult, 15 | Pagination, 16 | SearchResponse, 17 | ModResponse, 18 | ModsResponse, 19 | ModFilesResponse, 20 | FileResponse, 21 | FilesResponse, 22 | DownloadUrlResponse, 23 | FingerprintResponse, 24 | CaregoriesResponse, 25 | ) 26 | from app.config.mcim import MCIMConfig 27 | from app.utils.response import TrustableResponse, UncachedResponse, BaseResponse 28 | from app.utils.network import request as request_async 29 | from app.utils.loger import log 30 | from app.utils.response_cache import cache 31 | 32 | mcim_config = MCIMConfig.load() 33 | 34 | API = mcim_config.curseforge_api 35 | 36 | x_api_key = mcim_config.curseforge_api_key 37 | HEADERS = {"x-api-key": x_api_key} 38 | 39 | v1_router = APIRouter(prefix="/v1", tags=["curseforge"]) 40 | 41 | SEARCH_TIMEOUT = 3 42 | 43 | 44 | class ModsSearchSortField(int, Enum): 45 | Featured = 1 46 | Popularity = 2 47 | LastUpdated = 3 48 | Name = 4 49 | Author = 5 50 | TotalDownloads = 6 51 | Category = 7 52 | GameVersion = 8 53 | EarlyAccess = 9 54 | FeaturedReleased = 10 55 | ReleasedDate = 11 56 | Rating = 12 57 | 58 | 59 | class ModLoaderType(int, Enum): 60 | Any = 0 61 | Forge = 1 62 | Cauldron = 2 63 | LiteLoader = 3 64 | Fabric = 4 65 | Quilt = 5 66 | NeoForge = 6 67 | 68 | 69 | async def check_search_result(request: Request, res: dict): 70 | modids = set() 71 | for mod in res["data"]: 72 | # 排除小于 30000 的 modid 73 | if mod["id"] >= 30000: 74 | modids.add(mod["id"]) 75 | 76 | # check if modids in db 77 | if modids: 78 | mod_models: List[Mod] = await request.app.state.aio_mongo_engine.find( 79 | Mod, query.in_(Mod.id, list(modids)) 80 | ) 81 | 82 | not_found_modids = modids - set([mod.id for mod in mod_models]) 83 | 84 | if not_found_modids: 85 | await add_curseforge_modIds_to_queue(modIds=list(not_found_modids)) 86 | log.debug(f"modIds: {not_found_modids} not found, add to queue.") 87 | else: 88 | log.debug(f"All Mods have been found.") 89 | else: 90 | log.debug("Search esult is empty") 91 | 92 | 93 | @v1_router.get( 94 | "/mods/search", 95 | description="Curseforge Category 信息", 96 | response_model=SearchResponse, 97 | ) 98 | @cache(expire=mcim_config.expire_second.curseforge.search) 99 | async def curseforge_search( 100 | request: Request, 101 | gameId: int = 432, 102 | classId: Optional[int] = None, 103 | categoryId: Optional[int] = None, 104 | categoryIds: Optional[str] = None, 105 | gameVersion: Optional[str] = None, 106 | gameVersions: Optional[str] = None, 107 | searchFilter: Optional[str] = None, 108 | sortField: Optional[ModsSearchSortField] = None, 109 | sortOrder: Optional[str] = None, 110 | modLoaderType: Optional[ModLoaderType] = None, 111 | modLoaderTypes: Optional[str] = None, 112 | gameVersionTypeId: Optional[int] = None, 113 | authorId: Optional[int] = None, 114 | primaryAuthorId: Optional[int] = None, 115 | slug: Optional[str] = None, 116 | index: Optional[int] = None, 117 | pageSize: Optional[int] = 50, 118 | ): 119 | params = { 120 | "gameId": gameId, 121 | "classId": classId, 122 | "categoryId": categoryId, 123 | "categoryIds": categoryIds, 124 | "gameVersion": gameVersion, 125 | "gameVersions": gameVersions, 126 | "searchFilter": searchFilter, 127 | "sortField": sortField.value if not sortField is None else None, 128 | "sortOrder": sortOrder, 129 | "modLoaderType": modLoaderType.value if not modLoaderType is None else None, 130 | "modLoaderTypes": modLoaderTypes, 131 | "gameVersionTypeId": gameVersionTypeId, 132 | "authorId": authorId, 133 | "primaryAuthorId": primaryAuthorId, 134 | "slug": slug, 135 | "index": index, 136 | "pageSize": pageSize, 137 | } 138 | res = ( 139 | await request_async( 140 | f"{API}/v1/mods/search", 141 | params=params, 142 | headers=HEADERS, 143 | timeout=SEARCH_TIMEOUT, 144 | ) 145 | ).json() 146 | await check_search_result(request=request, res=res) 147 | return TrustableResponse(content=SearchResponse(**res)) 148 | 149 | 150 | @v1_router.get( 151 | "/mods/{modId}", 152 | description="Curseforge Mod 信息", 153 | response_model=ModResponse, 154 | ) 155 | @cache(expire=mcim_config.expire_second.curseforge.mod) 156 | async def curseforge_mod( 157 | modId: Annotated[int, Field(ge=30000, lt=9999999)], request: Request 158 | ): 159 | trustable: bool = True 160 | mod_model: Optional[Mod] = await request.app.state.aio_mongo_engine.find_one( 161 | Mod, Mod.id == modId 162 | ) 163 | if mod_model is None: 164 | await add_curseforge_modIds_to_queue(modIds=[modId]) 165 | log.debug(f"modId: {modId} not found, add to queue.") 166 | return UncachedResponse() 167 | return TrustableResponse( 168 | content=ModResponse(data=mod_model), 169 | trustable=trustable, 170 | ) 171 | 172 | 173 | class modIds_item(BaseModel): 174 | modIds: List[Annotated[int, Field(ge=30000, lt=9999999)]] 175 | filterPcOnly: Optional[bool] = True 176 | 177 | 178 | @v1_router.post( 179 | "/mods", 180 | description="Curseforge Mods 信息", 181 | response_model=ModsResponse, 182 | ) 183 | # @cache(expire=mcim_config.expire_second.curseforge.mod) 184 | async def curseforge_mods(item: modIds_item, request: Request): 185 | trustable: bool = True 186 | mod_models: Optional[List[Mod]] = await request.app.state.aio_mongo_engine.find( 187 | Mod, query.in_(Mod.id, item.modIds) 188 | ) 189 | mod_model_count = len(mod_models) 190 | item_count = len(item.modIds) 191 | if not mod_models: 192 | await add_curseforge_modIds_to_queue(modIds=item.modIds) 193 | log.debug(f"modIds: {item.modIds} not found, add to queue.") 194 | return TrustableResponse( 195 | content=ModsResponse(data=[]).model_dump(), 196 | trustable=False, 197 | ) 198 | elif mod_model_count != item_count: 199 | # 找到不存在的 modid 200 | not_match_modids = list(set(item.modIds) - set([mod.id for mod in mod_models])) 201 | await add_curseforge_modIds_to_queue(modIds=not_match_modids) 202 | log.debug( 203 | f"modIds: {item.modIds} {mod_model_count}/{item_count} not found, add to queue." 204 | ) 205 | trustable = False 206 | return TrustableResponse( 207 | content=ModsResponse(data=mod_models), 208 | trustable=trustable, 209 | ) 210 | 211 | 212 | def convert_modloadertype(type_id: int) -> Optional[str]: 213 | match type_id: 214 | case 1: 215 | return "Forge" 216 | case 2: 217 | return "Cauldron" 218 | case 3: 219 | return "LiteLoader" 220 | case 4: 221 | return "Fabric" 222 | case 5: 223 | return "Quilt" 224 | case 6: 225 | return "NeoForge" 226 | case _: 227 | return None 228 | 229 | 230 | @v1_router.get( 231 | "/mods/{modId}/files", 232 | description="Curseforge Mod 文件信息", 233 | response_model=ModFilesResponse, 234 | ) 235 | @cache(expire=mcim_config.expire_second.curseforge.file) 236 | async def curseforge_mod_files( 237 | request: Request, 238 | modId: Annotated[int, Field(gt=30000, lt=9999999)], 239 | gameVersion: Optional[str] = None, 240 | modLoaderType: Optional[int] = None, 241 | index: Optional[int] = 0, 242 | pageSize: Optional[ 243 | int 244 | ] = 50, # curseforge 官方的 limit 是摆设,启动器依赖此 bug 运行,不能设置 gt... 245 | ): 246 | # 定义聚合管道 247 | match_conditions = {"modId": modId} 248 | gameVersionFilter = [] 249 | if gameVersion: 250 | gameVersionFilter.append(gameVersion) 251 | if modLoaderType: 252 | modLoaderType = convert_modloadertype(modLoaderType) 253 | if modLoaderType: 254 | gameVersionFilter.append(modLoaderType) 255 | if len(gameVersionFilter) != 0: 256 | match_conditions["gameVersions"] = {"$all": gameVersionFilter} 257 | 258 | pipeline = [ 259 | {"$match": match_conditions}, 260 | { 261 | "$facet": { 262 | "resultCount": [ 263 | {"$count": "count"}, 264 | ], 265 | "totalCount": [ 266 | {"$match": {"modId": modId}}, 267 | {"$count": "count"}, 268 | ], 269 | "documents": [ 270 | {"$skip": index if index else 0}, 271 | {"$limit": pageSize}, 272 | ], 273 | } 274 | }, 275 | ] 276 | 277 | # 执行聚合查询 278 | files_collection = request.app.state.aio_mongo_engine.get_collection(File) 279 | result = await files_collection.aggregate(pipeline).to_list(length=None) 280 | 281 | if not result or not result[0]["documents"]: 282 | await add_curseforge_modIds_to_queue(modIds=[modId]) 283 | log.debug(f"modId: {modId} not found, add to queue.") 284 | return UncachedResponse() 285 | 286 | total_count = result[0]["totalCount"][0]["count"] 287 | result_count = result[0]["resultCount"][0]["count"] 288 | documents = result[0]["documents"] 289 | 290 | doc_results = [] 291 | for doc in documents: 292 | _id = doc.pop("_id") 293 | doc["id"] = _id 294 | doc_results.append(doc) 295 | 296 | return TrustableResponse( 297 | content=ModFilesResponse( 298 | data=doc_results, 299 | pagination=Pagination( 300 | index=index, 301 | pageSize=pageSize, 302 | resultCount=result_count, 303 | totalCount=total_count, 304 | ), 305 | ) 306 | ) 307 | 308 | 309 | class fileIds_item(BaseModel): 310 | fileIds: List[Annotated[int, Field(ge=530000, lt=99999999)]] 311 | 312 | 313 | # get files 314 | @v1_router.post( 315 | "/mods/files", 316 | description="Curseforge Mod 文件信息", 317 | response_model=FilesResponse, 318 | ) 319 | # @cache(expire=mcim_config.expire_second.curseforge.file) 320 | async def curseforge_files(item: fileIds_item, request: Request): 321 | trustable = True 322 | file_models: Optional[List[File]] = await request.app.state.aio_mongo_engine.find( 323 | File, query.in_(File.id, item.fileIds) 324 | ) 325 | if not file_models: 326 | await add_curseforge_fileIds_to_queue(fileIds=item.fileIds) 327 | return UncachedResponse() 328 | elif len(file_models) != len(item.fileIds): 329 | # 找到不存在的 fileid 330 | not_match_fileids = list( 331 | set(item.fileIds) - set([file.id for file in file_models]) 332 | ) 333 | await add_curseforge_fileIds_to_queue(fileIds=not_match_fileids) 334 | trustable = False 335 | return TrustableResponse( 336 | content=FilesResponse(data=file_models), 337 | trustable=trustable, 338 | ) 339 | 340 | 341 | # get file 342 | @v1_router.get( 343 | "/mods/{modId}/files/{fileId}", 344 | description="Curseforge Mod 文件信息", 345 | response_model=FileResponse, 346 | ) 347 | @cache(expire=mcim_config.expire_second.curseforge.file) 348 | async def curseforge_mod_file( 349 | modId: Annotated[int, Field(ge=30000, lt=9999999)], 350 | fileId: Annotated[int, Field(ge=530000, lt=99999999)], 351 | request: Request, 352 | ): 353 | trustable = True 354 | model: Optional[File] = await request.app.state.aio_mongo_engine.find_one( 355 | File, File.modId == modId, File.id == fileId 356 | ) 357 | if model is None: 358 | await add_curseforge_fileIds_to_queue(fileIds=[fileId]) 359 | return UncachedResponse() 360 | return TrustableResponse( 361 | content=FileResponse(data=model), 362 | trustable=trustable, 363 | ) 364 | 365 | 366 | @v1_router.get( 367 | "/mods/{modId}/files/{fileId}/download-url", 368 | description="Curseforge Mod 文件下载地址", 369 | response_model=DownloadUrlResponse, 370 | ) 371 | # @cache(expire=mcim_config.expire_second.curseforge.file) 372 | async def curseforge_mod_file_download_url( 373 | modId: Annotated[int, Field(ge=30000, lt=9999999)], 374 | fileId: Annotated[int, Field(ge=530000, lt=99999999)], 375 | request: Request, 376 | ): 377 | model: Optional[File] = await request.app.state.aio_mongo_engine.find_one( 378 | File, File.modId == modId, File.id == fileId 379 | ) 380 | if ( 381 | model is None or model.downloadUrl is None 382 | ): # 有 134539+ 的文件没有 downloadCount 383 | await add_curseforge_fileIds_to_queue(fileIds=[fileId]) 384 | return UncachedResponse() 385 | return TrustableResponse( 386 | content=DownloadUrlResponse(data=model.downloadUrl), 387 | trustable=True, 388 | ) 389 | 390 | 391 | class fingerprints_item(BaseModel): 392 | fingerprints: List[Annotated[int, Field(lt=99999999999)]] 393 | 394 | 395 | @v1_router.post( 396 | "/fingerprints", 397 | description="Curseforge Fingerprint 文件信息", 398 | response_model=FingerprintResponse, 399 | ) 400 | # @cache(expire=mcim_config.expire_second.curseforge.fingerprint) 401 | async def curseforge_fingerprints(item: fingerprints_item, request: Request): 402 | trustable = True 403 | fingerprints_models: List[ 404 | Fingerprint 405 | ] = await request.app.state.aio_mongo_engine.find( 406 | Fingerprint, query.in_(Fingerprint.id, item.fingerprints) 407 | ) 408 | not_match_fingerprints = list( 409 | set(item.fingerprints) 410 | - set([fingerprint.id for fingerprint in fingerprints_models]) 411 | ) 412 | if not fingerprints_models: 413 | await add_curseforge_fingerprints_to_queue(fingerprints=item.fingerprints) 414 | trustable = False 415 | return TrustableResponse( 416 | content=FingerprintResponse( 417 | data=_FingerprintResult(unmatchedFingerprints=item.fingerprints) 418 | ), 419 | trustable=trustable, 420 | ) 421 | elif len(fingerprints_models) != len(item.fingerprints): 422 | # 找到不存在的 fingerprint 423 | await add_curseforge_fingerprints_to_queue(fingerprints=not_match_fingerprints) 424 | trustable = False 425 | exactFingerprints = [] 426 | result_fingerprints_models = [] 427 | for fingerprint_model in fingerprints_models: 428 | # fingerprint_model.id = fingerprint_model.file.id 429 | # 神奇 primary_key 不能修改,没辙只能这样了 430 | fingerprint = fingerprint_model.model_dump() 431 | fingerprint["id"] = fingerprint_model.file.id 432 | result_fingerprints_models.append(fingerprint) 433 | exactFingerprints.append(fingerprint_model.id) 434 | return TrustableResponse( 435 | content=FingerprintResponse( 436 | data=_FingerprintResult( 437 | isCacheBuilt=True, 438 | exactFingerprints=exactFingerprints, 439 | exactMatches=result_fingerprints_models, 440 | unmatchedFingerprints=not_match_fingerprints, 441 | installedFingerprints=[], 442 | ) 443 | ), 444 | trustable=trustable, 445 | ) 446 | 447 | 448 | @v1_router.post( 449 | "/fingerprints/432", 450 | description="Curseforge Fingerprint 文件信息", 451 | response_model=FingerprintResponse, 452 | ) 453 | # @cache(expire=mcim_config.expire_second.curseforge.fingerprint) 454 | async def curseforge_fingerprints_432(item: fingerprints_item, request: Request): 455 | trustable = True 456 | fingerprints_models: List[ 457 | Fingerprint 458 | ] = await request.app.state.aio_mongo_engine.find( 459 | Fingerprint, query.in_(Fingerprint.id, item.fingerprints) 460 | ) 461 | not_match_fingerprints = list( 462 | set(item.fingerprints) 463 | - set([fingerprint.id for fingerprint in fingerprints_models]) 464 | ) 465 | if not fingerprints_models: 466 | await add_curseforge_fingerprints_to_queue(fingerprints=item.fingerprints) 467 | trustable = False 468 | return TrustableResponse( 469 | content=FingerprintResponse( 470 | data=_FingerprintResult(unmatchedFingerprints=item.fingerprints) 471 | ), 472 | trustable=trustable, 473 | ) 474 | elif len(fingerprints_models) != len(item.fingerprints): 475 | await add_curseforge_fingerprints_to_queue(fingerprints=not_match_fingerprints) 476 | trustable = False 477 | exactFingerprints = [] 478 | result_fingerprints_models = [] 479 | for fingerprint_model in fingerprints_models: 480 | # fingerprint_model.id = fingerprint_model.file.id 481 | # 神奇 primary_key 不能修改,没辙只能这样了 482 | fingerprint = fingerprint_model.model_dump() 483 | fingerprint["id"] = fingerprint_model.file.id 484 | result_fingerprints_models.append(fingerprint) 485 | exactFingerprints.append(fingerprint_model.id) 486 | return TrustableResponse( 487 | content=FingerprintResponse( 488 | data=_FingerprintResult( 489 | isCacheBuilt=True, 490 | exactFingerprints=exactFingerprints, 491 | exactMatches=result_fingerprints_models, 492 | unmatchedFingerprints=not_match_fingerprints, 493 | installedFingerprints=[], 494 | ) 495 | ), 496 | trustable=trustable, 497 | ) 498 | 499 | 500 | @v1_router.get( 501 | "/categories", 502 | description="Curseforge Categories 信息", 503 | response_model=CaregoriesResponse, 504 | ) 505 | @cache(expire=mcim_config.expire_second.curseforge.categories) 506 | async def curseforge_categories( 507 | request: Request, 508 | gameId: int, 509 | classId: Optional[int] = None, 510 | classOnly: Optional[bool] = None, 511 | ): 512 | if classId: 513 | categories: Optional[ 514 | List[Category] 515 | ] = await request.app.state.aio_mongo_engine.find( 516 | Category, 517 | query.and_(Category.gameId == gameId, Category.classId == classId), 518 | ) 519 | elif classOnly: 520 | categories: Optional[ 521 | List[Category] 522 | ] = await request.app.state.aio_mongo_engine.find( 523 | Category, Category.gameId == gameId, Category.isClass == True 524 | ) 525 | else: 526 | categories: Optional[ 527 | List[Category] 528 | ] = await request.app.state.aio_mongo_engine.find( 529 | Category, Category.gameId == gameId 530 | ) 531 | if not categories: 532 | return UncachedResponse() 533 | return TrustableResponse( 534 | content=CaregoriesResponse(data=categories), 535 | trustable=True, 536 | ) 537 | -------------------------------------------------------------------------------- /app/controller/curseforge/v1/game.py: -------------------------------------------------------------------------------- 1 | """ 2 | 暂时不提供 game 相关的接口,优先级靠后 3 | """ 4 | 5 | # from fastapi import APIRouter 6 | # from fastapi.responses import JSONResponse 7 | 8 | # games_router = APIRouter(prefix="/games", tags=["games"]) 9 | 10 | # @games_router.get( 11 | # "/curseforge/games", 12 | # responses={ 13 | # 200: { 14 | # "description": "Curseforge Games info", 15 | # "content": { 16 | # "application/json": { 17 | # "example": {"status": "success", "data": [curseforge_game_example]} 18 | # } 19 | # }, 20 | # } 21 | # }, 22 | # description="Curseforge 的全部 Game 信息", 23 | # tags=["Curseforge"], 24 | # ) 25 | # async def curseforge_games(): 26 | # with Session(bind=sql_engine) as session: 27 | # all_data = [] 28 | # t = tables.curseforge_game_info 29 | # sql_games_result = session.query(t, t.c.time, t.c.status, t.c.data).all() 30 | # for result in sql_games_result: 31 | # if result is None or result == () or result[1] != 200: 32 | # break 33 | # gameid, status, time_tag, data = result 34 | # if status == 200: 35 | # if int(time.time()) - int(data["cachetime"]) > 60 * 60 * 4: 36 | # break 37 | # all_data.append(data) 38 | # else: 39 | # return JSONResponse( 40 | # content={"status": "success", "data": all_data}, 41 | # headers={"Cache-Control": "max-age=300, public"}, 42 | # ) 43 | # # sync 44 | # return JSONResponse( 45 | # content={ 46 | # "status": "success", 47 | # "data": await _sync_curseforge_games(sess=session), 48 | # }, 49 | # headers={"Cache-Control": "max-age=300, public"}, 50 | # ) 51 | 52 | # @games_router.get( 53 | # "/curseforge/game/{gameid}", 54 | # responses={ 55 | # 200: { 56 | # "description": "Curseforge Game info", 57 | # "content": { 58 | # "application/json": { 59 | # "example": {"status": "success", "data": curseforge_game_example} 60 | # } 61 | # }, 62 | # } 63 | # }, 64 | # description="Curseforge Game 信息", 65 | # tags=["Curseforge"], 66 | # ) 67 | # async def curseforge_game(gameid: int): 68 | # return await _curseforge_get_game(gameid=gameid) 69 | -------------------------------------------------------------------------------- /app/controller/file_cdn/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Query 2 | from fastapi.responses import RedirectResponse, JSONResponse 3 | from odmantic import query 4 | from typing import Optional 5 | import time 6 | import hashlib 7 | from email.utils import formatdate 8 | from urllib.parse import quote 9 | 10 | from app.models.database.curseforge import File as cfFile 11 | from app.models.database.modrinth import File as mrFile 12 | from app.models.database.file_cdn import File as cdnFile 13 | from app.config import MCIMConfig 14 | from app.utils.loger import log 15 | from app.utils.response_cache import cache 16 | from app.utils.response import BaseResponse 17 | from app.utils.network import ResponseCodeException 18 | from app.utils.network import request as request_async 19 | 20 | from app.sync_queue.curseforge import add_curseforge_fileIds_to_queue 21 | from app.sync_queue.modrinth import add_modrinth_project_ids_to_queue 22 | from app.utils.metric import ( 23 | FILE_CDN_FORWARD_TO_ORIGIN_COUNT, 24 | FILE_CDN_FORWARD_TO_OPEN93HOME_COUNT, 25 | ) 26 | from app.config.mcim import FileCDNRedirectMode 27 | 28 | 29 | mcim_config = MCIMConfig.load() 30 | 31 | # expire 3h 32 | file_cdn_router = APIRouter() 33 | 34 | FILE_CDN_REDIRECT_MODE = mcim_config.file_cdn_redirect_mode 35 | 36 | MAX_AGE = int(60 * 60 * 2.5) 37 | 38 | CDN_MAX_AGE = int(60 * 60 * 2.8) 39 | 40 | # 这个根本不需要更新,是 sha1 https://files.mcimirror.top/files/mcim/8e7b73b39c0bdae84a4be445027747c9bae935c4 41 | _93ATHOME_MAX_AGE = int(60 * 60 * 24 * 7) 42 | 43 | # 缓存文件大小限制 44 | MAX_LENGTH = mcim_config.max_file_size 45 | 46 | TIMEOUT = 2.5 47 | 48 | 49 | def get_http_date(delay: int = CDN_MAX_AGE): 50 | """ 51 | Get the current timestamp 52 | """ 53 | timestamp = time.time() 54 | timestamp += delay 55 | 56 | # Convert the timestamp to an HTTP date 57 | http_date = formatdate(timestamp, usegmt=True) 58 | return http_date 59 | 60 | 61 | def file_cdn_check_secret(secret: str): 62 | if secret != mcim_config.file_cdn_secret: 63 | return False 64 | return True 65 | 66 | 67 | @file_cdn_router.get("/file_cdn/statistics", include_in_schema=False) 68 | async def file_cdn_statistics(request: Request): 69 | cdnFile_collection = request.app.state.aio_mongo_engine.get_collection(cdnFile) 70 | cdnFile_count = await cdnFile_collection.aggregate( 71 | [{"$collStats": {"count": {}}}] 72 | ).to_list(length=None) 73 | return BaseResponse(content={"file_cdn_files": cdnFile_count[0]["count"]}) 74 | 75 | 76 | # modrinth | example: https://cdn.modrinth.com/data/AANobbMI/versions/IZskON6d/sodium-fabric-0.5.8%2Bmc1.20.6.jar 77 | # WARNING: 直接查 version_id 忽略 project_id 78 | # WARNING: 必须文件名一致 79 | @file_cdn_router.get( 80 | "/data/{project_id}/versions/{version_id}/{file_name}", tags=["modrinth"] 81 | ) 82 | @cache( 83 | expire=( 84 | _93ATHOME_MAX_AGE 85 | if FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.ORIGIN 86 | else MAX_AGE 87 | ) 88 | ) 89 | async def get_modrinth_file( 90 | project_id: str, version_id: str, file_name: str, request: Request 91 | ): 92 | def get_origin_response( 93 | project_id: str, version_id: str, file_name: str 94 | ) -> RedirectResponse: 95 | url = f"https://cdn.modrinth.com/data/{project_id}/versions/{version_id}/{file_name}" 96 | FILE_CDN_FORWARD_TO_ORIGIN_COUNT.labels("modrinth").inc() 97 | return RedirectResponse( 98 | url=url, 99 | headers={"Cache-Control": f"public, age={3600 * 24 * 1}"}, 100 | ) 101 | 102 | def get_open93home_response(sha1: str) -> Optional[RedirectResponse]: 103 | # 重新检查 cdnFile 104 | # file_cdn_model: Optional[cdnFile] = ( 105 | # await request.app.state.aio_mongo_engine.find_one( 106 | # cdnFile, cdnFile.sha1 == sha1 107 | # ) 108 | # ) 109 | # if file_cdn_model: 110 | # return RedirectResponse( 111 | # url=f"{mcim_config.open93home_endpoint}/{file_cdn_model.path}", 112 | # headers={"Cache-Control": f"public, age={3600*24*7}"}, 113 | # 114 | # ) 115 | 116 | # 信任 file_cdn_cached 则不再检查 117 | # 在调用该函数之前应该已经检查过 file_cdn_cached 为 True 118 | return RedirectResponse( 119 | # url=f"{mcim_config.open93home_endpoint}/{file_cdn_model.path}", 120 | url=f"{mcim_config.open93home_endpoint}/{sha1}", # file_cdn_model.path 实际上是 sha1 121 | headers={"Cache-Control": f"public, age={3600 * 24 * 7}"}, 122 | ) 123 | 124 | def get_pysio_response( 125 | project_id: str, version_id: str, file_name: str 126 | ) -> RedirectResponse: 127 | url = f"{mcim_config.pysio_endpoint}/data/{project_id}/versions/{version_id}/{file_name}" 128 | return RedirectResponse( 129 | url=url, 130 | headers={"Cache-Control": f"public, age={3600 * 24 * 1}"}, 131 | ) 132 | 133 | if not mcim_config.file_cdn: 134 | return get_origin_response(project_id, version_id, file_name) 135 | elif FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.PYSIO: 136 | # Note: Pysio 表示无需筛选,所以直接跳过 file 检索 137 | pysio_response = get_pysio_response( 138 | project_id=project_id, version_id=version_id, file_name=file_name 139 | ) 140 | return pysio_response 141 | 142 | file: Optional[mrFile] = await request.app.state.aio_mongo_engine.find_one( 143 | mrFile, 144 | query.and_( 145 | mrFile.project_id == project_id, 146 | mrFile.version_id == version_id, 147 | mrFile.filename == file_name, 148 | ), 149 | ) 150 | if file: 151 | if file.size <= MAX_LENGTH and file.file_cdn_cached: # 检查 file_cdn_cached 152 | sha1 = file.hashes.sha1 153 | if FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.OPEN93HOME: 154 | open93home_response = get_open93home_response(sha1) 155 | if open93home_response: 156 | FILE_CDN_FORWARD_TO_OPEN93HOME_COUNT.labels("modrinth").inc() 157 | return open93home_response 158 | else: 159 | log.warning(f"Open93Home not found {sha1}") 160 | return get_origin_response(project_id, version_id, file_name) 161 | else: # FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.ORIGIN: # default 162 | return get_origin_response(project_id, version_id, file_name) 163 | else: 164 | # 文件信息不存在 165 | await add_modrinth_project_ids_to_queue(project_ids=[project_id]) 166 | log.debug(f"Project {project_id} add to queue.") 167 | 168 | return get_origin_response(project_id, version_id, file_name) 169 | 170 | 171 | # curseforge | example: https://edge.forgecdn.net/files/3040/523/jei_1.12.2-4.16.1.301.jar 172 | @file_cdn_router.get("/files/{fileid1}/{fileid2}/{file_name}", tags=["curseforge"]) 173 | @cache( 174 | expire=( 175 | _93ATHOME_MAX_AGE 176 | if FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.ORIGIN 177 | else MAX_AGE 178 | ) 179 | ) 180 | async def get_curseforge_file( 181 | fileid1: int, fileid2: int, file_name: str, request: Request 182 | ) -> RedirectResponse: 183 | def get_origin_response( 184 | fileid1: int, fileid2: int, file_name: str 185 | ) -> RedirectResponse: 186 | url = f"https://edge.forgecdn.net/files/{fileid1}/{fileid2}/{file_name}" 187 | FILE_CDN_FORWARD_TO_ORIGIN_COUNT.labels("curseforge").inc() 188 | return RedirectResponse( 189 | url=url, 190 | headers={"Cache-Control": f"public, age={3600 * 24 * 7}"}, 191 | ) 192 | 193 | def get_open93home_response(sha1: str) -> RedirectResponse: 194 | # 信任 file_cdn_cached 则不再检查 195 | # 在调用该函数之前应该已经检查过 file_cdn_cached 为 True 196 | return RedirectResponse( 197 | # url=f"{mcim_config.open93home_endpoint}/{file_cdn_model.path}", 198 | url=f"{mcim_config.open93home_endpoint}/{sha1}", # file_cdn_model.path 实际上是 sha1 199 | headers={"Cache-Control": f"public, age={3600 * 24 * 7}"}, 200 | ) 201 | 202 | def get_pysio_response( 203 | fileId1: int, fileId2: int, file_name: str 204 | ) -> RedirectResponse: 205 | # TODO:暂时不做进一步筛选 206 | return RedirectResponse( 207 | url=f"{mcim_config.pysio_endpoint}/files/{fileId1}/{fileId2}/{quote(file_name)}", 208 | headers={"Cache-Control": f"public, age={3600 * 24 * 7}"}, 209 | ) 210 | 211 | if not mcim_config.file_cdn: 212 | origin_response: RedirectResponse = get_origin_response( 213 | fileid1, fileid2, file_name 214 | ) 215 | return origin_response 216 | elif FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.PYSIO: 217 | # Note: Pysio 表示无需筛选,所以直接跳过 file 检索 218 | pysio_response = get_pysio_response( 219 | fileId1=fileid1, fileId2=fileid2, file_name=file_name 220 | ) 221 | return pysio_response 222 | 223 | fileid = int(f"{fileid1}{fileid2}") 224 | 225 | file: Optional[cfFile] = request.app.state.aio_mongo_engine.find_one( 226 | cfFile, 227 | query.and_(cfFile.id == fileid, cfFile.fileName == file_name), 228 | ) 229 | 230 | if file: # 数据库中有文件 231 | if file.fileLength <= MAX_LENGTH and file.file_cdn_cached: 232 | sha1 = ( 233 | file.hashes[0].value 234 | if file.hashes[0].algo == 1 235 | else file.hashes[1].value 236 | ) 237 | 238 | if FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.OPEN93HOME: 239 | open93home_response = get_open93home_response(sha1) 240 | if open93home_response: 241 | FILE_CDN_FORWARD_TO_OPEN93HOME_COUNT.labels("curseforge").inc() 242 | return open93home_response 243 | else: 244 | log.warning(f"Open93Home not found {sha1}") 245 | return get_origin_response(fileid1, fileid2, file_name) 246 | else: # FILE_CDN_REDIRECT_MODE == FileCDNRedirectMode.ORIGIN: 247 | return get_origin_response(fileid1, fileid2, file_name) 248 | 249 | else: 250 | log.trace(f"File {fileid} is too large, {file.fileLength} > {MAX_LENGTH}") 251 | else: 252 | if fileid >= 530000: 253 | await add_curseforge_fileIds_to_queue(fileIds=[fileid]) 254 | log.debug(f"FileId {fileid} add to queue.") 255 | 256 | return get_origin_response(fileid1, fileid2, file_name) 257 | 258 | 259 | @file_cdn_router.get("/file_cdn/list", include_in_schema=False) 260 | async def list_file_cdn( 261 | request: Request, 262 | secret: str, 263 | last_id: Optional[str] = None, 264 | last_modified: Optional[int] = None, 265 | page_size: int = Query( 266 | default=1000, 267 | le=10000, 268 | ), 269 | ): 270 | if ( 271 | not file_cdn_check_secret(secret) 272 | or not mcim_config.file_cdn_redirect_mode == FileCDNRedirectMode.OPEN93HOME 273 | ): 274 | return JSONResponse( 275 | status_code=403, content="Forbidden", headers={"Cache-Control": "no-cache"} 276 | ) 277 | files_collection = request.app.state.aio_mongo_engine.get_collection(cdnFile) 278 | 279 | # 动态构建 $match 阶段 280 | match_stage = {} 281 | if last_modified: 282 | match_stage["mtime"] = {"$gt": last_modified} 283 | if last_id: 284 | match_stage["_id"] = {"$gt": last_id} 285 | match_stage["disable"] = {"$ne": True} 286 | 287 | # 聚合管道 288 | pipeline = [{"$match": match_stage}, {"$sort": {"_id": 1}}, {"$limit": page_size}] 289 | 290 | results = await files_collection.aggregate(pipeline).to_list(length=None) 291 | return BaseResponse(content=results) 292 | 293 | 294 | async def check_file_hash_and_size(url: str, hash: str, size: int): 295 | sha1 = hashlib.sha1() 296 | try: 297 | resp = await request_async(method="GET", url=url, follow_redirects=True) 298 | if ( 299 | int(resp.headers["content-length"]) != size 300 | ): # check size | exapmple a5fb8e2a37f1772312e2c75af2866132ebf97e4f 301 | log.warning( 302 | f"Reported size: {size}, calculated size: {resp.headers['content-length']}" 303 | ) 304 | return False 305 | sha1.update(resp.content) 306 | log.warning(f"Reported hash: {hash}, calculated hash: {sha1.hexdigest()}") 307 | return sha1.hexdigest() == hash 308 | except ResponseCodeException as e: 309 | return False 310 | 311 | 312 | @file_cdn_router.get("/file_cdn/report", include_in_schema=False) 313 | async def report( 314 | request: Request, 315 | secret: str, 316 | _hash: str = Query(alias="hash"), 317 | ): 318 | if ( 319 | not file_cdn_check_secret(secret) 320 | or not mcim_config.file_cdn_redirect_mode == FileCDNRedirectMode.OPEN93HOME 321 | ): 322 | return JSONResponse( 323 | status_code=403, content="Forbidden", headers={"Cache-Control": "no-cache"} 324 | ) 325 | 326 | file: Optional[cdnFile] = await request.app.state.aio_mongo_engine.find_one( 327 | cdnFile, cdnFile.sha1 == _hash 328 | ) 329 | 330 | if file: 331 | check_result = await check_file_hash_and_size( 332 | url=file.url, hash=_hash, size=file.size 333 | ) 334 | cdnFile_collection = request.app.state.aio_mongo_engine.get_collection(cdnFile) 335 | if check_result: 336 | await cdnFile_collection.update_one( 337 | {"_id": file.sha1}, {"$set": {"disable": False}} 338 | ) 339 | return BaseResponse( 340 | status_code=500, 341 | content={ 342 | "code": 500, 343 | "message": "Hash and size match successfully, file is correct", 344 | }, 345 | headers={"Cache-Control": "no-cache"}, 346 | ) 347 | else: 348 | await cdnFile_collection.update_one( 349 | {"_id": file.sha1}, {"$set": {"disable": True}} 350 | ) 351 | return BaseResponse( 352 | status_code=200, 353 | content={ 354 | "code": 200, 355 | "message": "Hash or size not match, file is disabled", 356 | }, 357 | headers={"Cache-Control": "no-cache"}, 358 | ) 359 | else: 360 | return BaseResponse( 361 | status_code=404, 362 | content={"code": 404, "message": "File not found"}, 363 | headers={"Cache-Control": "no-cache"}, 364 | ) 365 | -------------------------------------------------------------------------------- /app/controller/modrinth/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request 2 | from pydantic import BaseModel 3 | 4 | from app.controller.modrinth.v2 import v2_router 5 | from app.utils.response_cache import cache 6 | from app.utils.response import BaseResponse 7 | from app.models.database.modrinth import Project, Version, File 8 | 9 | modrinth_router = APIRouter(prefix="/modrinth", tags=["modrinth"]) 10 | modrinth_router.include_router(v2_router) 11 | 12 | 13 | @modrinth_router.get("/") 14 | @cache(never_expire=True) 15 | async def get_curseforge(): 16 | return BaseResponse(content={"message": "Modrinth"}) 17 | 18 | 19 | class ModrinthStatistics(BaseModel): 20 | projects: int 21 | versions: int 22 | files: int 23 | 24 | 25 | @modrinth_router.get( 26 | "/statistics", 27 | description="Modrinth 缓存统计信息", 28 | response_model=ModrinthStatistics, 29 | include_in_schema=False, 30 | ) 31 | # @cache(expire=3600) 32 | async def modrinth_statistics(request: Request): 33 | """ 34 | 没有统计 author 35 | """ 36 | # count 37 | project_collection = request.app.state.aio_mongo_engine.get_collection(Project) 38 | version_collection = request.app.state.aio_mongo_engine.get_collection(Version) 39 | file_collection = request.app.state.aio_mongo_engine.get_collection(File) 40 | 41 | project_count = await project_collection.aggregate([{"$collStats": {"count": {}}}]).to_list(length=None) 42 | version_count = await version_collection.aggregate([{"$collStats": {"count": {}}}]).to_list(length=None) 43 | file_count = await file_collection.aggregate([{"$collStats": {"count": {}}}]).to_list(length=None) 44 | 45 | return BaseResponse( 46 | content=ModrinthStatistics( 47 | projects=project_count[0]["count"], 48 | versions=version_count[0]["count"], 49 | files=file_count[0]["count"], 50 | ) 51 | ) -------------------------------------------------------------------------------- /app/controller/modrinth/v2/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Query, Path, Request, BackgroundTasks 2 | from typing import List, Optional, Union, Dict, Annotated 3 | from enum import Enum 4 | from pydantic import BaseModel, Field 5 | from odmantic import query 6 | import json 7 | import time 8 | from datetime import datetime 9 | 10 | from app.models.database.modrinth import ( 11 | Project, 12 | Version, 13 | File, 14 | Category, 15 | Loader, 16 | GameVersion, 17 | ) 18 | from app.models.response.modrinth import ( 19 | SearchResponse, 20 | CategoryInfo, 21 | LoaderInfo, 22 | GameVersionInfo, 23 | ) 24 | from app.sync_queue.modrinth import ( 25 | add_modrinth_project_ids_to_queue, 26 | add_modrinth_version_ids_to_queue, 27 | add_modrinth_hashes_to_queue, 28 | ) 29 | from app.config.mcim import MCIMConfig 30 | from app.utils.response import ( 31 | TrustableResponse, 32 | UncachedResponse, 33 | BaseResponse, 34 | ) 35 | from app.utils.network import request as request_async 36 | from app.utils.loger import log 37 | from app.utils.response_cache import cache 38 | 39 | mcim_config = MCIMConfig.load() 40 | 41 | API = mcim_config.modrinth_api 42 | v2_router = APIRouter(prefix="/v2", tags=["modrinth"]) 43 | 44 | SEARCH_TIMEOUT = 3 45 | 46 | 47 | class ModrinthStatistics(BaseModel): 48 | projects: int 49 | versions: int 50 | files: int 51 | 52 | 53 | @v2_router.get( 54 | "/statistics", 55 | description="Modrinth 缓存统计信息", 56 | response_model=ModrinthStatistics, 57 | include_in_schema=False, 58 | ) 59 | @cache(expire=3600) 60 | async def modrinth_statistics(request: Request): 61 | """ 62 | 没有统计 author 63 | """ 64 | # count 65 | project_count = await request.app.state.aio_mongo_engine.count(Project) 66 | version_count = await request.app.state.aio_mongo_engine.count(Version) 67 | file_count = await request.app.state.aio_mongo_engine.count(File) 68 | return BaseResponse( 69 | content=ModrinthStatistics( 70 | projects=project_count, versions=version_count, files=file_count 71 | ) 72 | ) 73 | 74 | 75 | @v2_router.get( 76 | "/project/{idslug}", 77 | description="Modrinth Project 信息", 78 | response_model=Project, 79 | ) 80 | @cache(expire=mcim_config.expire_second.modrinth.project) 81 | async def modrinth_project(request: Request, idslug: str): 82 | trustable = True 83 | model: Optional[Project] = await request.app.state.aio_mongo_engine.find_one( 84 | Project, query.or_(Project.id == idslug, Project.slug == idslug) 85 | ) 86 | if model is None: 87 | await add_modrinth_project_ids_to_queue(project_ids=[idslug]) 88 | log.debug(f"Project {idslug} not found, add to queue.") 89 | return UncachedResponse() 90 | return TrustableResponse(content=model.model_dump(), trustable=trustable) 91 | 92 | 93 | @v2_router.get( 94 | "/projects", 95 | description="Modrinth Projects 信息", 96 | response_model=List[Project], 97 | ) 98 | @cache(expire=mcim_config.expire_second.modrinth.project) 99 | async def modrinth_projects(ids: str, request: Request): 100 | ids_list: List[str] = json.loads(ids) 101 | trustable = True 102 | # id or slug 103 | models: Optional[List[Project]] = await request.app.state.aio_mongo_engine.find( 104 | Project, 105 | query.and_( 106 | query.or_( 107 | query.in_(Project.id, ids_list), query.in_(Project.slug, ids_list) 108 | ), 109 | ), 110 | ) 111 | models_count = len(models) 112 | ids_count = len(ids_list) 113 | if not models: 114 | await add_modrinth_project_ids_to_queue(project_ids=ids_list) 115 | log.debug(f"Projects {ids_list} not found, add to queue.") 116 | return UncachedResponse() 117 | elif models_count != ids_count: 118 | # 找出没找到的 project_id 119 | not_match_ids = list(set(ids_list) - set([model.id for model in models])) 120 | await add_modrinth_project_ids_to_queue(project_ids=not_match_ids) 121 | log.debug( 122 | f"Projects {not_match_ids} {not_match_ids}/{ids_count} not found, add to queue." 123 | ) 124 | trustable = False 125 | return TrustableResponse( 126 | content=[model.model_dump() for model in models], trustable=trustable 127 | ) 128 | 129 | 130 | @v2_router.get( 131 | "/project/{idslug}/version", 132 | description="Modrinth Projects 全部版本信息", 133 | response_model=List[Project], 134 | ) 135 | @cache(expire=mcim_config.expire_second.modrinth.version) 136 | async def modrinth_project_versions(idslug: str, request: Request): 137 | """ 138 | 先查 Project 的 Version 列表再拉取...避免遍历整个 Version 表 139 | """ 140 | trustable = True 141 | project_model: Optional[Project] = ( 142 | await request.app.state.aio_mongo_engine.find_one( 143 | Project, query.or_(Project.id == idslug, Project.slug == idslug) 144 | ) 145 | ) 146 | if not project_model: 147 | await add_modrinth_project_ids_to_queue(project_ids=[idslug]) 148 | log.debug(f"Project {idslug} not found, add to queue.") 149 | return UncachedResponse() 150 | else: 151 | version_list = project_model.versions 152 | version_model_list: Optional[List[Version]] = ( 153 | await request.app.state.aio_mongo_engine.find( 154 | Version, query.in_(Version.id, version_list) 155 | ) 156 | ) 157 | 158 | return TrustableResponse( 159 | content=( 160 | [version.model_dump() for version in version_model_list] 161 | if version_model_list 162 | else [] 163 | ), 164 | trustable=trustable, 165 | ) 166 | 167 | 168 | async def check_search_result(request: Request, search_result: dict): 169 | project_ids = set([project["project_id"] for project in search_result["hits"]]) 170 | 171 | if project_ids: 172 | # check project in db 173 | project_models: List[Project] = await request.app.state.aio_mongo_engine.find( 174 | Project, query.in_(Project.id, list(project_ids)) 175 | ) 176 | 177 | not_found_project_ids = project_ids - set( 178 | [project.id for project in project_models] 179 | ) 180 | 181 | if not_found_project_ids: 182 | await add_modrinth_project_ids_to_queue( 183 | project_ids=list(not_found_project_ids) 184 | ) 185 | log.debug(f"Projects {not_found_project_ids} not found, add to queue.") 186 | else: 187 | log.debug("All Projects have been found.") 188 | else: 189 | log.debug("Search esult is empty") 190 | 191 | 192 | class SearchIndex(str, Enum): 193 | relevance = "relevance" 194 | downloads = "downloads" 195 | follows = "follows" 196 | newest = "newest" 197 | updated = "updated" 198 | 199 | 200 | @v2_router.get( 201 | "/search", 202 | description="Modrinth Projects 搜索", 203 | response_model=SearchResponse, 204 | ) 205 | @cache(expire=mcim_config.expire_second.modrinth.search) 206 | async def modrinth_search_projects( 207 | request: Request, 208 | query: Optional[str] = None, 209 | facets: Optional[str] = None, 210 | offset: Optional[int] = 0, 211 | limit: Optional[int] = 10, 212 | index: Optional[SearchIndex] = SearchIndex.relevance, 213 | ): 214 | res = ( 215 | await request_async( 216 | f"{API}/v2/search", 217 | params={ 218 | "query": query, 219 | "facets": facets, 220 | "offset": offset, 221 | "limit": limit, 222 | "index": index.value, 223 | }, 224 | timeout=SEARCH_TIMEOUT, 225 | ) 226 | ).json() 227 | await check_search_result(request=request, search_result=res) 228 | return TrustableResponse(content=res) 229 | 230 | 231 | @v2_router.get( 232 | "/version/{id}", 233 | description="Modrinth Version 信息", 234 | response_model=Version, 235 | ) 236 | @cache(expire=mcim_config.expire_second.modrinth.version) 237 | async def modrinth_version( 238 | version_id: Annotated[str, Path(alias="id", pattern=r"[a-zA-Z0-9]{8}")], 239 | request: Request, 240 | ): 241 | trustable = True 242 | model: Optional[Version] = await request.app.state.aio_mongo_engine.find_one( 243 | Version, 244 | Version.id == version_id, 245 | ) 246 | if model is None: 247 | await add_modrinth_version_ids_to_queue(version_ids=[version_id]) 248 | log.debug(f"Version {version_id} not found, add to queue.") 249 | return UncachedResponse() 250 | return TrustableResponse(content=model.model_dump(), trustable=trustable) 251 | 252 | 253 | @v2_router.get( 254 | "/versions", 255 | description="Modrinth Versions 信息", 256 | response_model=List[Version], 257 | ) 258 | @cache(expire=mcim_config.expire_second.modrinth.version) 259 | async def modrinth_versions(ids: str, request: Request): 260 | trustable = True 261 | ids_list = json.loads(ids) 262 | models: List[Version] = await request.app.state.aio_mongo_engine.find( 263 | Version, query.and_(query.in_(Version.id, ids_list)) 264 | ) 265 | models_count = len(models) 266 | ids_count = len(ids_list) 267 | if not models: 268 | await add_modrinth_version_ids_to_queue(version_ids=ids_list) 269 | log.debug(f"Versions {ids_list} not found, add to queue.") 270 | return UncachedResponse() 271 | elif models_count != ids_count: 272 | await add_modrinth_version_ids_to_queue(version_ids=ids_list) 273 | log.debug( 274 | f"Versions {ids_list} {models_count}/{ids_count} not completely found, add to queue." 275 | ) 276 | trustable = False 277 | return TrustableResponse( 278 | content=[model.model_dump() for model in models], trustable=trustable 279 | ) 280 | 281 | 282 | class Algorithm(str, Enum): 283 | sha1 = "sha1" 284 | sha512 = "sha512" 285 | 286 | 287 | @v2_router.get( 288 | "/version_file/{hash}", 289 | description="Modrinth File 信息", 290 | response_model=Version, 291 | ) 292 | @cache(expire=mcim_config.expire_second.modrinth.file) 293 | async def modrinth_file( 294 | request: Request, 295 | hash_: Annotated[ 296 | str, Path(alias="hash", pattern=r"[a-zA-Z0-9]{40}|[a-zA-Z0-9]{128}") 297 | ], 298 | algorithm: Optional[Algorithm] = Algorithm.sha1, 299 | ): 300 | trustable = True 301 | # ignore algo 302 | file: Optional[File] = await request.app.state.aio_mongo_engine.find_one( 303 | File, 304 | ( 305 | File.hashes.sha512 == hash_ 306 | if algorithm == Algorithm.sha512 307 | else File.hashes.sha1 == hash_ 308 | ), 309 | ) 310 | if file is None: 311 | await add_modrinth_hashes_to_queue([hash_], algorithm=algorithm.value) 312 | log.debug(f"File {hash_} not found, add to queue.") 313 | return UncachedResponse() 314 | 315 | # get version object 316 | version: Optional[Version] = await request.app.state.aio_mongo_engine.find_one( 317 | Version, query.and_(Version.id == file.version_id) 318 | ) 319 | if version is None: 320 | await add_modrinth_version_ids_to_queue(version_ids=[file.version_id]) 321 | log.debug(f"Version {file.version_id} not found, add to queue.") 322 | return UncachedResponse() 323 | 324 | return TrustableResponse(content=version, trustable=trustable) 325 | 326 | 327 | class HashesQuery(BaseModel): 328 | hashes: List[Annotated[str, Field(pattern=r"[a-zA-Z0-9]{40}|[a-zA-Z0-9]{128}")]] 329 | algorithm: Algorithm 330 | 331 | 332 | @v2_router.post( 333 | "/version_files", 334 | description="Modrinth Files 信息", 335 | response_model=Dict[str, Version], 336 | ) 337 | # @cache(expire=mcim_config.expire_second.modrinth.file) 338 | async def modrinth_files(items: HashesQuery, request: Request): 339 | trustable = True 340 | # ignore algo 341 | files_models: List[File] = await request.app.state.aio_mongo_engine.find( 342 | File, 343 | query.and_( 344 | ( 345 | query.in_(File.hashes.sha1, items.hashes) 346 | if items.algorithm == Algorithm.sha1 347 | else query.in_(File.hashes.sha512, items.hashes) 348 | ), 349 | ), 350 | ) 351 | model_count = len(files_models) 352 | hashes_count = len(items.hashes) 353 | if not files_models: 354 | await add_modrinth_hashes_to_queue( 355 | items.hashes, algorithm=items.algorithm.value 356 | ) 357 | log.debug("Files not found, add to queue.") 358 | return UncachedResponse() 359 | elif model_count != hashes_count: 360 | # 找出未找到的文件 361 | not_found_hashes = list( 362 | set(items.hashes) 363 | - set( 364 | [ 365 | ( 366 | file.hashes.sha1 367 | if items.algorithm == Algorithm.sha1 368 | else file.hashes.sha512 369 | ) 370 | for file in files_models 371 | ] 372 | ) 373 | ) 374 | if not_found_hashes: 375 | await add_modrinth_hashes_to_queue( 376 | not_found_hashes, algorithm=items.algorithm.value 377 | ) 378 | log.debug( 379 | f"Files {not_found_hashes} {len(not_found_hashes)}/{hashes_count} not completely found, add to queue." 380 | ) 381 | trustable = False 382 | 383 | version_ids = [file.version_id for file in files_models] 384 | version_models: List[Version] = await request.app.state.aio_mongo_engine.find( 385 | Version, query.in_(Version.id, version_ids) 386 | ) 387 | 388 | version_model_count = len(version_models) 389 | file_model_count = len(files_models) 390 | if not version_models: 391 | # 一个版本都没找到,直接重新同步 392 | await add_modrinth_version_ids_to_queue(version_ids=version_ids) 393 | log.debug("Versions not found, add to queue.") 394 | return UncachedResponse() 395 | elif version_model_count != file_model_count: 396 | # 找出未找到的版本 397 | not_found_version_ids = list( 398 | set(version_ids) - set([version.id for version in version_models]) 399 | ) 400 | if not_found_version_ids: 401 | await add_modrinth_version_ids_to_queue(version_ids=not_found_version_ids) 402 | log.debug( 403 | f"Versions {not_found_version_ids} {len(not_found_version_ids)}/{file_model_count} not completely found, add to queue." 404 | ) 405 | trustable = False 406 | 407 | result = { 408 | ( 409 | version.files[0].hashes.sha1 410 | if items.algorithm == Algorithm.sha1 411 | else version.files[0].hashes.sha512 412 | ): version.model_dump() 413 | for version in version_models 414 | } 415 | 416 | return TrustableResponse(content=result, trustable=trustable) 417 | 418 | 419 | class UpdateItems(BaseModel): 420 | loaders: List[str] 421 | game_versions: List[str] 422 | 423 | 424 | @v2_router.post("/version_file/{hash}/update") 425 | @cache(expire=mcim_config.expire_second.modrinth.file) 426 | async def modrinth_file_update( 427 | request: Request, 428 | items: UpdateItems, 429 | hash_: Annotated[ 430 | str, Path(alias="hash", pattern=r"[a-zA-Z0-9]{40}|[a-zA-Z0-9]{128}") 431 | ], 432 | algorithm: Optional[Algorithm] = Algorithm.sha1, 433 | ): 434 | trustable = True 435 | files_collection = request.app.state.aio_mongo_engine.get_collection(File) 436 | pipeline = [ 437 | ( 438 | {"$match": {"_id.sha1": hash_}} 439 | if algorithm is Algorithm.sha1 440 | else {"$match": {"_id.sha512": hash_}} 441 | ), 442 | { 443 | "$project": ( 444 | {"_id.sha1": 1, "project_id": 1} 445 | if algorithm is Algorithm.sha1 446 | else {"_id.sha512": 1, "project_id": 1} 447 | ) 448 | }, 449 | { 450 | "$lookup": { 451 | "from": "modrinth_versions", 452 | "localField": "project_id", 453 | "foreignField": "project_id", 454 | "as": "versions_fields", 455 | } 456 | }, 457 | {"$unwind": "$versions_fields"}, 458 | { 459 | "$match": { 460 | "versions_fields.game_versions": {"$in": items.game_versions}, 461 | "versions_fields.loaders": {"$in": items.loaders}, 462 | } 463 | }, 464 | {"$sort": {"versions_fields.date_published": -1}}, 465 | {"$replaceRoot": {"newRoot": "$versions_fields"}}, 466 | ] 467 | version_result = await files_collection.aggregate(pipeline).to_list(length=None) 468 | if len(version_result) != 0: 469 | version_result = version_result[0] 470 | # # version 不检查过期 471 | # if trustable and not ( 472 | # datetime.strptime( 473 | # version_result["sync_at"], "%Y-%m-%dT%H:%M:%SZ" 474 | # ).timestamp() 475 | # + mcim_config.expire_second.modrinth.file 476 | # > time.time() 477 | # ): 478 | # trustable = False 479 | else: 480 | await add_modrinth_hashes_to_queue([hash_], algorithm=algorithm.value) 481 | log.debug(f"Hash {hash_} not found, add to queue.") 482 | return UncachedResponse() 483 | return TrustableResponse(content=version_result, trustable=trustable) 484 | 485 | 486 | class MultiUpdateItems(BaseModel): 487 | hashes: List[Annotated[str, Field(pattern=r"[a-zA-Z0-9]{40}|[a-zA-Z0-9]{128}")]] 488 | algorithm: Algorithm 489 | loaders: Optional[List[str]] 490 | game_versions: Optional[List[str]] 491 | 492 | 493 | @v2_router.post("/version_files/update") 494 | # @cache(expire=mcim_config.expire_second.modrinth.file) 495 | async def modrinth_mutil_file_update(request: Request, items: MultiUpdateItems): 496 | trustable = True 497 | files_collection = request.app.state.aio_mongo_engine.get_collection(File) 498 | pipeline = [ 499 | ( 500 | {"$match": {"_id.sha1": {"$in": items.hashes}}} 501 | if items.algorithm is Algorithm.sha1 502 | else {"$match": {"_id.sha512": {"$in": items.hashes}}} 503 | ), 504 | { 505 | "$project": ( 506 | {"_id.sha1": 1, "project_id": 1} 507 | if items.algorithm is Algorithm.sha1 508 | else {"_id.sha512": 1, "project_id": 1} 509 | ) 510 | }, 511 | { 512 | "$lookup": { 513 | "from": "modrinth_versions", 514 | "localField": "project_id", 515 | "foreignField": "project_id", 516 | "as": "versions_fields", 517 | } 518 | }, 519 | {"$unwind": "$versions_fields"}, 520 | { 521 | "$match": { 522 | "versions_fields.game_versions": {"$in": items.game_versions}, 523 | "versions_fields.loaders": {"$in": items.loaders}, 524 | } 525 | }, 526 | {"$sort": {"versions_fields.date_published": -1}}, 527 | { 528 | "$group": { 529 | "_id": ( 530 | "$_id.sha1" if items.algorithm is Algorithm.sha1 else "$_id.sha512" 531 | ), 532 | "latest_date": {"$first": "$versions_fields.date_published"}, 533 | "detail": {"$first": "$versions_fields"}, # 只保留第一个匹配版本 534 | } 535 | }, 536 | ] 537 | versions_result = await files_collection.aggregate(pipeline).to_list(length=None) 538 | 539 | if len(versions_result) == 0: 540 | await add_modrinth_hashes_to_queue( 541 | items.hashes, algorithm=items.algorithm.value 542 | ) 543 | log.debug(f"Hashes {items.hashes} not found, send sync task") 544 | return UncachedResponse() 545 | 546 | not_found_hashes = list( 547 | set(items.hashes) - set([version["_id"] for version in versions_result]) 548 | ) 549 | if not_found_hashes: 550 | await add_modrinth_hashes_to_queue( 551 | not_found_hashes, algorithm=items.algorithm.value 552 | ) 553 | log.debug(f"Hashes {not_found_hashes} not completely found, add to queue.") 554 | trustable = False 555 | 556 | resp = {} 557 | for version_result in versions_result: 558 | original_hash = version_result["_id"] 559 | version_detail = version_result["detail"] 560 | resp[original_hash] = version_detail 561 | # # version 不检查过期 562 | # if trustable and not ( 563 | # datetime.strptime( 564 | # version_detail["sync_at"], "%Y-%m-%dT%H:%M:%SZ" 565 | # ).timestamp() 566 | # + mcim_config.expire_second.modrinth.file 567 | # > time.time() 568 | # ): 569 | # trustable = False 570 | 571 | return TrustableResponse(content=resp, trustable=trustable) 572 | 573 | 574 | @v2_router.get( 575 | "/tag/category", 576 | description="Modrinth Category 信息", 577 | response_model=List[CategoryInfo], 578 | ) 579 | @cache(expire=mcim_config.expire_second.modrinth.category) 580 | async def modrinth_tag_categories(request: Request): 581 | categories = await request.app.state.aio_mongo_engine.find(Category) 582 | if categories is None: 583 | return UncachedResponse() 584 | return TrustableResponse(content=[category for category in categories]) 585 | 586 | 587 | @v2_router.get( 588 | "/tag/loader", 589 | description="Modrinth Loader 信息", 590 | response_model=List[LoaderInfo], 591 | ) 592 | @cache(expire=mcim_config.expire_second.modrinth.category) 593 | async def modrinth_tag_loaders(request: Request): 594 | loaders = await request.app.state.aio_mongo_engine.find(Loader) 595 | if loaders is None: 596 | return UncachedResponse() 597 | return TrustableResponse(content=[loader for loader in loaders]) 598 | 599 | 600 | @v2_router.get( 601 | "/tag/game_version", 602 | description="Modrinth Game Version 信息", 603 | response_model=List[GameVersionInfo], 604 | ) 605 | @cache(expire=mcim_config.expire_second.modrinth.category) 606 | async def modrinth_tag_game_versions(request: Request): 607 | game_versions = await request.app.state.aio_mongo_engine.find(GameVersion) 608 | if game_versions is None: 609 | return UncachedResponse() 610 | return TrustableResponse(content=[game_version for game_version in game_versions]) 611 | -------------------------------------------------------------------------------- /app/controller/translate.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Query 2 | from typing import List, Optional 3 | 4 | 5 | from app.models.database.translate import ModrinthTranslation, CurseForgeTranslation 6 | from app.utils.response_cache import cache 7 | from app.utils.response import ( 8 | TrustableResponse, 9 | UncachedResponse, 10 | ) 11 | 12 | translate_router = APIRouter(prefix="/translate", tags=["translate"]) 13 | 14 | 15 | @translate_router.get( 16 | "/modrinth", 17 | description="Modrinth 翻译", 18 | response_model=ModrinthTranslation, 19 | ) 20 | @cache(expire=3600 * 24) 21 | async def modrinth_translate( 22 | request: Request, 23 | project_id: str = Query(..., description="Modrinth Project id"), 24 | ): 25 | result: Optional[ 26 | ModrinthTranslation 27 | ] = await request.app.state.aio_mongo_engine.find_one( 28 | ModrinthTranslation, ModrinthTranslation.project_id == project_id 29 | ) 30 | 31 | if result: 32 | return TrustableResponse(content=result) 33 | else: 34 | return UncachedResponse() 35 | 36 | 37 | @translate_router.get( 38 | "/curseforge", 39 | description="CurseForge 翻译", 40 | response_model=CurseForgeTranslation, 41 | ) 42 | @cache(expire=3600 * 24) 43 | async def curseforge_translate( 44 | request: Request, 45 | modId: int = Query(..., description="CurseForge Mod id"), 46 | ): 47 | result: Optional[ 48 | CurseForgeTranslation 49 | ] = await request.app.state.aio_mongo_engine.find_one( 50 | CurseForgeTranslation, CurseForgeTranslation.modId == modId 51 | ) 52 | 53 | if result: 54 | return TrustableResponse(content=result) 55 | else: 56 | return UncachedResponse() 57 | -------------------------------------------------------------------------------- /app/database/__init__.py: -------------------------------------------------------------------------------- 1 | from app.database.mongodb import ( 2 | aio_mongo_engine, 3 | sync_mongo_engine, 4 | init_mongodb_aioengine, 5 | init_mongodb_syncengine, 6 | ) 7 | from app.database._redis import ( 8 | aio_redis_engine, 9 | sync_redis_engine, 10 | init_redis_aioengine, 11 | init_sync_redis_engine, 12 | ) 13 | 14 | __all__ = [ 15 | "aio_mongo_engine", 16 | "sync_mongo_engine", 17 | "init_mongodb_aioengine", 18 | "init_mongodb_syncengine", 19 | "aio_redis_engine", 20 | "sync_redis_engine", 21 | "init_redis_aioengine", 22 | "init_sync_redis_engine", 23 | ] 24 | -------------------------------------------------------------------------------- /app/database/_redis.py: -------------------------------------------------------------------------------- 1 | from redis import Redis 2 | from redis.asyncio import Redis as AioRedis 3 | from app.utils.loger import log 4 | 5 | from app.config import RedisdbConfig 6 | 7 | _redis_config = RedisdbConfig.load() 8 | 9 | aio_redis_engine: AioRedis = None 10 | sync_redis_engine: Redis = None 11 | sync_queuq_redis_engine: AioRedis = None 12 | 13 | def init_redis_aioengine() -> AioRedis: 14 | global aio_redis_engine 15 | aio_redis_engine = AioRedis( 16 | host=_redis_config.host, 17 | port=_redis_config.port, 18 | password=_redis_config.password, 19 | db=_redis_config.database.info_cache, 20 | ) 21 | return aio_redis_engine 22 | 23 | 24 | def init_sync_redis_engine() -> Redis: 25 | global sync_redis_engine 26 | sync_redis_engine = Redis( 27 | host=_redis_config.host, 28 | port=_redis_config.port, 29 | password=_redis_config.password, 30 | db=_redis_config.database.info_cache, 31 | ) 32 | return sync_redis_engine 33 | 34 | def init_sync_queue_redis_engine() -> AioRedis: 35 | global sync_queuq_redis_engine 36 | sync_queuq_redis_engine = AioRedis( 37 | host=_redis_config.host, 38 | port=_redis_config.port, 39 | password=_redis_config.password, 40 | db=_redis_config.database.sync_queue, 41 | ) 42 | return sync_queuq_redis_engine 43 | 44 | 45 | async def close_aio_redis_engine(): 46 | """ 47 | Close aioredis when process stopped. 48 | """ 49 | global aio_redis_engine 50 | if aio_redis_engine is not None: 51 | await aio_redis_engine.aclose() 52 | log.success("closed redis connection") 53 | else: 54 | log.warning("no redis connection to close") 55 | aio_redis_engine = None 56 | 57 | 58 | def close_sync_redis_engine(): 59 | """ 60 | Close redis when process stopped. 61 | """ 62 | global sync_redis_engine 63 | if sync_redis_engine is not None: 64 | sync_redis_engine.close() 65 | log.success("closed redis connection") 66 | else: 67 | log.warning("no redis connection to close") 68 | sync_redis_engine = None 69 | 70 | async def close_sync_queue_redis_engine(): 71 | """ 72 | Close redis when process stopped. 73 | """ 74 | global sync_queuq_redis_engine 75 | if sync_queuq_redis_engine is not None: 76 | await sync_queuq_redis_engine.aclose() 77 | log.success("closed redis connection") 78 | else: 79 | log.warning("no redis connection to close") 80 | sync_queuq_redis_engine = None 81 | 82 | aio_redis_engine: AioRedis = init_redis_aioengine() 83 | sync_redis_engine: Redis = init_sync_redis_engine() 84 | sync_queuq_redis_engine: AioRedis = init_sync_queue_redis_engine() 85 | 86 | log.success("Redis connection established") # noqa 87 | -------------------------------------------------------------------------------- /app/database/mongodb.py: -------------------------------------------------------------------------------- 1 | from odmantic import AIOEngine, SyncEngine 2 | from motor.motor_asyncio import AsyncIOMotorClient 3 | from pymongo import MongoClient 4 | 5 | from app.config import MongodbConfig 6 | from app.models.database.curseforge import Mod, File, Fingerprint # , ModFilesSyncInfo 7 | from app.models.database.modrinth import Project, Version, File as ModrinthFile 8 | from app.models.database.file_cdn import File as CDNFile 9 | from app.utils.loger import log 10 | 11 | _mongodb_config = MongodbConfig.load() 12 | 13 | aio_mongo_engine: AIOEngine = None 14 | sync_mongo_engine: SyncEngine = None 15 | 16 | 17 | def init_mongodb_syncengine() -> SyncEngine: 18 | """ 19 | Raw Motor client handler, use it when beanie cannot work 20 | :return: 21 | """ 22 | global sync_mongo_engine 23 | sync_mongo_engine = SyncEngine( 24 | client=MongoClient( 25 | f"mongodb://{_mongodb_config.user}:{_mongodb_config.password}@{_mongodb_config.host}:{_mongodb_config.port}" 26 | if _mongodb_config.auth 27 | else f"mongodb://{_mongodb_config.host}:{_mongodb_config.port}" 28 | ), 29 | database="mcim_backend", 30 | ) 31 | return sync_mongo_engine 32 | 33 | 34 | def init_mongodb_aioengine() -> AIOEngine: 35 | """ 36 | Raw Motor client handler, use it when beanie cannot work 37 | :return: 38 | """ 39 | return AIOEngine( 40 | client=AsyncIOMotorClient( 41 | f"mongodb://{_mongodb_config.user}:{_mongodb_config.password}@{_mongodb_config.host}:{_mongodb_config.port}" 42 | if _mongodb_config.auth 43 | else f"mongodb://{_mongodb_config.host}:{_mongodb_config.port}" 44 | ), 45 | database="mcim_backend", 46 | ) 47 | 48 | 49 | async def setup_async_mongodb(engine: AIOEngine) -> None: 50 | """ 51 | Start beanie when process started. 52 | :return: 53 | """ 54 | # try: 55 | await engine.configure_database( 56 | [ 57 | # CurseForge 58 | Mod, 59 | File, 60 | Fingerprint, 61 | # Modrinth 62 | Project, 63 | Version, 64 | ModrinthFile, 65 | # File CDN 66 | CDNFile, 67 | ] 68 | ) 69 | 70 | 71 | aio_mongo_engine: AIOEngine = init_mongodb_aioengine() 72 | sync_mongo_engine: SyncEngine = init_mongodb_syncengine() 73 | 74 | log.success("MongoDB connection established.") 75 | -------------------------------------------------------------------------------- /app/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import Request 2 | from fastapi.responses import JSONResponse 3 | from typing import Optional 4 | 5 | from app.config.mcim import MCIMConfig 6 | 7 | mcim_config = MCIMConfig.load() 8 | 9 | 10 | class UncacheException(Exception): 11 | def __init__(self, name: str): 12 | self.task_name = name 13 | self.params: dict = {} 14 | 15 | 16 | class ApiException(Exception): 17 | """ 18 | API 基类异常。 19 | """ 20 | 21 | def __init__(self, msg: str = "出现了错误,但是未说明具体原因。"): 22 | super().__init__(msg) 23 | self.msg = msg 24 | 25 | def __str__(self): 26 | return self.msg 27 | 28 | 29 | class ResponseCodeException(ApiException): 30 | """ 31 | API 返回 code 错误。 32 | """ 33 | 34 | def __init__( 35 | self, 36 | status_code: int, 37 | msg: str, 38 | url: str, 39 | params: Optional[dict] = None, 40 | data: Optional[dict] = None, 41 | method: str = "GET", 42 | ): 43 | """ 44 | 45 | Args: 46 | status_code (int): 错误代码。 47 | 48 | msg (str): 错误信息。 49 | """ 50 | super().__init__(msg) 51 | self.method = method 52 | self.url = url 53 | self.data = data 54 | self.params = params 55 | self.msg = msg 56 | self.status_code = status_code 57 | 58 | def __str__(self): 59 | return ( 60 | f"[{self.method}] {self.status_code} {self.url} {self.params} {self.data}" 61 | ) 62 | -------------------------------------------------------------------------------- /app/models/database/curseforge.py: -------------------------------------------------------------------------------- 1 | from odmantic import Model, Field, EmbeddedModel 2 | from pydantic import BaseModel, field_serializer, model_validator 3 | from typing import List, Optional 4 | from datetime import datetime 5 | 6 | 7 | class FileDependencies(BaseModel): 8 | modId: int 9 | relationType: Optional[int] = None 10 | 11 | 12 | class FileSortableGameVersions(BaseModel): 13 | gameVersionName: Optional[str] = None 14 | gameVersionPadded: Optional[str] = None 15 | gameVersion: Optional[str] = None 16 | gameVersionReleaseDate: Optional[datetime] = None 17 | gameVersionTypeId: Optional[int] = None 18 | 19 | 20 | class Hash(BaseModel): 21 | value: str 22 | algo: int 23 | 24 | 25 | class Author(BaseModel): 26 | id: int 27 | name: str 28 | url: Optional[str] = None 29 | 30 | 31 | class Logo(BaseModel): 32 | id: int 33 | modId: int 34 | title: Optional[str] = None 35 | description: Optional[str] = None 36 | thumbnailUrl: Optional[str] = None 37 | url: Optional[str] = None 38 | 39 | 40 | class CategoryInfo(BaseModel): 41 | id: Optional[int] = None 42 | gameId: Optional[int] = None 43 | name: Optional[str] = None 44 | slug: Optional[str] = None # stupid curseforge dev | For id 4591 and 236, slug is None 45 | url: Optional[str] = None 46 | iconUrl: Optional[str] = None 47 | dateModified: Optional[datetime] = None 48 | isClass: Optional[bool] = None 49 | classId: Optional[int] = None 50 | parentCategoryId: Optional[int] = None 51 | displayIndex: Optional[int] = None 52 | 53 | 54 | class Links(BaseModel): 55 | websiteUrl: Optional[str] = None 56 | wikiUrl: Optional[str] = None 57 | issuesUrl: Optional[str] = None 58 | sourceUrl: Optional[str] = None 59 | 60 | 61 | class ScreenShot(BaseModel): 62 | id: int 63 | modId: int 64 | title: Optional[str] = None 65 | description: Optional[str] = None 66 | thumbnailUrl: Optional[str] = None 67 | url: Optional[str] = None 68 | 69 | 70 | class Module(BaseModel): 71 | name: Optional[str] = None 72 | fingerprint: Optional[int] = None 73 | 74 | 75 | class File(Model): 76 | id: int = Field(primary_field=True, index=True) 77 | gameId: int 78 | modId: int = Field(index=True) 79 | isAvailable: Optional[bool] = None 80 | displayName: Optional[str] = None 81 | fileName: Optional[str] = None 82 | releaseType: Optional[int] = None 83 | fileStatus: Optional[int] = None 84 | hashes: Optional[List[Hash]] = None 85 | fileDate: Optional[datetime] = None 86 | fileLength: Optional[int] = None 87 | downloadCount: Optional[int] = None 88 | fileSizeOnDisk: Optional[int] = None 89 | downloadUrl: Optional[str] = None 90 | gameVersions: Optional[List[str]] = None 91 | sortableGameVersions: Optional[List[FileSortableGameVersions]] = None 92 | dependencies: Optional[List[FileDependencies]] = None 93 | exposeAsAlternative: Optional[bool] = None 94 | parentProjectFileId: Optional[int] = None 95 | alternateFileId: Optional[int] = None 96 | isServerPack: Optional[bool] = None 97 | serverPackFileId: Optional[int] = None 98 | isEarlyAccessContent: Optional[bool] = None 99 | earlyAccessEndDate: Optional[datetime] = None 100 | fileFingerprint: Optional[int] = None 101 | modules: Optional[List[Module]] = None 102 | 103 | file_cdn_cached: bool = False 104 | sync_at: datetime = Field(default_factory=datetime.utcnow) 105 | 106 | model_config = { 107 | "collection": "curseforge_files", 108 | "title": "CurseForge File", 109 | } 110 | 111 | 112 | class FileInfo(BaseModel): 113 | id: int 114 | gameId: int 115 | modId: int 116 | isAvailable: Optional[bool] = None 117 | displayName: Optional[str] = None 118 | fileName: Optional[str] = None 119 | releaseType: Optional[int] = None 120 | fileStatus: Optional[int] = None 121 | hashes: Optional[List[Hash]] = None 122 | fileDate: Optional[datetime] = None 123 | fileLength: Optional[int] = None 124 | downloadCount: Optional[int] = None 125 | fileSizeOnDisk: Optional[int] = None 126 | downloadUrl: Optional[str] = None 127 | gameVersions: Optional[List[str]] = None 128 | sortableGameVersions: Optional[List[FileSortableGameVersions]] = None 129 | dependencies: Optional[List[FileDependencies]] = None 130 | exposeAsAlternative: Optional[bool] = None 131 | parentProjectFileId: Optional[int] = None 132 | alternateFileId: Optional[int] = None 133 | isServerPack: Optional[bool] = None 134 | serverPackFileId: Optional[int] = None 135 | isEarlyAccessContent: Optional[bool] = None 136 | earlyAccessEndDate: Optional[datetime] = None 137 | fileFingerprint: Optional[int] = None 138 | modules: Optional[List[Module]] = None 139 | 140 | 141 | class FileIndex(BaseModel): 142 | gameVersion: Optional[str] = None 143 | fileId: int 144 | filename: Optional[str] = None 145 | releaseType: Optional[int] = None 146 | gameVersionTypeId: Optional[int] = None 147 | modLoader: Optional[int] = None 148 | 149 | 150 | class Mod(Model): 151 | id: int = Field(primary_field=True, index=True) 152 | gameId: Optional[int] = None 153 | name: Optional[str] = None 154 | slug: str 155 | links: Optional[Links] = None 156 | summary: Optional[str] = None 157 | status: Optional[int] = None 158 | downloadCount: Optional[int] = None 159 | isFeatured: Optional[bool] = None 160 | primaryCategoryId: Optional[int] = None 161 | categories: Optional[List[CategoryInfo]] = None 162 | classId: Optional[int] = None 163 | authors: Optional[List[Author]] = None 164 | logo: Optional[Logo] = None 165 | screenshots: Optional[List[ScreenShot]] = None 166 | mainFileId: Optional[int] = None 167 | latestFiles: Optional[List[FileInfo]] = None 168 | latestFilesIndexes: Optional[List[FileIndex]] = None 169 | dateCreated: Optional[datetime] = None 170 | dateModified: Optional[datetime] = None 171 | dateReleased: Optional[datetime] = None 172 | allowModDistribution: Optional[bool] = None 173 | gamePopularityRank: Optional[int] = None 174 | isAvailable: Optional[bool] = None 175 | thumbsUpCount: Optional[int] = None 176 | rating: Optional[int] = None 177 | 178 | sync_at: datetime = Field(default_factory=datetime.utcnow) 179 | 180 | model_config = { 181 | "collection": "curseforge_mods", 182 | "title": "CurseForge Mod", 183 | } 184 | 185 | 186 | class Pagination(BaseModel): 187 | index: int 188 | pageSize: int 189 | resultCount: int 190 | totalCount: int 191 | 192 | 193 | # TODO: add latestFiles Mod reference but not refresh while refreshing File 194 | class Fingerprint(Model): 195 | id: int = Field(primary_field=True, index=True) 196 | file: FileInfo 197 | latestFiles: List[FileInfo] 198 | 199 | sync_at: datetime = Field(default_factory=datetime.utcnow) 200 | 201 | model_config = { 202 | "collection": "curseforge_fingerprints", 203 | "title": "CurseForge Fingerprint", 204 | } 205 | 206 | class Category(Model): 207 | id: int = Field(primary_field=True, index=True) 208 | gameId: int 209 | name: str 210 | slug: Optional[str] = None # stupid curseforge dev | For id 4591 and 236, slug is None 211 | url: str 212 | iconUrl: str 213 | dateModified: str 214 | isClass: Optional[bool] = None 215 | classId: Optional[int] = None 216 | parentCategoryId: Optional[int] = None 217 | displayIndex: int 218 | 219 | sync_at: datetime = Field(default_factory=datetime.utcnow) 220 | 221 | model_config = { 222 | "collection": "curseforge_categories", 223 | "title": "CurseForge Category", 224 | } -------------------------------------------------------------------------------- /app/models/database/file_cdn.py: -------------------------------------------------------------------------------- 1 | from odmantic import Model, Field, EmbeddedModel 2 | from pydantic import BaseModel, field_serializer, field_validator, model_validator 3 | import time 4 | 5 | from typing import List, Optional, Union 6 | from datetime import datetime 7 | 8 | class File(Model): 9 | sha1: str = Field(primary_field=True, index=True) 10 | url: str 11 | path: str 12 | size: int 13 | mtime: int = Field(default_factory=lambda: int(time.time()))# , index=True) # 不可能有修改,直接强制 1725767710 14 | # 需要修改的时候手动改成 now 15 | 16 | model_config = { 17 | "collection": "file_cdn_files", 18 | } 19 | 20 | # @field_serializer("mtime") 21 | # def serialize_sync_date(self, value: datetime, _info): 22 | # return value.strftime("%Y-%m-%dT%H:%M:%SZ") 23 | -------------------------------------------------------------------------------- /app/models/database/modrinth.py: -------------------------------------------------------------------------------- 1 | from odmantic import Model, Field, EmbeddedModel 2 | from pydantic import BaseModel, field_serializer, field_validator, model_validator 3 | 4 | from typing import List, Optional, Union 5 | from datetime import datetime 6 | 7 | 8 | class DonationUrl(BaseModel): 9 | id: Optional[str] = None 10 | platform: Optional[str] = None 11 | url: Optional[str] = None 12 | 13 | 14 | class License(BaseModel): 15 | id: Optional[str] = None 16 | name: Optional[str] = None 17 | url: Optional[str] = None 18 | 19 | 20 | class GalleryItem(BaseModel): 21 | url: str 22 | featured: bool 23 | title: Optional[str] = None 24 | description: Optional[str] = None 25 | created: datetime 26 | ordering: Optional[int] = None 27 | 28 | 29 | class Project(Model): 30 | id: str = Field(primary_field=True, index=True) 31 | slug: str = Field(index=True) 32 | title: Optional[str] = None 33 | description: Optional[str] = None 34 | categories: Optional[List[str]] = None 35 | client_side: Optional[str] = None 36 | server_side: Optional[str] = None 37 | body: Optional[str] = None 38 | status: Optional[str] = None 39 | requested_status: Optional[str] = None 40 | additional_categories: Optional[List[str]] = None 41 | issues_url: Optional[str] = None 42 | source_url: Optional[str] = None 43 | wiki_url: Optional[str] = None 44 | discord_url: Optional[str] = None 45 | donation_urls: Optional[List[DonationUrl]] = None 46 | project_type: Optional[str] = None 47 | downloads: Optional[int] = None 48 | icon_url: Optional[str] = None 49 | color: Optional[int] = None 50 | thread_id: Optional[str] = None 51 | monetization_status: Optional[str] = None 52 | team: str 53 | body_url: Optional[str] = None 54 | published: datetime 55 | updated: datetime 56 | approved: Optional[datetime] = None 57 | queued: Optional[datetime] = None 58 | followers: int 59 | license: Optional[License] = None 60 | versions: Optional[List[str]] = None 61 | game_versions: Optional[List[str]] = None 62 | loaders: Optional[List[str]] = None 63 | gallery: Optional[List[GalleryItem]] = None 64 | 65 | sync_at: datetime = Field(default_factory=datetime.utcnow) 66 | 67 | model_config = {"collection": "modrinth_projects", "title": "Modrinth Project"} 68 | 69 | 70 | class Dependencies(BaseModel): 71 | version_id: Optional[str] = None 72 | project_id: Optional[str] = None 73 | file_name: Optional[str] = None 74 | dependency_type: str 75 | 76 | 77 | class Hashes(EmbeddedModel): 78 | sha512: str = Field(index=True) 79 | sha1: str = Field(index=True) 80 | 81 | 82 | # TODO: Add Version reference directly but not query File again 83 | class File(Model): 84 | hashes: Hashes = Field(primary_field=True) 85 | url: str 86 | filename: str 87 | primary: bool 88 | size: int 89 | file_type: Optional[str] = None 90 | 91 | version_id: str 92 | project_id: str 93 | 94 | file_cdn_cached: Optional[bool] = False 95 | 96 | sync_at: datetime = Field(default_factory=datetime.utcnow) 97 | 98 | model_config = {"collection": "modrinth_files", "title": "Modrinth File"} 99 | 100 | 101 | class FileInfo(BaseModel): 102 | hashes: Hashes 103 | url: str 104 | filename: str 105 | primary: bool 106 | size: int 107 | file_type: Optional[str] = None 108 | 109 | 110 | class Version(Model): 111 | id: str = Field(primary_field=True, index=True) 112 | project_id: str = Field(index=True) 113 | name: Optional[str] = None 114 | version_number: Optional[str] = None 115 | changelog: Optional[str] = None 116 | dependencies: Optional[List[Dependencies]] = None 117 | game_versions: Optional[List[str]] = None 118 | version_type: Optional[str] = None 119 | loaders: Optional[List[str]] = None 120 | featured: Optional[bool] = None 121 | status: Optional[str] = None 122 | requested_status: Optional[str] = None 123 | author_id: str 124 | date_published: datetime 125 | downloads: int 126 | changelog_url: Optional[str] = None # Deprecated 127 | files: List[FileInfo] 128 | 129 | sync_at: datetime = Field(default_factory=datetime.utcnow) 130 | 131 | model_config = {"collection": "modrinth_versions", "title": "Modrinth Version"} 132 | 133 | 134 | class Category(Model): 135 | icon: str 136 | name: str 137 | project_type: str 138 | header: str 139 | 140 | sync_at: datetime = Field(default_factory=datetime.utcnow) 141 | 142 | model_config = {"collection": "modrinth_categories", "title": "Modrinth Category"} 143 | 144 | 145 | class Loader(Model): 146 | icon: str 147 | name: str 148 | supported_project_types: List[str] 149 | 150 | sync_at: datetime = Field(default_factory=datetime.utcnow) 151 | 152 | model_config = {"collection": "modrinth_loaders", "title": "Modrinth Loader"} 153 | 154 | 155 | class GameVersion(Model): 156 | version: str 157 | version_type: str 158 | date: datetime 159 | major: bool 160 | 161 | sync_at: datetime = Field(default_factory=datetime.utcnow) 162 | 163 | model_config = { 164 | "collection": "modrinth_game_versions", 165 | "title": "Modrinth Game Version", 166 | } 167 | -------------------------------------------------------------------------------- /app/models/database/translate.py: -------------------------------------------------------------------------------- 1 | from odmantic import Model, Field, EmbeddedModel 2 | from typing import List, Optional, Union 3 | from datetime import datetime 4 | 5 | class ModrinthTranslation(Model): 6 | project_id: str = Field(primary_field=True, index=True) 7 | translated: str 8 | original: str 9 | translated_at: datetime 10 | 11 | model_config = { 12 | "collection": "modrinth_translated", 13 | } 14 | 15 | class CurseForgeTranslation(Model): 16 | modId: int = Field(primary_field=True, index=True) 17 | translated: str 18 | original: str 19 | translated_at: datetime 20 | 21 | model_config = { 22 | "collection": "curseforge_translated", 23 | } -------------------------------------------------------------------------------- /app/models/response/curseforge.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, BaseModel 2 | from typing import List, Union, Optional 3 | 4 | from app.models.database.curseforge import Mod, File, Fingerprint, Category, Pagination 5 | 6 | 7 | class _FingerprintResult(BaseModel): 8 | isCacheBuilt: bool = True 9 | exactMatches: List[Fingerprint] = [] 10 | exactFingerprints: List[int] = [] 11 | installedFingerprints: List[int] = [] 12 | unmatchedFingerprints: List[int] = [] 13 | 14 | class SearchResponse(BaseModel): 15 | data: List[Mod] 16 | pagination: Pagination 17 | 18 | 19 | class DownloadUrlResponse(BaseModel): 20 | data: str 21 | 22 | 23 | class ModResponse(BaseModel): 24 | data: Mod 25 | 26 | 27 | class ModsResponse(BaseModel): 28 | data: List[Mod] 29 | 30 | 31 | class ModFilesResponse(BaseModel): 32 | data: List[File] 33 | pagination: Pagination 34 | 35 | 36 | class FileResponse(BaseModel): 37 | data: File 38 | 39 | 40 | class FilesResponse(BaseModel): 41 | data: List[File] 42 | 43 | 44 | class FingerprintResponse(BaseModel): 45 | data: _FingerprintResult 46 | 47 | 48 | class CaregoriesResponse(BaseModel): 49 | data: List[Category] 50 | -------------------------------------------------------------------------------- /app/models/response/modrinth.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field, BaseModel 2 | from typing import List, Union, Optional 3 | from datetime import datetime 4 | 5 | 6 | class SearchHit(BaseModel): 7 | project_id: str 8 | project_type: str 9 | slug: str 10 | author: str 11 | title: str 12 | description: str 13 | categories: List[str] 14 | display_categories: Optional[List[str]] 15 | versions: List[str] 16 | downloads: int 17 | follows: int 18 | icon_url: str 19 | date_created: datetime 20 | date_modified: datetime 21 | latest_version: Optional[str] 22 | license: str 23 | client_side: str 24 | server_side: str 25 | gallery: Optional[List[str]] 26 | featured_gallery: Optional[str] 27 | color: int 28 | 29 | 30 | class SearchResponse(BaseModel): 31 | """ 32 | https://docs.modrinth.com/api/operations/searchprojects/ 33 | """ 34 | hits: List[SearchHit] 35 | offset: int 36 | limit: int 37 | total_hits: int 38 | 39 | class CategoryInfo(BaseModel): 40 | icon: str 41 | name: str 42 | project_type: str 43 | header: str 44 | 45 | class LoaderInfo(BaseModel): 46 | icon: str 47 | name: str 48 | supported_project_types: List[str] 49 | 50 | class GameVersionInfo(BaseModel): 51 | version: str 52 | version_type: str 53 | date: datetime 54 | major: bool -------------------------------------------------------------------------------- /app/sync_queue/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcmod-info-mirror/mcim-api/c0b424f72d39bd318565de95b7b505a0073da60c/app/sync_queue/__init__.py -------------------------------------------------------------------------------- /app/sync_queue/curseforge.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional 2 | 3 | from app.database._redis import ( 4 | sync_queuq_redis_engine, 5 | ) 6 | 7 | # curseforge 8 | async def add_curseforge_modIds_to_queue(modIds: List[int]): 9 | if len(modIds) != 0: 10 | await sync_queuq_redis_engine.sadd("curseforge_modids", *modIds) 11 | 12 | 13 | async def add_curseforge_fileIds_to_queue(fileIds: List[int]): 14 | if len(fileIds) != 0: 15 | await sync_queuq_redis_engine.sadd("curseforge_fileids", *fileIds) 16 | 17 | 18 | async def add_curseforge_fingerprints_to_queue(fingerprints: List[int]): 19 | if len(fingerprints) != 0: 20 | await sync_queuq_redis_engine.sadd("curseforge_fingerprints", *fingerprints) 21 | -------------------------------------------------------------------------------- /app/sync_queue/modrinth.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Optional 2 | import re 3 | 4 | from app.database._redis import ( 5 | sync_queuq_redis_engine, 6 | ) 7 | 8 | 9 | # modrinth 10 | async def add_modrinth_project_ids_to_queue(project_ids: List[str]): 11 | if len(project_ids) != 0: 12 | await sync_queuq_redis_engine.sadd("modrinth_project_ids", *project_ids) 13 | 14 | 15 | async def add_modrinth_version_ids_to_queue(version_ids: List[str]): 16 | if len(version_ids) != 0: 17 | await sync_queuq_redis_engine.sadd( 18 | "modrinth_version_ids", 19 | *[ 20 | version_id 21 | for version_id in version_ids 22 | if re.match(r"[a-zA-Z0-9]{8}", version_id) 23 | ], 24 | ) 25 | 26 | 27 | async def add_modrinth_hashes_to_queue(hashes: List[str], algorithm: str = "sha1"): 28 | if algorithm not in ["sha1", "sha512"]: 29 | raise ValueError("algorithm must be one of sha1, sha512") 30 | if len(hashes) != 0: 31 | 32 | await sync_queuq_redis_engine.sadd( 33 | f"modrinth_hashes_{algorithm}", 34 | *[ 35 | hash 36 | for hash in hashes 37 | if re.match( 38 | r"[a-zA-Z0-9]{40}" if algorithm == "sha1" else r"[a-zA-Z0-9]{128}", 39 | hash, 40 | ) 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcmod-info-mirror/mcim-api/c0b424f72d39bd318565de95b7b505a0073da60c/app/utils/__init__.py -------------------------------------------------------------------------------- /app/utils/loger/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from loguru import logger 3 | import os 4 | import sys 5 | import re 6 | import logging 7 | import time 8 | 9 | from app.config import MCIMConfig 10 | 11 | if os.getenv("TZ") is not None: 12 | time.tzset() 13 | 14 | mcim_config = MCIMConfig.load() 15 | 16 | # 清空 root 日志器的 handlers 17 | logging.root.handlers = [] 18 | 19 | LOGGING_FORMAT = "{time:YYYYMMDD HH:mm:ss} | " # 颜色>时间 20 | "{process.name} | " # 进程名 21 | "{thread.name} | " # 进程名 22 | "{module}.{function} | " # 模块名.方法名 23 | ":{line} | " # 行号 24 | "{level}: " # 等级 25 | "{message}" # 日志内容 26 | 27 | # 定义一个拦截标准日志的处理器 28 | class InterceptHandler(logging.Handler): 29 | def emit(self, record): 30 | # 获取对应的 Loguru 日志等级 31 | try: 32 | level = logger.level(record.levelname).name 33 | except ValueError: 34 | level = record.levelno 35 | # 重建 LogRecord,以确保格式正确 36 | frame, depth = logging.currentframe(), 2 37 | while frame.f_code.co_filename == logging.__file__: 38 | frame = frame.f_back 39 | depth += 1 40 | logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 41 | 42 | # 配置 Loguru 日志器 43 | logger.remove() 44 | logger.add( 45 | sys.stdout, 46 | level="DEBUG" if mcim_config.debug else "INFO", 47 | format=LOGGING_FORMAT, 48 | # colorize=True, 49 | # backtrace=True, 50 | # diagnose=True, 51 | serialize=True, 52 | ) 53 | 54 | # 拦截标准日志并重定向到 Loguru 55 | logging.basicConfig(handlers=[InterceptHandler()], level=0) 56 | 57 | # 要忽略的路由列表 58 | routes_to_ignore = [r"/metrics", r"^/data/.*", r"^/files/.*"] 59 | 60 | # 定义过滤器 61 | def filter_uvicorn_access(record: logging.LogRecord) -> bool: 62 | message = record.getMessage() 63 | # 使用正则表达式提取请求路径 64 | match = re.search(r'"[A-Z]+ (.+?) HTTP/.*"', message) 65 | if match: 66 | path = match.group(1) 67 | for route_pattern in routes_to_ignore: 68 | if re.match(route_pattern, path): 69 | return False # 过滤该日志 70 | return True # 保留该日志 71 | 72 | # 为 uvicorn.access 日志器添加过滤器 73 | access_logger = logging.getLogger("uvicorn.access") 74 | access_logger.addFilter(filter_uvicorn_access) 75 | 76 | # 处理 uvicorn 日志器 77 | for uvicorn_logger in ("uvicorn", "uvicorn.error", "uvicorn.access", "uvicorn.asgi"): 78 | uv_logger = logging.getLogger(uvicorn_logger) 79 | uv_logger.handlers = [InterceptHandler()] 80 | uv_logger.propagate = False 81 | 82 | # 禁用日志器 83 | logging.getLogger("httpx").propagate = False 84 | logging.getLogger("httpcore").propagate = False 85 | logging.getLogger("pymongo").propagate = False 86 | 87 | # 导出 logger 88 | log = logger -------------------------------------------------------------------------------- /app/utils/metric/__init__.py: -------------------------------------------------------------------------------- 1 | from prometheus_fastapi_instrumentator import Instrumentator, metrics 2 | from prometheus_client import Gauge, CollectorRegistry, Counter 3 | from fastapi import FastAPI 4 | 5 | APP_REGISTRY = CollectorRegistry() 6 | 7 | 8 | FILE_CDN_FORWARD_TO_ORIGIN_COUNT = Counter( 9 | "origin_forwarded_total", 10 | "Number of times has been forwarded to origin.", 11 | labelnames=("platform",), 12 | registry=APP_REGISTRY, 13 | ) 14 | 15 | FILE_CDN_FORWARD_TO_OPEN93HOME_COUNT = Counter( 16 | "open93home_forwarded_total", 17 | "Number of times has been forwarded to open93home.", 18 | labelnames=("platform",), 19 | registry=APP_REGISTRY, 20 | ) 21 | 22 | TRUSTABLE_RESPONSE_COUNT = Counter( 23 | "trustable_response", 24 | "Trustable response count", 25 | labelnames=("route",), 26 | registry=APP_REGISTRY, 27 | ) 28 | 29 | UNRELIABLE_RESPONSE_COUNT = Counter( 30 | "unreliable_response", 31 | "Unreliable response count", 32 | labelnames=("route",), 33 | registry=APP_REGISTRY, 34 | ) 35 | 36 | 37 | 38 | def init_prometheus_metrics(app: FastAPI): 39 | INSTRUMENTATOR: Instrumentator = Instrumentator( 40 | should_round_latency_decimals=True, 41 | excluded_handlers=[ 42 | "/metrics", 43 | "/docs", 44 | "/redoc", 45 | "/favicon.ico", 46 | "/openapi.json", 47 | ], 48 | inprogress_name="inprogress", 49 | inprogress_labels=True, 50 | registry=APP_REGISTRY, 51 | ) 52 | INSTRUMENTATOR.add(metrics.default()) 53 | INSTRUMENTATOR.instrument(app).expose( 54 | app, include_in_schema=False, should_gzip=True 55 | ) 56 | -------------------------------------------------------------------------------- /app/utils/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from app.utils.middleware.timing import TimingMiddleware 2 | from app.utils.middleware.count_trustable import CountTrustableMiddleware 3 | from app.utils.middleware.etag import EtagMiddleware 4 | from app.utils.middleware.uncache_post import UncachePOSTMiddleware 5 | 6 | __ALL__ = [ 7 | TimingMiddleware, 8 | CountTrustableMiddleware, 9 | EtagMiddleware, 10 | UncachePOSTMiddleware, 11 | ] 12 | -------------------------------------------------------------------------------- /app/utils/middleware/count_trustable.py: -------------------------------------------------------------------------------- 1 | """ 2 | 统计 Trustable 请求 3 | """ 4 | 5 | from fastapi import Request 6 | from starlette.middleware.base import BaseHTTPMiddleware 7 | 8 | from app.utils.metric import TRUSTABLE_RESPONSE_COUNT, UNRELIABLE_RESPONSE_COUNT 9 | 10 | 11 | class CountTrustableMiddleware(BaseHTTPMiddleware): 12 | """ 13 | 统计 Trustable 请求 14 | """ 15 | 16 | async def dispatch(self, request: Request, call_next): 17 | response = await call_next(request) 18 | route = request.scope.get("route") 19 | if route: 20 | if response.headers.get("Trustable") == "True": 21 | TRUSTABLE_RESPONSE_COUNT.labels(route=route.name).inc() 22 | else: 23 | UNRELIABLE_RESPONSE_COUNT.labels(route=route.name).inc() 24 | return response -------------------------------------------------------------------------------- /app/utils/middleware/etag.py: -------------------------------------------------------------------------------- 1 | """ 2 | 给 Response 添加 Etag 3 | """ 4 | 5 | from fastapi.responses import Response 6 | import hashlib 7 | from fastapi import Request 8 | from starlette.middleware.base import BaseHTTPMiddleware 9 | 10 | 11 | # Etag 12 | def generate_etag(response: Response) -> str: 13 | """ 14 | Get Etag from response 15 | 16 | SHA1 hash of the response content and status code 17 | 18 | Args: 19 | response (Response): response 20 | 21 | Returns: 22 | str: Etag 23 | """ 24 | hash_tool = hashlib.sha1() 25 | hash_tool.update(response.body) 26 | hash_tool.update(str(response.status_code).encode()) 27 | return hash_tool.hexdigest() 28 | 29 | 30 | class EtagMiddleware(BaseHTTPMiddleware): 31 | """ 32 | 给 Response 添加 Etag 33 | """ 34 | 35 | async def dispatch(self, request: Request, call_next): 36 | response = await call_next(request) 37 | if request.url.path.split("/")[1] in ["modrinth", "curseforge", "file_cdn"] and response.status_code == 200: 38 | etag = generate_etag(response) 39 | 40 | # if_none_match = request.headers.get("If-None-Match") 41 | # if if_none_match == etag: 42 | # response = Response(status_code=304) 43 | # else: 44 | # response.headers["Etag"] = etag 45 | 46 | response.headers["Etag"] = f'"{etag}"' 47 | return response -------------------------------------------------------------------------------- /app/utils/middleware/timing.py: -------------------------------------------------------------------------------- 1 | """ 2 | DEBUG 记录请求处理时间 3 | """ 4 | 5 | import time 6 | import inspect 7 | from fastapi import Request 8 | from starlette.middleware.base import BaseHTTPMiddleware 9 | 10 | from app.utils.loger import log 11 | 12 | class TimingMiddleware(BaseHTTPMiddleware): 13 | async def dispatch(self, request: Request, call_next): 14 | start_time = time.time() 15 | response = await call_next(request) 16 | process_time = time.time() - start_time 17 | route = request.scope.get("route") 18 | if route: 19 | route_name = route.name 20 | if process_time >= 10: 21 | log.warning(f"{route_name} - {request.method} {request.url} {process_time:.2f}s") 22 | elif process_time < 0.01: # 这应该是 redis 缓存,直接忽略 23 | # log.debug(f"{route_name} - {request.method} {request.url} {process_time:.2f}s") 24 | pass 25 | else: 26 | log.debug(f"{route_name} - {request.method} {request.url} {process_time:.2f}s") 27 | return response 28 | -------------------------------------------------------------------------------- /app/utils/middleware/uncache_post.py: -------------------------------------------------------------------------------- 1 | """ 2 | 不缓存 POST 请求 3 | """ 4 | 5 | from fastapi import Request 6 | from starlette.middleware.base import BaseHTTPMiddleware 7 | 8 | 9 | class UncachePOSTMiddleware(BaseHTTPMiddleware): 10 | """ 11 | 不缓存 POST 请求 12 | """ 13 | 14 | async def dispatch(self, request: Request, call_next): 15 | response = await call_next(request) 16 | if request.method == "POST": 17 | response.headers["Cache-Control"] = "no-cache" 18 | return response 19 | -------------------------------------------------------------------------------- /app/utils/network/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 与网络请求相关的模块 3 | """ 4 | 5 | import os 6 | import hashlib 7 | import httpx 8 | import uuid 9 | 10 | from tenacity import retry, stop_after_attempt, retry_if_not_exception_type 11 | from typing import Optional, Union 12 | from app.exceptions import ApiException, ResponseCodeException 13 | from app.config.mcim import MCIMConfig 14 | from app.utils.loger import log 15 | 16 | mcim_config = MCIMConfig.load() 17 | 18 | 19 | PROXY: Optional[str] = mcim_config.proxies 20 | 21 | HEADERS = { 22 | "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36 Edg/116.0.1938.54", 23 | } 24 | 25 | TIMEOUT = 5 26 | RETRY_TIMES = 3 27 | REQUEST_LOG = True 28 | 29 | 30 | httpx_async_client: httpx.AsyncClient = httpx.AsyncClient(proxy=PROXY) 31 | httpx_sync_client: httpx.Client = httpx.Client(proxy=PROXY) 32 | 33 | 34 | def get_session() -> httpx.Client: 35 | global httpx_sync_client 36 | if httpx_sync_client: 37 | return httpx_sync_client 38 | else: 39 | httpx_sync_client = httpx.Client() 40 | return httpx_sync_client 41 | 42 | 43 | def get_async_session() -> httpx.AsyncClient: 44 | global httpx_async_client 45 | if httpx_async_client: 46 | return httpx_async_client 47 | else: 48 | httpx_async_client = httpx.AsyncClient() 49 | return httpx_async_client 50 | 51 | 52 | def verify_hash(path: str, hash_: str, algo: str) -> bool: 53 | with open(path, "rb") as f: 54 | if algo == "sha512": 55 | hash_tool = hashlib.sha512() 56 | elif algo == "sha1": 57 | hash_tool = hashlib.sha1() 58 | elif algo == "md5": 59 | hash_tool = hashlib.md5() 60 | 61 | while True: 62 | data = f.read(1024) 63 | if data is None: 64 | break 65 | hash_tool.update(data) 66 | return hash_ == hash_tool.hexdigest() 67 | 68 | 69 | @retry( 70 | stop=stop_after_attempt(RETRY_TIMES), 71 | retry=(retry_if_not_exception_type(ResponseCodeException)), 72 | reraise=True, 73 | ) 74 | def request_sync( 75 | url: str, 76 | method: str = "GET", 77 | data: Optional[dict] = None, 78 | params: Optional[dict] = None, 79 | json: Optional[dict] = None, 80 | timeout: Optional[Union[int, float]] = TIMEOUT, 81 | ignore_status_code: bool = False, 82 | **kwargs, 83 | ) -> httpx.Response: 84 | """ 85 | HTTPX 请求函数 86 | 87 | Args: 88 | url (str): 请求 URL 89 | 90 | method (str, optional): 请求方法 默认 GET 91 | 92 | timeout (Optional[Union[int, float]], optional): 超时时间,默认为 5 秒 93 | 94 | **kwargs: 其他参数 95 | 96 | Returns: 97 | Any: 请求结果 98 | """ 99 | # delete null query 100 | if params is not None: 101 | params = {k: v for k, v in params.items() if v is not None} 102 | 103 | session = get_session() 104 | 105 | if json is not None: 106 | res: httpx.Response = session.request( 107 | method, url, json=json, params=params, timeout=timeout, **kwargs 108 | ) 109 | else: 110 | res: httpx.Response = session.request( 111 | method, url, data=data, params=params, timeout=timeout, **kwargs 112 | ) 113 | if not ignore_status_code: 114 | if res.status_code != 200: 115 | raise ResponseCodeException( 116 | status_code=res.status_code, 117 | method=method, 118 | url=url, 119 | data=data if data is None else json, 120 | params=params, 121 | msg=res.text, 122 | ) 123 | return res 124 | 125 | 126 | @retry( 127 | stop=stop_after_attempt(RETRY_TIMES), 128 | retry=(retry_if_not_exception_type(ResponseCodeException)), 129 | reraise=True, 130 | ) 131 | async def request( 132 | url: str, 133 | method: str = "GET", 134 | data: Optional[dict] = None, 135 | params: Optional[dict] = None, 136 | json: Optional[dict] = None, 137 | timeout: Optional[Union[int, float]] = TIMEOUT, 138 | ignore_status_code: bool = False, 139 | **kwargs, 140 | ) -> httpx.Response: 141 | """ 142 | HTTPX 请求函数 143 | 144 | Args: 145 | url (str): 请求 URL 146 | 147 | method (str, optional): 请求方法 默认 GET 148 | 149 | timeout (Optional[Union[int, float]], optional): 超时时间,默认为 5 秒 150 | 151 | **kwargs: 其他参数 152 | 153 | Returns: 154 | Any: 请求结果 155 | """ 156 | # delete null query 157 | if params is not None: 158 | params = {k: v for k, v in params.items() if v is not None} 159 | 160 | session = get_async_session() 161 | 162 | if json is not None: 163 | res: httpx.Response = await session.request( 164 | method, url, json=json, params=params, timeout=timeout, **kwargs 165 | ) 166 | else: 167 | res: httpx.Response = await session.request( 168 | method, url, data=data, params=params, timeout=timeout, **kwargs 169 | ) 170 | if not ignore_status_code: 171 | if res.status_code != 200: 172 | raise ResponseCodeException( 173 | status_code=res.status_code, 174 | method=method, 175 | url=url, 176 | data=data if data is None else json, 177 | params=params, 178 | msg=res.text, 179 | ) 180 | return res -------------------------------------------------------------------------------- /app/utils/response/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import ORJSONResponse, Response 2 | from fastapi.encoders import jsonable_encoder 3 | from typing import Union, Optional, Any 4 | from pydantic import BaseModel 5 | import hashlib 6 | import orjson 7 | 8 | __ALL__ = ["BaseResponse", "TrustableResponse", "UncachedResponse", "ForceSyncResponse"] 9 | 10 | # Etag 11 | def generate_etag(content, status_code) -> str: 12 | """ 13 | Get Etag from response 14 | 15 | SHA1 hash of the response content and status code 16 | """ 17 | hash_tool = hashlib.sha1() 18 | hash_tool.update(orjson.dumps(content)) 19 | hash_tool.update(str(status_code).encode()) 20 | return hash_tool.hexdigest() 21 | 22 | class BaseResponse(ORJSONResponse): 23 | """ 24 | BaseResponse 类 25 | 26 | 用于返回 JSON 响应 27 | 28 | 默认 Cache-Control: public, max-age=86400 29 | """ 30 | 31 | def __init__( 32 | self, 33 | content: Any, 34 | status_code: int = 200, 35 | headers: dict = {}, 36 | ): 37 | raw_content = jsonable_encoder(content) 38 | 39 | # 默认 Cache-Control: public, max-age=86400 40 | if status_code == 200 and "Cache-Control" not in headers: 41 | headers["Cache-Control"] = "public, max-age=86400" 42 | 43 | # Etag 44 | if raw_content is not None and status_code == 200: 45 | headers["Etag"] = generate_etag(raw_content, status_code=status_code) 46 | 47 | super().__init__(status_code=status_code, content=raw_content, headers=headers) 48 | 49 | 50 | class TrustableResponse(BaseResponse): 51 | """ 52 | A response that indicates that the content is trusted. 53 | """ 54 | 55 | def __init__( 56 | self, 57 | status_code: int = 200, 58 | content: Union[dict, BaseModel, list] = None, 59 | headers: dict = {}, 60 | trustable: bool = True, 61 | ): 62 | headers["Trustable"] = "True" if trustable else "False" 63 | 64 | super().__init__( 65 | status_code=status_code, 66 | content=content, 67 | headers=headers, 68 | ) 69 | 70 | 71 | class UncachedResponse(Response): 72 | """ 73 | A response that indicates that the content is not cached. 74 | """ 75 | 76 | def __init__(self, status_code: int = 404, headers: dict = {}): 77 | headers = {"Trustable": "False"} 78 | 79 | super().__init__(status_code=status_code, headers=headers) 80 | -------------------------------------------------------------------------------- /app/utils/response_cache/__init__.py: -------------------------------------------------------------------------------- 1 | import orjson 2 | from functools import wraps 3 | from typing import Optional 4 | from fastapi.responses import Response 5 | from redis.asyncio import Redis 6 | from app.utils.response_cache.key_builder import default_key_builder, KeyBuilder 7 | from app.utils.response_cache.resp_builder import ResponseBuilder 8 | from app.utils.loger import log 9 | from app.config.redis import RedisdbConfig 10 | # from app.utils.metric import REDIS_CACHE_HIT_GAUGE 11 | 12 | redis_config = RedisdbConfig.load() 13 | 14 | 15 | class Cache: 16 | backend: Redis 17 | enabled: bool = False 18 | namespace: str = "fastapi_cache" 19 | key_builder: KeyBuilder = default_key_builder 20 | 21 | @classmethod 22 | def init( 23 | cls, 24 | backend: Optional[Redis] = None, 25 | enabled: Optional[bool] = False, 26 | namespace: Optional[str] = "fastapi_cache", 27 | key_builder: KeyBuilder = default_key_builder, 28 | ) -> None: 29 | cls.backend = ( 30 | Redis( 31 | host=redis_config.host, 32 | port=redis_config.port, 33 | db=redis_config.database.info_cache, 34 | password=redis_config.password, 35 | ) 36 | if backend is None 37 | else backend 38 | ) 39 | cls.enabled = enabled 40 | cls.namespace = namespace 41 | cls.key_builder = key_builder 42 | 43 | 44 | def cache(expire: Optional[int] = 60, never_expire: Optional[bool] = False): 45 | if not isinstance(expire, int): 46 | raise ValueError("expire must be an integer") 47 | 48 | def decorator(func): 49 | @wraps(func) 50 | async def wrapper(*args, **kwargs): 51 | if kwargs.get("force") is True or not Cache.enabled: 52 | return await func(*args, **kwargs) 53 | key = default_key_builder( 54 | func, namespace=Cache.namespace, args=args, kwargs=kwargs 55 | ) 56 | value = await Cache.backend.get(key) 57 | 58 | if value is not None: 59 | value = orjson.loads(value) 60 | # log.debug(f"Cached response: [{key}]") 61 | log.trace(f"Cached response: [{key}]") 62 | # REDIS_CACHE_HIT_GAUGE.labels(f'{func.__module__}:{func.__name__}').inc() 63 | return ResponseBuilder.decode(value) 64 | 65 | result = await func(*args, **kwargs) 66 | # REDIS_CACHE_HIT_GAUGE.labels(f'{func.__module__}:{func.__name__}').dec() 67 | if isinstance(result, Response): 68 | if result.status_code >= 400: 69 | return result 70 | elif "Cache-Control" in result.headers: 71 | if "no-cache" in result.headers["Cache-Control"]: 72 | return result 73 | 74 | to_set = ResponseBuilder.encode(result) 75 | else: 76 | return result 77 | value = orjson.dumps(to_set) 78 | 79 | if never_expire: 80 | await Cache.backend.set(key, value) 81 | else: 82 | await Cache.backend.set(key, value, ex=expire) 83 | # log.debug(f"Set cache: [{key}]") 84 | log.trace(f"Set cache: [{key}]") 85 | 86 | return result 87 | 88 | return wrapper 89 | 90 | return decorator 91 | -------------------------------------------------------------------------------- /app/utils/response_cache/key_builder.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | # import xxhash 3 | from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union 4 | from typing_extensions import Protocol 5 | 6 | _Func = Callable[..., Any] 7 | 8 | IGNORE_KWARGS = "requests" 9 | 10 | 11 | def filter_kwargs(kwargs: Dict[str, Any], keys: Tuple[str, ...]) -> Dict[str, Any]: 12 | return {k: v for k, v in kwargs.items() if k not in keys} 13 | 14 | 15 | class KeyBuilder(Protocol): 16 | def __call__( 17 | self, 18 | __function: _Func, 19 | __namespace: str = ..., 20 | *, 21 | args: Tuple[Any, ...], 22 | kwargs: Dict[str, Any], 23 | ) -> Union[Awaitable[str], str]: ... 24 | 25 | 26 | def default_key_builder( 27 | func: Callable[..., Any], 28 | namespace: str = "", 29 | *, 30 | args: Tuple[Any, ...], 31 | kwargs: Dict[str, Any], 32 | ) -> str: 33 | cache_key = hashlib.md5( # noqa: S324 34 | f"{func.__module__}:{func.__name__}:{args}:{filter_kwargs(kwargs, IGNORE_KWARGS)}".encode() 35 | ).hexdigest() 36 | return f"{namespace}:{cache_key}" 37 | 38 | 39 | # def xxhash_key_builder( 40 | # func: Callable[..., Any], 41 | # namespace: str = "", 42 | # *, 43 | # args: Tuple[Any, ...], 44 | # kwargs: Dict[str, Any], 45 | # ) -> str: 46 | # cache_key = xxhash.xxh3_64( # noqa: S324 47 | # f"{func.__module__}:{func.__name__}:{args}:{filter_kwargs(kwargs, IGNORE_KWARGS)}".encode() 48 | # ).hexdigest() 49 | # return f"{namespace}:{cache_key}" 50 | -------------------------------------------------------------------------------- /app/utils/response_cache/resp_builder.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import Response 2 | 3 | 4 | class BaseBuilder: 5 | @classmethod 6 | def encode(cls, value): 7 | raise NotImplementedError 8 | 9 | @classmethod 10 | def decode(cls, value): 11 | raise NotImplementedError 12 | 13 | 14 | class ResponseBuilder(BaseBuilder): 15 | @classmethod 16 | def encode(cls, value: Response) -> bytes: 17 | body: bytes = value.body 18 | headers: dict = dict(value.headers) 19 | status_code: int = value.status_code 20 | 21 | params = { 22 | "body": body.decode("utf-8"), 23 | "headers": headers, 24 | "status_code": status_code, 25 | } 26 | return params 27 | 28 | @classmethod 29 | def decode(cls, value: dict) -> Response: 30 | return Response( 31 | content=bytes(value["body"], encoding="utf-8"), 32 | headers=value["headers"], 33 | status_code=value["status_code"], 34 | ) 35 | -------------------------------------------------------------------------------- /config/mongod.conf: -------------------------------------------------------------------------------- 1 | storage: 2 | wiredTiger: 3 | engineConfig: 4 | cacheSizeGB: 0.5 -------------------------------------------------------------------------------- /config/redis.conf: -------------------------------------------------------------------------------- 1 | save "" 2 | 3 | # save 900 1 4 | # save 300 10 5 | # save 60 10000 6 | 7 | maxmemory 300mb 8 | maxmemory-policy allkeys-lru 9 | -------------------------------------------------------------------------------- /data/curseforge_mods.json: -------------------------------------------------------------------------------- 1 | [{"_id":946010,"allowModDistribution":true,"authors":[{"id":103996124,"name":"LillouArts","url":"https://www.curseforge.com/members/lillouarts"}],"categories":[{"id":424,"gameId":432,"name":"Cosmetic","slug":"cosmetic","url":"https://www.curseforge.com/minecraft/mc-mods/cosmetic","iconUrl":"https://media.forgecdn.net/avatars/6/39/635351497555976928.png","dateModified":"2014-05-08T17:42:35.597Z","isClass":false,"classId":6,"parentCategoryId":6,"displayIndex":null},{"id":420,"gameId":432,"name":"Storage","slug":"storage","url":"https://www.curseforge.com/minecraft/mc-mods/storage","iconUrl":"https://media.forgecdn.net/avatars/6/35/635351496772023801.png","dateModified":"2014-05-08T17:41:17.203Z","isClass":false,"classId":6,"parentCategoryId":6,"displayIndex":null},{"id":436,"gameId":432,"name":"Food","slug":"mc-food","url":"https://www.curseforge.com/minecraft/mc-mods/mc-food","iconUrl":"https://media.forgecdn.net/avatars/6/49/635351499265510402.png","dateModified":"2014-05-08T17:45:26.55Z","isClass":false,"classId":6,"parentCategoryId":6,"displayIndex":null}],"classId":6,"dateCreated":{"$date":"2023-12-03T14:27:43.82Z"},"dateModified":{"$date":"2025-01-02T11:13:04.457Z"},"dateReleased":{"$date":"2024-12-09T12:32:09.817Z"},"downloadCount":168423,"found":true,"gameId":432,"gamePopularityRank":2866,"isAvailable":true,"isFeatured":false,"latestFiles":[{"id":4958774,"gameId":432,"modId":946010,"isAvailable":true,"displayName":"christmasculinarydesires - 1.4.0 1.16.5 forge","fileName":"christmasculinarydesires - 1.4.0 1.16.5 forge.jar","releaseType":2,"fileStatus":4,"hashes":[{"value":"942a11caad91915d0433a5ff2df5dc2f44cdd083","algo":1},{"value":"2f8aa91341c1249af764207c545d7e24","algo":2}],"fileDate":{"$date":"2023-12-15T14:17:29.23Z"},"fileLength":3609876,"downloadCount":1764,"fileSizeOnDisk":null,"downloadUrl":"https://edge.forgecdn.net/files/4958/774/christmasculinarydesires%20-%201.4.0%201.16.5%20forge.jar","gameVersions":["Client","1.16.5","Forge","Server"],"sortableGameVersions":[{"gameVersionName":"Client","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208},{"gameVersionName":"1.16.5","gameVersionPadded":"0000000001.0000000016.0000000005","gameVersion":"1.16.5","gameVersionReleaseDate":"2021-01-15T14:14:48.91Z","gameVersionTypeId":70886},{"gameVersionName":"Forge","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-10-01T00:00:00Z","gameVersionTypeId":68441},{"gameVersionName":"Server","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208}],"dependencies":[],"exposeAsAlternative":null,"parentProjectFileId":null,"alternateFileId":0,"isServerPack":false,"serverPackFileId":null,"isEarlyAccessContent":null,"earlyAccessEndDate":null,"fileFingerprint":1904165976,"modules":[{"name":"META-INF","fingerprint":1875601336},{"name":"net","fingerprint":1268993459},{"name":"assets","fingerprint":364224264},{"name":"data","fingerprint":3379872559},{"name":"logo.png","fingerprint":4096234243},{"name":"pack.mcmeta","fingerprint":2125212863}],"sha1":null,"md5":null},{"id":5976953,"gameId":432,"modId":946010,"isAvailable":true,"displayName":"christmas_culinary_and_decorations-1.6.0-neoforge-1.21.1","fileName":"christmas_culinary_and_decorations-1.6.0-neoforge-1.21.1.jar","releaseType":1,"fileStatus":4,"hashes":[{"value":"b7f641c738a353d8251d7c25ce29e2520426e800","algo":1},{"value":"bb507368ad94de721ff332290ee9df33","algo":2}],"fileDate":{"$date":"2024-12-09T12:23:33.327Z"},"fileLength":3209939,"downloadCount":0,"fileSizeOnDisk":15722767,"downloadUrl":"https://edge.forgecdn.net/files/5976/953/christmas_culinary_and_decorations-1.6.0-neoforge-1.21.1.jar","gameVersions":["Client","1.21.1-Snapshot","NeoForge","1.21.1","Server"],"sortableGameVersions":[{"gameVersionName":"Client","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208},{"gameVersionName":"1.21.1-Snapshot","gameVersionPadded":"0000000001.0000000021.0000000001","gameVersion":"1.21.1","gameVersionReleaseDate":"2024-08-07T00:00:00Z","gameVersionTypeId":77784},{"gameVersionName":"NeoForge","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2023-07-25T00:00:00Z","gameVersionTypeId":68441},{"gameVersionName":"1.21.1","gameVersionPadded":"0000000001.0000000021.0000000001","gameVersion":"1.21.1","gameVersionReleaseDate":"2024-08-08T15:17:53.787Z","gameVersionTypeId":77784},{"gameVersionName":"Server","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208}],"dependencies":[],"exposeAsAlternative":null,"parentProjectFileId":null,"alternateFileId":0,"isServerPack":false,"serverPackFileId":null,"isEarlyAccessContent":null,"earlyAccessEndDate":null,"fileFingerprint":2070800629,"modules":[{"name":"META-INF","fingerprint":4041424669},{"name":"net","fingerprint":2618552534},{"name":"assets","fingerprint":3674339899},{"name":"data","fingerprint":1696803263},{"name":"logo.png","fingerprint":4096234243},{"name":"pack.mcmeta","fingerprint":32171693}],"sha1":null,"md5":null},{"id":5976975,"gameId":432,"modId":946010,"isAvailable":true,"displayName":"christmas_culinary_and_decorations-1.5.0-forge-1.20.1","fileName":"christmas_culinary_and_decorations-1.5.0-forge-1.20.1.jar","releaseType":1,"fileStatus":4,"hashes":[{"value":"a3af7f483bdf4f3d9b8a4a22090be1e23aad1088","algo":1},{"value":"db6493562312b91b4c78dc0f99dfebf1","algo":2}],"fileDate":{"$date":"2024-12-09T12:32:09.817Z"},"fileLength":3243184,"downloadCount":82332,"fileSizeOnDisk":15769623,"downloadUrl":"https://edge.forgecdn.net/files/5976/975/christmas_culinary_and_decorations-1.5.0-forge-1.20.1.jar","gameVersions":["Client","1.20.1","Forge","Server"],"sortableGameVersions":[{"gameVersionName":"Client","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208},{"gameVersionName":"1.20.1","gameVersionPadded":"0000000001.0000000020.0000000001","gameVersion":"1.20.1","gameVersionReleaseDate":"2023-06-12T14:26:38.477Z","gameVersionTypeId":75125},{"gameVersionName":"Forge","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-10-01T00:00:00Z","gameVersionTypeId":68441},{"gameVersionName":"Server","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-12-08T00:00:00Z","gameVersionTypeId":75208}],"dependencies":[],"exposeAsAlternative":null,"parentProjectFileId":null,"alternateFileId":0,"isServerPack":false,"serverPackFileId":null,"isEarlyAccessContent":null,"earlyAccessEndDate":null,"fileFingerprint":1252089992,"modules":[{"name":"META-INF","fingerprint":3859753849},{"name":"assets","fingerprint":973603148},{"name":"data","fingerprint":341568960},{"name":"logo.png","fingerprint":4096234243},{"name":"net","fingerprint":1370455315},{"name":"pack.mcmeta","fingerprint":3888393594}],"sha1":null,"md5":null}],"latestFilesIndexes":[{"gameVersion":"1.20.1","fileId":5976975,"filename":"christmas_culinary_and_decorations-1.5.0-forge-1.20.1.jar","releaseType":1,"gameVersionTypeId":75125,"modLoader":1},{"gameVersion":"1.21.1","fileId":5976953,"filename":"christmas_culinary_and_decorations-1.6.0-neoforge-1.21.1.jar","releaseType":1,"gameVersionTypeId":77784,"modLoader":6},{"gameVersion":"1.20.4","fileId":5287513,"filename":"christmasculinarydesires 1.5.0 1.20.4 neoforge.jar","releaseType":1,"gameVersionTypeId":75125,"modLoader":6},{"gameVersion":"1.16.5","fileId":4958774,"filename":"christmasculinarydesires - 1.4.0 1.16.5 forge.jar","releaseType":2,"gameVersionTypeId":70886,"modLoader":1},{"gameVersion":"1.18.2","fileId":4958751,"filename":"christmasculinarydesires - 1.3.0 1.18.2 forge.jar","releaseType":2,"gameVersionTypeId":73250,"modLoader":1},{"gameVersion":"1.19.2","fileId":4958633,"filename":"christmasculinarydesires - 1.2.0 1.19.2 forge.jar","releaseType":2,"gameVersionTypeId":73407,"modLoader":1},{"gameVersion":"1.19.4","fileId":4958576,"filename":"christmasculinarydesires - 1.1.0 1.19.4 forge.jar","releaseType":2,"gameVersionTypeId":73407,"modLoader":1}],"links":{"websiteUrl":"https://www.curseforge.com/minecraft/mc-mods/christmas-culinary-desires-decorations","wikiUrl":"https://drive.google.com/file/d/1axU0a9bnuPWEeSuIcWWaCtB8aL9ogAjE/view?usp=sharing","issuesUrl":null,"sourceUrl":null},"logo":{"id":1133230,"modId":946010,"title":"638693453159491811.jpeg","description":"","thumbnailUrl":"https://media.forgecdn.net/avatars/thumbnails/1133/230/256/256/638693453159491811.jpeg","url":"https://media.forgecdn.net/avatars/1133/230/638693453159491811.jpeg"},"mainFileId":5976975,"name":"Christmas Culinary & Decorations (Placeable festive foods, functional presents)","primaryCategoryId":436,"rating":null,"screenshots":[{"id":774883,"modId":946010,"title":"Models","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/774/883/310/172/2-2.png","url":"https://media.forgecdn.net/attachments/774/883/2-2.png"},{"id":774882,"modId":946010,"title":"Fairylights","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/774/882/310/172/1-2.jpg","url":"https://media.forgecdn.net/attachments/774/882/1-2.jpg"},{"id":774880,"modId":946010,"title":"Christmas Eve","description":"Complementary Shaders used","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/774/880/310/172/1-9.png","url":"https://media.forgecdn.net/attachments/774/880/1-9.png"}],"slug":"christmas-culinary-desires-decorations","status":4,"summary":"Mod containing Christmas Worldwide traditional dishes & decorations including functional presents.","sync_at":{"$date":"2025-01-07T15:02:22Z"},"thumbsUpCount":0,"translated_summary":null},{"_id":594678,"allowModDistribution":true,"authors":[{"id":100247822,"name":"purplik","url":"https://www.curseforge.com/members/purplik"}],"categories":[{"id":434,"gameId":432,"name":"Armor, Tools, and Weapons","slug":"armor-weapons-tools","url":"https://www.curseforge.com/minecraft/mc-mods/armor-weapons-tools","iconUrl":"https://media.forgecdn.net/avatars/6/47/635351498790409758.png","dateModified":"2014-05-08T17:44:39.057Z","isClass":false,"classId":6,"parentCategoryId":6,"displayIndex":null},{"id":424,"gameId":432,"name":"Cosmetic","slug":"cosmetic","url":"https://www.curseforge.com/minecraft/mc-mods/cosmetic","iconUrl":"https://media.forgecdn.net/avatars/6/39/635351497555976928.png","dateModified":"2014-05-08T17:42:35.597Z","isClass":false,"classId":6,"parentCategoryId":6,"displayIndex":null}],"classId":6,"dateCreated":{"$date":"2022-03-17T17:57:05.42Z"},"dateModified":{"$date":"2023-01-01T16:28:17.23Z"},"dateReleased":{"$date":"2023-01-01T16:19:51.07Z"},"downloadCount":168396,"found":true,"gameId":432,"gamePopularityRank":8428,"isAvailable":true,"isFeatured":false,"latestFiles":[{"id":3872689,"gameId":432,"modId":594678,"isAvailable":true,"displayName":"hats-and-cosmetics-1.2.1-1.19","fileName":"hats-and-cosmetics-1.2.1-1.19.jar","releaseType":2,"fileStatus":4,"hashes":[{"value":"8451c8829278f37a811ae70f21624e43dc26eb7a","algo":1},{"value":"5e6ed5e447c60dce70952386f3fbf017","algo":2}],"fileDate":{"$date":"2022-07-12T21:24:02.393Z"},"fileLength":120306,"downloadCount":3591,"fileSizeOnDisk":null,"downloadUrl":"https://edge.forgecdn.net/files/3872/689/hats-and-cosmetics-1.2.1-1.19.jar","gameVersions":["Forge","1.19"],"sortableGameVersions":[{"gameVersionName":"Forge","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-10-01T00:00:00Z","gameVersionTypeId":68441},{"gameVersionName":"1.19","gameVersionPadded":"0000000001.0000000019","gameVersion":"1.19","gameVersionReleaseDate":"2022-06-07T15:38:07.377Z","gameVersionTypeId":73407}],"dependencies":[{"modId":309927,"relationType":3}],"exposeAsAlternative":null,"parentProjectFileId":null,"alternateFileId":0,"isServerPack":false,"serverPackFileId":null,"isEarlyAccessContent":null,"earlyAccessEndDate":null,"fileFingerprint":2194437269,"modules":[{"name":"META-INF","fingerprint":3874194274},{"name":"com","fingerprint":4269535854},{"name":"assets","fingerprint":3455112628},{"name":"data","fingerprint":3000469934},{"name":"hat.png","fingerprint":1651265609},{"name":"pack.mcmeta","fingerprint":2592535084}],"sha1":null,"md5":null},{"id":4285139,"gameId":432,"modId":594678,"isAvailable":true,"displayName":"hats-and-cosmetics-1.4-1.18.2","fileName":"hats-and-cosmetics-1.4-1.18.2.jar","releaseType":1,"fileStatus":4,"hashes":[{"value":"6832509692a57ce11c77b361e4105e4006a55b6a","algo":1},{"value":"9506fb14511b080cad9446279e68a3c8","algo":2}],"fileDate":{"$date":"2023-01-01T16:19:51.07Z"},"fileLength":122261,"downloadCount":22181,"fileSizeOnDisk":null,"downloadUrl":"https://edge.forgecdn.net/files/4285/139/hats-and-cosmetics-1.4-1.18.2.jar","gameVersions":["1.18.2","Forge"],"sortableGameVersions":[{"gameVersionName":"1.18.2","gameVersionPadded":"0000000001.0000000018.0000000002","gameVersion":"1.18.2","gameVersionReleaseDate":"2022-02-28T14:23:37.723Z","gameVersionTypeId":73250},{"gameVersionName":"Forge","gameVersionPadded":"0","gameVersion":"","gameVersionReleaseDate":"2022-10-01T00:00:00Z","gameVersionTypeId":68441}],"dependencies":[{"modId":309927,"relationType":3}],"exposeAsAlternative":null,"parentProjectFileId":null,"alternateFileId":0,"isServerPack":false,"serverPackFileId":null,"isEarlyAccessContent":null,"earlyAccessEndDate":null,"fileFingerprint":4195791776,"modules":[{"name":"META-INF","fingerprint":2631189269},{"name":"com","fingerprint":1521715468},{"name":"assets","fingerprint":459797262},{"name":"data","fingerprint":3789743788},{"name":"hat.png","fingerprint":1651265609},{"name":"pack.mcmeta","fingerprint":2592535084}],"sha1":null,"md5":null}],"latestFilesIndexes":[{"gameVersion":"1.18.2","fileId":4285139,"filename":"hats-and-cosmetics-1.4-1.18.2.jar","releaseType":1,"gameVersionTypeId":73250,"modLoader":1},{"gameVersion":"1.19.2","fileId":4019148,"filename":"hats-and-cosmetics-1.4-1.19.2.jar","releaseType":1,"gameVersionTypeId":73407,"modLoader":1},{"gameVersion":"1.19.1","fileId":3913840,"filename":"hats-and-cosmetics-1.2.2-1.19.1.jar","releaseType":1,"gameVersionTypeId":73407,"modLoader":1},{"gameVersion":"1.19","fileId":3872689,"filename":"hats-and-cosmetics-1.2.1-1.19.jar","releaseType":2,"gameVersionTypeId":73407,"modLoader":1},{"gameVersion":"1.18.1","fileId":3696688,"filename":"hats-and-cosmetics-0.1.jar","releaseType":2,"gameVersionTypeId":73250,"modLoader":1}],"links":{"websiteUrl":"https://www.curseforge.com/minecraft/mc-mods/hats-cosmetics","wikiUrl":"","issuesUrl":null,"sourceUrl":"https://github.com/PurplikDev/Hats-and-Cosmetics"},"logo":{"id":564613,"modId":594678,"title":"637921174757480798.gif","description":"","thumbnailUrl":"https://media.forgecdn.net/avatars/thumbnails/564/613/256/256/637921174757480798_animated.gif","url":"https://media.forgecdn.net/avatars/564/613/637921174757480798.gif"},"mainFileId":4285139,"name":"Hats & Cosmetics","primaryCategoryId":424,"rating":null,"screenshots":[{"id":456799,"modId":594678,"title":"Ushanka","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/456/799/310/172/ushanka-showcase.png","url":"https://media.forgecdn.net/attachments/456/799/ushanka-showcase.png"},{"id":456802,"modId":594678,"title":"Lab Goggles","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/456/802/310/172/lab-goggles-showcase.png","url":"https://media.forgecdn.net/attachments/456/802/lab-goggles-showcase.png"},{"id":585784,"modId":594678,"title":"The 1.0 Update","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/585/784/310/172/the-1.png","url":"https://media.forgecdn.net/attachments/585/784/the-1.png"},{"id":585785,"modId":594678,"title":"The 1.1 Update","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/585/785/310/172/1.png","url":"https://media.forgecdn.net/attachments/585/785/1.png"},{"id":585790,"modId":594678,"title":"The 1.4 Update - Rat Update","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/585/790/310/172/1.png","url":"https://media.forgecdn.net/attachments/585/790/1.png"},{"id":585786,"modId":594678,"title":"The 1.2 Update - Rainy Days","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/585/786/310/172/rainy-days-update.png","url":"https://media.forgecdn.net/attachments/585/786/rainy-days-update.png"},{"id":456800,"modId":594678,"title":"Tophat","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/456/800/310/172/tophat-showcase.png","url":"https://media.forgecdn.net/attachments/456/800/tophat-showcase.png"},{"id":585787,"modId":594678,"title":"The 1.3 Update - Accessory Update","description":"","thumbnailUrl":"https://media.forgecdn.net/attachments/thumbnails/585/787/310/172/1.png","url":"https://media.forgecdn.net/attachments/585/787/1.png"}],"slug":"hats-cosmetics","status":4,"summary":"Wearable and fashionable Cosmetics!","sync_at":{"$date":"2025-01-07T15:03:12Z"},"thumbsUpCount":0,"translated_summary":null}] 2 | -------------------------------------------------------------------------------- /data/curseforge_translated.json: -------------------------------------------------------------------------------- 1 | [{"_id":238222,"original":"View Items and Recipes","translated":"查看物品和配方","translated_at":{"$date":"2025-02-02T10:45:23.641Z"}}] -------------------------------------------------------------------------------- /data/file_cdn_files.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "_id": "68469cfbcb1b7fcdb0d11c8b984a657adfac5684", 3 | "mtime": 1735712331, 4 | "path": "68469cfbcb1b7fcdb0d11c8b984a657adfac5684", 5 | "size": 1289453, 6 | "url": "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/sodium-fabric-0.6.5%2Bmc1.21.1.jar" 7 | }, 8 | { 9 | "_id": "aa1a508fa088116e4cf8a96c0b56dbadbe99e079", 10 | "url": "https://edge.forgecdn.net/files/5217/345/Vanilla-Expanded-1.20.1-forge.jar", 11 | "path": "aa1a508fa088116e4cf8a96c0b56dbadbe99e079", 12 | "size": 189647, 13 | "mtime": 1725767710 14 | }, 15 | { 16 | "_id": "627c93adb68e04ffb390ad0e5dbf62d342f27a28", 17 | "url": "https://cdn.modrinth.com/data/Ua7DFN59/versions/xET3UZBe/YungsApi-1.19.2-Forge-3.8.2.jar", 18 | "path": "627c93adb68e04ffb390ad0e5dbf62d342f27a28", 19 | "size": 208757, 20 | "mtime": 1735712173 21 | }, 22 | { 23 | "_id": "d3bcef6c363422b38cbd0298af63a27b5e75829d", 24 | "mtime": 1735712172, 25 | "path": "d3bcef6c363422b38cbd0298af63a27b5e75829d", 26 | "size": 1233138, 27 | "url": "https://cdn.modrinth.com/data/Ua7DFN59/versions/k1OTLc33/YungsApi-1.20-Fabric-4.0.4.jar" 28 | }] -------------------------------------------------------------------------------- /data/modrinth_projects.json: -------------------------------------------------------------------------------- 1 | [{"_id":"Wnxd13zP","additional_categories":[],"approved":"2023-06-09T23:48:35.117961Z","body":"Clumps groups XP orbs together into a single entity to reduce lag when there are many in a small area. On top of this, it also makes the player immediately collect the orbs once they touch the player, so you are not stuck with a bunch of orbs in your face.\n\n### Do I have to install it client or server side?\n* For **1.17 or newer versions** it is only required on **server side**.\n* For **older Minecraft versions** you have to install it on **both sides**.\n\nIf you would like to support me in my modding endeavors, you can become a patron via **[![Patreon logo](https://i.imgur.com/CAJuExT.png) Patreon](https://www.patreon.com/jaredlll08)**.\n\n[![Nodecraft sponsor banner](https://assets.blamejared.com/nodecraft/jared.jpg)](https://nodecraft.com/r/jared)\n\n**This project is sponsored by Nodecraft. Use code [JARED](https://nodecraft.com/r/jared) for 30% off your first month of service!**","body_url":null,"categories":["storage","utility"],"client_side":"required","color":12368900,"description":"Clumps XP orbs together to reduce lag","discord_url":null,"donation_urls":[{"id":"patreon","platform":"Patreon","url":"https://www.patreon.com/jaredlll08"}],"downloads":5509502,"followers":2091,"found":true,"gallery":[],"game_versions":["1.10.2","1.11.2","1.12","1.12.1","1.12.2","1.14.3","1.14.4","1.15.2","1.16.1","1.16.2","1.16.3","1.16.4","1.16.5","1.17","1.17.1-pre1","1.17.1-rc2","1.17.1","21w37a","1.18","1.18.1","1.18.2","1.19","1.19.1","1.19.2","1.19.3","1.19.4","1.20","1.20.1","1.20.2","1.20.3","1.20.4","1.20.5","1.20.6","1.21","1.21.1","1.21.2","1.21.3","1.21.4"],"icon_url":"https://cdn.modrinth.com/data/Wnxd13zP/6a965bb7974c3e759a53a1c89c35de4acd4cf86a_96.webp","issues_url":"https://github.com/jaredlll08/Clumps/issues","license":{"id":"MIT","name":"MIT License","url":null},"loaders":["fabric","forge","neoforge"],"moderator_message":null,"monetization_status":"monetized","project_type":"mod","published":{"$date":"2023-06-04T08:54:48.176Z"},"queued":{"$date":"2023-06-04T08:54:48.176Z"},"requested_status":"approved","server_side":"required","slug":"clumps","source_url":"https://github.com/jaredlll08/Clumps","status":"approved","sync_at":"2025-01-07T15:28:18Z","team":"twbRaNO5","thread_id":"Wnxd13zP","title":"Clumps","translated_description":"Clumps 提高了玩家的物品数量,从而减少了延迟。","updated":"2024-12-22T23:43:39.322218Z","versions":["9hlctcDE","2PT9pbRj","HWb4PbLd","YlKKvqsg","k8jlVbEc","gGoDFlua","gLaYxa4s","6fWV0xJt","WUfSMmN9","nZvGITpT","YBmchP0h","xVBjqLw6","8jfhokYb","fTWVa6NX","Km2A7nLe","2MDA8v5I","A8fHbFS6","D4jsjhXA","ihiDVwNa","F8pfwrMz","W9FnDe1l","aqIk2Edw","xGvjcE7h","HT5eROlq","KBjSLpWi","k0Jwkd3m","BLPxOCPb","14gDpVDM","At7EuPwE","ZG68IYMG","gKfIS5UE","CK3gP3au","ABZ5Czfa","gjSDwzYc","60tArTRZ","Ge8WfKwm","a4YkXTss","qMEjJ46Z","iKpD3bu5","v6nZL7ij","kX922wv5","QfW5PBK0","QqCGODhN","DeVfca9r","bXpU2lu9","Y3KgUihY","c5XwSPGO","LTLe60ZJ","dMXKxndc","qgAkNod5","iCqS0XV6","rcImocgP","ghtGew4o","JKr8b0On","THJnSpxM","WEPinRvr","cB8mFWgx","nZ5nhrKv","C52dmdfZ","4oD9zKPV","qE3ikVnU","3AH29I4c","pug6aepS","aogHNv3y","2ogOQExk","QdyDo2La","3H5r9v4h","EAjgpiKA","UutzgwbZ","QkUVbfJV","qy5gqUCM","BxGXjwSg","gRpWIMha","l4IskRR5","xJwCqvzt","oasiVUpa","ssnANiog","JJ8S77j7","dcOL77zc","lI1Rscqr","nLgUCki4","pePFYnZ8","yPvrslL3","eKxVcvwX","ptOjPKUD","nuz7LOld","JGu6BDFO","r69CWcGO","sh7X4I2y","2WUro9Vp","Hrt4D2se","hnxzDUav","UmFe4S0c","SYSnPECv","MkMRGkr6","1FT90X9E","Ke4qgpLt","uGVtPl4l","zkCCKRNT","oH2kHLZT","oTaQQP72","nvpGk3Xn","HkwE1Hla","N40QLcL8","jpwxpMB1","7mmSmyfc","wb2m4G4N","BcqP4XTU","FRktsfso","4h8s4N4m","UId1Oi4e","KYNXYqwG","MKiINIqy","N1HpDUJK","klW0myvn","hwWceR4m","t18CfscF","pu9hcKsp","3GURrv52","BlDe1jqg","Nbrq3pvu","IQGoFJYP","Y5BmdUtM","tiS16mGn","aTvlJ7V9","yvGMz75G","qPvk2bmy","na30ifJS","pf9z7BdE","EZUmgjMH","fTlqeNs3","LwT3i65m","5q2RWTZ9","zRROXgDL","GnrWAVQK","4AR5fMpn","30DEnQJE","fN6SUor0","ePSovy2R","yQVnODwU","l3neajc5","Z9fVV1cT","62dNqSyR","2oGTbJ0i","h7A9nDyj","Xe0n6L7a","JLW0F6Gv","fI2Xyf3B","7uEaptM0","KdL0z8sJ","6BTKfXuu","rr2I0f2d","TCo8qr8m","mMwP6MUu","FrcMJxfb","ATa0nUu5","2CnNdiUT","QMBYfAuO","tjDEdddl","eqzpUTwV","ShUp2kQ2","FszY2hte","jdeTwq6v","MWDyKE94","nAHGB5ls","hefSwtn6","LAhdYjOV","RE9nKxFT","5DAuFkN0","U9UF2NCO","U9tSAZs1","BBnb9L4J","fgAAuioX","s3z1jvdQ","twamfmIi","3ene3W1l","aeoQuGBI","jo7lDoK4","j6eeqvpj","ubLGNAmB","Lx1PnbXS","PLwDM096","vDtmuZ5u","pnjn9POx","E20MlpTh","EIZSZGPU","1ZHtT6Xo","Vy1NNwxO","2zQSp93Z"],"wiki_url":null},{"_id":"Ua7DFN59","additional_categories":[],"approved":"2022-11-28T18:42:13.412406Z","body":"

\"\"

\n\"Join \"Follow \"Subscribe \"Support

\n

This is a library mod for YUNG's mods.

\n

For all my mod devs out there - This provides a lot of useful stuff, especially if you're a worldgen modder!

\n

The API includes the following:

\n
    \n
  • AutoRegistration system (1.18+ only). Register any field with only a simple annotation, regardless of mod loader!
  • \n
  • Custom reimplementation of Jigsaw Manager with improved performance and custom pool element types with various new properties. Check out the Better Dungeons code to see it in action.
  • \n
  • New criteria trigger for safely locating any structure. If the given structure doesn't exist, the trigger simply fails rather than instantly passing (unlike vanilla).
  • \n
  • Interfaces for JSON serialization & deserialization with built-in type adapters.
  • \n
  • Simple, lightweight math utilities for vectors and column positions
  • \n
  • BlockStateRandomizer and ItemRandomizer, data abstractions that make adding block and item randomization to your structures incredibly simple. I use these for all of my mods!
  • \n
\n

If you're curious, you can check the code for my mods (especially the newer ones) to see how things work. Feel free to ask me any questions on Discord!

\n


\n\"Use

","body_url":null,"categories":["library","worldgen"],"client_side":"required","color":5970187,"description":"Library mod for YUNG's mods.","discord_url":"https://discord.gg/rns3beq","donation_urls":[{"id":"patreon","platform":"Patreon","url":"https://www.patreon.com/yungnickyoung"}],"downloads":5203545,"followers":1418,"found":true,"gallery":[],"game_versions":["1.18.2","1.19.2","1.19.3","1.19.4","1.20","1.20.1","1.20.4","1.21","1.21.1"],"icon_url":"https://cdn.modrinth.com/data/Ua7DFN59/0fab1c351bf00926a8e1c91dc64b7c88832c3e1f_96.webp","issues_url":"https://github.com/YUNG-GANG/YUNGs-API/issues","license":{"id":"LGPL-3.0-only","name":"GNU Lesser General Public License v3.0 only","url":null},"loaders":["fabric","forge","neoforge"],"moderator_message":null,"monetization_status":"monetized","project_type":"mod","published":{"$date":"2022-11-28T17:17:12.676Z"},"queued":{"$date":"2022-11-28T17:17:12.676Z"},"requested_status":null,"server_side":"required","slug":"yungs-api","source_url":"https://github.com/YUNG-GANG/YUNGs-API","status":"approved","sync_at":"2025-01-07T15:28:20Z","team":"3TidTIHz","thread_id":"Ua7DFN59","title":"YUNG's API","translated_description":"YUNG的API库模组","updated":"2024-11-21T04:28:42.692023Z","versions":["xET3UZBe","UNVzqGkX","82XBGKbQ","xvoWCwex","LYoQlbQt","YwHWUw19","LEuKu3qt","Em3G31xp","dpSzBMP6","IOIGqCVr","i0Z1vSK9","YZE1pnbT","GNNfW5IV","IxuGYnWF","yIFytswN","h32n7OPC","4Ek11kQV","NmrTF2A5","TT8tnzlH","HIRzLg0r","pxmQWPn7","L5GqhLVE","QnR5jGmc","hyQxutx9","rbgh8n1F","5Zb55w2q","k1OTLc33","sE5QMX20","RXxBbRs7","wddoDji1","dpTBMhjf","jLW564iU","a7qxhSOZ","aMs83SRk","zPT7QfIk","Nx7XHO30","mBbkZrZ1","fFD2YR4D","PJOYAmAs","lscV1N5k","LkDReYww","tumhJgug","MIGLewpu","PpGXywDf","ex8YYvxI","LMXPKbZf","DeaIlZ9A","97xRZcgc","MoMQNZ94"],"wiki_url":null}] -------------------------------------------------------------------------------- /data/modrinth_translated.json: -------------------------------------------------------------------------------- 1 | [{"_id":"AANobbMI","original":"The fastest and most compatible rendering optimization mod for Minecraft. Now available for both NeoForge and Fabric!","translated":"Minecraft 最快速且兼容性最好的渲染优化模组。现在已支持 NeoForge 和 Fabric 两大框架!","translated_at":{"$date":"2025-02-02T10:40:56.155Z"}}] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # x-logging: &default-logging 2 | # driver: "local" 3 | # options: 4 | # max-size: "20m" 5 | # max-file: "3" 6 | 7 | x-logging: &loki-logging 8 | driver: loki 9 | options: 10 | loki-url: "http://localhost:3100/loki/api/v1/push" 11 | max-size: "20m" 12 | max-file: "3" 13 | keep-file: "true" 14 | 15 | services: 16 | mongodb: 17 | # 容器的名称为 mongo 18 | container_name: mongo 19 | # 使用的镜像名称 20 | image: mongo:latest 21 | # 当 docker 服务启动后,自动启动 mongodb 容器 22 | restart: always 23 | # 对外映射的端口 24 | ports: 25 | - 27017:27017 26 | environment: 27 | MONGO_INITDB_ROOT_USERNAME: root 28 | MONGO_INITDB_ROOT_PASSWORD: example 29 | # 宿主机硬盘目录映射容器内的存放数据的目录 30 | volumes: 31 | - /data/db:/data/db 32 | - ./config/mongod.conf:/etc/mongod.conf 33 | 34 | command: 35 | - "--bind_ip_all" 36 | # logging: *default-logging 37 | logging: *loki-logging 38 | deploy: 39 | resources: 40 | limits: 41 | memory: 400M 42 | reservations: 43 | memory: 200M 44 | 45 | redis: 46 | container_name: redis 47 | image: redis:alpine 48 | ports: 49 | - 6379:6379 50 | restart: always 51 | # logging: *default-logging 52 | logging: *loki-logging 53 | volumes: 54 | - ./config/redis.conf:/etc/redis/redis.conf 55 | command: redis-server /etc/redis/redis.conf 56 | deploy: 57 | resources: 58 | limits: 59 | memory: 300M 60 | reservations: 61 | memory: 200M 62 | 63 | # mcim_api: 64 | # container_name: mcim_api 65 | # image: mcim_api 66 | # build: 67 | # context: . 68 | # dockerfile: ./docker/fastapi 69 | 70 | # ports: 71 | # - 8000:8000 72 | # restart: always 73 | # depends_on: 74 | # - mongodb 75 | # - redis 76 | # volumes: 77 | # - ./config:/config 78 | 79 | mcim_api_gunicorn: 80 | container_name: mcim_api_gunicorn 81 | image: mcim_api_gunicorn 82 | build: 83 | context: . 84 | dockerfile: ./docker/fastapi_gunicorn 85 | environment: 86 | PROMETHEUS_MULTIPROC_DIR: /tmp/prometheus 87 | TZ: Asia/Shanghai 88 | ports: 89 | - 8000:8000 90 | restart: always 91 | depends_on: 92 | - mongodb 93 | - redis 94 | volumes: 95 | - ./config:/app/config 96 | command: > 97 | sh -c "rm -rf /tmp/prometheus && 98 | mkdir -p /tmp/prometheus && 99 | gunicorn -k uvicorn.workers.UvicornWorker -c gunicorn_config.py app:APP" 100 | # logging: *default-logging 101 | logging: *loki-logging 102 | deploy: 103 | resources: 104 | limits: 105 | memory: 800M 106 | reservations: 107 | memory: 200M -------------------------------------------------------------------------------- /docker/fastapi: -------------------------------------------------------------------------------- 1 | # For more information, please refer to https://aka.ms/vscode-docker-python 2 | FROM python:3-slim 3 | 4 | EXPOSE 8000 5 | 6 | # Install pip requirements 7 | COPY requirements.txt . 8 | RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt 9 | 10 | COPY start.py . 11 | COPY ./app ./app 12 | 13 | ENTRYPOINT ["uvicorn", "app:APP", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers","--forwarded-allow-ips", "*"] -------------------------------------------------------------------------------- /docker/fastapi_gunicorn: -------------------------------------------------------------------------------- 1 | # 第一阶段:构建阶段 2 | FROM python:3.11-slim-buster AS builder 3 | 4 | WORKDIR /app 5 | 6 | COPY requirements.txt ./ 7 | RUN --mount=type=cache,target=/root/.cache/pip \ 8 | pip install --user --no-cache-dir -r requirements.txt \ 9 | && pip install --user --no-cache-dir gunicorn==22.0.0 uvicorn-worker==0.2.0 10 | 11 | # 复制应用代码 12 | COPY ./app ./app 13 | COPY scripts/gunicorn_config.py ./ 14 | 15 | # 第二阶段:运行阶段 16 | FROM python:3.11-slim-buster 17 | 18 | ENV PYTHONUNBUFFERED=1 \ 19 | PYTHONDONTWRITEBYTECODE=1 \ 20 | PATH=/root/.local/bin:$PATH 21 | 22 | WORKDIR /app 23 | 24 | # 从构建阶段复制已安装的依赖 25 | COPY --from=builder /root/.local /root/.local 26 | 27 | # 复制应用代码 28 | COPY ./app ./app 29 | COPY scripts/gunicorn_config.py ./ -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | mongodb-tool-install: 2 | sudo apt-get install gnupg curl 3 | curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | \ 4 | sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg \ 5 | --dearmor 6 | echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-8.0.list 7 | sudo apt-get update 8 | sudo apt install mongodb-database-tools 9 | 10 | redis-tool-install: 11 | sudo apt-get install lsb-release curl gpg 12 | curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg 13 | sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg 14 | echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list 15 | sudo apt-get update 16 | sudo apt install redis-tools # for redis-cli 17 | 18 | import-data: 19 | mongoimport --db mcim_backend --collection modrinth_projects --file ./data/modrinth_projects.json --jsonArray 20 | mongoimport --db mcim_backend --collection modrinth_versions --file ./data/modrinth_versions.json --jsonArray 21 | mongoimport --db mcim_backend --collection modrinth_files --file ./data/modrinth_files.json --jsonArray 22 | mongoimport --db mcim_backend --collection modrinth_loaders --file ./data/modrinth_loaders.json --jsonArray 23 | mongoimport --db mcim_backend --collection modrinth_game_versions --file ./data/modrinth_game_versions.json --jsonArray 24 | mongoimport --db mcim_backend --collection modrinth_categories --file ./data/modrinth_categories.json --jsonArray 25 | mongoimport --db mcim_backend --collection curseforge_mods --file ./data/curseforge_mods.json --jsonArray 26 | mongoimport --db mcim_backend --collection curseforge_files --file ./data/curseforge_files.json --jsonArray 27 | mongoimport --db mcim_backend --collection curseforge_fingerprints --file ./data/curseforge_fingerprints.json --jsonArray 28 | mongoimport --db mcim_backend --collection curseforge_categories --file ./data/curseforge_categories.json --jsonArray 29 | mongoimport --db mcim_backend --collection file_cdn_files --file ./data/file_cdn_files.json --jsonArray 30 | mongoimport --db mcim_backend --collection modrinth_translated --file ./data/modrinth_translated.json --jsonArray 31 | mongoimport --db mcim_backend --collection curseforge_translated --file ./data/curseforge_translated.json --jsonArray 32 | 33 | ci-install: 34 | pip install -r requirements.txt 35 | pip install pytest 36 | 37 | ci-test: 38 | pytest ./tests -vv -rpP 39 | 40 | ci-config: 41 | echo $MCIM_CONFIG > config/mcim.json 42 | echo $REDIS_CONFIG > config/redis.json 43 | echo $MONGODB_CONFIG > config/mongodb.json -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | log_cli = True -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | odmantic==1.0.2 2 | fastapi[all]==0.115.2 3 | httpx>=0.27.2 4 | loguru==0.7.2 5 | uvicorn==0.27.0 6 | redis==5.0.1 7 | tenacity==8.3.0 8 | prometheus-fastapi-instrumentator==7.0.0 -------------------------------------------------------------------------------- /scripts/gunicorn_config.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | bind = "0.0.0.0:8000" 4 | workers = multiprocessing.cpu_count() * 2 - 1 5 | 6 | from prometheus_client import multiprocess 7 | 8 | def child_exit(server, worker): 9 | multiprocess.mark_process_dead(worker.pid) 10 | 11 | import os, shutil 12 | os.environ["PROMETHEUS_MULTIPROC_DIR"] = "/tmp/prometheus_multiproc_dir" 13 | if os.path.exists("/tmp/prometheus_multiproc_dir"): 14 | shutil.rmtree("/tmp/prometheus_multiproc_dir") 15 | os.makedirs("/tmp/prometheus_multiproc_dir", exist_ok=True) 16 | 17 | access_log_format = '%({X-Forwarded-For}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' -------------------------------------------------------------------------------- /start.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from app.config import MCIMConfig 4 | from app import APP 5 | 6 | mcim_config = MCIMConfig.load() 7 | 8 | if __name__ == "__main__": 9 | config = uvicorn.Config(APP, host=mcim_config.host, port=mcim_config.port, log_config=None) 10 | server = uvicorn.Server(config) 11 | server.run() 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcmod-info-mirror/mcim-api/c0b424f72d39bd318565de95b7b505a0073da60c/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | 2 | import pytest 3 | from fastapi.testclient import TestClient 4 | 5 | from app import APP 6 | 7 | @pytest.fixture(scope="session", autouse=True) 8 | def client(): 9 | # return TestClient(APP) 10 | # You should use TestClient as a context manager, to ensure that the lifespan is called. 11 | # https://www.starlette.io/lifespan/#running-lifespan-in-tests 12 | with TestClient(app=APP) as client: 13 | yield client -------------------------------------------------------------------------------- /tests/test_curseforge.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | 4 | modIds = [946010, 594678] 5 | 6 | fileIds = [3913840, 5976953] 7 | 8 | fingerprints = [2070800629, 1904165976] 9 | 10 | error_fingerprints = [114514] 11 | 12 | test_fingerprints = fingerprints + error_fingerprints 13 | 14 | 15 | fileId = 3913840 16 | modId = 594678 17 | download_url = ( 18 | "https://edge.forgecdn.net/files/3913/840/hats-and-cosmetics-1.2.2-1.19.1.jar" 19 | ) 20 | 21 | classId = 6 22 | gameId = 432 23 | 24 | 25 | def test_curseforge_root(client: TestClient): 26 | response = client.get("/curseforge/") 27 | assert response.status_code == 200 28 | assert response.json() == {"message": "CurseForge"} 29 | 30 | 31 | def test_curseforge_mods_search(client: TestClient): 32 | response = client.get( 33 | "/curseforge/v1/mods/search", params={"searchFilter": "fabric-api"} 34 | ) 35 | assert response.status_code == 200 36 | 37 | 38 | def test_curseforge_mod(client: TestClient): 39 | for modId in modIds: 40 | response = client.get(f"/curseforge/v1/mods/{modId}") 41 | assert response.status_code == 200 42 | assert response.json()["data"]["id"] == modId 43 | 44 | 45 | def test_curseforge_mods(client: TestClient): 46 | response = client.post("/curseforge/v1/mods", json={"modIds": modIds}) 47 | assert response.status_code == 200 48 | assert len(response.json()["data"]) == len(modIds) 49 | 50 | 51 | def test_curseforge_mod_files(client: TestClient): 52 | for modId in modIds: 53 | response = client.get(f"/curseforge/v1/mods/{modId}/files") 54 | assert response.status_code == 200 55 | assert len(response.json()["data"]) > 0 56 | 57 | 58 | def test_curseforge_file(client: TestClient): 59 | response = client.get(f"/curseforge/v1/mods/{modId}/files/{fileId}") 60 | assert response.status_code == 200 61 | assert response.json()["data"]["id"] == fileId 62 | assert response.json()["data"]["modId"] == modId 63 | 64 | 65 | def test_curseforge_file_download_url(client: TestClient): 66 | response = client.get(f"/curseforge/v1/mods/{modId}/files/{fileId}/download-url") 67 | assert response.status_code == 200 68 | assert isinstance(response.json()["data"], str) 69 | 70 | 71 | def test_curseforge_files(client: TestClient): 72 | response = client.post("/curseforge/v1/mods/files", json={"fileIds": fileIds}) 73 | assert response.status_code == 200 74 | assert len(response.json()["data"]) == len(fileIds) 75 | 76 | 77 | def test_curseforge_fingerprints(client: TestClient): 78 | response = client.post( 79 | "/curseforge/v1/fingerprints", json={"fingerprints": test_fingerprints} 80 | ) 81 | assert response.status_code == 200 82 | assert sorted(response.json()["data"]["exactFingerprints"]) == sorted(fingerprints) 83 | assert sorted(response.json()["data"]["unmatchedFingerprints"]) == sorted( 84 | error_fingerprints 85 | ) 86 | assert len(response.json()["data"]["exactMatches"]) == len(fingerprints) 87 | 88 | 89 | def test_curseforge_fingerprints_432(client: TestClient): 90 | response = client.post( 91 | "/curseforge/v1/fingerprints/432", json={"fingerprints": test_fingerprints} 92 | ) 93 | assert response.status_code == 200 94 | assert sorted(response.json()["data"]["exactFingerprints"]) == sorted(fingerprints) 95 | assert sorted(response.json()["data"]["unmatchedFingerprints"]) == sorted( 96 | error_fingerprints 97 | ) 98 | assert len(response.json()["data"]["exactMatches"]) == len(fingerprints) 99 | 100 | 101 | def test_curseforge_categories(client: TestClient): 102 | # only gameId 432 103 | response = client.get("/curseforge/v1/categories", params={"gameId": gameId}) 104 | assert response.status_code == 200 105 | assert len(response.json()["data"]) > 0 106 | # with classId 6 107 | response = client.get( 108 | "/curseforge/v1/categories", params={"gameId": gameId, "classId": classId} 109 | ) 110 | assert response.status_code == 200 111 | assert len(response.json()["data"]) > 0 112 | assert all([category["classId"] == classId for category in response.json()["data"]]) 113 | # classOnly 114 | response = client.get( 115 | "/curseforge/v1/categories", params={"gameId": gameId, "classOnly": True} 116 | ) 117 | assert response.status_code == 200 118 | assert len(response.json()["data"]) > 0 119 | assert all([category["isClass"] == True for category in response.json()["data"]]) 120 | -------------------------------------------------------------------------------- /tests/test_file_cdn.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | import pytest 3 | 4 | from app.config import MCIMConfig 5 | 6 | mcim_config = MCIMConfig.load() 7 | 8 | cached_modrinth_sample = [ 9 | "/data/Ua7DFN59/versions/xET3UZBe/YungsApi-1.19.2-Forge-3.8.2.jar", # 627c93adb68e04ffb390ad0e5dbf62d342f27a28 10 | "/data/Ua7DFN59/versions/k1OTLc33/YungsApi-1.20-Fabric-4.0.4.jar", # d3bcef6c363422b38cbd0298af63a27b5e75829d 11 | ] 12 | 13 | uncached_modrinth_sample = [ 14 | "/data/MdwFAVRL/versions/DQC4nTXQ/Cobblemon-forge-1.1.1%2B1.19.2.jar", 15 | "/data/fM515JnW/versions/n7Rl5o6Y/AmbientSounds_FABRIC_v6.0.1_mc1.20.1.jar", 16 | ] 17 | 18 | cached_curseforge_sample = [ 19 | "/files/6000/080/sodium-fabric-0.6.5%2Bmc1.21.1.jar", # 68469cfbcb1b7fcdb0d11c8b984a657adfac5684 20 | "/files/5217/345/Vanilla-Expanded-1.20.1-forge.jar", # aa1a508fa088116e4cf8a96c0b56dbadbe99e079 21 | "/files/5503/516/comforts-forge-6.4.0%2B1.20.1.jar" # test + -> %2B 22 | ] 23 | 24 | uncached_curseforge_sample = [ 25 | "/files/4706/778/cinderextras53g.jar", 26 | "/files/5529/108/tacz-1.18.2-1.0.2-release.jar", 27 | ] 28 | 29 | 30 | def test_modrinth_file_cdn(client: TestClient): 31 | for url in cached_modrinth_sample: 32 | response = client.get(url, follow_redirects=False) 33 | assert 300 <= response.status_code <= 400 34 | assert response.headers.get("Location") is not None 35 | 36 | for url in uncached_modrinth_sample: 37 | response = client.get(url, follow_redirects=False) 38 | assert 300 <= response.status_code <= 400 39 | assert response.headers.get("Location") is not None 40 | 41 | 42 | def test_curseforge_file_cdn(client: TestClient): 43 | for url in cached_curseforge_sample: 44 | response = client.get(url, follow_redirects=False) 45 | assert 300 <= response.status_code <= 400 46 | assert response.headers.get("Location") is not None 47 | 48 | for url in uncached_curseforge_sample: 49 | response = client.get(url, follow_redirects=False) 50 | assert 300 <= response.status_code <= 400 51 | assert response.headers.get("Location") is not None 52 | 53 | @pytest.mark.skip("OPENMCIM OFFLINE, SKIP THIS TEST") 54 | def test_file_cdn_list(client: TestClient): 55 | response = client.get( 56 | "/file_cdn/list", params={"secret": mcim_config.file_cdn_secret} 57 | ) 58 | assert response.status_code == 200 59 | assert isinstance(response.json(), list) 60 | assert len(response.json()) > 0 61 | -------------------------------------------------------------------------------- /tests/test_modrinth.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | import json 3 | 4 | project_ids = ["Wnxd13zP", "Ua7DFN59"] 5 | version_ids = [ 6 | "dpSzBMP6", 7 | "IOIGqCVr", 8 | "xVBjqLw6", 9 | "8jfhokYb", 10 | "fTWVa6NX", 11 | "Km2A7nLe", 12 | ] 13 | sha512_sample = [ 14 | "be134c430492bb8933ff60cc59ff6143b25c4b79aa0d4a6e0332d9de7cfe1bacd16a43fe17167e9cc57d4747237f68cf584b99dd78233799239fb6acc0807d61", 15 | "1c97698babd08c869f76c53e86b4cfca3f369d0fdf0d8237d5d37d03d37cc4de9fc6a831f00c5ce8de6b145e774a31d0adc301e85fb24a4649e9af5c75156a0f", 16 | "4962062a240a10d1eb3507b28477270d7557a2d3d83ef459f9939a4be32fa8f8fcc92c3eab5125b183f7da11a73cd9f06fb049a8b6cbc276fe3401bbede766de", 17 | ] 18 | sha1_sample = [ 19 | "f0cea90219f681c3183e0d37d021cb8902d2d085", 20 | "627c93adb68e04ffb390ad0e5dbf62d342f27a28", 21 | "e8b77ed731002c41d0658d5386cfc25f0df12dc4", 22 | "d3bcef6c363422b38cbd0298af63a27b5e75829d", 23 | ] 24 | 25 | 26 | def test_modrinth_root(client: TestClient): 27 | response = client.get("/modrinth/") 28 | assert response.status_code == 200 29 | assert response.json() == {"message": "Modrinth"} 30 | 31 | 32 | def test_modrinth_search(client: TestClient): 33 | response = client.get("/modrinth/v2/search", params={"query": "sodium"}) 34 | assert response.status_code == 200 35 | 36 | def test_modrinth_project(client: TestClient): 37 | for project_id in project_ids: 38 | response = client.get(f"/modrinth/v2/project/{project_id}") 39 | assert response.status_code == 200 40 | assert response.json()["id"] == project_id 41 | 42 | 43 | def test_modrinth_projects(client: TestClient): 44 | response = client.get( 45 | "/modrinth/v2/projects", params={"ids": json.dumps(project_ids)}) 46 | assert response.status_code == 200 47 | assert len(response.json()) == len(project_ids) 48 | 49 | 50 | def test_modrinth_project_versions(client: TestClient): 51 | for project_id in project_ids: 52 | response = client.get(f"/modrinth/v2/project/{project_id}/version") 53 | assert response.status_code == 200 54 | assert len(response.json()) > 0 55 | 56 | 57 | def test_modrinth_version(client: TestClient): 58 | for version_id in version_ids: 59 | response = client.get(f"/modrinth/v2/version/{version_id}") 60 | assert response.status_code == 200 61 | assert response.json()["id"] == version_id 62 | 63 | 64 | def test_modrinth_versions(client: TestClient): 65 | response = client.get( 66 | "/modrinth/v2/versions", params={"ids": json.dumps(version_ids)} 67 | ) 68 | assert response.status_code == 200 69 | assert len(response.json()) == len(version_ids) 70 | 71 | 72 | def test_modrinth_version_file_sha1(client: TestClient): 73 | for sha1_hash in sha1_sample: 74 | response = client.get( 75 | f"/modrinth/v2/version_file/{sha1_hash}", params={"algorithm": "sha1"} 76 | ) 77 | assert response.status_code == 200 78 | 79 | 80 | def test_modrinth_version_file_sha512(client: TestClient): 81 | for sha512_hash in sha512_sample: 82 | response = client.get( 83 | f"/modrinth/v2/version_file/{sha512_hash}", params={"algorithm": "sha512"} 84 | ) 85 | assert response.status_code == 200 86 | 87 | 88 | def test_modrinth_version_file_sha1_update(client: TestClient): 89 | for sha1_hash in sha1_sample: 90 | response = client.post( 91 | f"/modrinth/v2/version_file/{sha1_hash}/update", 92 | params={"algorithm": "sha1"}, 93 | json={ 94 | "loaders": ["fabric"], 95 | "game_versions": ["1.20.1"], 96 | }, 97 | ) 98 | assert response.status_code == 200 99 | 100 | def test_modrinth_version_file_sha512_update(client: TestClient): 101 | for sha512_hash in sha512_sample: 102 | response = client.post( 103 | f"/modrinth/v2/version_file/{sha512_hash}/update", 104 | params={"algorithm": "sha512"}, 105 | json={ 106 | "loaders": ["fabric"], 107 | "game_versions": ["1.20.1"], 108 | }, 109 | ) 110 | assert response.status_code == 200 111 | 112 | def test_modrinth_version_files_sha1(client: TestClient): 113 | response = client.post( 114 | "/modrinth/v2/version_files", 115 | json={"algorithm": "sha1", "hashes": sha1_sample}, 116 | ) 117 | assert response.status_code == 200 118 | assert len(response.json().keys()) == len(sha1_sample) 119 | 120 | def test_modrinth_version_files_sha512(client: TestClient): 121 | response = client.post( 122 | "/modrinth/v2/version_files", 123 | json={"algorithm": "sha512", "hashes": sha512_sample}, 124 | ) 125 | assert response.status_code == 200 126 | assert len(response.json().keys()) == len(sha512_sample) 127 | 128 | def test_modrinth_version_files_sha1_update(client: TestClient): 129 | response = client.post( 130 | "/modrinth/v2/version_files/update", 131 | json={ 132 | "hashes": sha1_sample, 133 | "algorithm": "sha1", 134 | "loaders": ["fabric"], 135 | "game_versions": ["1.20.1"], 136 | }, 137 | ) 138 | assert response.status_code == 200 139 | assert len(response.json()) > 0 140 | 141 | def test_modrinth_version_files_sha512_update(client: TestClient): 142 | response = client.post( 143 | "/modrinth/v2/version_files/update", 144 | json={ 145 | "hashes": sha512_sample, 146 | "algorithm": "sha512", 147 | "loaders": ["fabric"], 148 | "game_versions": ["1.20.1"], 149 | }, 150 | ) 151 | assert response.status_code == 200 152 | assert len(response.json()) > 0 153 | 154 | def test_modrinth_tag_category(client: TestClient): 155 | response = client.get("/modrinth/v2/tag/category") 156 | assert response.status_code == 200 157 | res = response.json() 158 | assert len(response.json()) > 0 159 | 160 | def test_modrinth_tag_loader(client: TestClient): 161 | response = client.get("/modrinth/v2/tag/loader") 162 | assert response.status_code == 200 163 | assert len(response.json()) > 0 164 | 165 | def test_modrinth_tag_game_version(client: TestClient): 166 | response = client.get("/modrinth/v2/tag/game_version") 167 | assert response.status_code == 200 168 | assert len(response.json()) > 0 -------------------------------------------------------------------------------- /tests/test_root.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | def test_root(client: TestClient): 4 | response = client.get("/") 5 | assert response.status_code == 200 6 | assert response.json()["status"] == "success" 7 | 8 | def test_statistics(client: TestClient): 9 | response = client.get("/statistics") 10 | assert response.status_code == 200 11 | 12 | def test_docs(client: TestClient): 13 | response = client.get("/docs") 14 | assert response.status_code == 200 15 | response = client.get("/openapi.json") 16 | assert response.status_code == 200 -------------------------------------------------------------------------------- /tests/test_translate.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | 3 | project_id = "AANobbMI" 4 | 5 | modId = 238222 6 | 7 | def test_curseforge_translate(client: TestClient): 8 | response = client.get(f"/translate/curseforge?modId={modId}") 9 | assert response.status_code == 200 10 | assert response.json()["modId"] == modId 11 | 12 | def test_modrinth_translate(client: TestClient): 13 | response = client.get(f"/translate/modrinth?project_id={project_id}") 14 | assert response.status_code == 200 15 | assert response.json()["project_id"] == project_id --------------------------------------------------------------------------------