|
| 1 | +from asyncio import run |
| 2 | +from contextlib import suppress |
| 3 | +from os import getcwd |
| 4 | +from pathlib import Path |
| 5 | +from re import compile |
| 6 | +from typing import List |
| 7 | + |
| 8 | +from aiofiles import open as async_open |
| 9 | +from cache import AsyncTTL |
| 10 | +from httpx import AsyncClient |
| 11 | +from zipstream import AioZipStream |
| 12 | + |
| 13 | +from steamcmdwrapper import SteamCMDWrapper, SteamCMDException, SteamCommand |
| 14 | + |
| 15 | +steamcmd_path = Path(getcwd()) / "steamcmd" |
| 16 | + |
| 17 | +steam_path = steamcmd_path / "steam" |
| 18 | +steam_path.mkdir(exist_ok=True, parents=True) |
| 19 | + |
| 20 | +steam = SteamCMDWrapper(steamcmd_path) |
| 21 | + |
| 22 | +with suppress(SteamCMDException): |
| 23 | + run(steam.install()) |
| 24 | + |
| 25 | +workshop_pattern = compile(rb'<a\s+href="https://steamcommunity\.com/sharedfiles/filedetails/\?id=(' |
| 26 | + rb'\d+)">\s*<div\s+class="workshopItemTitle">([^<]+)</div>\s*</a>') |
| 27 | +collection_title_pattern = compile(rb'<div\s+class=\"workshopItemTitle\">([^<]+)</div>') |
| 28 | + |
| 29 | + |
| 30 | +@AsyncTTL(time_to_live=60, maxsize=40 << 10) |
| 31 | +async def get_collection_items(workshop_id) -> tuple[str, list[tuple[int, str]]]: |
| 32 | + var = [] |
| 33 | + title = "" |
| 34 | + |
| 35 | + async with AsyncClient() as client: |
| 36 | + response = await client.get(f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}") |
| 37 | + |
| 38 | + response.raise_for_status() |
| 39 | + |
| 40 | + content = await response.aread() |
| 41 | + |
| 42 | + title = collection_title_pattern.search(content) |
| 43 | + cols = workshop_pattern.findall(content) |
| 44 | + |
| 45 | + title = title.group(1).decode() |
| 46 | + |
| 47 | + if not cols or not title: |
| 48 | + return title, var |
| 49 | + |
| 50 | + for coll in cols: |
| 51 | + var.append((int(coll[0]), coll[1].decode())) |
| 52 | + |
| 53 | + return title, var |
| 54 | + |
| 55 | + |
| 56 | +@AsyncTTL(time_to_live=60, maxsize=40 << 10) |
| 57 | +async def get_workshop_name(workshop_id): |
| 58 | + title = "" |
| 59 | + |
| 60 | + async with AsyncClient() as client: |
| 61 | + response = await client.get(f"https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}") |
| 62 | + |
| 63 | + response.raise_for_status() |
| 64 | + |
| 65 | + content = await response.aread() |
| 66 | + |
| 67 | + title = collection_title_pattern.search(content) |
| 68 | + if not title: |
| 69 | + return None |
| 70 | + |
| 71 | + title = title.group(1).decode() |
| 72 | + |
| 73 | + return title |
| 74 | + |
| 75 | + |
| 76 | +async def workshop_download(app_id, workshop_id, validate=True): |
| 77 | + workshop_name = await get_workshop_name(workshop_id) |
| 78 | + if not workshop_name: |
| 79 | + return None |
| 80 | + |
| 81 | + workshop_download_path = (steam_path / "steamapps" / "workshop" / "content" / str(app_id) / str(workshop_id)) |
| 82 | + workshop_zip = workshop_download_path.as_posix() + "%s .zip" % workshop_name |
| 83 | + |
| 84 | + if not workshop_download_path.exists(): |
| 85 | + try: |
| 86 | + await steam.workshop_update(app_id, workshop_id, steam_path, validate, n_tries=3) |
| 87 | + except Exception as e: |
| 88 | + return "an error occured while downloading the item: " + str(e) |
| 89 | + |
| 90 | + if not workshop_download_path.exists(): |
| 91 | + return None |
| 92 | + |
| 93 | + items = [] |
| 94 | + add_directory(items, workshop_download_path, "%s %s" % (workshop_id, workshop_name)) |
| 95 | + atomic = AioZipStream(items, chunksize=32768) |
| 96 | + |
| 97 | + async with async_open(workshop_zip, mode='wb') as z: |
| 98 | + async for chunk in atomic.stream(): |
| 99 | + await z.write(chunk) |
| 100 | + |
| 101 | + return workshop_zip |
| 102 | + |
| 103 | + |
| 104 | +def is_empty(item_path: Path): |
| 105 | + return not any(item_path.iterdir()) |
| 106 | + |
| 107 | + |
| 108 | +def add_directory(items, item_path, target_dir): |
| 109 | + if not item_path.exists(): |
| 110 | + return |
| 111 | + |
| 112 | + if item_path.is_file(): |
| 113 | + items.append({"file": item_path, "name": target_dir}) |
| 114 | + elif item_path.is_dir(): |
| 115 | + for item in item_path.iterdir(): |
| 116 | + item_name = item.name.replace('\\', '/') |
| 117 | + add_directory(items, item, f"{target_dir}/{item_name}") |
| 118 | + |
| 119 | + |
| 120 | +async def collection_download(app_id: int, workshop_id: int, validate: bool = True, batch_size: int = 20): |
| 121 | + game_path = steam_path / "steamapps" / "workshop" / "content" / str(app_id) |
| 122 | + title, workshop_matches = await get_collection_items(workshop_id) |
| 123 | + |
| 124 | + if not workshop_matches or not title: |
| 125 | + return None |
| 126 | + |
| 127 | + collection_path = game_path / f"{workshop_id} {title}.zip" |
| 128 | + |
| 129 | + if collection_path.exists() and collection_path.stat().st_size > 0: |
| 130 | + return collection_path |
| 131 | + |
| 132 | + items = list() |
| 133 | + |
| 134 | + while workshop_matches: |
| 135 | + batch = workshop_matches[:batch_size] |
| 136 | + workshop_matches = workshop_matches[batch_size:] |
| 137 | + |
| 138 | + sc = SteamCommand(steam_path) |
| 139 | + for item_id, work_title in batch: |
| 140 | + item_path = game_path / str(item_id) |
| 141 | + |
| 142 | + add_directory(items, item_path, f"{item_id} {work_title}") |
| 143 | + |
| 144 | + if item_path.exists() and not is_empty(item_path): |
| 145 | + continue |
| 146 | + |
| 147 | + sc.workshop_download_item(app_id, item_id, validate) |
| 148 | + |
| 149 | + if sc.commands: |
| 150 | + await steam.execute(sc, n_tries=3) |
| 151 | + |
| 152 | + atomic = AioZipStream(items, chunksize=32768) |
| 153 | + async with async_open(collection_path, mode='wb') as z: |
| 154 | + async for chunk in atomic.stream(): |
| 155 | + await z.write(chunk) |
| 156 | + |
| 157 | + return collection_path |
0 commit comments