Add SQLite build script and CI workflow for automated releases
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
name: Build cameras.db
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "brands/**"
|
||||||
|
- "presets/**"
|
||||||
|
- "scripts/build_sqlite.py"
|
||||||
|
- "scripts/generate_presets.py"
|
||||||
|
push_tags:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Validate brand files
|
||||||
|
run: python3 scripts/validate.py
|
||||||
|
|
||||||
|
- name: Build cameras.db
|
||||||
|
run: python3 scripts/build_sqlite.py -o cameras.db
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cameras-db
|
||||||
|
path: cameras.db
|
||||||
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Update latest release
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA="${COMMIT_SHA:0:7}"
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
gh release delete latest --yes --cleanup-tag 2>/dev/null || true
|
||||||
|
gh release create latest cameras.db \
|
||||||
|
--title "Latest build" \
|
||||||
|
--notes "Auto-built from \`${SHORT_SHA}\` on ${DATE}" \
|
||||||
|
--prerelease
|
||||||
|
|
||||||
|
- name: Create versioned release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG_REF: ${{ github.ref }}
|
||||||
|
run: |
|
||||||
|
TAG="${TAG_REF#refs/tags/}"
|
||||||
|
gh release create "${TAG}" cameras.db \
|
||||||
|
--title "${TAG}" \
|
||||||
|
--generate-notes
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Built artifacts (generated by scripts/build_sqlite.py)
|
||||||
|
cameras.db
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build cameras.db (SQLite) from brand and preset JSON files.
|
||||||
|
|
||||||
|
Reads brands/*.json and presets/*.json, creates a normalized SQLite database
|
||||||
|
optimized for fast queries by brand, model, stream type, and URL pattern.
|
||||||
|
|
||||||
|
Output: cameras.db in the repository root (or specified path).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/build_sqlite.py # outputs ./cameras.db
|
||||||
|
python3 scripts/build_sqlite.py -o /tmp/cam.db # custom output path
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
BRANDS_DIR = os.path.join(os.path.dirname(__file__), "..", "brands")
|
||||||
|
PRESETS_DIR = os.path.join(os.path.dirname(__file__), "..", "presets")
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
-- Database metadata
|
||||||
|
CREATE TABLE meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Camera brands
|
||||||
|
CREATE TABLE brands (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
brand_id TEXT NOT NULL UNIQUE,
|
||||||
|
brand TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stream URL patterns
|
||||||
|
CREATE TABLE streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
brand_id TEXT NOT NULL,
|
||||||
|
stream_id TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
FOREIGN KEY (brand_id) REFERENCES brands(brand_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Many-to-many: which models work with which streams
|
||||||
|
CREATE TABLE stream_models (
|
||||||
|
stream_id INTEGER NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (stream_id) REFERENCES streams(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Presets (curated URL pattern lists)
|
||||||
|
CREATE TABLE presets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
preset_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Preset stream entries
|
||||||
|
CREATE TABLE preset_streams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
preset_id TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT,
|
||||||
|
brand_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (preset_id) REFERENCES presets(preset_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for fast lookups
|
||||||
|
CREATE INDEX idx_streams_brand_id ON streams(brand_id);
|
||||||
|
CREATE INDEX idx_streams_type ON streams(type);
|
||||||
|
CREATE INDEX idx_streams_protocol ON streams(protocol);
|
||||||
|
CREATE INDEX idx_streams_url ON streams(url);
|
||||||
|
CREATE INDEX idx_stream_models_model ON stream_models(model);
|
||||||
|
CREATE INDEX idx_stream_models_stream_id ON stream_models(stream_id);
|
||||||
|
CREATE INDEX idx_preset_streams_preset_id ON preset_streams(preset_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_brands(brands_dir):
|
||||||
|
"""Load all brand JSON files. Yields (filename, data) tuples."""
|
||||||
|
for filename in sorted(os.listdir(brands_dir)):
|
||||||
|
if not filename.endswith(".json"):
|
||||||
|
continue
|
||||||
|
filepath = os.path.join(brands_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(filepath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict) and "streams" in data:
|
||||||
|
yield filename, data
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
print(f" WARN: skipping {filename}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def load_presets(presets_dir):
|
||||||
|
"""Load all preset JSON files. Yields (filename, data) tuples."""
|
||||||
|
if not os.path.isdir(presets_dir):
|
||||||
|
return
|
||||||
|
for filename in sorted(os.listdir(presets_dir)):
|
||||||
|
if not filename.endswith(".json"):
|
||||||
|
continue
|
||||||
|
filepath = os.path.join(presets_dir, filename)
|
||||||
|
try:
|
||||||
|
with open(filepath) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if isinstance(data, dict) and "streams" in data:
|
||||||
|
yield filename, data
|
||||||
|
except (json.JSONDecodeError, IOError) as e:
|
||||||
|
print(f" WARN: skipping preset {filename}: {e}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def build(output_path):
|
||||||
|
"""Build the SQLite database."""
|
||||||
|
brands_dir = os.path.abspath(BRANDS_DIR)
|
||||||
|
presets_dir = os.path.abspath(PRESETS_DIR)
|
||||||
|
|
||||||
|
if not os.path.isdir(brands_dir):
|
||||||
|
print(f"Error: brands directory not found: {brands_dir}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Remove existing db to start fresh
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.remove(output_path)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(output_path)
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
brand_count = 0
|
||||||
|
stream_count = 0
|
||||||
|
model_ref_count = 0
|
||||||
|
|
||||||
|
# Insert brands and streams
|
||||||
|
for filename, data in load_brands(brands_dir):
|
||||||
|
brand_id = data["brand_id"]
|
||||||
|
brand_name = data["brand"]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO brands (brand_id, brand) VALUES (?, ?)",
|
||||||
|
(brand_id, brand_name),
|
||||||
|
)
|
||||||
|
brand_count += 1
|
||||||
|
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO streams (brand_id, stream_id, url, type, protocol, port, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
brand_id,
|
||||||
|
stream["id"],
|
||||||
|
stream["url"],
|
||||||
|
stream["type"],
|
||||||
|
stream["protocol"],
|
||||||
|
stream["port"],
|
||||||
|
stream.get("notes"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row_id = cursor.lastrowid
|
||||||
|
stream_count += 1
|
||||||
|
|
||||||
|
# Insert model associations
|
||||||
|
for model in stream.get("models", []):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stream_models (stream_id, model) VALUES (?, ?)",
|
||||||
|
(row_id, model),
|
||||||
|
)
|
||||||
|
model_ref_count += 1
|
||||||
|
|
||||||
|
# Insert presets
|
||||||
|
preset_count = 0
|
||||||
|
preset_stream_count = 0
|
||||||
|
|
||||||
|
for filename, data in load_presets(presets_dir):
|
||||||
|
preset_id = data["preset_id"]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO presets (preset_id, name, description) VALUES (?, ?, ?)",
|
||||||
|
(preset_id, data["name"], data.get("description")),
|
||||||
|
)
|
||||||
|
preset_count += 1
|
||||||
|
|
||||||
|
for ps in data.get("streams", []):
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO preset_streams (preset_id, url, type, protocol, port, notes, brand_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
preset_id,
|
||||||
|
ps["url"],
|
||||||
|
ps["type"],
|
||||||
|
ps["protocol"],
|
||||||
|
ps["port"],
|
||||||
|
ps.get("notes"),
|
||||||
|
ps.get("brand_count", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
preset_stream_count += 1
|
||||||
|
|
||||||
|
# Insert metadata
|
||||||
|
elapsed = time.time() - start
|
||||||
|
conn.execute("INSERT INTO meta VALUES ('version', '2')")
|
||||||
|
conn.execute("INSERT INTO meta VALUES ('format', 'StrixCamDB')")
|
||||||
|
conn.execute(f"INSERT INTO meta VALUES ('brands', '{brand_count}')")
|
||||||
|
conn.execute(f"INSERT INTO meta VALUES ('streams', '{stream_count}')")
|
||||||
|
conn.execute(f"INSERT INTO meta VALUES ('presets', '{preset_count}')")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Optimize: run VACUUM and ANALYZE for compact file and up-to-date stats
|
||||||
|
conn.execute("VACUUM")
|
||||||
|
conn.execute("ANALYZE")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# File size
|
||||||
|
size_bytes = os.path.getsize(output_path)
|
||||||
|
size_mb = size_bytes / (1024 * 1024)
|
||||||
|
|
||||||
|
print("=" * 50)
|
||||||
|
print("SQLite build complete")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f" Output: {output_path}")
|
||||||
|
print(f" File size: {size_mb:.1f} MB ({size_bytes:,} bytes)")
|
||||||
|
print(f" Brands: {brand_count}")
|
||||||
|
print(f" Streams: {stream_count}")
|
||||||
|
print(f" Model refs: {model_ref_count}")
|
||||||
|
print(f" Presets: {preset_count}")
|
||||||
|
print(f" Preset streams: {preset_stream_count}")
|
||||||
|
print(f" Build time: {elapsed:.2f}s")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Build cameras.db from JSON sources")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--output",
|
||||||
|
default=os.path.join(os.path.dirname(__file__), "..", "cameras.db"),
|
||||||
|
help="Output path for SQLite database (default: ./cameras.db)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
build(os.path.abspath(args.output))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user