Qnet song match — fetcha Songs.txt iz 5 MB playerjev (FOLX DE/SLO, ZWEI, ONE, ADRIA), 20K+ songs, fuzzy match na upload-u → clean parsed_artist/parsed_title + auto tv_station. /api/qnet/{stats,match,sync}
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Qnet baz fetcher za reels-app.
|
||||
|
||||
Fetcha Songs.txt iz 5 Qnet instalacij na MB playerjih (preko ssh-api proxy-ja
|
||||
na openclaw → SSH na Windows playerje), pretvori iz Windows-1252 v UTF-8,
|
||||
parsa TSV in shrani enotno JSON bazo v /data/qnet/songs.json.
|
||||
|
||||
Cron-friendly: poženi enkrat na uro.
|
||||
|
||||
Output struktura:
|
||||
{
|
||||
"synced_at": 1746198000.0,
|
||||
"stations": {
|
||||
"FOLX DE": {"count": 4038, "fetched_at": 1746198000.0},
|
||||
...
|
||||
},
|
||||
"songs": [
|
||||
{
|
||||
"station": "FOLX DE",
|
||||
"artist": "Sašo Avsenik und seine Oberkrainer",
|
||||
"title": "Na Golici",
|
||||
"file": "Sašo Avsenik und seine Oberkrainer - Na Golici.mp4",
|
||||
"type": "DGL",
|
||||
"length": "2:32.277",
|
||||
"comments": "",
|
||||
"last_played": "17/4/2026"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import base64
|
||||
import requests
|
||||
from pathlib import Path
|
||||
|
||||
SSH_API = os.environ.get("PTC_SSH_API", "https://mail.folx.tv/ssh-api/v2")
|
||||
SSH_TOKEN = os.environ.get("PTC_SSH_TOKEN") or "ptc-ssh-2026-a7b3c9d4e5f6012389abcdef01234567890abcdef01234567890abcdef012345"
|
||||
|
||||
OUT_PATH = Path(os.environ.get("QNET_DB_PATH", "/data/qnet/songs.json"))
|
||||
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# (station_label, player_ip, qnet_subdir_on_C)
|
||||
STATIONS = [
|
||||
("FOLX DE", "100.64.0.2", "qnet"),
|
||||
("ZWEI", "100.64.0.2", "qnetzwei"),
|
||||
("ONE", "100.64.0.3", "QnetONE"),
|
||||
("ADRIA", "100.64.0.4", "Qnet"),
|
||||
("FOLX SLO", "100.64.0.4", "QnetFOLXSLO"),
|
||||
]
|
||||
|
||||
SSH_KEY = "/root/.ssh/players/folx_players"
|
||||
|
||||
|
||||
def ssh_exec(cmd: str, timeout: int = 60) -> dict:
|
||||
"""Pošlji ukaz preko ssh-api na openclaw."""
|
||||
r = requests.post(
|
||||
SSH_API,
|
||||
headers={
|
||||
"Authorization": f"Bearer {SSH_TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"host": "openclaw", "cmd": cmd, "timeout": timeout},
|
||||
timeout=timeout + 30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def fetch_one(station: str, ip: str, subdir: str) -> str:
|
||||
"""Fetcha Songs.txt z windows playerja, vrne UTF-8 string."""
|
||||
# 1) scp z playerja na openclaw, iconv v utf8, base64 nazaj
|
||||
cmd = (
|
||||
f"set -e; "
|
||||
f"TMP=$(mktemp); "
|
||||
f"scp -i {SSH_KEY} -o StrictHostKeyChecking=no "
|
||||
f'"folxadmin@{ip}:c:/{subdir}/Data/Songs.txt" "$TMP"; '
|
||||
f'iconv -f WINDOWS-1252 -t UTF-8 "$TMP" | base64 -w 0; '
|
||||
f'rm -f "$TMP"'
|
||||
)
|
||||
res = ssh_exec(cmd, timeout=90)
|
||||
if res.get("exit_code") != 0:
|
||||
raise RuntimeError(f"{station}: ssh-api error: {res}")
|
||||
b64 = res.get("output", "").strip()
|
||||
if not b64:
|
||||
raise RuntimeError(f"{station}: empty response")
|
||||
return base64.b64decode(b64).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def parse_songs_tsv(text: str, station: str) -> list[dict]:
|
||||
"""Parse TSV → list of clean dicts. Drop incomplete rows."""
|
||||
out = []
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter="\t")
|
||||
for row in reader:
|
||||
artist = (row.get("Artist") or "").strip()
|
||||
title = (row.get("Title") or "").strip()
|
||||
file_ = (row.get("File") or "").strip()
|
||||
# Skip popolnoma prazne vrstice
|
||||
if not (artist or title or file_):
|
||||
continue
|
||||
out.append({
|
||||
"station": station,
|
||||
"artist": artist,
|
||||
"title": title,
|
||||
"file": file_,
|
||||
"type": (row.get("Type") or "").strip(),
|
||||
"length": (row.get("Length") or "").strip(),
|
||||
"comments": (row.get("Comments") or "").strip(),
|
||||
"language": (row.get("Language") or "").strip(),
|
||||
"genre": (row.get("Genre") or "").strip(),
|
||||
"last_played": (row.get("Last date played") or "").strip(),
|
||||
"display_artist": (row.get("Display artist") or "").strip(),
|
||||
"display_title": (row.get("Display title") or "").strip(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
t0 = time.time()
|
||||
all_songs = []
|
||||
stations_meta = {}
|
||||
errors = []
|
||||
|
||||
for station, ip, subdir in STATIONS:
|
||||
try:
|
||||
print(f"→ {station} ({ip}:c:/{subdir}/Data/Songs.txt)", flush=True)
|
||||
text = fetch_one(station, ip, subdir)
|
||||
songs = parse_songs_tsv(text, station)
|
||||
all_songs.extend(songs)
|
||||
stations_meta[station] = {
|
||||
"count": len(songs),
|
||||
"fetched_at": time.time(),
|
||||
"ok": True,
|
||||
}
|
||||
print(f" ✓ {len(songs)} songov", flush=True)
|
||||
except Exception as e:
|
||||
err = f"{station}: {type(e).__name__}: {e}"
|
||||
print(f" ✗ {err}", flush=True)
|
||||
errors.append(err)
|
||||
stations_meta[station] = {"count": 0, "ok": False, "error": str(e)}
|
||||
|
||||
# Zapiši na disk (atomic preko temp + rename)
|
||||
payload = {
|
||||
"synced_at": time.time(),
|
||||
"duration_seconds": round(time.time() - t0, 1),
|
||||
"total_songs": len(all_songs),
|
||||
"stations": stations_meta,
|
||||
"errors": errors,
|
||||
"songs": all_songs,
|
||||
}
|
||||
|
||||
tmp = OUT_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
tmp.replace(OUT_PATH)
|
||||
|
||||
# Tudi ločen "lookup index" — manjši fajl samo za matching
|
||||
lookup = []
|
||||
for s in all_songs:
|
||||
if s["artist"] and s["title"]:
|
||||
lookup.append({
|
||||
"station": s["station"],
|
||||
"artist": s["artist"],
|
||||
"title": s["title"],
|
||||
"file": s["file"],
|
||||
})
|
||||
lookup_path = OUT_PATH.parent / "songs_lookup.json"
|
||||
tmp2 = lookup_path.with_suffix(".json.tmp")
|
||||
tmp2.write_text(json.dumps(lookup, ensure_ascii=False), encoding="utf-8")
|
||||
tmp2.replace(lookup_path)
|
||||
|
||||
print(f"\n✓ Done: {len(all_songs)} songov v {OUT_PATH} ({round(time.time()-t0,1)}s)")
|
||||
if errors:
|
||||
print(f"⚠ {len(errors)} napak:")
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
sys.exit(1 if len(errors) == len(STATIONS) else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user