Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 25 additions & 19 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
name: Release

on:
pull_request:
types: [closed]
push:
branches: [main]

jobs:
release:
name: Create Release on merge
if: github.event.pull_request.merged == true
name: Create Release on merge to main
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -17,17 +15,19 @@ jobs:
with:
fetch-depth: 0
fetch-tags: true
ref: ${{ github.event.pull_request.merge_commit_sha }}

- name: Bump version from commits
id: version
run: |
# Conventional commits: feat! = major, feat = minor, fix = patch. Start from v1.0.0.
MERGE="${{ github.event.pull_request.merge_commit_sha }}"
# Commits in the PR (works for merge and squash)
COMMITS=$(git log $MERGE^1..$MERGE --format=%s 2>/dev/null || git log -1 --format=%s $MERGE)

# Conventional commits: feat! = major, feat = minor, fix = patch
BEFORE="${{ github.event.before }}"
AFTER="${{ github.sha }}"
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
COMMITS=$(git log --format=%s 2>/dev/null)
else
COMMITS=$(git log $BEFORE..$AFTER --format=%s 2>/dev/null || git log -1 --format=%s)
fi

BUMP="patch"
echo "$COMMITS" | grep -qE '^feat!|^feat\([^)]*\)!' && BUMP="major"
if [ "$BUMP" != "major" ]; then
Expand All @@ -37,7 +37,6 @@ jobs:
echo "$COMMITS" | grep -qE '^fix(\([^)]*\))?:?' && BUMP="patch"
fi

# Get latest tag or use v1.0.0
LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
[[ "$LATEST" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+) ]] || LATEST="v0.0.0"
MAJOR=${BASH_REMATCH[1]:-0}
Expand All @@ -50,13 +49,8 @@ jobs:
patch) PATCH=$((PATCH+1)) ;;
esac

# If no tags, start at v1.0.0
if [ "$LATEST" = "v0.0.0" ]; then
case "$BUMP" in
major) VERSION="v1.0.0" ;;
minor) VERSION="v1.0.0" ;;
patch) VERSION="v1.0.0" ;;
esac
VERSION="v1.0.0"
else
VERSION="v${MAJOR}.${MINOR}.${PATCH}"
fi
Expand All @@ -67,13 +61,25 @@ jobs:

- name: Write release body
run: |
echo '${{ toJSON(github.event.pull_request.body) }}' | jq -r 'if . == null or . == "" then "No description." else . end' > release_body.md
# Try to get PR body from merged PR (commit message may contain "Merge pull request #N")
PR_NUM=$(git log -1 --format=%B "${{ github.sha }}" | grep -oE 'Merge pull request #[0-9]+' | grep -oE '[0-9]+' | head -1)
if [ -n "$PR_NUM" ]; then
BODY=$(gh pr view "$PR_NUM" --repo "${{ github.repository }}" --json body -q .body 2>/dev/null || true)
fi
if [ -z "$BODY" ]; then
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
BODY="## Changes\n\n$(git log --format='- %s' 2>/dev/null || echo 'No description.')"
else
BODY="## Changes\n\n$(git log $BEFORE..$AFTER --format='- %s' 2>/dev/null || echo 'No description.')"
fi
fi
echo -e "$BODY" > release_body.md

- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
target_commitish: ${{ github.event.pull_request.merge_commit_sha }}
target_commitish: ${{ github.sha }}
body_path: release_body.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
35 changes: 24 additions & 11 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import html
import json
import os
import time
import re
import signal
import sys
Expand Down Expand Up @@ -382,7 +383,7 @@ def admin():
return render_template('admin.html', **ctx)


def _fetch_og_meta(url: str, timeout: float = 4.0) -> dict:
def _fetch_og_meta(url: str, timeout: float = 5.0) -> dict:
"""Fetch og:title and og:image from URL. Returns {title, image} or empty dict on failure."""
if not url or not url.startswith(('http://', 'https://')):
return {}
Expand All @@ -395,26 +396,36 @@ def _fetch_og_meta(url: str, timeout: float = 4.0) -> dict:
cache = json.load(f)
except (json.JSONDecodeError, OSError):
pass
if url in cache:
return cache[url]
cached = cache.get(url)
if cached and isinstance(cached, dict):
ts = cached.get('_ts', 0)
if ts and (time.time() - ts) < 86400: # 24h TTL
return {k: v for k, v in cached.items() if k != '_ts'}
result = {}
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (compatible; StreamStats/1.0)'})
req = urllib.request.Request(
url,
headers={
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'en-US,en;q=0.9',
},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
html = resp.read().decode('utf-8', errors='replace')
m_title = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.I)
html_content = resp.read().decode('utf-8', errors='replace')
m_title = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html_content, re.I)
if not m_title:
m_title = re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:title["\']', html, re.I)
m_image = re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.I)
m_title = re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:title["\']', html_content, re.I)
m_image = re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html_content, re.I)
if not m_image:
m_image = re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', html, re.I)
m_image = re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', html_content, re.I)
if m_title:
result['title'] = html.unescape(m_title.group(1).strip())[:120]
if m_image:
result['image'] = m_image.group(1).strip()
except Exception:
pass
cache[url] = result
cache[url] = dict(result, _ts=time.time())
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
with open(cache_path, 'w') as f:
Expand All @@ -439,10 +450,12 @@ def _stats_context():
for item in play_counts.get('models', []):
model, count = item[0], item[1]
video_id = item[2] if len(item) > 2 else None
stored_thumb = item[3] if len(item) > 3 else None
url = model if model.startswith('http') else 'https://' + model
meta = _fetch_og_meta(url)
title = html.unescape(meta.get('title') or url)
thumbnail = f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg" if video_id else meta.get('image')
# Prefer YouTube thumbnail from video_id (play_counts) when available; else stored_thumb, else OG image
thumbnail = (f"https://img.youtube.com/vi/{video_id}/hqdefault.jpg" if video_id else None) or stored_thumb or meta.get('image')
models_enriched.append({
'url': url,
'count': count,
Expand Down
71 changes: 63 additions & 8 deletions clip_pusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
STREAM_STATS_FILENAME = ".stream_stats.json"
CHUNKS_CREATED_FILENAME = ".chunks_created_total"
PLAY_COUNTS_FILENAME = ".play_counts.json"
AUDIO_QUEUE_FILENAME = ".audio_queue.txt"

# ── Output normalization ──────────────────────────────────────────
OUTPUT_AUDIO_RATE = 44100
Expand Down Expand Up @@ -63,6 +64,7 @@ def __init__(self, chunk_folder: str, rtmp_url: str,
self._streamer_process: Optional[subprocess.Popen] = None
self._play_chunk_next: Optional[str] = None
self._play_chunk_lock = threading.Lock()
self._audio_queue: List[str] = []

self._load_stream_stats()

Expand Down Expand Up @@ -182,29 +184,36 @@ def _record_play_count(self, chunk_path: str, audio_name: Optional[str]) -> None
meta = json.load(f)
raw_sources = meta.get('source_videos') or []
model_to_video = {}
model_to_thumbnail = {}
fallback_vid = None
for item in raw_sources:
path = item.get('path') if isinstance(item, dict) else (item if isinstance(item, str) else None)
model = item.get('model') if isinstance(item, dict) else None
thumb = item.get('thumbnail_url') if isinstance(item, dict) else None
if path:
vid = self._extract_video_id(path)
if vid:
if not fallback_vid:
fallback_vid = vid
if model and model not in model_to_video:
model_to_video[model] = vid
if model and model not in model_to_thumbnail:
model_to_thumbnail[model] = thumb or f"https://img.youtube.com/vi/{vid}/hqdefault.jpg"
for m in (meta.get('model_info') or []):
if m:
vid = model_to_video.get(m) or fallback_vid
thumb = model_to_thumbnail.get(m)
entry = models.get(m)
if isinstance(entry, dict):
entry['count'] = entry.get('count', 0) + 1
if vid:
entry['video_id'] = vid
if thumb:
entry['thumbnail_url'] = thumb
elif isinstance(entry, (int, float)):
models[m] = {'count': entry + 1, 'video_id': vid} if vid else entry + 1
models[m] = {'count': entry + 1, 'video_id': vid, 'thumbnail_url': thumb} if (vid or thumb) else entry + 1
else:
models[m] = {'count': 1, 'video_id': vid} if vid else 1
models[m] = {'count': 1, 'video_id': vid, 'thumbnail_url': thumb} if (vid or thumb) else 1
except (json.JSONDecodeError, OSError):
pass

Expand All @@ -224,7 +233,8 @@ def get_play_counts(self) -> dict:
for url, entry in models.items():
count = entry.get('count', entry) if isinstance(entry, dict) else entry
video_id = entry.get('video_id') if isinstance(entry, dict) else None
top_models.append((url, count, video_id))
thumbnail_url = entry.get('thumbnail_url') if isinstance(entry, dict) else None
top_models.append((url, count, video_id, thumbnail_url))
top_models.sort(key=lambda x: -x[1])
top_models = top_models[:20]
top_audio = sorted(audio.items(), key=lambda x: -x[1])[:20]
Expand Down Expand Up @@ -314,8 +324,54 @@ def play_chunk(self, chunk_name: str) -> bool:

# ── Internal ──────────────────────────────────────────────────

def _audio_queue_path(self) -> str:
return os.path.join(self._stats_dir, AUDIO_QUEUE_FILENAME)

def _load_audio_queue(self) -> List[str]:
path = self._audio_queue_path()
if os.path.isfile(path):
try:
with open(path, 'r') as f:
lines = [l.strip() for l in f if l.strip()]
valid = [p for p in lines if os.path.isfile(p)]
if valid:
return valid
except OSError:
pass
return []

def _save_audio_queue(self, queue: List[str]) -> None:
path = self._audio_queue_path()
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write('\n'.join(queue))
except OSError:
pass

def _get_next_audio(self) -> Optional[str]:
"""Get next audio from LRU queue (take from head, move to tail). Ensures fair rotation."""
if not self._audio_files:
return None
if not self._audio_queue:
self._audio_queue = self._load_audio_queue()
valid = [p for p in self._audio_queue if os.path.isfile(p)]
new_files = [p for p in self._audio_files if p not in valid]
if not valid or new_files:
valid = list(valid) + new_files
random.shuffle(valid)
if not valid:
valid = list(self._audio_files)
random.shuffle(valid)
audio = valid.pop(0)
valid.append(audio)
self._audio_queue = valid
self._save_audio_queue(valid)
self._current_audio = os.path.basename(audio)
return audio

def _get_audio_file(self) -> Optional[str]:
"""Get a random audio file for background music."""
"""Get a random audio file (used by skip_to_next_audio)."""
if not self._audio_files:
return None
audio = random.choice(self._audio_files)
Expand Down Expand Up @@ -442,10 +498,10 @@ def _push_loop(self):
chunks.remove(full)
chunks.insert(0, full)

# Pick one audio track for the whole round; get duration so we can resume position across chunks
# Pick one audio track for the whole round (LRU queue for fair rotation when switching)
if self._audio_files:
if self._persistent_audio_path is None or not os.path.isfile(self._persistent_audio_path):
self._persistent_audio_path = self._get_audio_file()
self._persistent_audio_path = self._get_next_audio()
self._current_audio = os.path.basename(self._persistent_audio_path) if self._persistent_audio_path else None
self._audio_position = 0.0
if self._persistent_audio_path:
Expand All @@ -456,14 +512,13 @@ def _push_loop(self):
)
self._persistent_audio_duration = float(out.decode('utf-8').strip())
except Exception as e:
# If we can't get duration, position would never advance (always 0). Use fallback.
self._persistent_audio_duration = 3600.0
print(f"Warning: ffprobe duration failed for {self._persistent_audio_path}: {e}. Using 3600s fallback.")

for chunk in chunks:
if not self._running:
break

self._current_chunk = os.path.basename(chunk)
self._current_chunk_started_at = time.time()
self._current_chunk_duration = None # set in _stream_chunk after ffprobe
Expand Down
Loading
Loading