Initial Commit
This commit is contained in:
3 files changed
+395
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 }}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ffmpeg \
|
||||||
|
curl
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
requests \
|
||||||
|
requests-toolbelt
|
||||||
|
|
||||||
|
COPY uploader.py /app/uploader.py
|
||||||
|
RUN chmod +x /app/uploader.py
|
||||||
|
|
||||||
|
CMD ["python3", "/app/uploader.py"]
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
import requests
|
||||||
|
from requests_toolbelt.multipart.encoder import MultipartEncoder, MultipartEncoderMonitor
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("peertube-uploader")
|
||||||
|
|
||||||
|
# ----------------- Configurations -----------------
|
||||||
|
WATCH_DIR = Path(os.getenv("WATCH_DIR", "/media"))
|
||||||
|
UPLOAD_HISTORY_FILE = WATCH_DIR / ".uploaded_history.txt"
|
||||||
|
|
||||||
|
PEERTUBE_URL = os.getenv("PEERTUBE_URL", "http://peertube:9000").rstrip("/")
|
||||||
|
PEERTUBE_USER = os.getenv("PEERTUBE_USER", "").strip()
|
||||||
|
PEERTUBE_PASSWORD = os.getenv("PEERTUBE_PASSWORD", "").strip()
|
||||||
|
PEERTUBE_CHANNEL = os.getenv("PEERTUBE_CHANNEL", "").strip()
|
||||||
|
PEERTUBE_HOST_HEADER = os.getenv("PEERTUBE_HOST_HEADER", "").strip()
|
||||||
|
|
||||||
|
# Video Metadata Template
|
||||||
|
TITLE_PREFIX = os.getenv("TITLE_PREFIX", "Practice")
|
||||||
|
VIDEO_CATEGORY = int(os.getenv("VIDEO_CATEGORY", "5")) # 5 = Sports
|
||||||
|
VIDEO_PRIVACY = int(os.getenv("VIDEO_PRIVACY", "4")) # 1=Public, 2=Unlisted, 3=Private, 4=Internal
|
||||||
|
VIDEO_LANGUAGE = os.getenv("VIDEO_LANGUAGE", "en")
|
||||||
|
VIDEO_TAGS = [t.strip() for t in os.getenv("VIDEO_TAGS", "volleyball,practice").split(",") if t.strip()]
|
||||||
|
VIDEO_DESCRIPTION_TEMPLATE = os.getenv(
|
||||||
|
"VIDEO_DESCRIPTION",
|
||||||
|
"Recorded on {date}.\nAutomated ingest and upload from GoPro HERO13."
|
||||||
|
)
|
||||||
|
|
||||||
|
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "15"))
|
||||||
|
MOVE_TO_UPLOADED = os.getenv("MOVE_TO_UPLOADED", "false").lower() == "true"
|
||||||
|
DELETE_AFTER_UPLOAD = os.getenv("DELETE_AFTER_UPLOAD", "false").lower() == "true"
|
||||||
|
UPLOADED_DIR = WATCH_DIR / "uploaded"
|
||||||
|
|
||||||
|
# ----------------- Helpers -----------------
|
||||||
|
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_upload_history() -> set[str]:
|
||||||
|
if not UPLOAD_HISTORY_FILE.exists():
|
||||||
|
return set()
|
||||||
|
try:
|
||||||
|
with open(UPLOAD_HISTORY_FILE, "r") as f:
|
||||||
|
return {line.strip() for line in f if line.strip()}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load upload history: {e}")
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def record_upload_history(filename: str):
|
||||||
|
try:
|
||||||
|
with open(UPLOAD_HISTORY_FILE, "a") as f:
|
||||||
|
f.write(f"{filename}\n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to record upload history for {filename}: {e}")
|
||||||
|
|
||||||
|
def get_take_number(filepath: Path) -> int:
|
||||||
|
"""Extract numeric sequence ID (GX_0061 -> 61) for deterministic sorting."""
|
||||||
|
m = re.search(r'(\d{4})', filepath.stem)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def get_video_recording_datetime(filepath: Path) -> datetime.datetime:
|
||||||
|
"""Extract true recording datetime from video tags, stream tags, or timecode."""
|
||||||
|
cmd = [
|
||||||
|
"ffprobe", "-v", "quiet",
|
||||||
|
"-print_format", "json",
|
||||||
|
"-show_entries", "format_tags=creation_time:stream_tags=creation_time:stream_tags=timecode",
|
||||||
|
str(filepath)
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# 1. Format creation_time
|
||||||
|
creation_str = data.get("format", {}).get("tags", {}).get("creation_time")
|
||||||
|
|
||||||
|
# 2. Stream creation_time
|
||||||
|
if not creation_str:
|
||||||
|
for stream in data.get("streams", []):
|
||||||
|
creation_str = stream.get("tags", {}).get("creation_time")
|
||||||
|
if creation_str:
|
||||||
|
break
|
||||||
|
|
||||||
|
if creation_str:
|
||||||
|
creation_str = creation_str.replace("Z", "+00:00")
|
||||||
|
dt = datetime.datetime.fromisoformat(creation_str)
|
||||||
|
return dt.astimezone()
|
||||||
|
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"ffprobe metadata extraction failed for {filepath.name}: {err}")
|
||||||
|
|
||||||
|
# Fallback to filesystem mtime (synced with original capture timestamp)
|
||||||
|
mtime = filepath.stat().st_mtime
|
||||||
|
return datetime.datetime.fromtimestamp(mtime).astimezone()
|
||||||
|
|
||||||
|
# ----------------- PeerTube API Client -----------------
|
||||||
|
class PeerTubeClient:
|
||||||
|
def __init__(self, base_url, username, password, host_header=""):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.host_header = host_header
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.channel_id = None
|
||||||
|
|
||||||
|
if self.host_header:
|
||||||
|
self.session.headers.update({
|
||||||
|
"Host": self.host_header,
|
||||||
|
"X-Forwarded-Proto": "https"
|
||||||
|
})
|
||||||
|
|
||||||
|
def authenticate(self):
|
||||||
|
logger.info(f"Authenticating with PeerTube at {self.base_url} as '{self.username}'...")
|
||||||
|
oauth_resp = self.session.get(f"{self.base_url}/api/v1/oauth-clients/local", timeout=10)
|
||||||
|
oauth_resp.raise_for_status()
|
||||||
|
client_data = oauth_resp.json()
|
||||||
|
|
||||||
|
token_payload = {
|
||||||
|
"client_id": client_data["client_id"],
|
||||||
|
"client_secret": client_data["client_secret"],
|
||||||
|
"grant_type": "password",
|
||||||
|
"response_type": "code",
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password
|
||||||
|
}
|
||||||
|
|
||||||
|
token_resp = self.session.post(
|
||||||
|
f"{self.base_url}/api/v1/users/token",
|
||||||
|
data=token_payload,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Authentication failed ({token_resp.status_code}): {token_resp.text}")
|
||||||
|
|
||||||
|
access_token = token_resp.json()["access_token"]
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||||||
|
logger.info(f"Authenticated successfully as '{self.username}'")
|
||||||
|
|
||||||
|
def resolve_channel_id(self, channel_name: str) -> int:
|
||||||
|
if self.channel_id:
|
||||||
|
return self.channel_id
|
||||||
|
|
||||||
|
if not channel_name:
|
||||||
|
me_resp = self.session.get(f"{self.base_url}/api/v1/users/me", timeout=10)
|
||||||
|
me_resp.raise_for_status()
|
||||||
|
channels = me_resp.json().get("videoChannels", [])
|
||||||
|
if not channels:
|
||||||
|
raise RuntimeError("No channel found on this PeerTube account.")
|
||||||
|
self.channel_id = channels[0]["id"]
|
||||||
|
logger.info(f"Using default channel: {channels[0]['name']} (ID: {self.channel_id})")
|
||||||
|
return self.channel_id
|
||||||
|
|
||||||
|
ch_resp = self.session.get(f"{self.base_url}/api/v1/video-channels/{channel_name}", timeout=10)
|
||||||
|
if ch_resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Channel '{channel_name}' not found: {ch_resp.text}")
|
||||||
|
self.channel_id = ch_resp.json()["id"]
|
||||||
|
logger.info(f"Resolved channel '{channel_name}' -> ID {self.channel_id}")
|
||||||
|
return self.channel_id
|
||||||
|
|
||||||
|
def upload_video(self, file_path: Path, title: str, description: str, category: int, privacy: int, tags: list[str], language: str):
|
||||||
|
total_size = file_path.stat().st_size
|
||||||
|
logger.info(f"Uploading '{title}' ({format_bytes(total_size)})...")
|
||||||
|
|
||||||
|
fields = [
|
||||||
|
("channelId", str(self.channel_id)),
|
||||||
|
("name", title[:120]),
|
||||||
|
("category", str(category)),
|
||||||
|
("privacy", str(privacy)),
|
||||||
|
("language", language),
|
||||||
|
("description", description),
|
||||||
|
("videofile", (file_path.name, open(file_path, "rb"), "video/mp4"))
|
||||||
|
]
|
||||||
|
for tag in tags[:5]:
|
||||||
|
fields.append(("tags[]", tag))
|
||||||
|
|
||||||
|
encoder = MultipartEncoder(fields=fields)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
last_log_time = start_time
|
||||||
|
last_bytes = 0
|
||||||
|
|
||||||
|
def progress_callback(monitor):
|
||||||
|
nonlocal last_log_time, last_bytes
|
||||||
|
now = time.time()
|
||||||
|
if now - last_log_time >= 5:
|
||||||
|
delta_t = now - last_log_time
|
||||||
|
delta_b = monitor.bytes_read - last_bytes
|
||||||
|
speed = (delta_b / delta_t) if delta_t > 0 else 0
|
||||||
|
pct = (monitor.bytes_read / monitor.len) * 100
|
||||||
|
logger.info(f"Upload [{title}]: {pct:.1f}% ({format_bytes(monitor.bytes_read)} / {format_bytes(monitor.len)}) @ {format_bytes(speed)}/s")
|
||||||
|
last_log_time = now
|
||||||
|
last_bytes = monitor.bytes_read
|
||||||
|
|
||||||
|
monitor = MultipartEncoderMonitor(encoder, progress_callback)
|
||||||
|
headers = {"Content-Type": monitor.content_type}
|
||||||
|
if self.host_header:
|
||||||
|
headers["Host"] = self.host_header
|
||||||
|
headers["X-Forwarded-Proto"] = "https"
|
||||||
|
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.base_url}/api/v1/videos/upload",
|
||||||
|
data=monitor,
|
||||||
|
headers=headers,
|
||||||
|
timeout=None
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201, 202):
|
||||||
|
raise RuntimeError(f"Upload failed ({resp.status_code}): {resp.text}")
|
||||||
|
|
||||||
|
elapsed = max(time.time() - start_time, 0.1)
|
||||||
|
video_uuid = resp.json().get("video", {}).get("uuid", "UNKNOWN")
|
||||||
|
logger.info(f"Published '{title}' (UUID: {video_uuid}) in {elapsed:.1f}s ({format_bytes(total_size / elapsed)}/s)")
|
||||||
|
return video_uuid
|
||||||
|
|
||||||
|
# ----------------- Processing Routine -----------------
|
||||||
|
def scan_and_upload():
|
||||||
|
history = load_upload_history()
|
||||||
|
|
||||||
|
pending_files = []
|
||||||
|
for f in WATCH_DIR.glob("*.mp4"):
|
||||||
|
if f.name.startswith(".") or f.is_dir():
|
||||||
|
continue
|
||||||
|
if f.name not in history:
|
||||||
|
size1 = f.stat().st_size
|
||||||
|
time.sleep(0.5)
|
||||||
|
size2 = f.stat().st_size
|
||||||
|
if size1 == size2 and size1 > 0:
|
||||||
|
pending_files.append(f)
|
||||||
|
|
||||||
|
if not pending_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group files by true recording date
|
||||||
|
date_groups = defaultdict(list)
|
||||||
|
for f in pending_files:
|
||||||
|
dt = get_video_recording_datetime(f)
|
||||||
|
take_num = get_take_number(f)
|
||||||
|
date_str = dt.strftime("%Y-%m-%d")
|
||||||
|
date_groups[date_str].append((take_num, dt, f))
|
||||||
|
|
||||||
|
client = PeerTubeClient(
|
||||||
|
base_url=PEERTUBE_URL,
|
||||||
|
username=PEERTUBE_USER,
|
||||||
|
password=PEERTUBE_PASSWORD,
|
||||||
|
host_header=PEERTUBE_HOST_HEADER
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
client.authenticate()
|
||||||
|
client.resolve_channel_id(PEERTUBE_CHANNEL)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PeerTube connection failed: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for date_str, items in date_groups.items():
|
||||||
|
# Sort strictly by GoPro take number (0061 before 0062)
|
||||||
|
items.sort(key=lambda x: (x[0], x[1]))
|
||||||
|
total_parts = len(items)
|
||||||
|
|
||||||
|
for idx, (take_num, dt, video_path) in enumerate(items, 1):
|
||||||
|
if total_parts > 1:
|
||||||
|
title = f"{TITLE_PREFIX} - {date_str} - Part {idx}"
|
||||||
|
else:
|
||||||
|
title = f"{TITLE_PREFIX} - {date_str}"
|
||||||
|
|
||||||
|
description = VIDEO_DESCRIPTION_TEMPLATE.format(
|
||||||
|
date=date_str,
|
||||||
|
time=dt.strftime("%H:%M:%S"),
|
||||||
|
filename=video_path.name,
|
||||||
|
part=idx,
|
||||||
|
total_parts=total_parts
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.upload_video(
|
||||||
|
file_path=video_path,
|
||||||
|
title=title,
|
||||||
|
description=description,
|
||||||
|
category=VIDEO_CATEGORY,
|
||||||
|
privacy=VIDEO_PRIVACY,
|
||||||
|
tags=VIDEO_TAGS,
|
||||||
|
language=VIDEO_LANGUAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
record_upload_history(video_path.name)
|
||||||
|
|
||||||
|
if DELETE_AFTER_UPLOAD:
|
||||||
|
logger.info(f"Removing local file: {video_path.name}")
|
||||||
|
video_path.unlink(missing_ok=True)
|
||||||
|
elif MOVE_TO_UPLOADED:
|
||||||
|
UPLOADED_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = UPLOADED_DIR / video_path.name
|
||||||
|
logger.info(f"Moving to archive: {dest}")
|
||||||
|
video_path.rename(dest)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to upload {video_path.name}: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.info(f"PeerTube Auto-Uploader started. Monitoring '{WATCH_DIR}' -> '{PEERTUBE_URL}'")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
scan_and_upload()
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(f"Scanner error: {err}")
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Uploader service stopped.")
|
||||||
Reference in new issue
Block a user