Initial commit: add Minecraft plugin updater

Add Dockerfile, main Python script, example config, requirements, and README for a Dockerized Minecraft plugin updater. The updater supports GitHub, Modrinth, and Spigot plugins, automatically checks for updates, downloads new versions, and manages plugin files and state.
This commit is contained in:
2025-12-28 16:47:11 +00:00
parent 1fd3bcdb87
commit 042368fa11
5 changed files with 307 additions and 0 deletions

14
Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
# Create volume mount points
# /data should contain config.yaml and will be where plugins are downloaded (in /data/plugins)
VOLUME /data
CMD ["python", "-u", "main.py"]

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# Minecraft Plugin Updater
A Dockerized Python script that automatically checks for and downloads updates for Minecraft plugins from GitHub, Modrinth, and Spigot.
## Features
- Supports GitHub, Modrinth, and Spigot (via Spiget).
- Automatically deletes old versions upon update.
- Configurable check interval.
- Maintains state to track installed versions.
## Usage
1. **Create a data directory:**
Create a folder on your host machine (e.g., `updater-data`).
2. **Create configuration:**
Copy `config.example.yaml` to your data directory, rename it to `config.yaml`, and edit it with your plugins.
```yaml
check_interval: 3600
plugins:
- name: "MyPlugin"
source: "github"
repo: "user/repo"
```
3. **Run with Docker:**
```bash
docker run -d \
--name plugin-updater \
-v /path/to/updater-data:/data \
minecraft-plugin-updater
```
Plugins will be downloaded to `/path/to/updater-data/plugins`.
## Configuration Options
- **GitHub:**
- `source`: "github"
- `repo`: "owner/repository"
- `asset_regex`: (Optional) Regex to match specific release asset names.
- **Modrinth:**
- `source`: "modrinth"
- `id`: Project ID or Slug
- `loader`: (Optional) e.g., "spigot", "paper"
- `game_version`: (Optional) e.g., "1.20.1"
- **Spigot:**
- `source`: "spigot"
- `id`: Resource ID (found in the URL, e.g., `https://www.spigotmc.org/resources/plugin-name.12345/` -> `12345`)

17
config.example.yaml Normal file
View File

@@ -0,0 +1,17 @@
check_interval: 3600 # Check every hour
plugins:
- name: "EssentialsX"
source: "github"
repo: "EssentialsX/Essentials"
# asset_regex: "EssentialsX-.*.jar" # Optional regex to match specific asset
- name: "FastAsyncWorldEdit"
source: "modrinth"
id: "fawe" # Project ID or Slug
loader: "spigot" # Optional: fabric, forge, spigot
game_version: "1.20.4" # Optional
- name: "Vault"
source: "spigot"
id: "34315" # Resource ID from URL

220
main.py Normal file
View File

@@ -0,0 +1,220 @@
import os
import time
import json
import yaml
import requests
import re
import logging
import hashlib
# Configuration
CONFIG_FILE = os.getenv("CONFIG_FILE", "/data/config.yaml")
STATE_FILE = os.getenv("STATE_FILE", "/data/state.json")
PLUGINS_DIR = os.getenv("PLUGINS_DIR", "/data/plugins")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger()
def load_config():
if not os.path.exists(CONFIG_FILE):
logger.error(f"Config file not found at {CONFIG_FILE}")
return None
with open(CONFIG_FILE, 'r') as f:
return yaml.safe_load(f)
def load_state():
if not os.path.exists(STATE_FILE):
return {}
with open(STATE_FILE, 'r') as f:
return json.load(f)
def save_state(state):
with open(STATE_FILE, 'w') as f:
json.dump(state, f, indent=2)
def download_file(url, dest_path):
response = requests.get(url, stream=True)
response.raise_for_status()
with open(dest_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return dest_path
def get_github_latest(repo, asset_regex=None):
try:
url = f"https://api.github.com/repos/{repo}/releases/latest"
resp = requests.get(url)
resp.raise_for_status()
data = resp.json()
assets = data.get('assets', [])
target_asset = None
if not assets:
return None, None
if asset_regex:
pattern = re.compile(asset_regex)
for asset in assets:
if pattern.match(asset['name']):
target_asset = asset
break
else:
# Default to first jar if not specified, or just the first asset
for asset in assets:
if asset['name'].endswith('.jar'):
target_asset = asset
break
if not target_asset:
target_asset = assets[0]
if target_asset:
return target_asset['name'], target_asset['browser_download_url']
except Exception as e:
logger.error(f"Error checking GitHub {repo}: {e}")
return None, None
def get_modrinth_latest(project_id, loader=None, game_version=None):
try:
url = f"https://api.modrinth.com/v2/project/{project_id}/version"
params = {}
if loader:
params['loaders'] = f'["{loader}"]'
if game_version:
params['game_versions'] = f'["{game_version}"]'
resp = requests.get(url, params=params)
resp.raise_for_status()
versions = resp.json()
if not versions:
return None, None
latest = versions[0]
files = latest.get('files', [])
if not files:
return None, None
primary_file = next((f for f in files if f.get('primary')), files[0])
return primary_file['filename'], primary_file['url']
except Exception as e:
logger.error(f"Error checking Modrinth {project_id}: {e}")
return None, None
def get_spiget_latest(resource_id):
try:
# Get latest version info
url = f"https://api.spiget.org/v2/resources/{resource_id}/versions/latest"
resp = requests.get(url)
resp.raise_for_status()
version_data = resp.json()
# Spiget download URL (proxies to SpigotMC)
# Note: This might not work for external downloads or paid resources
download_url = f"https://api.spiget.org/v2/resources/{resource_id}/download"
# We need a filename. Spiget doesn't always give it directly in the version endpoint easily
# without downloading. We'll construct one or try to fetch headers.
# Let's try a HEAD request to download_url to get filename
head_resp = requests.head(download_url, allow_redirects=True)
filename = f"{resource_id}-{version_data['name']}.jar" # Fallback
if 'content-disposition' in head_resp.headers:
cd = head_resp.headers['content-disposition']
# extract filename="name.jar"
fname = re.findall('filename="(.+)"', cd)
if fname:
filename = fname[0]
return filename, download_url
except Exception as e:
logger.error(f"Error checking Spiget {resource_id}: {e}")
return None, None
def process_plugin(plugin, state):
name = plugin.get('name')
source = plugin.get('source')
logger.info(f"Checking {name}...")
filename = None
download_url = None
if source == 'github':
filename, download_url = get_github_latest(plugin['repo'], plugin.get('asset_regex'))
elif source == 'modrinth':
filename, download_url = get_modrinth_latest(plugin['id'], plugin.get('loader'), plugin.get('game_version'))
elif source == 'spigot':
filename, download_url = get_spiget_latest(plugin['id'])
if not filename or not download_url:
logger.warning(f"Could not resolve update for {name}")
return
# Check against state
current_state = state.get(name, {})
last_filename = current_state.get('filename')
# Check if file exists on disk
if last_filename:
last_filepath = os.path.join(PLUGINS_DIR, last_filename)
if os.path.exists(last_filepath) and last_filename == filename:
# Just a simple filename check.
# Ideally we would check hashes, but filenames usually change with version.
logger.info(f"{name} is up to date.")
return
logger.info(f"Update found for {name}: {filename}. Downloading...")
try:
dest_path = os.path.join(PLUGINS_DIR, filename)
download_file(download_url, dest_path)
logger.info(f"Downloaded {filename}")
# Delete old file
if last_filename and last_filename != filename:
old_path = os.path.join(PLUGINS_DIR, last_filename)
if os.path.exists(old_path):
os.remove(old_path)
logger.info(f"Deleted old version: {last_filename}")
# Update state
state[name] = {
'filename': filename,
'source': source,
'updated_at': time.time()
}
save_state(state)
except Exception as e:
logger.error(f"Failed to update {name}: {e}")
def main():
if not os.path.exists(PLUGINS_DIR):
os.makedirs(PLUGINS_DIR)
logger.info("Starting Plugin Updater...")
while True:
config = load_config()
if not config:
logger.warning("No config found, sleeping...")
time.sleep(60)
continue
state = load_state()
for plugin in config.get('plugins', []):
try:
process_plugin(plugin, state)
except Exception as e:
logger.error(f"Error processing {plugin.get('name')}: {e}")
interval = config.get('check_interval', 3600)
logger.info(f"Sleeping for {interval} seconds...")
time.sleep(interval)
if __name__ == "__main__":
main()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests
pyyaml