diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e48150..0bfb778 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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} @@ -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 @@ -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 }} diff --git a/app.py b/app.py index 0ad585d..c7bc230 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import html import json import os +import time import re import signal import sys @@ -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 {} @@ -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']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.I) + html_content = resp.read().decode('utf-8', errors='replace') + m_title = re.search(r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html_content, re.I) if not m_title: - m_title = re.search(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:title["\']', html, re.I) - m_image = re.search(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.I) + m_title = re.search(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:title["\']', html_content, re.I) + m_image = re.search(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html_content, re.I) if not m_image: - m_image = re.search(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', html, re.I) + m_image = re.search(r']+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: @@ -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, diff --git a/clip_pusher.py b/clip_pusher.py index 7fa3d6c..e072e59 100644 --- a/clip_pusher.py +++ b/clip_pusher.py @@ -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 @@ -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() @@ -182,10 +184,12 @@ 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: @@ -193,18 +197,23 @@ def _record_play_count(self, chunk_path: str, audio_name: Optional[str]) -> None 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 @@ -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] @@ -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) @@ -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: @@ -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 diff --git a/templates/dashboard.html b/templates/dashboard.html index 24a6dda..b3c5cbf 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -34,7 +34,7 @@
No source files.
'; } else { + var srcList = sources || []; + var modelToThumb = {}; + srcList.forEach(function(s) { + var mod = typeof s === 'object' && s && s.model; + var thumb = typeof s === 'object' && s && s.thumbnail_url; + var path = typeof s === 'object' && s && s.path; + if (!mod) return; + if (modelToThumb[mod]) return; + if (thumb) { modelToThumb[mod] = thumb; return; } + if (path) { + var stem = path.replace(/^.*\//, '').replace(/\.(mp4|mkv|avi)$/i, ''); + if (stem.length === 11 && /^[a-zA-Z0-9_-]+$/.test(stem)) { + modelToThumb[mod] = 'https://img.youtube.com/vi/' + stem + '/hqdefault.jpg'; + } + } + }); this.body = (items || []).map(m => { const href = (m.indexOf('http') === 0 ? m : 'https://' + m); - return ''; + var thumbSrc = modelToThumb[m]; + var escaped = (m || '').replace(//g, '>'); + var linkContent = '' + escaped + ''; + if (thumbSrc) { + return ''; + } + return 'No models.
'; } this.isOpen = true; @@ -404,7 +426,7 @@