fix(musicseer): implement sequential playlist scraping via embeds, add Deno to Dockerfile for yt-dlp, and configure Always imagePullPolicy
Build and Push Docker Images / build (api) (push) Successful in 2m58s Details
Build and Push Docker Images / build (musicseerr) (push) Successful in 8m1s Details
Build and Push Docker Images / build (web) (push) Successful in 1m55s Details
Build and Push Docker Images / build (nextgen) (push) Successful in 7m31s Details
Build and Push Docker Images / build (worker) (push) Successful in 10m9s Details
Build and Push Docker Images / deploy (push) Successful in 48s Details

This commit is contained in:
fchinembiri 2026-07-10 07:24:43 +02:00
parent 6ec5cbaa13
commit c79cf68db9
8 changed files with 548 additions and 158 deletions

View File

@ -245,9 +245,14 @@ async def create_inference_job(job_req: InferenceJobRequest, current_user: dict
cached["cached"] = True cached["cached"] = True
return cached return cached
# Transform payload to match worker expectations
payload = job_req.model_dump()
payload['radius_m'] = int(payload.pop('radius_km') * 1000)
payload['model'] = payload.pop('model_name')
job = task_queue.enqueue( job = task_queue.enqueue(
'worker.run_inference', 'worker.run_job',
job_req.model_dump(), payload,
job_timeout='25m', job_timeout='25m',
result_ttl=86400, result_ttl=86400,
failure_ttl=86400 failure_ttl=86400

View File

@ -8,6 +8,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Deno for yt-dlp JavaScript decryption support
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app

View File

@ -4,6 +4,7 @@ import subprocess
import logging import logging
import re import re
import requests import requests
import json
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from urllib.parse import urljoin from urllib.parse import urljoin
from mutagen.mp3 import EasyMP3 from mutagen.mp3 import EasyMP3
@ -229,38 +230,79 @@ def send_telegram_notification(chat_id: int, text: str):
def extract_spotify_track_title(url: str, proxy_url: str) -> str: def extract_spotify_track_title(url: str, proxy_url: str) -> str:
""" """
Fetches the Spotify track page and extracts the track name and artists from the title tag. Scrapes a Spotify track embed page and returns the track search string ("Artist - Title").
""" """
match = re.search(r'track/([a-zA-Z0-9]+)', url)
if not match:
logger.warning(f"Could not extract track ID from URL: {url}")
return ""
track_id = match.group(1)
embed_url = f"https://open.spotify.com/embed/track/{track_id}"
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
headers = { headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
} }
logger.info(f"Extracting track title from Spotify page: {url}")
logger.info(f"Scraping Spotify track via embed URL: {embed_url}")
try: try:
response = requests.get(url, headers=headers, proxies=proxies, timeout=15) response = requests.get(embed_url, headers=headers, proxies=proxies, timeout=15)
if response.status_code == 200: if response.status_code == 200:
soup = BeautifulSoup(response.text, "html.parser") next_data_match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', response.text)
title_tag = soup.find("title") if next_data_match:
if title_tag: data = json.loads(next_data_match.group(1))
title_text = title_tag.text entity = data['props']['pageProps']['state']['data']['entity']
# Format is typically "Song Name - song and lyrics by Artist1, Artist2 | Spotify" name = entity.get('name', '').strip()
# Or "Song Name - song by Artist | Spotify" artists = [a.get('name', '') for a in entity.get('artists', [])]
if " - song " in title_text: artists = [a for a in artists if a]
parts = title_text.split(" - song ", 1) if name and artists:
song_name = parts[0].strip() return f"{', '.join(artists)} - {name}"
artist_part = parts[1].split(" by ", 1) elif name:
if len(artist_part) > 1: return name
artists = artist_part[1].split(" | Spotify", 1)[0].strip()
return f"{artists} - {song_name}"
return song_name
elif " | Spotify" in title_text:
return title_text.split(" | Spotify")[0].strip()
return title_text.strip()
except Exception as e: except Exception as e:
logger.warning(f"Failed to extract Spotify track title: {e}") logger.warning(f"Failed to extract Spotify track title: {e}")
return "" return ""
def scrape_spotify_playlist(url: str, proxy_url: str) -> list:
"""
Scrapes a Spotify playlist embed page and returns a list of track search strings ("Artist - Title").
"""
match = re.search(r'playlist/([a-zA-Z0-9]+)', url)
if not match:
logger.warning(f"Could not extract playlist ID from URL: {url}")
return []
playlist_id = match.group(1)
embed_url = f"https://open.spotify.com/embed/playlist/{playlist_id}"
proxies = {"http": proxy_url, "https": proxy_url} if proxy_url else None
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
logger.info(f"Scraping Spotify playlist via embed URL: {embed_url}")
try:
response = requests.get(embed_url, headers=headers, proxies=proxies, timeout=15)
if response.status_code == 200:
next_data_match = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', response.text)
if next_data_match:
data = json.loads(next_data_match.group(1))
entity = data['props']['pageProps']['state']['data']['entity']
tracks = entity.get('trackList', [])
track_queries = []
for t in tracks:
title = t.get('title', '').strip()
subtitle = t.get('subtitle', '').replace('\xa0', ' ').strip()
if title and subtitle:
track_queries.append(f"{subtitle} - {title}")
elif title:
track_queries.append(title)
return track_queries
except Exception as e:
logger.warning(f"Failed to scrape Spotify playlist: {e}")
return []
def symlink_existing_tracks(temp_dir: str): def symlink_existing_tracks(temp_dir: str):
""" """
Finds all audio files recursively in /remote-music/music, extracts artist and title, Finds all audio files recursively in /remote-music/music, extracts artist and title,
@ -353,7 +395,95 @@ def run_download_task(task_id: str, query: str, proxy_url: str, jobs_dict: dict,
resolved_title = extract_spotify_track_title(query, resolved_proxy_url) resolved_title = extract_spotify_track_title(query, resolved_proxy_url)
if resolved_title: if resolved_title:
logger.info(f"[{task_id}] Pre-resolved Spotify URL to text title: '{resolved_title}'") logger.info(f"[{task_id}] Pre-resolved Spotify URL to text title: '{resolved_title}'")
query = resolved_title
is_playlist = "spotify.com/playlist/" in query.lower()
if is_playlist:
jobs_dict[task_id]["priority_used"] = "playlist-scraper"
logger.info(f"[{task_id}] Spotify playlist detected: '{query}'")
track_queries = scrape_spotify_playlist(query, resolved_proxy_url)
if not track_queries:
raise Exception("Could not scrape any tracks from the Spotify playlist embed page.")
logger.info(f"[{task_id}] Successfully scraped {len(track_queries)} tracks from playlist.")
download_count = 0
for idx, track_query in enumerate(track_queries):
logger.info(f"[{task_id}] [Track {idx+1}/{len(track_queries)}] Downloading '{track_query}'")
track_success = False
# 1. spotDL
env = os.environ.copy()
if resolved_proxy_url:
env["http_proxy"] = resolved_proxy_url
env["https_proxy"] = resolved_proxy_url
cmd = ["spotdl", "download", track_query]
if resolved_proxy_url:
cmd += ["--proxy", resolved_proxy_url]
logger.info(f"[{task_id}] [Track {idx+1}] Trying spotDL...")
result = subprocess.run(cmd, cwd=temp_dir, env=env, capture_output=True, text=True)
if result.returncode == 0:
logger.info(f"[{task_id}] [Track {idx+1}] spotDL succeeded.")
track_success = True
else:
logger.warning(f"[{task_id}] [Track {idx+1}] spotDL failed: {result.stderr or result.stdout}. Trying yt-dlp...")
# 2. yt-dlp
if not track_success:
search_query = f"ytsearch1:{track_query}"
cmd_ytdlp = [
"yt-dlp",
"-x",
"--audio-format", "mp3",
"--embed-metadata",
"--yes-playlist",
"--output", "%(title)s.%(ext)s"
]
if resolved_proxy_url:
cmd_ytdlp += ["--proxy", resolved_proxy_url]
cmd_ytdlp.append(search_query)
logger.info(f"[{task_id}] [Track {idx+1}] Trying yt-dlp...")
result_ytdlp = subprocess.run(cmd_ytdlp, cwd=temp_dir, env=env, capture_output=True, text=True)
if result_ytdlp.returncode == 0:
logger.info(f"[{task_id}] [Track {idx+1}] yt-dlp succeeded.")
track_success = True
else:
logger.warning(f"[{task_id}] [Track {idx+1}] yt-dlp failed. Trying Tubidy...")
# 3. Tubidy
if not track_success:
try:
logger.info(f"[{task_id}] [Track {idx+1}] Trying Tubidy...")
search_results = search_tubidy(track_query, resolved_proxy_url)
if search_results:
first_result = search_results[0]
mp3_url = get_tubidy_download_link(first_result["id"], resolved_proxy_url)
filename = f"{first_result['title']}.mp3"
filename = "".join([c for c in filename if c.isalnum() or c in " .-_()"]).strip()
if not filename.endswith(".mp3"):
filename += ".mp3"
dest_path = os.path.join(temp_dir, filename)
download_file_stream(mp3_url, dest_path, resolved_proxy_url)
tag_mp3(dest_path, filename)
track_success = True
logger.info(f"[{task_id}] [Track {idx+1}] Tubidy succeeded.")
except Exception as e:
logger.error(f"[{task_id}] [Track {idx+1}] Tubidy failed: {e}")
if track_success:
download_count += 1
if download_count > 0:
download_successful = True
logger.info(f"[{task_id}] Playlist download finished. Successfully downloaded {download_count} tracks.")
else:
raise Exception("Failed to download any tracks from the playlist.")
else:
is_tubidy = "tubidy" in query.lower() is_tubidy = "tubidy" in query.lower()
is_spotify = "spotify.com" in query.lower() is_spotify = "spotify.com" in query.lower()
is_youtube = "youtube.com" in query.lower() or "youtu.be" in query.lower() is_youtube = "youtube.com" in query.lower() or "youtu.be" in query.lower()

View File

@ -1810,7 +1810,7 @@ spec:
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
env: env:
- name: API_EXTERNAL_URL - name: API_EXTERNAL_URL
value: "http://supabase.local" value: "https://basket.techarvest.co.zw"
- name: DB_DRIVER - name: DB_DRIVER
value: "postgres" value: "postgres"
- name: DB_SSL - name: DB_SSL
@ -1824,7 +1824,9 @@ spec:
- name: GOTRUE_DISABLE_SIGNUP - name: GOTRUE_DISABLE_SIGNUP
value: "false" value: "false"
- name: GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED - name: GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED
value: "false" value: "true"
- name: GOTRUE_EXTERNAL_ANONYMOUS_ENABLED
value: "true"
- name: GOTRUE_EXTERNAL_EMAIL_ENABLED - name: GOTRUE_EXTERNAL_EMAIL_ENABLED
value: "true" value: "true"
- name: GOTRUE_EXTERNAL_PHONE_ENABLED - name: GOTRUE_EXTERNAL_PHONE_ENABLED
@ -1848,7 +1850,7 @@ spec:
- name: GOTRUE_MAILER_URLPATHS_RECOVERY - name: GOTRUE_MAILER_URLPATHS_RECOVERY
value: "/auth/v1/verify" value: "/auth/v1/verify"
- name: GOTRUE_SITE_URL - name: GOTRUE_SITE_URL
value: "http://supabase.local" value: "https://basket.techarvest.co.zw"
- name: GOTRUE_SMS_AUTOCONFIRM - name: GOTRUE_SMS_AUTOCONFIRM
value: "false" value: "false"
- name: GOTRUE_SMTP_ADMIN_EMAIL - name: GOTRUE_SMTP_ADMIN_EMAIL

View File

@ -54,7 +54,7 @@ spec:
containers: containers:
- name: musicseerr - name: musicseerr
image: frankchine/geocrop-musicseerr:latest image: frankchine/geocrop-musicseerr:latest
imagePullPolicy: IfNotPresent imagePullPolicy: Always
env: env:
- name: GLUETUN_PROXY_URL - name: GLUETUN_PROXY_URL
value: "http://gluetun-svc:8888" value: "http://gluetun-svc:8888"

View File

@ -123,3 +123,44 @@ spec:
name: next-gen-dev name: next-gen-dev
port: port:
number: 80 number: 80
---
apiVersion: v1
kind: Service
metadata:
name: next-gen-main
namespace: nextgen
spec:
selector:
app: next-gen-main
ports:
- name: http
protocol: TCP
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: next-gen-main
namespace: nextgen
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/force-ssl-redirect: "false"
spec:
ingressClassName: nginx
tls:
- hosts:
- next-gen.techarvest.co.zw
secretName: next-gen-main-tls
rules:
- host: next-gen.techarvest.co.zw
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: next-gen-main
port:
number: 80

View File

@ -63,6 +63,24 @@ resource "kubernetes_namespace" "portainer" {
} }
} }
resource "kubernetes_namespace" "argo" {
metadata {
name = "argo"
}
}
resource "kubernetes_namespace" "kubernetes_dashboard" {
metadata {
name = "kubernetes-dashboard"
}
}
resource "kubernetes_namespace" "dressup" {
metadata {
name = "dressup"
}
}
# ========================================== # ==========================================
# PORTAINER (kubectl deployed) # PORTAINER (kubectl deployed)
# Port installed via kubectl manifest at: # Port installed via kubectl manifest at:
@ -150,3 +168,25 @@ resource "kubernetes_namespace" "portainer" {
# - argo-server # - argo-server
# - workflow-controller # - workflow-controller
# ========================================== # ==========================================
# ==========================================
# PRIORITY CLASSES
# ==========================================
resource "kubernetes_priority_class_v1" "dressup_priority" {
metadata {
name = "dressup-priority"
}
value = 800000
global_default = false
description = "High priority scheduling for dressup app workloads during spikes."
}
resource "kubernetes_priority_class_v1" "inference_worker_priority" {
metadata {
name = "inference-worker-priority"
}
value = 900000
global_default = false
description = "High priority scheduling for GeoCrop inference and worker pods."
}

View File

@ -1,10 +1,45 @@
{ {
"version": 4, "version": 4,
"terraform_version": "1.14.9", "terraform_version": "1.14.9",
"serial": 19, "serial": 25,
"lineage": "80e41663-9b90-f349-cc6c-be6879179605", "lineage": "80e41663-9b90-f349-cc6c-be6879179605",
"outputs": {}, "outputs": {},
"resources": [ "resources": [
{
"mode": "managed",
"type": "kubernetes_namespace",
"name": "argo",
"provider": "provider[\"registry.terraform.io/hashicorp/kubernetes\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "argo",
"metadata": [
{
"annotations": {},
"generate_name": "",
"generation": 0,
"labels": {},
"name": "argo",
"resource_version": "3086761",
"uid": "2c5f8fa2-cdb8-4ff8-9c00-f1f5f589a9f1"
}
],
"timeouts": null,
"wait_for_default_service_account": null
},
"sensitive_attributes": [],
"identity_schema_version": 1,
"identity": {
"api_version": "v1",
"kind": "Namespace",
"name": "argo"
},
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjozMDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
}
]
},
{ {
"mode": "managed", "mode": "managed",
"type": "kubernetes_namespace", "type": "kubernetes_namespace",
@ -110,6 +145,41 @@
} }
] ]
}, },
{
"mode": "managed",
"type": "kubernetes_namespace",
"name": "dressup",
"provider": "provider[\"registry.terraform.io/hashicorp/kubernetes\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "dressup",
"metadata": [
{
"annotations": {},
"generate_name": "",
"generation": 0,
"labels": {},
"name": "dressup",
"resource_version": "1817654",
"uid": "93ccf1db-35f5-47ba-89c8-d5a186a4f9e6"
}
],
"timeouts": null,
"wait_for_default_service_account": null
},
"sensitive_attributes": [],
"identity_schema_version": 1,
"identity": {
"api_version": "v1",
"kind": "Namespace",
"name": "dressup"
},
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjozMDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
}
]
},
{ {
"mode": "managed", "mode": "managed",
"type": "kubernetes_namespace", "type": "kubernetes_namespace",
@ -180,6 +250,41 @@
} }
] ]
}, },
{
"mode": "managed",
"type": "kubernetes_namespace",
"name": "kubernetes_dashboard",
"provider": "provider[\"registry.terraform.io/hashicorp/kubernetes\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "kubernetes-dashboard",
"metadata": [
{
"annotations": {},
"generate_name": "",
"generation": 0,
"labels": {},
"name": "kubernetes-dashboard",
"resource_version": "3212",
"uid": "05cc388b-3829-43dd-ab0f-b3acf5170423"
}
],
"timeouts": null,
"wait_for_default_service_account": null
},
"sensitive_attributes": [],
"identity_schema_version": 1,
"identity": {
"api_version": "v1",
"kind": "Namespace",
"name": "kubernetes-dashboard"
},
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjozMDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
}
]
},
{ {
"mode": "managed", "mode": "managed",
"type": "kubernetes_namespace", "type": "kubernetes_namespace",
@ -284,6 +389,70 @@
"private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjozMDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjAifQ==" "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiZGVsZXRlIjozMDAwMDAwMDAwMDB9LCJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
} }
] ]
},
{
"mode": "managed",
"type": "kubernetes_priority_class_v1",
"name": "dressup_priority",
"provider": "provider[\"registry.terraform.io/hashicorp/kubernetes\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"description": "High priority scheduling for dressup app workloads during spikes.",
"global_default": false,
"id": "dressup-priority",
"metadata": [
{
"annotations": null,
"generate_name": "",
"generation": 1,
"labels": null,
"name": "dressup-priority",
"resource_version": "4396811",
"uid": "b4d9b507-3608-4ce4-8f53-646cca98bbe7"
}
],
"preemption_policy": "PreemptLowerPriority",
"value": 800000
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA=="
}
]
},
{
"mode": "managed",
"type": "kubernetes_priority_class_v1",
"name": "inference_worker_priority",
"provider": "provider[\"registry.terraform.io/hashicorp/kubernetes\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"description": "High priority scheduling for GeoCrop inference and worker pods.",
"global_default": false,
"id": "inference-worker-priority",
"metadata": [
{
"annotations": null,
"generate_name": "",
"generation": 1,
"labels": null,
"name": "inference-worker-priority",
"resource_version": "4396810",
"uid": "78d9337d-f1a5-496d-b63b-068141b1f3e3"
}
],
"preemption_policy": "PreemptLowerPriority",
"value": 900000
},
"sensitive_attributes": [],
"identity_schema_version": 0,
"private": "bnVsbA=="
}
]
} }
], ],
"check_results": null "check_results": null