fgsmdb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fgsmdb/__init__.py +3 -0
- fgsmdb/__main__.py +34 -0
- fgsmdb/commands/__init__.py +1 -0
- fgsmdb/commands/export.py +180 -0
- fgsmdb/config.py +37 -0
- fgsmdb/db.py +66 -0
- fgsmdb/steps/__init__.py +1 -0
- fgsmdb/steps/dirindex.py +147 -0
- fgsmdb/steps/export_models.py +104 -0
- fgsmdb/steps/export_stg.py +57 -0
- fgsmdb/steps/gndelev.py +107 -0
- fgsmdb/steps/rsync_distribute.py +84 -0
- fgsmdb/steps/sanitize.py +32 -0
- fgsmdb/steps/sync.py +150 -0
- fgsmdb-0.1.0.dist-info/METADATA +168 -0
- fgsmdb-0.1.0.dist-info/RECORD +19 -0
- fgsmdb-0.1.0.dist-info/WHEEL +5 -0
- fgsmdb-0.1.0.dist-info/entry_points.txt +2 -0
- fgsmdb-0.1.0.dist-info/top_level.txt +1 -0
fgsmdb/__init__.py
ADDED
fgsmdb/__main__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""CLI entry point for fgsmdb.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python3 -m fgsmdb export [options]
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .commands import export
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="fgsmdb",
|
|
16
|
+
description="FlightGear Scenemodels Database Tools",
|
|
17
|
+
)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
19
|
+
|
|
20
|
+
# Register the export subcommand
|
|
21
|
+
export.register(subparsers)
|
|
22
|
+
|
|
23
|
+
args = parser.parse_args()
|
|
24
|
+
|
|
25
|
+
if args.command is None:
|
|
26
|
+
parser.print_help()
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
# Dispatch to the command handler
|
|
30
|
+
args.func(args)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fgsmdb command modules
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Export command — orchestrates the full scenemodels export pipeline.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python3 -m fgsmdb export [--env-file .env] [--steps sanitize,gndelev,export,mkdiridx,upload,distribute]
|
|
5
|
+
python3 -m fgsmdb export --all
|
|
6
|
+
python3 -m fgsmdb export --steps export,mkdiridx
|
|
7
|
+
|
|
8
|
+
Available steps:
|
|
9
|
+
sanitize - Fix data integrity (tile numbers, elevation offsets)
|
|
10
|
+
gndelev - Compute ground elevations using fgelev
|
|
11
|
+
export - Export models and STG files from the database
|
|
12
|
+
mkdiridx - Create .dirindex files and Airports archive
|
|
13
|
+
upload - Sync export directory to local scenery tree
|
|
14
|
+
distribute - Rsync scenery to mirror servers
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import tarfile
|
|
20
|
+
|
|
21
|
+
from ..config import load_config
|
|
22
|
+
from ..db import Database
|
|
23
|
+
from ..steps import sanitize, gndelev, export_models, export_stg, dirindex, sync, rsync_distribute
|
|
24
|
+
|
|
25
|
+
ALL_STEPS = ["sanitize", "gndelev", "export", "mkdiridx", "upload"]
|
|
26
|
+
VALID_STEPS = ALL_STEPS + ["distribute"]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def register(subparsers):
|
|
32
|
+
"""Register the export subcommand with the argument parser."""
|
|
33
|
+
parser = subparsers.add_parser(
|
|
34
|
+
"export",
|
|
35
|
+
help="Export scenemodels database to TerraSync file tree",
|
|
36
|
+
description="Export the FlightGear scenemodels database into a "
|
|
37
|
+
"TerraSync-compatible file tree and optionally distribute to mirrors.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--env-file",
|
|
41
|
+
default=None,
|
|
42
|
+
help="Path to .env file (default: auto-detect)",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--steps",
|
|
46
|
+
default=None,
|
|
47
|
+
help="Comma-separated list of steps to run (default: all except distribute)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--all",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Run all steps (equivalent to: %s)" % ",".join(ALL_STEPS),
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--skip-check",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Skip the pending-updates threshold check",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--min-updates",
|
|
61
|
+
type=int,
|
|
62
|
+
default=None,
|
|
63
|
+
help="Override MIN_UPDATES threshold from .env",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--verbose", "-v",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="Enable debug logging",
|
|
69
|
+
)
|
|
70
|
+
parser.set_defaults(func=run)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run(args):
|
|
74
|
+
"""Execute the export pipeline."""
|
|
75
|
+
# Setup logging
|
|
76
|
+
log_level = logging.DEBUG if args.verbose else logging.INFO
|
|
77
|
+
logging.basicConfig(
|
|
78
|
+
level=log_level,
|
|
79
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
80
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Load configuration
|
|
84
|
+
config = load_config(args.env_file)
|
|
85
|
+
|
|
86
|
+
# Determine which steps to run
|
|
87
|
+
if args.steps:
|
|
88
|
+
steps = [s.strip() for s in args.steps.split(",")]
|
|
89
|
+
for s in steps:
|
|
90
|
+
if s not in VALID_STEPS:
|
|
91
|
+
logger.error("Unknown step: %s. Valid steps: %s", s, ", ".join(VALID_STEPS))
|
|
92
|
+
raise SystemExit(1)
|
|
93
|
+
else:
|
|
94
|
+
steps = ALL_STEPS
|
|
95
|
+
|
|
96
|
+
# Override min_updates if specified
|
|
97
|
+
min_updates = args.min_updates if args.min_updates is not None else config["min_updates"]
|
|
98
|
+
|
|
99
|
+
# Connect to the database
|
|
100
|
+
db = Database(config["pguri"])
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Always update statistics first
|
|
104
|
+
logger.info("Updating statistics...")
|
|
105
|
+
db.execute("SELECT fn_update_statistics()")
|
|
106
|
+
|
|
107
|
+
# Check if there are enough pending updates
|
|
108
|
+
if not args.skip_check:
|
|
109
|
+
rows = db.execute(
|
|
110
|
+
"SELECT count(*) AS cnt FROM fgs_objects WHERE ob_gndelev = -9999"
|
|
111
|
+
)
|
|
112
|
+
count = rows[0]["cnt"] if rows else 0
|
|
113
|
+
logger.info("Pending model updates: %d (minimum: %d)", count, min_updates)
|
|
114
|
+
if count < min_updates:
|
|
115
|
+
logger.info("Not enough pending updates. Exiting.")
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
# Run pipeline steps
|
|
119
|
+
if "sanitize" in steps:
|
|
120
|
+
sanitize.run(db)
|
|
121
|
+
|
|
122
|
+
if "gndelev" in steps:
|
|
123
|
+
gndelev.run(
|
|
124
|
+
db,
|
|
125
|
+
fgelev_path=config["fgelev_path"],
|
|
126
|
+
fg_root=config["fg_root"],
|
|
127
|
+
fg_scenery=config["fg_scenery"],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if "export" in steps:
|
|
131
|
+
export_models.run(db, target=config["fg_scenery_export"])
|
|
132
|
+
export_stg.run(db, target=config["fg_scenery_export"])
|
|
133
|
+
|
|
134
|
+
if "mkdiridx" in steps:
|
|
135
|
+
dirindex.create_root_dirindex(
|
|
136
|
+
fg_scenery=config["fg_scenery"],
|
|
137
|
+
fg_scenery_export=config["fg_scenery_export"],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if "upload" in steps:
|
|
141
|
+
sync.run(
|
|
142
|
+
fg_scenery_export=config["fg_scenery_export"],
|
|
143
|
+
fg_scenery=config["fg_scenery"],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Update statistics after export
|
|
147
|
+
if any(s in steps for s in ("export", "upload")):
|
|
148
|
+
db.execute("SELECT fn_update_statistics()")
|
|
149
|
+
|
|
150
|
+
# Create SharedModels tarball
|
|
151
|
+
if "upload" in steps:
|
|
152
|
+
_create_shared_models_tarball(config["fg_scenery"])
|
|
153
|
+
|
|
154
|
+
# Distribution (rsync to mirrors)
|
|
155
|
+
if "distribute" in steps:
|
|
156
|
+
rsync_distribute.run(
|
|
157
|
+
fg_scenery=config["fg_scenery"],
|
|
158
|
+
sync_targets=config["sync_targets"],
|
|
159
|
+
cdn_invalidate_cmd=config["cdn_invalidate_cmd"],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
logger.info("Export pipeline complete.")
|
|
163
|
+
|
|
164
|
+
finally:
|
|
165
|
+
db.close()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _create_shared_models_tarball(fg_scenery: str):
|
|
169
|
+
"""Create SharedModels.txz from the Models directory."""
|
|
170
|
+
models_path = os.path.join(fg_scenery, "Models")
|
|
171
|
+
output_path = os.path.join(fg_scenery, "SharedModels.txz")
|
|
172
|
+
|
|
173
|
+
if not os.path.isdir(models_path):
|
|
174
|
+
logger.warning("No Models directory at %s, skipping tarball.", models_path)
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
logger.info("Creating SharedModels.txz...")
|
|
178
|
+
with tarfile.open(output_path, "w:xz") as tar:
|
|
179
|
+
tar.add(models_path, arcname="Models")
|
|
180
|
+
logger.info("SharedModels.txz created at %s", output_path)
|
fgsmdb/config.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Configuration loader — reads .env and provides settings to all modules."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_config(env_file: str | None = None) -> dict:
|
|
8
|
+
"""Load configuration from .env file and environment variables.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
env_file: Path to .env file. If None, searches in current directory
|
|
12
|
+
and parent directories.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Dictionary with all configuration values.
|
|
16
|
+
"""
|
|
17
|
+
if env_file:
|
|
18
|
+
load_dotenv(env_file)
|
|
19
|
+
else:
|
|
20
|
+
load_dotenv()
|
|
21
|
+
|
|
22
|
+
config = {
|
|
23
|
+
"pguri": os.environ["PGURI"],
|
|
24
|
+
"fgelev_path": os.environ.get("FGELEV_PATH", "/usr/local/bin/fgelev"),
|
|
25
|
+
"fg_root": os.environ.get("FG_ROOT", "/app/data"),
|
|
26
|
+
"fg_scenery": os.environ.get("FG_SCENERY", "./fgscenery"),
|
|
27
|
+
"fg_scenery_export": os.environ.get("FG_SCENERY_EXPORT", "./export"),
|
|
28
|
+
"groundnets_path": os.environ.get("GROUNDNETS_PATH", "./groundnets"),
|
|
29
|
+
"min_updates": int(os.environ.get("MIN_UPDATES", "1")),
|
|
30
|
+
"sync_targets": [
|
|
31
|
+
t.strip()
|
|
32
|
+
for t in os.environ.get("SYNC_TARGETS", "").split(",")
|
|
33
|
+
if t.strip()
|
|
34
|
+
],
|
|
35
|
+
"cdn_invalidate_cmd": os.environ.get("CDN_INVALIDATE_CMD", ""),
|
|
36
|
+
}
|
|
37
|
+
return config
|
fgsmdb/db.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Database connection helper with automatic reconnect."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import psycopg2
|
|
5
|
+
import psycopg2.extras
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Database:
|
|
11
|
+
"""Thin wrapper around psycopg2 with reconnect logic."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, pguri: str):
|
|
14
|
+
self.pguri = pguri
|
|
15
|
+
self._conn = None
|
|
16
|
+
|
|
17
|
+
def connect(self):
|
|
18
|
+
"""Establish database connection."""
|
|
19
|
+
if self._conn is None or self._conn.closed:
|
|
20
|
+
logger.info("Connecting to database...")
|
|
21
|
+
self._conn = psycopg2.connect(self.pguri)
|
|
22
|
+
self._conn.autocommit = True
|
|
23
|
+
return self._conn
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def conn(self):
|
|
27
|
+
return self.connect()
|
|
28
|
+
|
|
29
|
+
def cursor(self):
|
|
30
|
+
"""Return a DictCursor, reconnecting if needed."""
|
|
31
|
+
return self.conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
|
32
|
+
|
|
33
|
+
def execute(self, sql: str, params=None, retries: int = 3):
|
|
34
|
+
"""Execute a query with automatic reconnect on OperationalError.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of result rows, or empty list for non-SELECT statements.
|
|
38
|
+
"""
|
|
39
|
+
for attempt in range(retries):
|
|
40
|
+
try:
|
|
41
|
+
cur = self.cursor()
|
|
42
|
+
cur.execute(sql, params)
|
|
43
|
+
if cur.description:
|
|
44
|
+
return cur.fetchall()
|
|
45
|
+
return []
|
|
46
|
+
except psycopg2.OperationalError:
|
|
47
|
+
logger.warning(
|
|
48
|
+
"Database connection lost (attempt %d/%d), reconnecting...",
|
|
49
|
+
attempt + 1,
|
|
50
|
+
retries,
|
|
51
|
+
)
|
|
52
|
+
self._conn = None
|
|
53
|
+
if attempt == retries - 1:
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
def execute_batch(self, statements: list[str]):
|
|
57
|
+
"""Execute multiple SQL statements in sequence."""
|
|
58
|
+
cur = self.cursor()
|
|
59
|
+
for stmt in statements:
|
|
60
|
+
cur.execute(stmt)
|
|
61
|
+
|
|
62
|
+
def close(self):
|
|
63
|
+
"""Close the database connection."""
|
|
64
|
+
if self._conn and not self._conn.closed:
|
|
65
|
+
self._conn.close()
|
|
66
|
+
self._conn = None
|
fgsmdb/steps/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# fgsmdb pipeline step modules
|
fgsmdb/steps/dirindex.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Directory index generation — creates .dirindex files for TerraSync.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy CreateDirectoryIndexes.py and mkdiridx.sh scripts.
|
|
4
|
+
|
|
5
|
+
TerraSync .dirindex format:
|
|
6
|
+
version:1
|
|
7
|
+
path:<relative_path>
|
|
8
|
+
f:<filename>:<sha1>:<size> (regular files)
|
|
9
|
+
t:<filename>:<sha1>:<size> (tar archives)
|
|
10
|
+
d:<dirname>:<sha1_of_dirindex> (subdirectories)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import tarfile
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
DIRINDEX_VERSION = 1
|
|
22
|
+
DIRINDEX_FILENAME = ".dirindex"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def sha1_of_file(path: str) -> str:
|
|
26
|
+
"""Compute SHA1 hash of a file."""
|
|
27
|
+
h = hashlib.sha1()
|
|
28
|
+
try:
|
|
29
|
+
with open(path, "rb") as f:
|
|
30
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
31
|
+
h.update(chunk)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
return h.hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_dirindex(path: str, parent: str = "") -> str:
|
|
38
|
+
"""Recursively create .dirindex files in a directory tree.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
path: Absolute or relative path to the directory.
|
|
42
|
+
parent: Relative path prefix for the dirindex 'path:' field.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
SHA1 hash of the generated .dirindex file.
|
|
46
|
+
"""
|
|
47
|
+
dirindex_path = os.path.join(path, DIRINDEX_FILENAME)
|
|
48
|
+
|
|
49
|
+
entries = sorted(os.listdir(path))
|
|
50
|
+
|
|
51
|
+
with open(dirindex_path, "w") as f:
|
|
52
|
+
f.write(f"version:{DIRINDEX_VERSION}\n")
|
|
53
|
+
f.write(f"path:{parent}\n")
|
|
54
|
+
|
|
55
|
+
for entry in entries:
|
|
56
|
+
full_path = os.path.join(path, entry)
|
|
57
|
+
|
|
58
|
+
if entry == DIRINDEX_FILENAME:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if os.path.isfile(full_path):
|
|
62
|
+
file_hash = sha1_of_file(full_path)
|
|
63
|
+
file_size = os.stat(full_path).st_size
|
|
64
|
+
if entry.endswith(".tgz") or entry.endswith(".txz"):
|
|
65
|
+
f.write(f"t:{entry}:{file_hash}:{file_size}\n")
|
|
66
|
+
else:
|
|
67
|
+
f.write(f"f:{entry}:{file_hash}:{file_size}\n")
|
|
68
|
+
|
|
69
|
+
elif os.path.isdir(full_path) and entry != ".svn":
|
|
70
|
+
child_parent = os.path.join(parent, entry) if parent else entry
|
|
71
|
+
child_hash = create_dirindex(full_path, child_parent)
|
|
72
|
+
f.write(f"d:{entry}:{child_hash}\n")
|
|
73
|
+
|
|
74
|
+
return sha1_of_file(dirindex_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def create_root_dirindex(fg_scenery: str, fg_scenery_export: str):
|
|
78
|
+
"""Create the root .dirindex and Airports archive.
|
|
79
|
+
|
|
80
|
+
This combines data from the remote scenery (Airports, Terrain) with
|
|
81
|
+
exported data (Objects, Models) into a unified root .dirindex.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
fg_scenery: Path to the remote/mounted scenery (has Airports, Terrain).
|
|
85
|
+
fg_scenery_export: Path to the export directory (has Objects, Models).
|
|
86
|
+
"""
|
|
87
|
+
logger.info("Creating directory indexes...")
|
|
88
|
+
|
|
89
|
+
# Create Airports dirindex and archive
|
|
90
|
+
airports_path = os.path.join(fg_scenery, "Airports")
|
|
91
|
+
if os.path.isdir(airports_path):
|
|
92
|
+
logger.info("Creating Airports .dirindex...")
|
|
93
|
+
create_dirindex(airports_path, "Airports")
|
|
94
|
+
|
|
95
|
+
# Create Airports archive
|
|
96
|
+
archive_path = os.path.join(fg_scenery, "Airports_archive.tgz")
|
|
97
|
+
logger.info("Creating Airports_archive.tgz...")
|
|
98
|
+
with tarfile.open(archive_path, "w:gz") as tar:
|
|
99
|
+
tar.add(airports_path, arcname="Airports")
|
|
100
|
+
|
|
101
|
+
# Create Objects and Models dirindexes in the export directory
|
|
102
|
+
objects_path = os.path.join(fg_scenery_export, "Objects")
|
|
103
|
+
if os.path.isdir(objects_path):
|
|
104
|
+
logger.info("Creating Objects .dirindex...")
|
|
105
|
+
create_dirindex(objects_path, "Objects")
|
|
106
|
+
|
|
107
|
+
models_path = os.path.join(fg_scenery_export, "Models")
|
|
108
|
+
if os.path.isdir(models_path):
|
|
109
|
+
logger.info("Creating Models .dirindex...")
|
|
110
|
+
create_dirindex(models_path, "Models")
|
|
111
|
+
|
|
112
|
+
# Build the root .dirindex
|
|
113
|
+
logger.info("Creating root .dirindex...")
|
|
114
|
+
root_dirindex_path = os.path.join(fg_scenery_export, DIRINDEX_FILENAME)
|
|
115
|
+
with open(root_dirindex_path, "w") as f:
|
|
116
|
+
f.write(f"version:{DIRINDEX_VERSION}\n")
|
|
117
|
+
f.write("path:\n")
|
|
118
|
+
f.write(f"time:{datetime.now(timezone.utc).strftime('%Y%m%d-%H:%MZ')}\n")
|
|
119
|
+
|
|
120
|
+
# Airports (from remote scenery)
|
|
121
|
+
airports_dirindex = os.path.join(airports_path, DIRINDEX_FILENAME)
|
|
122
|
+
if os.path.isfile(airports_dirindex):
|
|
123
|
+
f.write(f"d:Airports:{sha1_of_file(airports_dirindex)}\n")
|
|
124
|
+
|
|
125
|
+
# Models (from export)
|
|
126
|
+
models_dirindex = os.path.join(models_path, DIRINDEX_FILENAME)
|
|
127
|
+
if os.path.isfile(models_dirindex):
|
|
128
|
+
f.write(f"d:Models:{sha1_of_file(models_dirindex)}\n")
|
|
129
|
+
|
|
130
|
+
# Objects (from export)
|
|
131
|
+
objects_dirindex = os.path.join(objects_path, DIRINDEX_FILENAME)
|
|
132
|
+
if os.path.isfile(objects_dirindex):
|
|
133
|
+
f.write(f"d:Objects:{sha1_of_file(objects_dirindex)}\n")
|
|
134
|
+
|
|
135
|
+
# Terrain (from remote scenery)
|
|
136
|
+
terrain_dirindex = os.path.join(fg_scenery, "Terrain", DIRINDEX_FILENAME)
|
|
137
|
+
if os.path.isfile(terrain_dirindex):
|
|
138
|
+
f.write(f"d:Terrain:{sha1_of_file(terrain_dirindex)}\n")
|
|
139
|
+
|
|
140
|
+
# Airports archive tarball
|
|
141
|
+
archive_path = os.path.join(fg_scenery, "Airports_archive.tgz")
|
|
142
|
+
if os.path.isfile(archive_path):
|
|
143
|
+
archive_hash = sha1_of_file(archive_path)
|
|
144
|
+
archive_size = os.stat(archive_path).st_size
|
|
145
|
+
f.write(f"t:Airports_archive.tgz:{archive_hash}:{archive_size}\n")
|
|
146
|
+
|
|
147
|
+
logger.info("Root .dirindex created at %s", root_dirindex_path)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Export models step — extract 3D model files from the database.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy exportmodels.py script.
|
|
4
|
+
Exports shared models (to Models/) and per-hemisphere object models (to Objects/).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import io
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import tarfile
|
|
12
|
+
from ..db import Database
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(db: Database, target: str, incremental: bool = False):
|
|
18
|
+
"""Export all models: shared models + east/west hemisphere objects.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
db: Database connection wrapper.
|
|
22
|
+
target: Target directory for exported files.
|
|
23
|
+
incremental: If True, only export when new models exist.
|
|
24
|
+
"""
|
|
25
|
+
os.makedirs(target, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
if incremental and not _has_new_models(db):
|
|
28
|
+
logger.info("No new models to export (incremental mode).")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
_export_shared_models(db, target)
|
|
32
|
+
_export_hemisphere_models(db, target, "west")
|
|
33
|
+
_export_hemisphere_models(db, target, "east")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _has_new_models(db: Database) -> bool:
|
|
37
|
+
"""Check if there are new models since the last export timestamp."""
|
|
38
|
+
rows = db.execute(
|
|
39
|
+
"""SELECT count(*) AS cnt FROM fgs_models
|
|
40
|
+
WHERE mo_modified > (SELECT max(ti_stamp) FROM fgs_timestamps WHERE ti_type=1)"""
|
|
41
|
+
)
|
|
42
|
+
return rows[0]["cnt"] > 0 if rows else False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _export_shared_models(db: Database, target: str):
|
|
46
|
+
"""Export shared models (mo_shared > 0) to Models/ subdirectory."""
|
|
47
|
+
logger.info("Exporting shared models...")
|
|
48
|
+
|
|
49
|
+
rows = db.execute(
|
|
50
|
+
"""SELECT m.mo_id AS id,
|
|
51
|
+
concat('Models/', g.mg_path) AS path,
|
|
52
|
+
m.mo_modelfile AS modelfile
|
|
53
|
+
FROM fgs_models AS m
|
|
54
|
+
LEFT JOIN fgs_modelgroups AS g ON m.mo_shared = g.mg_id
|
|
55
|
+
WHERE m.mo_shared > 0"""
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
count = 0
|
|
59
|
+
for row in rows:
|
|
60
|
+
_extract_model(row, target)
|
|
61
|
+
count += 1
|
|
62
|
+
|
|
63
|
+
logger.info("Exported %d shared models.", count)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _export_hemisphere_models(db: Database, target: str, hemisphere: str):
|
|
67
|
+
"""Export per-object models for a hemisphere.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
hemisphere: "east" or "west"
|
|
71
|
+
"""
|
|
72
|
+
constraint = ">= 0" if hemisphere == "east" else "< 0"
|
|
73
|
+
logger.info("Exporting %s hemisphere models...", hemisphere)
|
|
74
|
+
|
|
75
|
+
rows = db.execute(
|
|
76
|
+
f"""SELECT m.mo_id AS id,
|
|
77
|
+
concat('Objects/', fn_SceneDir(o.wkb_geometry), '/', fn_SceneSubDir(o.wkb_geometry), '/') AS path,
|
|
78
|
+
m.mo_modelfile AS modelfile
|
|
79
|
+
FROM fgs_objects AS o
|
|
80
|
+
LEFT JOIN fgs_models AS m ON o.ob_model = m.mo_id
|
|
81
|
+
WHERE o.ob_gndelev > -9999 AND m.mo_shared = 0 AND ST_X(o.wkb_geometry) {constraint}"""
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
count = 0
|
|
85
|
+
for row in rows:
|
|
86
|
+
_extract_model(row, target)
|
|
87
|
+
count += 1
|
|
88
|
+
|
|
89
|
+
logger.info("Exported %d %s hemisphere models.", count, hemisphere)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _extract_model(row, target: str):
|
|
93
|
+
"""Decode and extract a model's tar.gz archive to the target path."""
|
|
94
|
+
model_path = row["path"]
|
|
95
|
+
full_path = os.path.join(target, model_path)
|
|
96
|
+
|
|
97
|
+
os.makedirs(full_path, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
tar_bytes = io.BytesIO(base64.b64decode(row["modelfile"]))
|
|
101
|
+
with tarfile.open(fileobj=tar_bytes, mode="r:gz") as tar:
|
|
102
|
+
tar.extractall(path=full_path)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
logger.warning("Failed to extract model %s: %s", row["id"], e)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Export STG step — generate .stg files (object placement) for each scenery tile.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy exportstg.py script.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from ..db import Database
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(db: Database, target: str):
|
|
14
|
+
"""Generate all .stg files from the database.
|
|
15
|
+
|
|
16
|
+
Queries all distinct tiles that have objects or signs, then generates
|
|
17
|
+
an .stg file for each tile using the database function fn_DumpStgRows().
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
db: Database connection wrapper.
|
|
21
|
+
target: Target directory for exported files.
|
|
22
|
+
"""
|
|
23
|
+
logger.info("Querying tile list...")
|
|
24
|
+
|
|
25
|
+
sql_path = "concat('Objects/', fn_SceneDir(wkb_geometry), '/', fn_SceneSubDir(wkb_geometry), '/') AS obpath"
|
|
26
|
+
rows = db.execute(
|
|
27
|
+
f"""SELECT DISTINCT fn_gettilenumber(o.wkb_geometry) AS tile, {sql_path}
|
|
28
|
+
FROM fgs_objects AS o
|
|
29
|
+
UNION
|
|
30
|
+
SELECT DISTINCT fn_gettilenumber(s.wkb_geometry) AS tile, {sql_path}
|
|
31
|
+
FROM fgs_signs AS s
|
|
32
|
+
ORDER BY tile"""
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
num_tiles = len(rows)
|
|
36
|
+
logger.info("Generating .stg files for %d tiles...", num_tiles)
|
|
37
|
+
|
|
38
|
+
for i, row in enumerate(rows, 1):
|
|
39
|
+
ob_path = row["obpath"]
|
|
40
|
+
ob_tile = row["tile"]
|
|
41
|
+
stg_file = f"{ob_tile}.stg"
|
|
42
|
+
|
|
43
|
+
if i % 1000 == 0:
|
|
44
|
+
logger.info("Progress: %d / %d tiles", i, num_tiles)
|
|
45
|
+
|
|
46
|
+
# Get STG content from the database function
|
|
47
|
+
stg_rows = db.execute("SELECT fn_DumpStgRows(%s)", (ob_tile,))
|
|
48
|
+
|
|
49
|
+
if stg_rows and stg_rows[0][0]:
|
|
50
|
+
parent = os.path.join(target, ob_path)
|
|
51
|
+
os.makedirs(parent, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
stg_full_path = os.path.join(parent, stg_file)
|
|
54
|
+
with open(stg_full_path, "w") as f:
|
|
55
|
+
f.write(f"{stg_rows[0][0]}\n")
|
|
56
|
+
|
|
57
|
+
logger.info("STG export complete: %d tiles processed.", num_tiles)
|
fgsmdb/steps/gndelev.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Ground elevation step — compute ground elevation for new objects using fgelev.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy gndelev.sh script.
|
|
4
|
+
|
|
5
|
+
This step requires:
|
|
6
|
+
- fgelev binary (from FlightGear/SimGear)
|
|
7
|
+
- Terrain data in FG_SCENERY
|
|
8
|
+
- FG_ROOT pointing to FlightGear data (defaults.xml)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
from ..db import Database
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run(db: Database, fgelev_path: str, fg_root: str, fg_scenery: str):
|
|
20
|
+
"""Compute ground elevations for objects with ob_gndelev = -9999.
|
|
21
|
+
|
|
22
|
+
Queries unprocessed objects from the DB, pipes their coordinates through
|
|
23
|
+
fgelev, then updates the database with computed elevations.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
db: Database connection wrapper.
|
|
27
|
+
fgelev_path: Path to the fgelev binary.
|
|
28
|
+
fg_root: FG_ROOT path (FlightGear data directory).
|
|
29
|
+
fg_scenery: FG_SCENERY path (terrain data directory).
|
|
30
|
+
"""
|
|
31
|
+
terrain_path = os.path.join(fg_scenery, "Terrain")
|
|
32
|
+
if not os.path.isdir(terrain_path):
|
|
33
|
+
logger.error("No Terrain found in %s. Skipping gndelev.", fg_scenery)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
logger.info("Querying objects needing ground elevation...")
|
|
37
|
+
rows = db.execute(
|
|
38
|
+
"""SELECT ob_id, ST_X(wkb_geometry) AS lon, ST_Y(wkb_geometry) AS lat
|
|
39
|
+
FROM fgs_objects
|
|
40
|
+
WHERE ob_valid IS true AND ob_gndelev = -9999
|
|
41
|
+
ORDER BY ob_tile"""
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if not rows:
|
|
45
|
+
logger.info("No objects need ground elevation updates.")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
logger.info("Computing ground elevation for %d objects...", len(rows))
|
|
49
|
+
|
|
50
|
+
# Prepare input for fgelev: "id lon lat\n" per line
|
|
51
|
+
fgelev_input = ""
|
|
52
|
+
for row in rows:
|
|
53
|
+
fgelev_input += f"{row['ob_id']} {row['lon']} {row['lat']}\n"
|
|
54
|
+
|
|
55
|
+
# Run fgelev
|
|
56
|
+
env = os.environ.copy()
|
|
57
|
+
env["FG_ROOT"] = fg_root
|
|
58
|
+
env["FG_SCENERY"] = fg_scenery
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
[fgelev_path],
|
|
63
|
+
input=fgelev_input,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
env=env,
|
|
67
|
+
)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
logger.error("fgelev binary not found at %s", fgelev_path)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# fgelev may return non-zero on EOF, that's expected
|
|
73
|
+
if result.stdout:
|
|
74
|
+
_process_fgelev_output(db, result.stdout)
|
|
75
|
+
else:
|
|
76
|
+
logger.warning("fgelev produced no output. stderr: %s", result.stderr[:500])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _process_fgelev_output(db: Database, output: str):
|
|
80
|
+
"""Parse fgelev output and update the database.
|
|
81
|
+
|
|
82
|
+
fgelev output format: "id:elevation\n"
|
|
83
|
+
"""
|
|
84
|
+
update_count = 0
|
|
85
|
+
for line in output.strip().split("\n"):
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if not line or ":" not in line:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
parts = line.split(":")
|
|
91
|
+
if len(parts) != 2:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
ob_id = int(parts[0].strip())
|
|
96
|
+
elevation = float(parts[1].strip())
|
|
97
|
+
except ValueError:
|
|
98
|
+
logger.warning("Could not parse fgelev output line: %s", line)
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
db.execute(
|
|
102
|
+
"UPDATE fgs_objects SET ob_gndelev = %s WHERE ob_id = %s AND ob_gndelev = -9999",
|
|
103
|
+
(elevation, ob_id),
|
|
104
|
+
)
|
|
105
|
+
update_count += 1
|
|
106
|
+
|
|
107
|
+
logger.info("Updated ground elevation for %d objects.", update_count)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Distribution step — rsync exported scenery to mirror servers.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy sync.sh script.
|
|
4
|
+
This step shells out to rsync since it's the right tool for efficient
|
|
5
|
+
network file transfer. SSH configuration (host, user, key) is read from
|
|
6
|
+
~/.ssh/config as usual.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import shlex
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rsync_to_target(scenery_dir: str, target: str, items: list[str]) -> int:
|
|
19
|
+
"""Rsync specific items to a target.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
scenery_dir: Local scenery directory (source).
|
|
23
|
+
target: rsync target URI (e.g., "host:/path/").
|
|
24
|
+
items: List of file/directory names to sync.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
rsync exit code.
|
|
28
|
+
"""
|
|
29
|
+
cmd = [
|
|
30
|
+
"rsync", "-av",
|
|
31
|
+
"--no-owner", "--no-group", "--no-perms",
|
|
32
|
+
"--delete", "--copy-links", "--omit-dir-times",
|
|
33
|
+
] + items + [target]
|
|
34
|
+
|
|
35
|
+
logger.info("rsync to %s: %s", target, " ".join(items))
|
|
36
|
+
result = subprocess.run(cmd, cwd=scenery_dir, capture_output=True, text=True)
|
|
37
|
+
|
|
38
|
+
if result.returncode != 0:
|
|
39
|
+
logger.error("rsync to %s failed: %s", target, result.stderr[:500])
|
|
40
|
+
else:
|
|
41
|
+
logger.info("rsync to %s complete.", target)
|
|
42
|
+
|
|
43
|
+
return result.returncode
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(fg_scenery: str, sync_targets: list[str], cdn_invalidate_cmd: str = ""):
|
|
47
|
+
"""Distribute scenery to all mirror targets.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
fg_scenery: Local scenery directory.
|
|
51
|
+
sync_targets: List of rsync target URIs.
|
|
52
|
+
cdn_invalidate_cmd: Optional shell command to invalidate CDN.
|
|
53
|
+
"""
|
|
54
|
+
if not sync_targets:
|
|
55
|
+
logger.info("No sync targets configured. Skipping distribution.")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
scenery_items = ["Objects", "Models", "Airports", ".dirindex", "Airports_archive.tgz"]
|
|
59
|
+
tarball = "SharedModels.txz"
|
|
60
|
+
|
|
61
|
+
with ThreadPoolExecutor(max_workers=len(sync_targets) * 2) as executor:
|
|
62
|
+
futures = []
|
|
63
|
+
|
|
64
|
+
for target in sync_targets:
|
|
65
|
+
# Main scenery sync
|
|
66
|
+
futures.append(
|
|
67
|
+
executor.submit(rsync_to_target, fg_scenery, target, scenery_items)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# SharedModels tarball to parent directory
|
|
71
|
+
tarball_path = os.path.join(fg_scenery, tarball)
|
|
72
|
+
if os.path.isfile(tarball_path):
|
|
73
|
+
parent_target = target.rsplit("/", 2)[0] + "/"
|
|
74
|
+
futures.append(
|
|
75
|
+
executor.submit(rsync_to_target, fg_scenery, parent_target, [tarball])
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
for future in as_completed(futures):
|
|
79
|
+
future.result()
|
|
80
|
+
|
|
81
|
+
# Invalidate CDN if configured
|
|
82
|
+
if cdn_invalidate_cmd:
|
|
83
|
+
logger.info("Invalidating CDN: %s", cdn_invalidate_cmd)
|
|
84
|
+
subprocess.run(shlex.split(cdn_invalidate_cmd), check=False)
|
fgsmdb/steps/sanitize.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Sanitize step — fix data integrity issues in the database.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy sanitize.sh script.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from ..db import Database
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(db: Database):
|
|
13
|
+
"""Fix tile numbers and elevation offsets in the database."""
|
|
14
|
+
logger.info("Running sanitize...")
|
|
15
|
+
|
|
16
|
+
statements = [
|
|
17
|
+
# Update object tile numbers where they don't match geometry
|
|
18
|
+
"""UPDATE fgs_objects SET ob_tile = fn_GetTileNumber(wkb_geometry)
|
|
19
|
+
WHERE ob_tile != fn_GetTileNumber(wkb_geometry) OR ob_tile IS NULL""",
|
|
20
|
+
# Update sign tile numbers where invalid
|
|
21
|
+
"""UPDATE fgs_signs SET si_tile = fn_GetTileNumber(wkb_geometry)
|
|
22
|
+
WHERE si_tile < 1 OR si_tile IS NULL""",
|
|
23
|
+
# Nullify zero elevation offsets
|
|
24
|
+
"""UPDATE fgs_objects SET ob_elevoffset = NULL
|
|
25
|
+
WHERE ob_elevoffset = 0""",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
for stmt in statements:
|
|
29
|
+
logger.debug("Executing: %s", stmt.strip()[:80])
|
|
30
|
+
db.execute(stmt)
|
|
31
|
+
|
|
32
|
+
logger.info("Sanitize complete.")
|
fgsmdb/steps/sync.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Sync step — synchronize exported scenery to the local scenery tree.
|
|
2
|
+
|
|
3
|
+
Equivalent to the legacy upload.sh + LocalSync.py scripts.
|
|
4
|
+
Uses .dirindex-based diffing to copy only changed files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
DIRINDEX_FILENAME = ".dirindex"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sha1_of_file(path: str) -> str:
|
|
18
|
+
"""Compute SHA1 hash of a file."""
|
|
19
|
+
h = hashlib.sha1()
|
|
20
|
+
try:
|
|
21
|
+
with open(path, "rb") as f:
|
|
22
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
23
|
+
h.update(chunk)
|
|
24
|
+
except OSError:
|
|
25
|
+
pass
|
|
26
|
+
return h.hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DirIndex:
|
|
30
|
+
"""Parser for .dirindex files."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path: str):
|
|
33
|
+
self.directories: list[dict] = []
|
|
34
|
+
self.files: list[dict] = []
|
|
35
|
+
self.version = 0
|
|
36
|
+
self.path = ""
|
|
37
|
+
|
|
38
|
+
with open(path) as f:
|
|
39
|
+
for line in f:
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line or line.startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
tokens = line.split(":")
|
|
45
|
+
if tokens[0] == "version":
|
|
46
|
+
self.version = int(tokens[1])
|
|
47
|
+
elif tokens[0] == "path":
|
|
48
|
+
self.path = tokens[1] if len(tokens) > 1 else ""
|
|
49
|
+
elif tokens[0] == "d":
|
|
50
|
+
self.directories.append({"name": tokens[1], "hash": tokens[2]})
|
|
51
|
+
elif tokens[0] in ("f", "t"):
|
|
52
|
+
self.files.append({
|
|
53
|
+
"name": tokens[1],
|
|
54
|
+
"hash": tokens[2],
|
|
55
|
+
"size": tokens[3] if len(tokens) > 3 else "0",
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
def get_file_by_name(self, name: str) -> dict | None:
|
|
59
|
+
"""Find a file entry by name."""
|
|
60
|
+
for f in self.files:
|
|
61
|
+
if f["name"] == name:
|
|
62
|
+
return f
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sync_path(src: str, dst: str, expected_hash: str | None = None) -> str | None:
|
|
67
|
+
"""Sync a directory from src to dst using .dirindex-based diffing.
|
|
68
|
+
|
|
69
|
+
Only copies files whose hashes differ between source and destination.
|
|
70
|
+
Removes files present in destination but not in source.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
src: Source directory path.
|
|
74
|
+
dst: Destination directory path.
|
|
75
|
+
expected_hash: Expected hash of the source .dirindex (for validation).
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
SHA1 hash of the source .dirindex file.
|
|
79
|
+
"""
|
|
80
|
+
src_dirindex_path = os.path.join(src, DIRINDEX_FILENAME)
|
|
81
|
+
if not os.path.isfile(src_dirindex_path):
|
|
82
|
+
logger.debug("No .dirindex at %s, skipping.", src)
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
src_index = DirIndex(src_dirindex_path)
|
|
86
|
+
|
|
87
|
+
# Recurse into subdirectories first
|
|
88
|
+
for sub_dir in src_index.directories:
|
|
89
|
+
sync_path(
|
|
90
|
+
os.path.join(src, sub_dir["name"]),
|
|
91
|
+
os.path.join(dst, sub_dir["name"]),
|
|
92
|
+
sub_dir["hash"],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Compute hash if not provided
|
|
96
|
+
if expected_hash is None:
|
|
97
|
+
expected_hash = sha1_of_file(src_dirindex_path)
|
|
98
|
+
|
|
99
|
+
dst_dirindex_path = os.path.join(dst, DIRINDEX_FILENAME)
|
|
100
|
+
|
|
101
|
+
if not os.path.isfile(dst_dirindex_path):
|
|
102
|
+
# No destination .dirindex — copy everything
|
|
103
|
+
os.makedirs(dst, exist_ok=True)
|
|
104
|
+
for file_entry in src_index.files:
|
|
105
|
+
_copy_file(file_entry["name"], src, dst)
|
|
106
|
+
logger.debug("copy (new) %s : %s -> %s", file_entry["name"], src, dst)
|
|
107
|
+
_copy_file(DIRINDEX_FILENAME, src, dst)
|
|
108
|
+
else:
|
|
109
|
+
# Have destination .dirindex — do incremental sync
|
|
110
|
+
dst_index = DirIndex(dst_dirindex_path)
|
|
111
|
+
|
|
112
|
+
for src_file in src_index.files:
|
|
113
|
+
dst_file = dst_index.get_file_by_name(src_file["name"])
|
|
114
|
+
if dst_file is None or dst_file["hash"] != src_file["hash"]:
|
|
115
|
+
os.makedirs(dst, exist_ok=True)
|
|
116
|
+
_copy_file(src_file["name"], src, dst)
|
|
117
|
+
logger.debug("copy (changed) %s : %s -> %s", src_file["name"], src, dst)
|
|
118
|
+
|
|
119
|
+
# Update .dirindex if hashes differ
|
|
120
|
+
if expected_hash != sha1_of_file(dst_dirindex_path):
|
|
121
|
+
_copy_file(DIRINDEX_FILENAME, src, dst)
|
|
122
|
+
|
|
123
|
+
# Remove files that no longer exist in source
|
|
124
|
+
for dst_file in dst_index.files:
|
|
125
|
+
if src_index.get_file_by_name(dst_file["name"]) is None:
|
|
126
|
+
dst_file_path = os.path.join(dst, dst_file["name"])
|
|
127
|
+
if os.path.exists(dst_file_path):
|
|
128
|
+
os.remove(dst_file_path)
|
|
129
|
+
logger.debug("delete %s", dst_file_path)
|
|
130
|
+
|
|
131
|
+
return expected_hash
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _copy_file(name: str, src_dir: str, dst_dir: str):
|
|
135
|
+
"""Copy a single file from src_dir to dst_dir."""
|
|
136
|
+
src_path = os.path.join(src_dir, name)
|
|
137
|
+
dst_path = os.path.join(dst_dir, name)
|
|
138
|
+
shutil.copy2(src_path, dst_path)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run(fg_scenery_export: str, fg_scenery: str):
|
|
142
|
+
"""Sync the export directory into the scenery directory.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
fg_scenery_export: Source export directory.
|
|
146
|
+
fg_scenery: Destination scenery directory.
|
|
147
|
+
"""
|
|
148
|
+
logger.info("Syncing export to scenery: %s -> %s", fg_scenery_export, fg_scenery)
|
|
149
|
+
sync_path(fg_scenery_export, fg_scenery)
|
|
150
|
+
logger.info("Sync complete.")
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fgsmdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FlightGear Scenemodels Database Tools
|
|
5
|
+
Author-email: Torsten Dreyer <torsten@t3r.de>
|
|
6
|
+
License-Expression: GPL-2.0-only
|
|
7
|
+
Project-URL: Homepage, https://github.com/FlightGear/scenemodels
|
|
8
|
+
Project-URL: Repository, https://github.com/FlightGear/scenemodels
|
|
9
|
+
Project-URL: Issues, https://github.com/FlightGear/scenemodels/issues
|
|
10
|
+
Keywords: flightgear,scenery,terrasync,export
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Games/Entertainment :: Simulation
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: psycopg2-binary>=2.9
|
|
23
|
+
Requires-Dist: python-dotenv>=1.0
|
|
24
|
+
|
|
25
|
+
# fgsmdb — FlightGear Scenemodels Database Tools
|
|
26
|
+
|
|
27
|
+
Command-line tools for the FlightGear scenemodels database.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install fgsmdb
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or install from source:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
cd export-legacy/fgsmdb
|
|
39
|
+
pip install .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For development:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Copy `.env.example` to `.env` and fill in your values:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cp .env.example .env
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Variable | Description | Default |
|
|
57
|
+
|----------|-------------|---------|
|
|
58
|
+
| `PGURI` | PostgreSQL connection URI (required) | — |
|
|
59
|
+
| `FGELEV_PATH` | Path to the fgelev binary | `/usr/local/bin/fgelev` |
|
|
60
|
+
| `FG_ROOT` | FlightGear data root (needed by fgelev) | `/app/data` |
|
|
61
|
+
| `FG_SCENERY` | Local scenery path (Terrain, Airports, etc.) | `./fgscenery` |
|
|
62
|
+
| `FG_SCENERY_EXPORT` | Working directory for exported files | `./export` |
|
|
63
|
+
| `GROUNDNETS_PATH` | Path to groundnets git repository | `./groundnets` |
|
|
64
|
+
| `MIN_UPDATES` | Minimum pending updates to trigger export | `1` |
|
|
65
|
+
| `SYNC_TARGETS` | Comma-separated rsync target URIs | — |
|
|
66
|
+
| `CDN_INVALIDATE_CMD` | Shell command to run after distribution | — |
|
|
67
|
+
|
|
68
|
+
For remote distribution, rsync uses your `~/.ssh/config` for host aliases,
|
|
69
|
+
usernames, and identity files.
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Run the export pipeline
|
|
75
|
+
python3 -m fgsmdb export
|
|
76
|
+
|
|
77
|
+
# Or use the installed command
|
|
78
|
+
fgsmdb export
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Export Command
|
|
82
|
+
|
|
83
|
+
Exports the scenemodels database to a TerraSync-compatible file tree.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Full pipeline (all steps except distribute)
|
|
87
|
+
python3 -m fgsmdb export
|
|
88
|
+
|
|
89
|
+
# With a specific .env file
|
|
90
|
+
python3 -m fgsmdb export --env-file /path/to/.env
|
|
91
|
+
|
|
92
|
+
# Run specific steps only
|
|
93
|
+
python3 -m fgsmdb export --steps sanitize,export,mkdiridx
|
|
94
|
+
|
|
95
|
+
# Include distribution to mirrors
|
|
96
|
+
python3 -m fgsmdb export --steps sanitize,gndelev,export,mkdiridx,upload,distribute
|
|
97
|
+
|
|
98
|
+
# Skip the pending-updates threshold check
|
|
99
|
+
python3 -m fgsmdb export --skip-check
|
|
100
|
+
|
|
101
|
+
# Override the minimum updates threshold
|
|
102
|
+
python3 -m fgsmdb export --min-updates 10
|
|
103
|
+
|
|
104
|
+
# Enable verbose (debug) logging
|
|
105
|
+
python3 -m fgsmdb export -v
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Pipeline Steps
|
|
109
|
+
|
|
110
|
+
| Step | Description |
|
|
111
|
+
|------|-------------|
|
|
112
|
+
| `sanitize` | Fix data integrity: update tile numbers, nullify zero elevation offsets |
|
|
113
|
+
| `gndelev` | Compute ground elevation for new objects using fgelev |
|
|
114
|
+
| `export` | Export shared/per-hemisphere models and generate .stg files from the database |
|
|
115
|
+
| `mkdiridx` | Create `.dirindex` files for TerraSync and the Airports archive |
|
|
116
|
+
| `upload` | Sync exported files to the local scenery tree (hash-based, copies only changes) |
|
|
117
|
+
| `distribute` | Rsync scenery to remote mirror servers (parallel) |
|
|
118
|
+
|
|
119
|
+
By default, all steps except `distribute` are run. Distribution must be explicitly
|
|
120
|
+
requested via `--steps`.
|
|
121
|
+
|
|
122
|
+
Before any steps execute, the pipeline calls `fn_update_statistics()` and checks
|
|
123
|
+
whether the number of objects pending ground elevation meets the configured threshold.
|
|
124
|
+
Use `--skip-check` to bypass this gate.
|
|
125
|
+
|
|
126
|
+
### Command Line Options
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
python3 -m fgsmdb export [-h] [--env-file ENV_FILE] [--steps STEPS] [--all]
|
|
130
|
+
[--skip-check] [--min-updates N] [-v]
|
|
131
|
+
|
|
132
|
+
Options:
|
|
133
|
+
--env-file ENV_FILE Path to .env file (default: auto-detect)
|
|
134
|
+
--steps STEPS Comma-separated steps: sanitize, gndelev, export,
|
|
135
|
+
mkdiridx, upload, distribute
|
|
136
|
+
--all Run all steps (same as default, without distribute)
|
|
137
|
+
--skip-check Skip the pending-updates threshold check
|
|
138
|
+
--min-updates N Override the MIN_UPDATES threshold from .env
|
|
139
|
+
-v, --verbose Enable debug logging
|
|
140
|
+
-h, --help Show help message
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Output Structure
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
fgscenery/
|
|
147
|
+
├── .dirindex
|
|
148
|
+
├── Airports_archive.tgz
|
|
149
|
+
├── SharedModels.txz
|
|
150
|
+
├── Airports/
|
|
151
|
+
│ ├── .dirindex
|
|
152
|
+
│ └── ...
|
|
153
|
+
├── Models/
|
|
154
|
+
│ ├── .dirindex
|
|
155
|
+
│ └── <group_path>/
|
|
156
|
+
├── Objects/
|
|
157
|
+
│ ├── .dirindex
|
|
158
|
+
│ └── <e|w>NNN<n|s>NN/
|
|
159
|
+
│ └── <e|w>NNN<n|s>NN/
|
|
160
|
+
│ ├── <tile>.stg
|
|
161
|
+
│ └── <model_files>
|
|
162
|
+
└── Terrain/
|
|
163
|
+
└── .dirindex
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
GNU General Public License v2.0 — see [LICENCE](../LICENCE).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
fgsmdb/__init__.py,sha256=d0Xj6sy_mmFEazfAaqFG93uC37JJk5_o-eWDuDhAOws,79
|
|
2
|
+
fgsmdb/__main__.py,sha256=VEts6B7jmw5qF-6jdOIhXcZf9NdNcj-6xWAVYnMBSgw,643
|
|
3
|
+
fgsmdb/config.py,sha256=bviHS-NON-dTplcvb6JvVz91bsb84I047Ca9XBCSpcg,1252
|
|
4
|
+
fgsmdb/db.py,sha256=FP3_Cq4BnH2J-_qVPIYfox1Wzm7uiHhYS3vu960t2wk,2038
|
|
5
|
+
fgsmdb/commands/__init__.py,sha256=R8BQiqA6D0JentyffjXv6ffF6ZyrH25T0AaHogg1SwM,25
|
|
6
|
+
fgsmdb/commands/export.py,sha256=QJaNoo1HhYxy1-50x4QyHy_yhhnjGbcxWxXrjOXCEww,5937
|
|
7
|
+
fgsmdb/steps/__init__.py,sha256=H9UzBR9zBfZalAFewdzh3IZ0lQTa0xDsh19JJrPkv20,31
|
|
8
|
+
fgsmdb/steps/dirindex.py,sha256=UZ3VxVmEIiTVmXilolswlTkmUslmAbZrJAg9VCGJ7dU,5388
|
|
9
|
+
fgsmdb/steps/export_models.py,sha256=Ej08S_ijPAtWo88HmfWM1-qW0xSZRDMpxyM4Frl_OqU,3331
|
|
10
|
+
fgsmdb/steps/export_stg.py,sha256=qf9oH5s8pgl7nH8BhBbcs__PO8V1X4UNKQR-7hp_Y2w,1853
|
|
11
|
+
fgsmdb/steps/gndelev.py,sha256=h3pUQ0rsXsUxX2SOY944PpHN7bRrFFolKCLt4kJqXTQ,3239
|
|
12
|
+
fgsmdb/steps/rsync_distribute.py,sha256=mEPStPO6XeEY7OmgPCrkm0QLsHBHFYi2dvf7kY1qgJ8,2774
|
|
13
|
+
fgsmdb/steps/sanitize.py,sha256=IF0J4pgTxscA03F1U2BJ51hAT0keAkWKmx8o8gFvaPI,1035
|
|
14
|
+
fgsmdb/steps/sync.py,sha256=6PeVfIwL0q96W-VpMwX-7M8Afa5U0elGpOUEBbyEfjo,5100
|
|
15
|
+
fgsmdb-0.1.0.dist-info/METADATA,sha256=-7gLREoGkU9pMozI14E7ry8_qg8rVJ-l_WkPEmxuMHU,5122
|
|
16
|
+
fgsmdb-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
17
|
+
fgsmdb-0.1.0.dist-info/entry_points.txt,sha256=7anFRXVzUAzM8vzeKUKEr4LQjdAy8KaYA4Nnut0Umy8,48
|
|
18
|
+
fgsmdb-0.1.0.dist-info/top_level.txt,sha256=SAnULTGAK0uWFh-Yn9bD-uIz9iR1U_iQUJyCWfF4YKc,7
|
|
19
|
+
fgsmdb-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fgsmdb
|