1+ #!/usr/bin/env python3
2+
3+ """
4+ Mod updater script
5+
6+ This script packs up the coop mod files, writes them to /opt/faf/data/content/legacy-featured-mod-files/.../, and updates the database.
7+
8+ Code is mostly self-explanatory - haha, fat chance! Read it from bottom to top and don't blink. Blink and you're dead, no wait where were we?
9+ To adapt this duct-tape based blob of shit for new mission voice overs, just change files array at the very bottom.
10+
11+ Environment variables required:
12+ PATCH_VERSION
13+ DATABASE_HOST
14+ DATABASE_NAME
15+ DATABASE_USERNAME
16+ DATABASE_PASSWORD
17+ """
18+ import glob
19+ import hashlib
20+ import json
21+ import os
22+ import re
23+ import shutil
24+ import subprocess
25+ import sys
26+ import tempfile
27+ import urllib .request
28+ import urllib .error
29+ import zipfile
30+
31+ import mysql .connector
32+
33+
34+ def get_db_connection ():
35+ """Establish and return a MySQL connection using environment variables."""
36+ host = os .getenv ("DATABASE_HOST" , "localhost" )
37+ db = os .getenv ("DATABASE_NAME" , "faf" )
38+ user = os .getenv ("DATABASE_USERNAME" , "root" )
39+ password = os .getenv ("DATABASE_PASSWORD" , "banana" )
40+
41+ return mysql .connector .connect (
42+ host = host ,
43+ user = user ,
44+ password = password ,
45+ database = db ,
46+ )
47+
48+
49+ def read_db (conn , mod ):
50+ """
51+ Read latest versions and md5's from db
52+ Returns dict {fileId: {version, name, md5}}
53+ """
54+ query = f"""
55+ SELECT uf.fileId, uf.version, uf.name, uf.md5
56+ FROM (
57+ SELECT fileId, MAX(version) AS version
58+ FROM updates_{ mod } _files
59+ GROUP BY fileId
60+ ) AS maxthings
61+ INNER JOIN updates_{ mod } _files AS uf
62+ ON maxthings.fileId = uf.fileId AND maxthings.version = uf.version;
63+ """
64+
65+ with conn .cursor () as cursor :
66+ cursor .execute (query )
67+
68+ oldfiles = {}
69+ for (fileId , version , name , md5 ) in cursor .fetchall ():
70+ oldfiles [int (fileId )] = {
71+ "version" : version ,
72+ "name" : name ,
73+ "md5" : md5 ,
74+ }
75+
76+ return oldfiles
77+
78+
79+ def update_db (conn , mod , fileId , version , name , md5 , dryrun ):
80+ """
81+ Delete and reinsert a file record in updates_{mod}_files
82+ """
83+ delete_query = f"DELETE FROM updates_{ mod } _files WHERE fileId=%s AND version=%s"
84+ insert_query = f"""
85+ INSERT INTO updates_{ mod } _files (fileId, version, name, md5, obselete)
86+ VALUES (%s, %s, %s, %s, 0)
87+ """
88+
89+ print (f"Updating DB for { name } (fileId={ fileId } , version={ version } )" )
90+
91+ if not dryrun :
92+ try :
93+ with conn .cursor () as cursor :
94+ cursor .execute (delete_query , (fileId , version ))
95+ cursor .execute (insert_query , (fileId , version , name , md5 ))
96+ conn .commit ()
97+ except mysql .connector .Error as err :
98+ print (f"MySQL error while updating { name } : { err } " )
99+ conn .rollback ()
100+ exit (1 )
101+ else :
102+ print (f"Dryrun: would run for { name } " )
103+
104+ def calc_md5 (fname ):
105+ hash_md5 = hashlib .md5 ()
106+ with open (fname , "rb" ) as f :
107+ for chunk in iter (lambda : f .read (4096 ), b"" ):
108+ hash_md5 .update (chunk )
109+ return hash_md5 .hexdigest ()
110+
111+ def zipdir (path , ziph ):
112+ # ziph is zipfile handle
113+
114+ if not os .path .exists (path ):
115+ print (f"Warning: { path } does not exist, skipping" )
116+ return
117+
118+ if os .path .isdir (path ):
119+ for root , dirs , files in os .walk (path ):
120+ for file in files :
121+ arcname = os .path .relpath (os .path .join (root , file ), start = path )
122+ ziph .write (os .path .join (root , file ), arcname = arcname )
123+
124+ else :
125+ ziph .write (path )
126+
127+
128+ def create_file (conn , mod , fileId , version , name , source , target_dir , old_md5 , dryrun ):
129+ """Pack or copy files, compare MD5, update DB if changed."""
130+ target_dir = os .path .join (target_dir , f"updates_{ mod } _files" )
131+ os .makedirs (target_dir , exist_ok = True )
132+
133+ name = name .format (version )
134+ target_name = os .path .join (target_dir , name )
135+
136+ print (f"Processing { name } (fileId { fileId } )" )
137+
138+ if isinstance (source , list ):
139+ print (f"Zipping { source } -> { target_name } " )
140+ fd , fname = tempfile .mkstemp ("_" + name , "patcher_" )
141+ os .close (fd )
142+ with zipfile .ZipFile (fname , "w" , zipfile .ZIP_DEFLATED ) as zf :
143+ for sm in source :
144+ zipdir (sm , zf )
145+ rename = True
146+ checksum = calc_md5 (fname )
147+ else :
148+ rename = False
149+ fname = source
150+ if source is None :
151+ checksum = calc_md5 (target_name ) if os .path .exists (target_name ) else None
152+ else :
153+ checksum = calc_md5 (fname )
154+
155+ if checksum is None :
156+ print (f"Skipping { name } (no source file and no existing file to checksum)" )
157+ return
158+
159+ print (f"Compared checksums: Old { old_md5 } New { checksum } " )
160+
161+ if checksum != old_md5 :
162+ if fname is not None :
163+ print (f"Copying { fname } -> { target_name } " )
164+ if not dryrun :
165+ shutil .copy (fname , target_name )
166+ elif rename :
167+ print (f"Dry run, not moving tempfile. Please delete { fname } ." )
168+ else :
169+ print ("No source file, not moving" )
170+
171+ if os .path .exists (target_name ):
172+ update_db (conn , mod , fileId , version , name , checksum , dryrun )
173+ if not dryrun :
174+ try :
175+ os .chmod (target_name , 0o664 )
176+ except PermissionError :
177+ print (f"Warning: Could not chmod { target_name } " )
178+ else :
179+ print (f"Target file { target_name } does not exist, not updating db" )
180+
181+ else :
182+ print ("New {} file is identical to current version - skipping update" .format (name ))
183+ if rename :
184+ if not dryrun :
185+ os .unlink (fname )
186+ else :
187+ print ('Dry run, not moving tempfile. Please delete {}.' .format (fname ))
188+
189+
190+ def do_files (conn , mod , version , files , target_dir , dryrun ):
191+ """Process all files for given mod/version."""
192+ current_files = read_db (conn , mod )
193+ for name , fileId , source in files :
194+ old_md5 = current_files .get (fileId , {}).get ("md5" )
195+ create_file (conn , mod , fileId , version , name , source , target_dir , old_md5 , dryrun )
196+
197+
198+ def prepare_repo ():
199+ """Clone or update the fa-coop repository and checkout the specified ref."""
200+ repo_url = os .getenv ("GIT_REPO_URL" , "https://github.com/FAForever/fa-coop.git" )
201+ git_ref = os .getenv ("GIT_REF" , "v" + os .getenv ("PATCH_VERSION" ))
202+ workdir = os .getenv ("GIT_WORKDIR" , "/tmp/fa-coop" )
203+
204+ if not git_ref :
205+ print ("Error: GIT_REF or PATCH_VERSION must be specified." )
206+ sys .exit (1 )
207+
208+ print (f"=== Preparing repository { repo_url } at ref { git_ref } in { workdir } ===" )
209+
210+ # Clone if not exists
211+ if not os .path .isdir (os .path .join (workdir , ".git" )):
212+ print (f"Cloning repository into { workdir } ..." )
213+ subprocess .check_call (["git" , "clone" , repo_url , workdir ])
214+ else :
215+ print (f"Repository already exists in { workdir } , fetching latest changes..." )
216+ subprocess .check_call (["git" , "-C" , workdir , "fetch" , "--all" , "--tags" ])
217+
218+ # Checkout the desired ref
219+ print (f"Checking out { git_ref } ..." )
220+ subprocess .check_call (["git" , "-C" , workdir , "fetch" , "--tags" ])
221+ subprocess .check_call (["git" , "-C" , workdir , "checkout" , git_ref ])
222+
223+ print (f"=== Repository ready at { workdir } ===" )
224+ return workdir
225+
226+
227+ def download_vo_assets (version , target_dir ):
228+ """
229+ Download VO .nx2 files from latest GitHub release of fa-coop,
230+ rename them for the given patch version, and copy to target directory.
231+ """
232+ os .makedirs (target_dir , exist_ok = True )
233+ print (f"Fetching VO assets for patch version { version } ..." )
234+
235+ # 1. Get latest release JSON from GitHub
236+ api_url = "https://api.github.com/repos/FAForever/fa-coop/releases/latest"
237+ with urllib .request .urlopen (api_url ) as response :
238+ release_info = json .load (response )
239+
240+ # 2. Filter assets ending with .nx2
241+ nx2_urls = [
242+ asset ["browser_download_url" ]
243+ for asset in release_info .get ("assets" , [])
244+ if asset ["browser_download_url" ].endswith (".nx2" )
245+ ]
246+
247+ if not nx2_urls :
248+ print ("No VO .nx2 assets found in the latest release." )
249+ return
250+
251+ temp_dir = os .path .join ("/tmp" , f"vo_download_{ version } " )
252+ os .makedirs (temp_dir , exist_ok = True )
253+
254+ # 3. Download each .nx2 file
255+ for url in nx2_urls :
256+ filename = os .path .basename (url )
257+ dest_path = os .path .join (temp_dir , filename )
258+ print (f"Downloading { url } -> { dest_path } " )
259+ urllib .request .urlretrieve (url , dest_path )
260+
261+ # 4. Rename files to include patch version (e.g., A01_VO.v49.nx2)
262+ for filepath in glob .glob (os .path .join (temp_dir , "*.nx2" )):
263+ base = os .path .basename (filepath )
264+ # Insert .vXX. before the extension
265+ new_name = re .sub (r"\.nx2$" , f".v{ version } .nx2" , base )
266+ new_path = os .path .join (temp_dir , new_name )
267+ os .rename (filepath , new_path )
268+
269+ # 5. Copy to target directory
270+ for filepath in glob .glob (os .path .join (temp_dir , "*.nx2" )):
271+ target_path = os .path .join (target_dir , os .path .basename (filepath ))
272+ print (f"Copying { filepath } -> { target_path } " )
273+ shutil .copy (filepath , target_path )
274+ # Set permissions like in your script
275+ os .chmod (target_path , 0o664 )
276+ try :
277+ shutil .chown (target_path , group = "www-data" )
278+ except Exception :
279+ print (f"Warning: could not chown { target_path } , continue..." )
280+
281+ print ("VO assets processed successfully." )
282+
283+
284+ def main ():
285+ mod = "coop"
286+ dryrun = os .getenv ("DRY_RUN" , "false" ).lower () in ("1" , "true" , "yes" )
287+ version = os .getenv ("PATCH_VERSION" )
288+
289+ if version is None :
290+ print ('Please pass patch version in environment variable PATCH_VERSION' )
291+ sys .exit (1 )
292+
293+ print (f"=== Starting mod updater for version { version } , dryrun={ dryrun } ===" )
294+
295+ # /updater_{mod}_files will be appended by create_file
296+ target_dir = './legacy-featured-mod-files'
297+
298+ # Prepare git repo
299+ repo_dir = prepare_repo ()
300+
301+ # Download VO assets
302+ vo_dir = os .path .join (target_dir , f"updates_{ mod } _files" )
303+ download_vo_assets (version , vo_dir )
304+
305+ # target filename / fileId in updates_{mod}_files table / source files with version placeholder
306+ # if source files is single string, file is copied directly
307+ # if source files is a list, files are zipped
308+ files = [
309+ ('init_coop.v{}.lua' , 1 , os .path .join (repo_dir , 'init_coop.lua' )),
310+ ('lobby_coop_v{}.cop' , 2 , [
311+ os .path .join (repo_dir , 'lua' ),
312+ os .path .join (repo_dir , 'mods' ),
313+ os .path .join (repo_dir , 'units' ),
314+ os .path .join (repo_dir , 'mod_info.lua' ),
315+ os .path .join (repo_dir , 'readme.md' ),
316+ os .path .join (repo_dir , 'changelog.md' ),
317+ ]),
318+ ('A01_VO.v{}.nx2' , 3 , None ),
319+ ('A02_VO.v{}.nx2' , 4 , None ),
320+ ('A03_VO.v{}.nx2' , 5 , None ),
321+ ('A04_VO.v{}.nx2' , 6 , None ),
322+ ('A05_VO.v{}.nx2' , 7 , None ),
323+ ('A06_VO.v{}.nx2' , 8 , None ),
324+ ('C01_VO.v{}.nx2' , 9 , None ),
325+ ('C02_VO.v{}.nx2' , 10 , None ),
326+ ('C03_VO.v{}.nx2' , 11 , None ),
327+ ('C04_VO.v{}.nx2' , 12 , None ),
328+ ('C05_VO.v{}.nx2' , 13 , None ),
329+ ('C06_VO.v{}.nx2' , 14 , None ),
330+ ('E01_VO.v{}.nx2' , 15 , None ),
331+ ('E02_VO.v{}.nx2' , 16 , None ),
332+ ('E03_VO.v{}.nx2' , 17 , None ),
333+ ('E04_VO.v{}.nx2' , 18 , None ),
334+ ('E05_VO.v{}.nx2' , 19 , None ),
335+ ('E06_VO.v{}.nx2' , 20 , None ),
336+ ('Prothyon16_VO.v{}.nx2' , 21 , None ),
337+ ('TCR_VO.v{}.nx2' , 22 , None ),
338+ ('SCCA_Briefings.v{}.nx2' , 23 , None ),
339+ ('SCCA_FMV.nx2.v{}.nx2' , 24 , None ),
340+ ('FAF_Coop_Operation_Tight_Spot_VO.v{}.nx2' , 25 , None ),
341+ ]
342+
343+ conn = get_db_connection ()
344+ try :
345+ do_files (conn , mod , version , files , target_dir , dryrun )
346+ finally :
347+ conn .close ()
348+
349+
350+ if __name__ == "__main__" :
351+ main ()
0 commit comments