Initial Commit
This commit is contained in:
3 files changed
+587
No files matched your search
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Build and Push Docker image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.stws.cc
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: git.stws.cc/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push Docker images
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
usbutils \
|
||||||
|
iproute2 \
|
||||||
|
dhcpcd \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
gcc \
|
||||||
|
musl-dev \
|
||||||
|
linux-headers \
|
||||||
|
libffi-dev
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies and strip compiler tools
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"open-gopro>=0.22.0" \
|
||||||
|
aiohttp \
|
||||||
|
&& apk del gcc musl-dev linux-headers libffi-dev
|
||||||
|
|
||||||
|
COPY ingest.py /app/ingest.py
|
||||||
|
RUN chmod +x /app/ingest.py
|
||||||
|
|
||||||
|
CMD ["python3", "/app/ingest.py"]
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import warnings
|
||||||
|
import shutil
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
import aiohttp
|
||||||
|
from open_gopro import WiredGoPro
|
||||||
|
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
warnings.filterwarnings("ignore", message=".*unregister_all_services.*")
|
||||||
|
|
||||||
|
DEBUG_MODE = os.getenv("DEBUG", "false").lower() == "true"
|
||||||
|
LOG_LEVEL = logging.DEBUG if DEBUG_MODE else logging.INFO
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=LOG_LEVEL,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("gopro-ingest")
|
||||||
|
|
||||||
|
if not DEBUG_MODE:
|
||||||
|
for lib in ["open_gopro", "zeroconf", "asyncio", "aiohttp", "urllib3"]:
|
||||||
|
logging.getLogger(lib).setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
DEST_DIR = Path(os.getenv("INGEST_DIR", "/media"))
|
||||||
|
TEMP_DIR = DEST_DIR / "temp"
|
||||||
|
HISTORY_FILE = DEST_DIR / ".ingested_history.txt"
|
||||||
|
|
||||||
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "5"))
|
||||||
|
LOG_PROGRESS_INTERVAL = int(os.getenv("LOG_PROGRESS_INTERVAL", "5"))
|
||||||
|
DELETE_AFTER_INGEST = os.getenv("DELETE_AFTER_INGEST", "false").lower() == "true"
|
||||||
|
KEEP_RAW_CHAPTERS = os.getenv("KEEP_RAW_CHAPTERS", "false").lower() == "true"
|
||||||
|
|
||||||
|
GOPRO_USB_VENDOR_ID = "2672"
|
||||||
|
last_synced_fingerprint = ""
|
||||||
|
|
||||||
|
def format_bytes(size_bytes: int | float) -> str:
|
||||||
|
if size_bytes <= 0:
|
||||||
|
return "0 B"
|
||||||
|
units = ["B", "KB", "MB", "GB", "TB"]
|
||||||
|
unit_idx = 0
|
||||||
|
val = float(size_bytes)
|
||||||
|
while val >= 1024.0 and unit_idx < len(units) - 1:
|
||||||
|
val /= 1024.0
|
||||||
|
unit_idx += 1
|
||||||
|
return f"{val:.2f} {units[unit_idx]}"
|
||||||
|
|
||||||
|
def load_history() -> set[str]:
|
||||||
|
if not HISTORY_FILE.exists():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
with open(HISTORY_FILE, "r") as f:
|
||||||
|
return {line.strip() for line in f if line.strip()}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not read history file: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def record_history(filenames: list[str] | set[str]):
|
||||||
|
try:
|
||||||
|
with open(HISTORY_FILE, "a") as f:
|
||||||
|
for fn in filenames:
|
||||||
|
f.write(f"{fn}\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update history file: {e}")
|
||||||
|
|
||||||
|
def get_source_creation_time(filepath: Path) -> str | None:
|
||||||
|
"""Extract creation_time string from the original raw GoPro file."""
|
||||||
|
cmd = [
|
||||||
|
"ffprobe", "-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_entries", "format_tags=creation_time:stream_tags=creation_time",
|
||||||
|
str(filepath)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
res = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
import json
|
||||||
|
data = json.loads(res.stdout)
|
||||||
|
c_time = data.get("format", {}).get("tags", {}).get("creation_time")
|
||||||
|
if not c_time:
|
||||||
|
for s in data.get("streams", []):
|
||||||
|
c_time = s.get("tags", {}).get("creation_time")
|
||||||
|
if c_time:
|
||||||
|
break
|
||||||
|
return c_time
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_carrier_ready(iface: str) -> bool:
|
||||||
|
carrier_file = Path(f"/sys/class/net/{iface}/carrier")
|
||||||
|
try:
|
||||||
|
return carrier_file.read_text().strip() == "1"
|
||||||
|
except (FileNotFoundError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def auto_configure_gopro_interfaces():
|
||||||
|
net_path = Path("/sys/class/net")
|
||||||
|
if not net_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
for iface_dir in net_path.iterdir():
|
||||||
|
iface = iface_dir.name
|
||||||
|
if iface.startswith(("lo", "docker", "br", "veth", "tun", "wg")):
|
||||||
|
continue
|
||||||
|
|
||||||
|
dev_path = iface_dir / "device"
|
||||||
|
if not dev_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
curr = dev_path.resolve()
|
||||||
|
is_gopro = False
|
||||||
|
for _ in range(4):
|
||||||
|
vendor_file = curr / "idVendor"
|
||||||
|
if vendor_file.exists():
|
||||||
|
try:
|
||||||
|
if vendor_file.read_text().strip().lower() == GOPRO_USB_VENDOR_ID:
|
||||||
|
is_gopro = True
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
curr = curr.parent
|
||||||
|
|
||||||
|
if is_gopro:
|
||||||
|
try:
|
||||||
|
addr_output = subprocess.check_output(["ip", "-4", "addr", "show", "dev", iface], text=True)
|
||||||
|
if "inet " in addr_output:
|
||||||
|
continue # Already has an IP address
|
||||||
|
|
||||||
|
# Bring link up administratively
|
||||||
|
subprocess.run(["ip", "link", "set", iface, "up"], check=False)
|
||||||
|
|
||||||
|
# Wait up to 5 seconds for the hardware carrier to settle
|
||||||
|
carrier_detected = False
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(0.5)
|
||||||
|
if is_carrier_ready(iface):
|
||||||
|
carrier_detected = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not carrier_detected:
|
||||||
|
logger.debug(f"GoPro interface '{iface}' present, but has NO-CARRIER. Is camera turned on and set to GoPro Connect?")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Carrier active on '{iface}'. Requesting DHCP lease...")
|
||||||
|
# udhcpc in Alpine exits cleanly and does not lock sockets
|
||||||
|
if shutil.which("udhcpc"):
|
||||||
|
subprocess.run(["udhcpc", "-i", iface, "-n", "-q", "-t", "4"], check=False)
|
||||||
|
elif shutil.which("dhcpcd"):
|
||||||
|
subprocess.run(["dhcpcd", "-1", "-4", "-q", "-t", "10", iface], check=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Interface configuration error on {iface}: {e}")
|
||||||
|
|
||||||
|
def detect_gopro_ip() -> str | None:
|
||||||
|
candidates = set()
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["ip", "-4", "addr", "show"], text=True)
|
||||||
|
subnets = re.findall(r'inet (172\.(?:2[0-9]|3[0-1])\.\d+)\.\d+/\d+', out)
|
||||||
|
for subnet in subnets:
|
||||||
|
candidates.add(f"{subnet}.51")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["ip", "-4", "route"], text=True)
|
||||||
|
subnets = re.findall(r'(172\.(?:2[0-9]|3[0-1])\.\d+)\.0/\d+', out)
|
||||||
|
for subnet in subnets:
|
||||||
|
candidates.add(f"{subnet}.51")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for ip in candidates:
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(f"http://{ip}:8080/gopro/camera/state", method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return ip
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_media_files(media_data) -> list[str]:
|
||||||
|
files = []
|
||||||
|
if isinstance(media_data, dict):
|
||||||
|
for group in media_data.get("media", []):
|
||||||
|
dir_name = group.get("directory", group.get("d", ""))
|
||||||
|
entries = group.get("file_system") or group.get("fs") or group.get("files") or []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
fn = entry.get("filename") or entry.get("n")
|
||||||
|
if fn:
|
||||||
|
files.append(f"{dir_name}/{fn}" if (dir_name and "/" not in fn) else fn)
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
files.append(f"{dir_name}/{entry}" if dir_name else entry)
|
||||||
|
elif hasattr(media_data, "files"):
|
||||||
|
for f in media_data.files:
|
||||||
|
files.append(f.filename if hasattr(f, "filename") else str(f))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def parse_gopro_filename(filename: str) -> tuple[str, int] | None:
|
||||||
|
m = re.match(r'^(GX|GH)(\d{2})(\d{4})\.MP4$', filename, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
prefix, chapter, take_num = m.group(1).upper(), int(m.group(2)), m.group(3)
|
||||||
|
return (f"{prefix}_{take_num}", chapter)
|
||||||
|
|
||||||
|
m = re.match(r'^GOPR(\d{4})\.MP4$', filename, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return (f"GOPR_{m.group(1)}", 1)
|
||||||
|
|
||||||
|
m = re.match(r'^GP(\d{2})(\d{4})\.MP4$', filename, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return (f"GOPR_{m.group(2)}", int(m.group(1)) + 1)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_file_already_processed(cam_path: str, history: set[str]) -> bool:
|
||||||
|
file_name = Path(cam_path).name
|
||||||
|
if file_name in history or cam_path in history:
|
||||||
|
return True
|
||||||
|
|
||||||
|
parsed = parse_gopro_filename(file_name)
|
||||||
|
if parsed:
|
||||||
|
take_id, _ = parsed
|
||||||
|
if (DEST_DIR / f"{take_id}.mp4").exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if (TEMP_DIR / file_name).exists() or (DEST_DIR / file_name).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def download_file_async(gopro_ip: str, cam_path: str, temp_file: Path, max_retries: int = 3) -> bool:
|
||||||
|
file_name = Path(cam_path).name
|
||||||
|
url = f"http://{gopro_ip}:8080/videos/DCIM/{cam_path}"
|
||||||
|
# Increase socket read timeout to prevent disconnects during SD card lag
|
||||||
|
timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=120)
|
||||||
|
|
||||||
|
for attempt in range(1, max_retries + 1):
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.get(url) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"Failed to fetch {cam_path}: HTTP {resp.status} (attempt {attempt}/{max_retries})")
|
||||||
|
if attempt < max_retries:
|
||||||
|
await asyncio.sleep(2 * attempt)
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
total_bytes = int(resp.headers.get("Content-Length", 0))
|
||||||
|
size_label = f" ({format_bytes(total_bytes)})" if total_bytes > 0 else ""
|
||||||
|
logger.info(f"Downloading {file_name}{size_label} [Attempt {attempt}/{max_retries}]...")
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
last_log_time = start_time
|
||||||
|
last_log_bytes = 0
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with open(temp_file, "wb") as f:
|
||||||
|
async for chunk in resp.content.iter_chunked(1024 * 1024):
|
||||||
|
f.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
if now - last_log_time >= LOG_PROGRESS_INTERVAL:
|
||||||
|
delta_t = now - last_log_time
|
||||||
|
delta_b = downloaded - last_log_bytes
|
||||||
|
speed = (delta_b / delta_t) if delta_t > 0 else 0
|
||||||
|
speed_str = f"{format_bytes(speed)}/s"
|
||||||
|
|
||||||
|
if total_bytes > 0:
|
||||||
|
pct = (downloaded / total_bytes) * 100
|
||||||
|
logger.info(
|
||||||
|
f"Progress [{file_name}]: {pct:.1f}% "
|
||||||
|
f"({format_bytes(downloaded)} / {format_bytes(total_bytes)}) @ {speed_str}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Progress [{file_name}]: {format_bytes(downloaded)} @ {speed_str}")
|
||||||
|
|
||||||
|
last_log_time = now
|
||||||
|
last_log_bytes = downloaded
|
||||||
|
|
||||||
|
# Verify received bytes match Content-Length header if provided
|
||||||
|
if total_bytes > 0 and downloaded < total_bytes:
|
||||||
|
raise aiohttp.ClientPayloadError(f"Incomplete download: got {downloaded}/{total_bytes} bytes")
|
||||||
|
|
||||||
|
elapsed = max(time.time() - start_time, 0.1)
|
||||||
|
avg_speed = downloaded / elapsed
|
||||||
|
logger.info(f"Completed {file_name}: {format_bytes(downloaded)} in {elapsed:.1f}s ({format_bytes(avg_speed)}/s)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
logger.warning(f"Error transferring {cam_path} (attempt {attempt}/{max_retries}): {err}")
|
||||||
|
if temp_file.exists():
|
||||||
|
temp_file.unlink(missing_ok=True)
|
||||||
|
if attempt < max_retries:
|
||||||
|
await asyncio.sleep(3 * attempt)
|
||||||
|
else:
|
||||||
|
logger.error(f"Exhausted all retries for {cam_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def process_and_finalize_videos(all_camera_files: list[str]):
|
||||||
|
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Map what chapters exist in the camera inventory
|
||||||
|
camera_takes = defaultdict(set)
|
||||||
|
for p in all_camera_files:
|
||||||
|
fn = Path(p).name
|
||||||
|
parsed = parse_gopro_filename(fn)
|
||||||
|
if parsed:
|
||||||
|
take_id, chapter = parsed
|
||||||
|
camera_takes[take_id].add(chapter)
|
||||||
|
|
||||||
|
# 2. Map what staged chapters we have on disk in TEMP_DIR
|
||||||
|
staged_takes = defaultdict(dict)
|
||||||
|
for p in TEMP_DIR.glob("*.[mM][pP]4"):
|
||||||
|
parsed = parse_gopro_filename(p.name)
|
||||||
|
if parsed:
|
||||||
|
take_id, chapter = parsed
|
||||||
|
staged_takes[take_id][chapter] = p
|
||||||
|
|
||||||
|
for take_id, chapters in staged_takes.items():
|
||||||
|
expected_chapters = camera_takes.get(take_id, set())
|
||||||
|
staged_chapters = set(chapters.keys())
|
||||||
|
|
||||||
|
# If any chapter for this take is still missing from TEMP_DIR, wait for future run
|
||||||
|
if expected_chapters and not expected_chapters.issubset(staged_chapters):
|
||||||
|
missing = sorted(list(expected_chapters - staged_chapters))
|
||||||
|
logger.info(f"Skipping finalize for '{take_id}': waiting for missing chapter(s) {missing}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
sorted_chapters = [chapters[k] for k in sorted(chapters.keys())]
|
||||||
|
output_file = DEST_DIR / f"{take_id}.mp4"
|
||||||
|
temp_output_file = DEST_DIR / f".{take_id}.tmp.mp4"
|
||||||
|
|
||||||
|
if output_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_hevc = take_id.startswith("GX")
|
||||||
|
tag_args = ["-tag:v", "hvc1"] if is_hevc else []
|
||||||
|
primary_source_file = sorted_chapters[0]
|
||||||
|
|
||||||
|
creation_time = get_source_creation_time(primary_source_file)
|
||||||
|
meta_args = ["-metadata", f"creation_time={creation_time}"] if creation_time else []
|
||||||
|
|
||||||
|
if len(sorted_chapters) == 1:
|
||||||
|
logger.info(f"Finalizing single-take '{take_id}' -> {output_file.name}...")
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", str(primary_source_file),
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "0:a?",
|
||||||
|
*meta_args,
|
||||||
|
"-c", "copy",
|
||||||
|
"-sn", "-dn",
|
||||||
|
*tag_args,
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
str(temp_output_file)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
logger.info(f"Stitching {len(sorted_chapters)} chapters for '{take_id}' -> {output_file.name} (Timestamp: {creation_time})...")
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as concat_list:
|
||||||
|
for ch in sorted_chapters:
|
||||||
|
concat_list.write(f"file '{ch.resolve()}'\n")
|
||||||
|
concat_path = concat_list.name
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-f", "concat",
|
||||||
|
"-safe", "0",
|
||||||
|
"-i", concat_path,
|
||||||
|
"-map", "0:v:0",
|
||||||
|
"-map", "0:a?",
|
||||||
|
*meta_args,
|
||||||
|
"-c", "copy",
|
||||||
|
"-sn", "-dn",
|
||||||
|
*tag_args,
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
str(temp_output_file)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
|
stderr=asyncio.subprocess.PIPE
|
||||||
|
)
|
||||||
|
_, stderr = await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode == 0 and temp_output_file.exists():
|
||||||
|
temp_output_file.rename(output_file)
|
||||||
|
src_stat = primary_source_file.stat()
|
||||||
|
os.utime(output_file, (src_stat.st_atime, src_stat.st_mtime))
|
||||||
|
|
||||||
|
logger.info(f"Ready for upload: {output_file.name} ({format_bytes(output_file.stat().st_size)})")
|
||||||
|
|
||||||
|
raw_names = [ch.name for ch in sorted_chapters]
|
||||||
|
record_history(raw_names)
|
||||||
|
|
||||||
|
if not KEEP_RAW_CHAPTERS:
|
||||||
|
logger.info(f"Cleaning raw staging files for '{take_id}' from temp...")
|
||||||
|
for ch in sorted_chapters:
|
||||||
|
ch.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
logger.error(f"FFmpeg processing failed for {take_id}: {stderr.decode()}")
|
||||||
|
if temp_output_file.exists():
|
||||||
|
temp_output_file.unlink(missing_ok=True)
|
||||||
|
finally:
|
||||||
|
if 'concat_path' in locals() and Path(concat_path).exists():
|
||||||
|
Path(concat_path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
async def sync_gopro(gopro_ip: str):
|
||||||
|
global last_synced_fingerprint
|
||||||
|
DEST_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with WiredGoPro(ip=gopro_ip) as gopro:
|
||||||
|
resp = await gopro.http_command.get_media_list()
|
||||||
|
if not resp.ok:
|
||||||
|
return
|
||||||
|
|
||||||
|
all_files = extract_media_files(resp.data)
|
||||||
|
if not all_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
history = load_history()
|
||||||
|
pending_files = [f for f in all_files if not is_file_already_processed(f, history)]
|
||||||
|
current_fingerprint = f"{gopro_ip}:{len(all_files)}:{len(pending_files)}"
|
||||||
|
|
||||||
|
if not pending_files:
|
||||||
|
if last_synced_fingerprint != current_fingerprint:
|
||||||
|
logger.info(f"GoPro connected ({len(all_files)} files found). All media up to date. Idle...")
|
||||||
|
last_synced_fingerprint = current_fingerprint
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"GoPro connected: {len(pending_files)} new file(s) to ingest (Total on SD: {len(all_files)}).")
|
||||||
|
|
||||||
|
ingested_files_this_run = []
|
||||||
|
for cam_path in pending_files:
|
||||||
|
file_name = Path(cam_path).name
|
||||||
|
|
||||||
|
if file_name.upper().endswith(".MP4"):
|
||||||
|
target_file = TEMP_DIR / file_name
|
||||||
|
temp_file = TEMP_DIR / f".{file_name}.tmp"
|
||||||
|
else:
|
||||||
|
target_file = DEST_DIR / file_name
|
||||||
|
temp_file = DEST_DIR / f".{file_name}.tmp"
|
||||||
|
|
||||||
|
try:
|
||||||
|
success = await download_file_async(
|
||||||
|
gopro_ip=gopro_ip,
|
||||||
|
cam_path=cam_path,
|
||||||
|
temp_file=temp_file
|
||||||
|
)
|
||||||
|
|
||||||
|
if success and temp_file.exists():
|
||||||
|
temp_file.rename(target_file)
|
||||||
|
logger.info(f"Ingested to staging: {file_name}")
|
||||||
|
ingested_files_this_run.append(file_name)
|
||||||
|
|
||||||
|
if not file_name.upper().endswith(".MP4"):
|
||||||
|
record_history([file_name])
|
||||||
|
|
||||||
|
if DELETE_AFTER_INGEST:
|
||||||
|
logger.info(f"Deleting {cam_path} from camera SD card...")
|
||||||
|
await gopro.http_command.delete_file(path=cam_path)
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to ingest: {cam_path}")
|
||||||
|
if temp_file.exists():
|
||||||
|
temp_file.unlink()
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Error transferring {cam_path}: {err}")
|
||||||
|
if temp_file.exists():
|
||||||
|
temp_file.unlink()
|
||||||
|
|
||||||
|
logger.info("Checking staged media for completed takes...")
|
||||||
|
await process_and_finalize_videos(all_files)
|
||||||
|
|
||||||
|
logger.info("All media finalized in root directory. Camera can be safely unplugged.")
|
||||||
|
last_synced_fingerprint = f"{gopro_ip}:{len(all_files)}:0"
|
||||||
|
|
||||||
|
except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
|
||||||
|
last_synced_fingerprint = ""
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Sync event: {e}")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
global last_synced_fingerprint
|
||||||
|
logger.info(f"GoPro Ingest Daemon active. Target folder: '{DEST_DIR}'")
|
||||||
|
while True:
|
||||||
|
auto_configure_gopro_interfaces()
|
||||||
|
gopro_ip = detect_gopro_ip()
|
||||||
|
if gopro_ip:
|
||||||
|
await sync_gopro(gopro_ip)
|
||||||
|
else:
|
||||||
|
last_synced_fingerprint = ""
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
asyncio.run(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Ingest daemon stopped.")
|
||||||
Reference in new issue
Block a user