Compare commits

..
101 Commits
Author SHA1 Message Date
Claude 08ca5893c7 Fix IN/OUT marker placement: bližina trim handle namesto fiksnega initialCenter
PROBLEM: Ko je clip range premaknjen zelo daleč od LLM-jevega prvotnega
center-ja, klikni+Enter za zelen IN postavi rdeč OUT (in vice versa) —
ker se odločitev IN vs OUT temelji na 'initialCenter' (fiksen iz LLM
analize), ki ne predstavlja več trenutne uporabnikove pozicije.

Primer (Die Granaten — A Frau is a Frau):
- initialIn=0:50, initialOut=1:30 → initialCenter=1:10
- User razširi clip: trimStart=0:06, trimEnd=2:33
- User klikne pri 2:02, želi postaviti IN bližje začetku
- Stara koda: 2:02 > 1:10 → postavi OUT (NAROBE, ker je playhead daleč
  desno od dejanskega center-ja trim range-a)

FIX: Odločitev IN vs OUT po BLIŽINI playhead-a do trim handle-a:
- Bližje trimStart → IN marker
- Bližje trimEnd  → OUT marker

Plus: clamp markerjev znotraj trim range-a (vsaj 0.5s razlika med IN/OUT)
Plus: počisti staro initialCenter logiko v onPointerMove (handle drag)
2026-05-04 13:18:16 +00:00
Claude d2c9a48cc2 Google Sign-In auth + email whitelist + session cookies
WHY: Doslej basic auth z deljenim AUTH_USER/AUTH_PASS — tvegan za delitev
z drugimi (urednika ipd.). Z Google Sign-In dobi vsak uporabnik svoj
account, dostop pa nadzira whitelist v env.

NEW BACKEND:
- GET  /login              — login stran z Google Sign-In gumbom (GSI popup)
- POST /auth/google/callback  — verify Google ID token + set session cookie
- GET  /auth/me            — vrne email + auth method (za frontend header)
- GET  /logout             — pobriše cookie + redirect /login
- POST /logout

AUTH FLOW:
1. Browser GET /              → check_auth() → ni session → redirect /login
2. /login                     → Google Sign-In popup (preko gsi/client.js)
3. User izbere Google account → JS pošlje credential na /auth/google/callback
4. Server verifies token (oauth2.googleapis.com/tokeninfo)
5. Email mora biti v ALLOWED_EMAILS env
6. Set HttpOnly+Secure session cookie (HMAC-SHA256, 30 dni)
7. Redirect /

SECURITY:
- Session token: base64url(email|expiry|HMAC). Server ne ranji ne hrani.
- HMAC z SESSION_SECRET (auto-derived iz AUTH_USER+AUTH_PASS če ni nastavljen)
- Cookie HttpOnly + Secure + SameSite=lax
- Token verify: aud check, iss check, exp check, email_verified check

BACKWARD COMPAT:
- HTTPBasic auth še vedno deluje (cron, scripte, API klici)
- check_auth() probaj prvo cookie, potem basic
- Brez GOOGLE_CLIENT_ID env: vse še vedno dela na basic auth

ENV VARS (treba dodati v Coolify):
- GOOGLE_CLIENT_ID=938379241163-pvb328plec2207rbtufic8u5fgb6mkn9.apps.googleusercontent.com
- ALLOWED_EMAILS=sebastjan.artic@gmail.com,ales.cadez@gmail.com
- SESSION_SECRET=<random 64-char hex> (optional — defaultni se izračuna)

FRONTEND:
- Header: 👤 email + ↪ Odjava gumb (samo ko je prijavljen)
- DOMContentLoaded fetcha /auth/me, prikaže email
2026-05-04 12:26:53 +00:00
Claude 12e8edba93 Delete job: cascade delete povsod (Nextcloud + dedup DB + S3 + lokal)
PROBLEM: '✕' gumb je do zdaj brisal samo lokalne fajle + S3, ampak
NE Nextcloud upload (rel je ostal na folxspeed/REELS/{TV}/) in NE
dedup DB zapis (zato se enaka pesem ni mogla več upload-ati).

NEW BACKEND:
- _nextcloud_delete(filename, target_subdir) helper preko WebDAV DELETE
  (404 šteje kot success — če že ne obstaja, OK)
- delete_job() razširjen:
  1. Nextcloud delete (če nextcloud_status='uploaded' ali ima nextcloud_url)
  2. Dedup DB remove (processed_videos zapis za tisto TV postajo)
  3. Lokal + S3 delete vseh workfile-ov (kot prej)
  4. Glob za yt-dlp artifacte ({job_id}_yt*) — info.json, .part, .f137.mp4
  5. Job metadata
- Response: {deleted, nextcloud_delete: 'ok'|'not_found'|'fail: msg', nextcloud_filename}

NEW FRONTEND:
- buildJobEl() doda data-nc-status atribut na kartico
- deleteJob() bere dataset.ncStatus za Nextcloud info
- Confirm dialog detail razložen seznam KAJ se zbriše + opozorilo če Nextcloud
- Če Nextcloud delete ni uspel po API klicu, alert
2026-05-03 14:52:40 +00:00
Claude 79f611ba73 Retranscribe feature: ponovi STT z drugim providerjem v Edit modalu
PROBLEM: STT (Soniox/Scribe) včasih popolnoma narobe slišije besedilo —
npr. 'BILA JE LJUBEZEN PRVA' postane 'BILAL JO ME ZANPRLA' (TRIO ŠUBIC).
Edit modal je do zdaj zahteval popoln recut z novim clip range.

NEW: '🔁 Ponovi transkript' gumb v Edit modalu z dropdown za provider:
  - Scribe (ElevenLabs) — default, najboljši za nemščino + slovenščino
  - Soniox — slovenski default v auto routing
  - Whisper local (faster-whisper)

Backend:
  POST /api/jobs/{id}/retranscribe { provider, auto_upload?, lang? }
  - Briše stari MP4 + analysis.json + .srt + .ass (lokal + S3)
  - Re-queue job z whisper_provider override
  - Ohrani isti clip range — analyze.py si naredi svežo analizo
  - retranscribe_count števec za sledenje poskusov

UX: confirm dialog → 1.5s status → auto-close modal → watchJob za live progress.
Brez auto_upload v Nextcloud — preveri rezultat preden re-uploadaš.
2026-05-03 14:38:35 +00:00
Claude 2abd9daae1 Fix CP1250 encoding bug v sync_qnet.py — È→Č
PROBLEM: Songs.txt na MB Windows playerjih je v CP1250 (slovenski/CEE),
NE Windows-1252 (Western European). iconv -f WINDOWS-1252 je 'Č' (0xC8)
napačno interpretiral kot 'È', zaradi česar je 811 zapisov v Qnet bazi
imelo 'È' namesto 'Č' (npr. 'POSKOÈNI', 'ÈAS ZA ZABAVO', 'STORŽIÈ').

Posledica: ko je qnet_match povezal job na napačno labeliran zapis,
je 'parsed_title' polnil z mojibake iz Qnet baze (15 jobov).

FIX: WINDOWS-1252 → WINDOWS-1250.
Razlike v CP1250 vs CP1252 (slovanske črke):
  Č↔È, č↔è, Ć↔Æ, ć↔æ, Đ↔Ð, đ↔ð, Ń↔Ñ, Ł↔£, ł↔³, Ś↔Œ, ś↔œ, ź↔Ÿ
  Ž, š, ž — ostanejo (isti byte v obeh)

BACKFILL (ločen skript, že apliciran):
- Qnet lookup: 2746 polj v 20860 zapisih popravljenih
- Qnet songs.json: 2856 polj
- 15 jobov: parsed_artist/title popravljen na pravilen UTF-8
2026-05-03 14:32:25 +00:00
Claude 576cc807b5 Fix parse_artist_title (ANS.* bug) + unify station naming na FOLX SLO
PROBLEMS:
1. parse_artist_title je uporabljal Path(s).stem za stripping ext-a,
   kar je pri YT title 'ANS.NAVEZA - SREČA OPOTEČA' vrnilo 'ANS' (Path
   smatra '.NAVEZA - SREČA OPOTEČA' kot extension). Posledica: parser
   failed → ACR fallback → 'Folx' kot artist na 15+ jobih.

2. tv_station je imel dva imena: 'FOLX SLOVENIJA' (frontend default)
   in 'FOLX SLO' (qnet match output) — UI tabi niso seštevali pravilno.

FIXES:
- parse_artist_title: ext stripping samo za znane ekstenzije
  (.mp4, .mp3, .m4a, .webm, .mkv, .avi, .mov, .wav, .flac, .aac,
   .opus, .ogg, .wmv, .mxf), NE za naključne pike v YT title.
- Vsi defaultni 'FOLX SLOVENIJA' → 'FOLX SLO' v Pydantic modelih +
  templates + filter tabi.
- Nextcloud mapping STATION_TO_NEXTCLOUD_FOLDER ostane nespremenjen
  (FOLX SLO → mapa 'FOLX SLOVENIJA', kjer pač zaplane).

BACKFILL (že apliciran prek scripte):
- 15 jobov z parsed_artist='Folx' popravljenih na pravi izvajalec
  iz youtube_title (ANS.NAVEZA, ANS.BITENC, ANS. ROKA ŽLINDRE, ipd.).
- 86 jobov tv_station 'FOLX SLOVENIJA' → 'FOLX SLO'.
2026-05-03 14:26:42 +00:00
Claude cd872d8bea Layout: razširi main na 1600px + levi panel fiksno 440px
Prej: max-width 1100px, 1fr/1fr grid → na velikih ekranih je bil
levi 'Nov reel' panel zelo otesnjen, desni list pa premajhen.

Zdaj:
- main max-width 1600px (več prostora na velikih ekranih)
- levi panel fiksno 440px (vsebina diha + tabi v eni vrstici)
- desni jobs list zavzema vso preostalo širino (1fr)
- Responsive: <1100px → levi 380px, <900px → stack 1 stolpec
2026-05-03 14:15:42 +00:00
Claude 2fcc4b8075 Dashboard: TV station filter tabs nad jobs-list
- Tabi: Vse / FOLX SLOVENIJA / FOLX DE / ONE DE / ZWEI MUSIC / ADRIA / (brez postaje)
- Števec na vsakem tabu = število NEPOTRJENIH jobov (!hidden_after_upload) za to postajo
- Klik na tab = filter samo tisti station, samo nepotrjeni
- 'Vse' = vsi nepotrjeni (kot do zdaj)
- '(brez postaje)' tab se skrije, če ni jobov brez postaje
- Persist v localStorage (reels_jobs_station_filter)
- Iskanje IGNORIRA station filter (vrne tudi naložene + vse postaje)
- Empty state sporočilo prilagojeno glede na izbran filter
2026-05-03 12:48:31 +00:00
Claude 0d72d70f5d S3 mirror integration: workfiles auto-mirror to s3://folxspeed/reels-app/
- main.py: 4 helper funcs (_persist_to_s3, _ensure_local, _delete_from_s3,
  _ffmpeg_then_persist) - no-op fallback when S3 creds missing
- save_job(): mirror metadata JSON to S3
- process_job(): mirror YT download + render output + analysis/srt/ass to S3
- upload_video(): mirror direct uploads to S3
- _precache_edit_assets(): Popen->threaded with S3 sync after ffmpeg
- read endpoints (download, preview, source_video, waveform, preview_clip,
  get_transcript, recut_job): _ensure_local() fallback fetch from S3
- delete_job(): cascade delete to S3 (mirror unlink)
- cleanup.py: NEW module, deletes local files >48h that exist in S3.
  Verified by S3 head_object + size match. NOT YET ACTIVATED in cron.

Backward compat: lokalna mapa ostane primary. Brez env vars S3_* vsi
helperji vrnejo False (no-op). Production behavior identičen, dokler
ne dobi S3 creds.
2026-05-03 12:24:18 +00:00
Claude ec1d109e3b S3 storage module: boto3 abstraction for reels-app workfiles (uploads/outputs/jobs prefixes) 2026-05-03 11:57:12 +00:00
OpenClaw Agent 48bf0cf050 UI: Nextcloud gumb \u2014 optimistic feedback. Takoj ob kliku gumb postane oran\u017een "\u23f3 Po\u0161iljam...", disabled, cursor:wait. Po uspe\u0161nem upload-u refreshJobs() zamenja v zelen \u2713 Nextcloud. Pri napaki vrne original state. 2026-05-03 12:27:24 +02:00
OpenClaw Agent 6a9e20da19 Nextcloud upload: mapping Qnet station -> NC folder. Qnet baza ima FOLX SLO/ONE/ZWEI, Nextcloud pa FOLX SLOVENIJA/ONE DE/ZWEI MUSIC \u2014 STATION_TO_NEXTCLOUD_FOLDER dict normalizira pri uploadu. Re\u0161i HTTP 404 NotFound za FOLX SLO jobe (106 jobov). 2026-05-03 12:24:35 +02:00
OpenClaw Agent a960b7157f UI: dashboard prikazuje Artist - Title (parsed_artist + parsed_title) namesto YT URL. Fallback chain: parsed -> youtube_title -> youtube_url -> filename -> id. Ker commit 968eba7 \u017ee shrani vse YT metapodatke in Qnet match na single video submit, dashboard zdaj prikazuje \u010cisti Artist - Title za vse jobe. 2026-05-03 11:50:45 +02:00
OpenClaw Agent 968eba7205 YT metadata fetch: razširi --info-only output (id, uploader, description, upload_date, view_count, tags, ...). Single video submit fetcha metadata + Qnet match takoj (kot playlist). Worker preskoči info fetch če metadata že obstaja, sicer shrani vsa polja in naredi Qnet match.
- yt_download.py: get_info() probaj najprej yt.biba.live API /download/info (residential IP, sveži cookies), fallback na lokalni yt-dlp. --info-only output razširjen na 17 polj.
- main.py submit_youtube single video: fetcha metadata (yt_get_info) ob submit, shrani youtube_title/uploader/id/description/duration/thumbnail/upload_date in naredi Qnet match (parity s playlist branch).
- main.py worker: skip info fetch če youtube_title in youtube_uploader že obstajata. Sicer shrani VSE polja + Qnet match + parser fallback.
2026-05-02 15:54:28 +00:00
OpenClaw Agent bc73fd8dd3 Reels-app: integracija z yt.biba.live API. get_cookies_file fetcha sve\u017ee cookies iz /cookies/raw (5 min cache), get_playlist najprej probava /download/playlist API, fallback na lokalni yt-dlp. ENV: YT_API_URL, YT_API_TOKEN. 2026-05-02 13:17:30 +00:00
OpenClaw Agent 77075795ce YouTube playlist support: /api/youtube/playlist-preview za pred-confirm, /api/youtube zaznava 'list=' URL in kreira batch (1 job/video). Qnet auto-match na YT naslovu, Confirm dialog v UI z prvih 5 naslovov. 2026-05-02 12:34:27 +00:00
OpenClaw Agent 7a7d7ea20d Preview cache-busting: Cache-Control: no-cache na endpoint + ?v=Date.now() v frontend URL — browser ne sme cachat starega output-a po recutu 2026-05-02 12:24:19 +00:00
OpenClaw Agent c1e00b7b73 Final SAR=1 fix: dodaj setsar=1 na konec vfilter-ja v reframe.py + ass filtrom v subtitle.py (kompenzira rounding errore iz scale/crop filtrov, ki dajo SAR 10240:10239 namesto 1:1) 2026-05-02 12:12:38 +00:00
OpenClaw Agent 6279b0ec03 subtitle.py: dodaj -pix_fmt yuv420p v burn-in encode (subtitle re-encode je perpetuiral broadcast yuv422p iz prej\u0161nje stopnje) 2026-05-02 12:05:47 +00:00
OpenClaw Agent 46ec0cec9c Anamorphic source fix: pred-scale na square pixel (PAL DV 720x576 SAR 64:45 \u2192 1024x576) preden track/center/blur filter dela. Brez tega so se reels iz starih MPG-2 (npr. Global Kryner Like a Virgin) razteg-nili horizontalno. 2026-05-02 11:57:09 +00:00
OpenClaw Agent 0fe1a47295 Fix output pixel format: -pix_fmt yuv420p + setsar=1 v vseh ffmpeg ukazih (broadcast .MPG iz Qnet je yuv422p z anamorphic SAR 64:45 \u2192 popa\u010den DAR 4:5 namesto 9:16, ne kompatibilen z Instagram/FB/mobile playerji) 2026-05-02 11:42:08 +00:00
OpenClaw Agent 6270c92b44 STT routing: FOLX DE / ZWEI \u2192 Scribe default (4\u00d7 hitrej\u0161e + brez Mississippi/Mrs. Sadie halucinacij). SLO postaje ostanejo na Soniox. User lahko override v UI. 2026-05-02 11:17:51 +00:00
OpenClaw Agent 1f8565413a Qnet match VEDNO shranjen v job.qnet_match (audit + tv_station auto-fill), tudi ko client po\u0161lje artist+title 2026-05-02 10:57:09 +00:00
OpenClaw Agent 24e1b53aa8 Qnet match v upload queue — auto-prepoznavanje pesmi takoj ko izbere\u0161 fajle (parallel POST /api/qnet/match-batch). \u010ce baza prepozna komad, prikaz Artist \u2014 Title z station badge namesto 'Brez razvidnega imena'. 2026-05-02 10:47:51 +00:00
OpenClaw Agent b938d1e4d8 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} 2026-05-02 10:42:35 +00:00
OpenClaw Agent 6f79aaea8d Iskalnik v 'moji reels' (artist+title+filename, debounced 150ms) 2026-05-02 10:02:21 +00:00
sebastjan 4febf0b844 Trailing bare 4K/HD/8K (brez oklepajev)
Edge case: 'Naslov (Official video) 4K.mxf'
Pred: '(Official video)' odstranjen, ampak '4K' brez oklepajev ostane

Pattern: r'\b(?:4K|HD|HQ|8K|1080p|720p)\s*$'
- Samo na koncu stringa
- Ne v sredini (Top 100 hitov ohranjen)
2026-05-02 08:55:44 +00:00
sebastjan 2aec7f7a29 Odstrani trailing 2-4 cifrene številke (leto/verzija)
User: 'včasih številka 23 pri Modrijanih in pri Firbcih, te se pojavlajo
v naslovih'

Razlog: NAS interno označeni kot leto produkcije.
'PA KAJ (Official Video) 23' = leto 2023
'ONA HOČE (Official video) 22' = leto 2022
'S tabo res rad (Official Video) 33' = leto 2033 (?) ali interno

Pattern: r'\s+\d{2,4}\s*$' — trailing 2-4 cifrena številka

Test rezultati:
- 'S tabo res rad 33' → 'S tabo res rad' ✅
- 'PA KAJ 23' → 'PA KAJ' ✅
- 'PESEM GORENJSKIH TRAT (2023)' → 'PESEM GORENJSKIH TRAT' ✅

Edge cases (NI odstranjeno):
- 'Pesem 25 ljubljanskih ulic' (število v sredini) ✅
- 'Top 100 hitov' ✅
2026-05-02 08:52:40 +00:00
sebastjan 4e2c690bc5 Bolj agresivno čiščenje filename: () prazni + catch-all noise besede
User: 'Topliška pomlad — KAR PADA NAJ SNEG ( - ) — tile oklepaji
pa Official video itd. Daj ko se nalaga na Nextcloud mora biti samo
izvajalec in naslov komada.'

Dodatni NOISE_PATTERNS:
1. Prazni / dummy oklepaji: '( )', '( - )', '(-)', '(.)' itd.
2. Catch-all za oklepaje z noise besedami:
   video|audio|version|mix|edit|remix|cover|live|hd|hq|4k|8k|
   remaster|extended|clean|explicit|radio|lyric|official|musik
3. Avtor/producer brackets: '(prod. by X)', '(feat. Y)', '(ft. Z)'

Test rezultat:
'Topliška pomlad - KAR PADA NAJ SNEG ( - )(Official 4K Video).mp4'
→ 'Topliška pomlad - KAR PADA NAJ SNEG - REEL.mp4'

'Sarah Connor - FICKA (Offizielles Musikvideo).mp4'
→ 'Sarah Connor - FICKA - REEL.mp4'

Vsi novi uploadi bodo imeli čista imena.
TODO ločeno: rename obstoječih 31 datotek na Nextcloudu (skript pripravljen)
2026-05-02 08:10:30 +00:00
sebastjan 376bb4db09 Manual Nextcloud upload tudi nastavi hidden_after_upload=True
User: 'naredi da ko damo IN sejvamo na nextcloud da izgine iz pregleda
in se vrne če stisnemo Pokaži že naložene'

Bug: 2 mesti uploadamo na Nextcloud:
1. Avto-upload po recut/Save → hidden_after_upload=True ✅
2. Manual ☁ Nextcloud klik → samo nextcloud_status='uploaded' ❌

Fix: oba puta nastavi hidden_after_upload=True

Tudi: batch fix obstoječih 19 uploaded jobs ki niso imeli hidden flag,
posredno preko docker exec — zdaj se skrijejo tudi oni.

Workflow zdaj:
- ☁ Nextcloud klik → upload + hidden=true → izgine
- Save (Edit) → re-render + auto-upload + hidden=true → izgine
- ☐→☑ 'Pokaži tudi že naložene' → vidiš vse z zelenim borderjem
2026-05-02 08:01:37 +00:00
sebastjan f5ef136bf4 Marker center = ORIGINAL clip center (not dynamic)
User: 'če razširim zoom in podaljšam komad potem polovica ni več tam
kjer je reel IN in tam kjer je OUT. Naj se program drži polovice
avtomatično narejenega reela'

Pred (bug):
  center = (trimStart + trimEnd) / 2  # dynamic
  Ko user razširi clip 60-90 → 50-110:
    nov center = 80 (prej 75)
    Klik pri 78s → IN (čeprav user mislil OUT!)

Po (fix):
  initialCenter = (startInit + endInit) / 2  # FIKSEN, izračunan ob open
  Original LLM center se NE spreminja
  Klik pri 78s vedno → OUT (če je 78 > 75)
  Klik pri 70s vedno → IN

Ne glede koliko user razširi/oža clip, marker assignment uporablja
fiksno 'sredino' originalnega LLM-jevega clipa.

Tudi drag handle: če marker zaide čez initialCenter → reset na trim border.
2026-05-02 07:53:44 +00:00
sebastjan 40151f8f57 STROGI marker boundaries: zelen samo levo od center, rdeč samo desno
User: 'še vedno mi skače zeleni trikotnik na konec! Naredi da ne gre
čez polovico ne en ne drug'

Bug analiza: prej Math.min(t, center - 0.1) NI bilo, samo direktna
assignment. Če bi user nekje napačno klikal, ali če je drag handle-a
spremenil center, marker je pristal na napačni strani.

Strogo zdaj:
- markerInTime = Math.min(t, center - 0.1)  → zelen NIKOLI čez center
- markerOutTime = Math.max(t, center + 0.1) → rdeč NIKOLI čez center

Plus: po drag-u handle-ja se markerji preverijo:
- Če je markerIn na desni strani novega centra → reset na trimStart
- Če je markerOut na levi strani novega centra → reset na trimEnd

Plus console.log za debug — vidiš v Dev Tools kateri vrednosti uporabljam.
2026-05-02 07:26:54 +00:00
sebastjan bdc1d14498 Toggle 'Pokaži tudi že naložene' + recut prepiše Nextcloud
User feedback: 'načeloma bi se moral samo če kaj spregledamo in že
sejvamo pa ne moremo nazaj vrniti in popraviti'

Frontend:
- Nov toggle '☁ Pokaži tudi že naložene' nad 'moji reels' headerjem
- Default OFF: vidiš samo aktivne (ki niso uploaded)
- ON: vidiš VSE, vključno z uploaded (z zelenim borderjem)
- buildJobEl: uploaded reels imajo border-left zelen + bg #4ade80 0.04 opacity
- Listener: change event sproži refreshJobs()

Backend:
- recut endpoint: reset hidden_after_upload=false, nextcloud_status='recutting'
- Po končanem recut: avto-upload na Nextcloud (PUT prepiše obstoječi file)
- Hidden_after_upload=true spet po uspešnem re-uploadu

Workflow:
1. Reel uploaded → hidden, ni v UI
2. Klik toggle 'Pokaži tudi že naložene' → vidiš ga (zelen border)
3. Edit → Save → re-render (visible spet, status='processing')
4. Re-upload PUT na Nextcloud → prepiše obstoječi file (ista pot, isto ime)
5. Hidden=true → izgine spet (osim če toggle on)
2026-05-02 07:23:03 +00:00
sebastjan 129f7d5f33 Marker assignment: split po sredini clipa (ne po bližini handle-a)
User feedback: 'če kliknemo na konec se premika IN, in ne OUT.
Če je bližje OUT ko kliknemo, se mora premikati OUT trikotnik —
tudi če nismo prej izbrali IN. Če je IN ok potem tega ne delamo.'

Pred (bug):
  distToLeft = |t - trimStart|
  distToRight = |t - trimEnd|
  Če enaka razdalja → IN (default '<=')
  Ko si že popravil IN, distance lahko enaka → klik blizu OUT
  pomotoma premakne IN

Po:
  center = (trimStart + trimEnd) / 2
  if t < center → IN
  else → OUT

Logika: leva polovica clipa = IN domain, desna = OUT domain.
Klik kjerkoli blizu desnega handle (= konec) bo VEDNO premaknil OUT.
Klik kjerkoli blizu levega handle (= začetek) bo VEDNO premaknil IN.
Tudi če si že popravil IN, popravljanje OUT ne bo motilo IN markerja.
2026-05-02 07:02:05 +00:00
sebastjan c58875c072 Upload: timeout + retry + ne ustavi loop ob enem fail-u
User: 'zakaj se je ustavilo? Naložil sem več kot 70.'

Diagnoza: 12/70+ je prišlo do server-ja (vsi 200 OK).
Browser-side problem: en upload je stuck → cel for-loop blokiran.

Fixes:
1. xhr.timeout = 10min per file (prej: večnost)
2. xhr.ontimeout, xhr.upload.ontimeout — proper error handling
3. NEW: uploadFileWithRetry() — 2x retry z 2s/4s eksponentnim delay-om
   za očasne mrežne odpovedi
4. Catch v loop ne kliče liveFail() (kar bi naredil disabled submit-btn)
   ampak samo showLive() z 'preskočil' sporočilo → loop nadaljuje
5. Console.warn v retry attempts za debugging

Sedaj če eno failes:
- Retry 2x avtomatsko (2s + 4s delay)
- Če še vedno ne uspe → preskoči to datoteko, nadaljuj z naslednjo
- Pri koncu vidiš katere so failed (v console + showLive)
2026-04-30 16:28:27 +00:00
sebastjan 2de58ca7a5 Skrij vse settings razen no-subs — vse drugo zapečen preset
User: 'sej je že preset narejen, ne kompliciraj. Vse je enako za reels.
Naredi da so podnapisi po default GOR (brez kljukice). Klukica = brez napisov.
Ostalo zapeči, nimamo kaj spreminjat.'

Hidden defaults (zapečen preset za vse reels):
- mode=track
- quality=medium
- llm-provider=claude
- whisper-model=large-v3
- subtitle-style=reels
- auto-chorus=true (data-checked)
- include-prebuild=false (data-checked)
- duration=30

Vidno samo:
- TV postaja (tabs) — kam gre na Nextcloud
- Brez kljukice = napisi V VIDEO (default)
- Kljukica = brez napisov

Side note: 'Brez kljukice = napisi' je obratno od prej (kljukica = brez).
Default je sedaj 'with subtitles' (no-subs unchecked). Persistence v
localStorage ohranja izbiro med reload-i.

Bonus: odstranjen 'Auto-chorus toggle' handler ki je iskal #manual-times
ki ne obstaja več.
2026-04-30 15:20:52 +00:00
sebastjan d0e78cca02 Persist UI settings v localStorage (no-subs, station, mode, ...)
User feedback: 'ko refrešam se ponovno vklopi kljukica da ni
podnapisov to bo problem'

Saved fields:
- no-subs (checkbox)
- auto-chorus (checkbox)
- include-prebuild (checkbox)
- mode (track/center/blur)
- quality (fast/medium/high)
- llm-provider (claude/gemini/auto)
- tv-station (FOLX SLOVENIJA / ONE DE / ...)

On page load: re-aplicira saved values
On change: shrani v localStorage
TV station: tudi posodobi aktiven tab style

Defaults ostanejo isti če nikoli niso spremenjeni.
2026-04-30 15:17:16 +00:00
sebastjan c2a593de78 Fix JS syntax error: duplicate q.appendChild block
Bug: previous str_replace introduced duplicate '});' lines after
file queue rendering. Caused 'Unexpected token )' parse error,
which broke ALL JS — addFilesToQueue was undefined → upload didn't work.

User feedback: 'ne dela upload'

Verified with: node -c on extracted script — passes now.
2026-04-30 15:14:09 +00:00
sebastjan b9c8c066ec Odstrani emoji iz TV postaj + cleanup all reels
User feedback: 'pa ne maram teh emojijev ki si ji dal k Adria in ZWEI Music.
Daj počisti sedaj kar je že narejeno in gremnalagat.'

UI changes:
- Tab labels: 'FOLX SLOVENIJA', 'FOLX DE', 'ONE DE', 'ZWEI MUSIC', 'ADRIA'
  (without emoji prefixes)
- Job card badge: brez emoji prefix

Cleanup (manual via container):
- /data/jobs/*.json (15 → 0)
- /data/outputs/* (132 → 0)
- /data/uploads/* (15 → 0)

Dedup baza je ohranjena (15 zapisov), tako da če uporabnik poskuša
naložiti komad ki je že naložen na Nextcloud, dobi opozorilo.
2026-04-30 15:10:28 +00:00
sebastjan 5f339e91b5 Bolj jasen tekst: 'Izklopi podnapise' s pojasnilom
User feedback: 'ko ni kljukice so napisi, zato napiši samo izklopi
podnapise in ko je kljukica so izklopljeni'

Pred: 'Brez podnapisov (privzeto — bolj zanesljivo)'
Po:   'Izklopi podnapise (kljukica = brez napisov · brez kljukice = napisi v video)'

Logika ostane ista (no_subs flag), samo label je bolj jasen.
2026-04-30 15:05:22 +00:00
sebastjan f2034f9970 Dedup: SQLite baza za že obdelane komade
User feedback: 'dodaj da če čekira in shranjuje že obdelani komadi v SQL bazo,
da če nalagamo komad ki smo ga že naložili da ga ne naloži'

NEW: SQLite dedup database at /data/processed.db
Schema: processed_videos
  - normalized_name (PK part 1)
  - tv_station (PK part 2) — isti komad lahko obstaja na različnih postajah
  - filename_orig
  - job_id
  - nextcloud_url
  - file_size_mb
  - uploaded_at

Filename normalization removes noise:
  'BRAJDE (Official Video).mp4' → 'brajde'
  'Brajde (HD).mxf' → 'brajde'
  'BRAJDE - LIVE 2024.mp4' → 'brajde'
(strips parentheses, suffixes like Official/HD/4K/Live, extension, lowercase)

NEW endpoints:
- POST /api/dedup/check — preveri katera imena so že obdelana
- POST /api/dedup/remove — pobriše dedup zapis (Re-process)
- GET /api/dedup/list — seznam vseh obdelanih (opt. filter po tv_station)

Integration:
- Nextcloud upload (manual + auto): zabeleži v dedup po uspešnem PUT
- File queue (frontend): pred dodajanjem preveri dedup
  → prikaže rdeč warning '⚠ Že naložen na ONE DE (29.4.2026) — Re-process'
  → opacity 0.6 (vizualno blediji)
  → submit jih SKIP-a (osim če 'Re-process' kliknil)
2026-04-30 15:00:10 +00:00
sebastjan 16c332b490 Save → avto-upload v Nextcloud → reel izgine iz seznama
User feedback: 'zaenkrat bomo ročno popravljali in pregledovali. Ko kliknemo
Save potem se shrani v pravi folder in izgine.'

Workflow:
1. User izbere TV postajo (zavihek)
2. Naloži komade
3. Reel se renderira (auto chorus)
4. User pregleda + Edit če treba
5. Save → re-render z user popravki
6. Po končanem re-render: AVTO-upload v Nextcloud /folxspeed/REELS/{station}/
7. Reel IZGINE iz seznama (hidden_after_upload flag)

Backend changes:
- RecutRequest: nov field auto_upload (default True)
- update_job: shrani auto_upload_to_nextcloud
- process_job done block: če flag set + Nextcloud configured →
  upload + nextcloud_status='uploaded' + hidden_after_upload=True

Frontend changes:
- refreshJobs: filter out jobs with hidden_after_upload
- TV station badge na vsaki kartici (z emoji + ime postaje)
- Vidiš na prvi pogled kam bo šlo

Workflow rezultat: po Save reel izgine, je avtomatsko v pravi mapi
2026-04-30 14:53:01 +00:00
sebastjan 1c11dfe630 TV station tabs + per-station Nextcloud upload target
User feedback: 'Sarah Connor in Abracadabra grejo v ONE DE, ne v FOLX SLO.
Naredi zavihke za vsako TV postajo (FOLX SLO, FOLX DE, ONE DE, ZWEI MUSIC, ADRIA)
in upload gre v ustrezno Nextcloud podmapo.'

Backend changes:
- StartJobIn + YouTubeJobIn: nov field 'tv_station' (default 'FOLX SLOVENIJA')
- update_job: shrani tv_station v job JSON
- POST /api/jobs/{id}/upload-nextcloud:
  bere tv_station iz job, target_subdir = folxspeed/REELS/{station}

Frontend changes:
- 5 TV station tabs: FOLX SLOVENIJA (active), FOLX DE, ONE DE, ZWEI MUSIC, ADRIA
- Hidden input #tv-station-input drži current selection
- Klik na tab ga aktivira (accent color)
- collectSettings() vključuje tv_station

Manual fix: Sarah Connor in Abracadabra job.json popravljena → tv_station=ONE DE
Reset njihovih nextcloud_* polj da bo upload v pravo mapo.
2026-04-30 14:38:45 +00:00
sebastjan 8284181fb3 Cleanup: odstrani duplikat upload-nextcloud endpoint
Endpoint /api/jobs/{id}/upload-nextcloud je že obstajal (commit dbb8ab3)
in deluje. Moja nova varianta je bila duplikat — odstranjena.

Aktivni endpoint: line 1593 (upload_nextcloud), uporablja
_nextcloud_upload() in _nextcloud_configured() helper-je.

Zdaj imamo:
- _safe_filename_for_nextcloud() helper (ostane, lahko pride prav)
- Frontend gumb '☁ Nextcloud' z 4 stanji (default/uploading/uploaded/failed)
- 14 reelov že uspešno uploadanih v /folxspeed/REELS/FOLX SLOVENIJA/
2026-04-30 14:32:04 +00:00
sebastjan d03beddd0d Nextcloud: URL-encode path segments + use FOLX SLOVENIJA subfolder
- NEXTCLOUD_FOLDER env updated: folxspeed/REELS → folxspeed/REELS/FOLX SLOVENIJA
- urllib.parse.quote() each segment (handles spaces in folder names)
- e.g. 'FOLX SLOVENIJA' → 'FOLX%20SLOVENIJA' in URL
2026-04-30 14:27:28 +00:00
sebastjan dbb8ab3059 Nextcloud upload za FOLX SLOVENIJA reels
User wants reels saved directly to Nextcloud /folxspeed/REELS/FOLX SLOVENIJA/

NEW backend endpoint: POST /api/jobs/{id}/upload-nextcloud
- WebDAV PUT preko stdlib urllib (no new deps)
- Uses NEXTCLOUD_URL/USER/PASS/REELS_PATH env vars
- Updates job status: uploading → uploaded / error
- Stores nextcloud_url + nextcloud_error in job

Frontend already had button (☁ Nextcloud) and handler — just needed
backend endpoint. UI states:
- ☁ Nextcloud (blue) — not yet uploaded
- ☁ ✓ Nextcloud (green) — uploaded successfully
- ☁ ✕ Poskusi znova (red) — upload failed (hover for error)

Env vars added in Coolify:
- NEXTCLOUD_URL=https://nextcloud.folx.tv
- NEXTCLOUD_USER=admin
- NEXTCLOUD_PASS=<app token>
- NEXTCLOUD_REELS_PATH=folxspeed/REELS/FOLX SLOVENIJA
2026-04-30 14:23:15 +00:00
sebastjan faf002d4f8 UI: posodobi zastarel opis (Whisper → Soniox + Claude)
User feedback: 'tukaj imava cel kup stvari ki niso res, kako oblikujemo?'

Old text was misleading:
- 'Whisper, 3-sample voting' → not used since Soniox integration
- 'Model: medium' → irrelevant (Whisper not used)
- 'Whisper + energy → najde refren' → now Soniox + Claude LLM

New text reflects actual stack:
- STT: Soniox (primary) → ElevenLabs Scribe → Gemini fallback
- LLM: Claude Sonnet 4.6
- Energy profile + word-level timestamps + 15 reference examples
- Mention ✏️ Edit button for manual fine-tuning
2026-04-30 14:13:48 +00:00
sebastjan de094b76a5 Edit: add 'Konec (5s)' button — preview just last 5s of clip
User feedback: 'ko hočemo preveriti konec, predvajaj samo 5s.
začetek ni problematičen ker plej začne od začetka'

NEW button: ▶ Konec (5s) — green
- Seeks to (trimEnd - 5s)
- Plays from there to trimEnd
- Auto-stops at trimEnd (existing logic)
- Quick way to verify if 'OUT' position is correct without
  waiting for full clip playback (which can be 30-60s)

Renamed: '▶ Predvajaj odsek' → '▶ Predvajaj cel' for clarity
(plays full clip from start to end)

Workflow now:
- Adjust handles
- '▶ Predvajaj cel' to hear whole clip (when needed)
- '▶ Konec (5s)' to quickly check if end is right
- Iterate handles until perfect
- Save
2026-04-30 14:04:36 +00:00
sebastjan 02fbae7c4f Edit: triangles INSIDE trim bar (overflow:hidden was clipping them)
Bug: triangles positioned at top:-14px were outside trim bar bounds.
Trim bar has overflow:hidden, so triangles were clipped (invisible).

Fix: top:0 (inside trim bar, at the very top edge).
Triangle 14px tall now sits at top of trim bar (overlapping waveform
slightly but visible, with drop-shadow to make them stand out).
2026-04-30 13:57:09 +00:00
sebastjan 5d817c586b Edit: IN/OUT marker triangles + Postavi IN/OUT buttons
User feedback: Workflow is - click + Enter sets a marker triangle, then
button moves the red handle to that triangle. Triangle near LEFT handle
= IN candidate (green), near RIGHT = OUT candidate (red).

Visual:
- Green triangle (▼) above trim bar = IN candidate position
- Red triangle (▼) above trim bar = OUT candidate position
- White line (playhead) = current video position (moves during playback)
- Red handles (existing) = actual clip start/end

Workflow:
1. Click on waveform → white playhead jumps there
2. Press Enter → playhead starts moving (plays)
   ALSO: triangle gets placed at current position
   - If position closer to LEFT handle → green IN triangle
   - If position closer to RIGHT handle → red OUT triangle
3. Listen, decide 'this is the right spot'
4. Click ▼ Postavi IN button → red LEFT handle jumps to green triangle
   (or ▼ Postavi OUT for right handle)
5. Now red handle and triangle are aligned = clip boundary committed

Triangles persist until next play press (= next candidate).
Buttons styled with matching color (green for IN, red for OUT).
2026-04-30 13:42:30 +00:00
sebastjan 4376f7529f Click = seek only (no auto-play); Enter = play/pause from position
User feedback: 'naj ne začne takoj predvajati. naj začne ko pritisnem
Enter, in pozicija naj ostane črta ker bomo tja dali tracker'

Changes:
- Click on waveform: just seek + render playhead (was: seek + auto-play)
- Click on segment row: just seek + render playhead (was: seek + auto-play)
- Playhead: brighter, with triangle marker on top (tracker placeholder)
- Enter key: play/pause toggle from current position
- Space key: also play/pause (back-compat)
- Hint texts updated to reflect new workflow

Workflow now:
1. Click on waveform/segment → playhead jumps there (no sound)
2. Read transcript, look at waveform around the position
3. Press Enter → plays from there
4. Press Enter again → pauses
5. Click somewhere else → playhead moves there (paused)
6. Press Enter → plays from new position

Allows precise positioning before commit to playback.
2026-04-30 13:02:28 +00:00
sebastjan 1d6af29a23 Parallel workers (3) + pre-cache Edit assets
User feedback: 'ne morem nič drugega delat dokler izvaža reel?
a če bi bile večje mašine bi blo bolj?'

Without GPU upgrade, optimize CPU usage:

1. PARALLEL WORKERS:
   - Was: 1 worker thread, processes 1 job at a time
   - Now: NUM_WORKERS=3 parallel threads (configurable via env)
   - Each worker locks its job atomically (set instead of single var)
   - 3 reels render simultaneously instead of sequentially
   - Edit feature usable while other reels render

2. PRE-CACHE EDIT ASSETS:
   - On job done, fire-and-forget ffmpeg subprocess.Popen for:
     * low-q source video (480p) — used in Edit modal video player
     * waveform PNG (2400x72) — used in Edit modal trim bar
   - Both run in background, don't block pipeline
   - When user later clicks Edit, assets already cached → modal instant
   - On-demand fallback still works if precache failed

Result: Edit modal opens instantly even while other reels render.
3 reels can render in parallel = ~3x throughput on multi-core CPU.
2026-04-30 12:55:38 +00:00
sebastjan 47a114ce6a Edit modal: zoom + play-from-position + Space toggle
User feedback:
1. 'Wave form je premajhen — zoom'
2. 'Ko nastavimo pozicijo, play od začetka — ne moremo predvajat od tam'

NEW Zoom feature:
- 5 zoom levels: 1x, 2x, 5x, 10x, 20x
- Trim bar wrapped in scrollable container
- On zoom: bar width grows to 100*N%, scroll auto-centers on trim region
- Higher zoom = more pixels per second = micro-tuning possible
  (1x: 5px/s, 20x: 100px/s for 4min song)
- Active zoom button highlighted accent red

NEW Play-from-position:
- Click on waveform/trim bar = playhead JUMPS THERE + auto-plays
  (was: just moved playhead, no play)
- Space key = play/pause toggle from current position
  (works anywhere except in input fields)
- '▶ Predvajaj odsek' still does start-to-end of selection
- Cleanup keydown listener on modal close

Waveform now rendered at 2400x72 (higher res) so zoom looks crisp.

User can now:
- Zoom 10x to see exact word boundaries in waveform
- Click anywhere → instant play from there
- Hit Space to toggle while watching
2026-04-30 12:37:06 +00:00
sebastjan facfd6bd39 Edit modal: waveform + napisi desno + live highlight
User feedback: 'če bi imeli spodaj wave strukturo bi se po tem prmikali,
in narediti da teksti laufajo na desni strani ob robu videja'

NEW: backend /api/waveform/{id}?width&height
- ffmpeg showwavespic generates PNG (~10-50KB)
- Cached forever per song
- Red color (#ff6b6b) matching accent

Frontend layout RESTRUCTURED:
- 1200px max-width (was 900px)
- Top section: GRID 1fr / 320px
  - LEFT: video (16:9)
  - RIGHT: napisi panel (sticky header, scrollable, 55vh max)
- Bottom: trim bar full-width with WAVEFORM as background image
- Hint text updated: 'Klik na valove ali napise = skoči video'

INTERACTIONS:
- Click segment row → seekToSegment() jumps video to that timestamp
- Live highlight: gold (#ffd700) on currently playing segment
- Auto-scroll panel to keep active segment in view
- Drag handles updates segment row colors (in-clip = red bg, outside = gray)
- Click on trim bar (waveform) still works as seek

User can now:
- See visual audio shape (loud parts = vocals, quiet = instrumental)
- See ALL napisi at once on the right
- Click any napis to jump to it
- Watch live highlight follow the song
- Edit any napis text inline
2026-04-30 12:25:08 +00:00
sebastjan c94e6214ca Edit: instant client-side preview with low-q source
User feedback: 'predvaja odsek in začne iz nule kar ni ok, ne moremo
premikati levo dolj levo... za to bi rabili low-q?'

REPLACED render-on-demand approach with low-q source download:

1. Backend: GET /api/source-video/{id}?quality=low
   - 480p re-encode of full source (cached after first request)
   - veryfast preset, CRF 28
   - First request: ~5-10s (depends on song length)
   - Subsequent: instant (cached)

2. Frontend: Edit modal loads ?quality=low
   - 'Pripravljam predogled (~5s prvič, potem instant)' status
   - Once loaded: ALL preview is client-side instant
   - 'Predvajaj odsek' jumps to trimStart and plays
   - Auto-stop at trimEnd (loops back)
   - Drag handles DURING playback = instant seek (browser scrubs in 5MB)
   - Drag NOT blocked during play (you can fine-tune in/out live)

3. Removed old /api/preview-clip endpoint logic (no longer needed)
   Note: kept the route as cache cleanup for old jobs

Workflow now:
- Open Edit → 5s wait first time
- Drag handles freely (instant scrubbing)
- Click Predvajaj → starts at trimStart immediately
- Drag handles WHILE playing → live preview
- Save when satisfied → 70s full render
2026-04-30 12:14:37 +00:00
sebastjan 63da3ad2e2 Preview-clip: validate cache, support HEAD, cleanup on fail
Bugs from puppeteer inspection:
1. Old buggy renders left 0-byte cache files behind. New code never
   re-rendered because cache_path.exists() was True.
   Fix: validate cache file is >1KB, otherwise re-render.

2. FastAPI @app.get only handles GET, not HEAD. Frontend's HEAD check
   returned 405, then GET re-rendered (correct), but second click also
   returned 405 then 200 again — confusing.
   Fix: use @app.api_route with methods=['GET', 'HEAD']

3. If ffmpeg fails partway, broken file remains in cache.
   Fix: unlink on any failure path.

Also deleted existing empty cache files in container.
2026-04-30 12:05:11 +00:00
sebastjan 69062205fd Fix preview-clip ffmpeg: force even dimensions for libx264
Bug: 'width not divisible by 2 (853x480)' from screenshot.
libx264 requires even width/height. scale=854:480 + decrease can result
in 853x480 (odd width).

Fix: chain second scale filter that truncates to nearest even number:
  scale=trunc(iw/2)*2:trunc(ih/2)*2

Verified locally: 4.4MB clip in 4.8s on CPU.
2026-04-30 12:01:06 +00:00
sebastjan 0513768466 Live preview in Edit modal — fast low-q clip render
User feedback: 'dejstvo je da trajna ker more najprej zrenderirat? to traja?
za to bi rabili hudo mašino al?'

Solution before GPU upgrade: live preview that renders just the selected
range as low-quality 480p clip. ~2-3s instead of ~70s full reel render.

NEW endpoint: GET /api/preview-clip/{job_id}?start=X&end=Y
- ffmpeg fast extract (no reframe, no subtitles, no face tracking)
- 480p ultrafast x264 preset, CRF 30
- Cached per job+range (re-clicks are instant)
- ~2-3s on CPU

Frontend:
- '▶ Predvajaj odsek' button now triggers preview-clip render
- Shows status: '🎬 Renderiram odsek... (~3s)'
- After render: video element switches to preview src
- User sees EXACTLY what reel will contain (just without face track)
- Subsequent clicks on same range are instant (cached)

Workflow:
- Drag handles → click '▶ Predvajaj odsek' → 3s wait → see + hear it
- Iterate fast: drag → preview → drag → preview
- Final '✅ Shrani in re-render' only when satisfied (~70s full render)
2026-04-30 11:56:54 +00:00
sebastjan 274ff80b34 Edit: play selection feature
- Big '▶ Predvajaj odsek' button: plays from trim start
- Auto-stop when video reaches trim end (loops back to trim start)
- iPhone trim preview behavior: see exactly what reel will contain
2026-04-30 11:51:31 +00:00
sebastjan ce827e939a Trim bar: explicit width:100% + flex-shrink:0
Screenshot revealed: trim bar element has only 4px width even after
ResizeObserver fires. Likely the parent (.modal-content) is a flex
container that shrinks the trim-bar.

Force trim bar to take full width with width:100% and prevent shrinking
with flex-shrink:0.
2026-04-30 11:40:59 +00:00
sebastjan 4f7d8df659 Fix trim bar handles invisible: ResizeObserver + rAF
Root cause found via puppeteer inspection:
- trimBarWidth was 4px when renderTrim() ran
- That made calc(32.64% - 12px) = ~-10px, putting handles offscreen left

Modal element gets actual width AFTER appendChild + browser layout pass.
Original code called renderTrim() synchronously right after appendChild,
before the modal had real dimensions.

Fix:
1. Use ResizeObserver on trim-bar to re-render whenever it gets actual width
2. Also use double requestAnimationFrame as fallback (waits for layout)

Verified via puppeteer:
  Before: leftStyle='calc(32.6443% - 12px)' but trimBarWidth=4
  After: handles correctly positioned within visible bar
2026-04-30 11:38:20 +00:00
sebastjan 47d313f39c Debug: log trim modal init values 2026-04-30 11:32:40 +00:00
sebastjan 8d7397699c Trim handles: set initial inline left positions
JS renderTrim() likely failed silently (Cannot read undefined of length).
Set handle positions inline in HTML template so they show immediately
without waiting for renderTrim() to fire.

Added pctOfStr helper to compute percentage as string for inline style.
2026-04-30 11:27:19 +00:00
sebastjan a3550a444a Fix trim bar handles not visible
Bug from screenshot: trim bar visible but red handles not showing.

Causes:
1. video_duration in job is None for old jobs (was not saved on initial
   processing). Without it, fallback was endInit+60 which placed handles
   off-screen.

2. videoDuration was const, couldn't be updated when video metadata loads.

3. Handle offset was 9px but handles are now 24px wide (need 12px offset).

Fixes:
- Backend /api/transcript: fallback to last segment end time if
  video_duration missing in job
- Frontend: videoDuration is let, updated on loadedmetadata
- Handle offset 9px → 12px for 24px wide handles
- Re-render trim after metadata loads to pick up actual video.duration
2026-04-30 11:21:43 +00:00
sebastjan 446769b68f Trim bar: visible styling (was too transparent on dark theme)
Bug from screenshot: trim bar was invisible due to:
- Background rgba(255,255,255,0.05) too transparent
- Handles 18px width with low contrast
- Removed video controls

Fixed:
- Trim bar background #1a1a1a + 2px #444 border (visible)
- Handles 24px width, full red #ff6b6b with strong glow
- Region 35-20% opacity (brighter)
- Playhead 3px white with shadow (visible)
- Restored video controls
- Added hint text below trim bar
2026-04-30 11:17:57 +00:00
sebastjan 9dba2a1185 iPhone-style trim bar: drag handles instead of sliders
User feedback: 'tako kot imajo na iphonu - potegnem iz leve in iz
desne za na konec... reel pa more biti že v stanju postavljen'

Replaced 2 separate range sliders with iPhone-style trim bar:
- Single horizontal bar showing full video duration
- 2 draggable handles (left = start, right = end)
- Selected region highlighted in accent color
- Live playhead during playback
- Mouse + touch support
- Click anywhere on bar = seek to that position
- Initial state: handles positioned at auto-selected clip range
  (just fine-tune left/right, no need to set from scratch)

formatTime helper for nice m:ss.c display.
2026-04-30 10:34:34 +00:00
sebastjan b4294e7113 Fix typo: OUTPUTS_DIR -> OUTPUT_DIR (causing 500 in /api/transcript) 2026-04-30 10:30:18 +00:00
sebastjan 7cb4302dcd Edit feature: slider + napis edit + recut endpoint
User insight: 'treba je narediti da ko se reels naredijo da jih lahko
popravljamo... delamo na avtomatiko ampak lahk pa tudi popravljam'

Avto pipeline ostane (Soniox + Claude + render). Po render-u uporabnik
lahko klikne ✏️ Edit gumb in:

1. **Slider za clip start/end**:
   - Vidi 16:9 original video
   - Drag start/end slider z živim preview-om
   - Dolžina prikazana real-time
   - Min 5s, max 60s

2. **Edit napisov** (collapsed, opcijsko):
   - Klik na vrstico → input za popravek besedila
   - Original timestamp ostane, samo besedilo se posodobi
   - Uporabno za 'doline IZBOR' → 'doline IZPOD' tip popravkov

3. **Re-render**:
   - Backend POST /api/jobs/{id}/recut z {start, end, custom_segments}
   - Worker preskoči Soniox + Claude (custom_clip flag)
   - Re-uporabi cached transcript + analysis
   - Re-render samo: clip → reframe → subtitle → output
   - ~30s namesto 3-5 min

New endpoints:
- GET /api/source-video/{id} — 16:9 original za editor preview
- GET /api/transcript/{id} — segmenti + clip range za editor
- POST /api/jobs/{id}/recut — re-render z user timestampi

Worker change: če job ima custom_clip=True, preskoči auto_chorus
analizo in samo re-uporabi obstoječi clip_range iz analysis.json
(updated by recut endpoint).
2026-04-30 10:26:25 +00:00
sebastjan f99574daff Sebastjan's filing: 15 reference examples in prompt
User insight: 'to so moje odločitve pomojem občutki in ja t so refreni
.. kako ga naučimomojega občutka'

Solution: few-shot learning. Instead of trying to express Sebastjan's
filing in abstract rules, give the LLM 15 concrete examples of his
choices as a reference table.

Each example contains:
- Song title
- Exact chorus text (Sebastjan's choice)
- Filing note (his reasoning: 2x ponovitev, 1 polni refren, NE outro,
  intro klici included, etc.)

Coverage:
- Hitri komadi: BRAJDE, PA PA, PIJAN, FICKA, ABRACADABRA
- Počasne NZ (Avsenik): CVETELE, ENA BOLHA, ŽENA ME TEPE
- Fehtarji: GOJZAR TANC, GORENJSKA, PODEŽELSKI
- Schlager/pop: STISN SE K MEN, KO MISLIM NATE, NA LEPO SKUPNO POT
- Sentimentalne NZ: DOMOTOŽJE V POMLADI

Plus key filing notes:
✅ DA vključi: intro klici (Ajmo Janezi/Slovenci), 2 zaporedna
   refrena (~30s), naravni outro filler (kratko), pevčev držan ton
❌ NE vključi: verzi/kitice, pre-chorus, 2 različna refrena pomešana,
   dolg outro filler (5+s), instrumental break

LLM bo pri NOVIH pesmih posnemal ta filing iz 15 primerov.
2026-04-30 10:01:24 +00:00
sebastjan 6cf52e1918 Soften 'title must be in chorus' rule + fix CVETELE example
User feedback: 'Odmakni da se zacne refren na besedo ki je v naslovu'

Problem: Many Slovenian folk-pop songs have the title in the VERSE,
not in the chorus:
- 'Cvetele so maline' → title is in verse, real chorus is 'Naj veter zdaj...'
- 'Domotožje v pomladi' → title is theme, real chorus is 'Bele breze...'

Old prompt forced LLM to find title phrase in chorus, leading it to
pick verse parts (mid-line, wrong timing) just because they contained
the title.

Changes:
1. REMOVED forced rule: 'Naslov pesmi = REFREN HOOK (80-90% primerov)'
2. NEW guidance: 'Naslov pesmi je VČASIH v refrenu, VČASIH v verzu. NE silujte!'
3. NEW principle: 'Refren je tisti del ki se PONAVLJA 2-3x z ENAKIM besedilom'
4. Fixed CVETELE example: chorus is 'Naj veter zdaj ponese...' (not Cvetele)
   with explicit warning that title is in VERSE 2 at ~125s
5. Added: 'NE izberi outro/3. nastop — izberi PRVI nastop refrena'

This should let LLM find the actual repeating chorus instead of
chasing the title phrase into verses.
2026-04-30 05:39:44 +00:00
sebastjan d8c2aae9c1 Prompt: chorus must start on FIRST WORD of FIRST LINE
User feedback after re-processing 14 reels:
- 8 perfect (BRAJDE 100%, FICKA, Abracadabra, Žena, Stisn, PA PA,
  Gojzar tanc, GADI Pijan)
- 4 problematic patterns identified:
  1. CVETELE: clip extends 22s into instrumental on 'nekoč oba' hold
  2. GORENJSKA LJUBLJENA: clip starts mid-line at 'obrnem nazaj'
     instead of 'V Ljubljani se obrnem nazaj'
  3. Fantje: clip starts mid-chorus at 'vabijo me' (2nd line)
     instead of first line
  4. PODEŽELSKI: extends into 'o o o' outro filler

Common cause: Soniox can group end-of-verse + start-of-chorus into
same segment (e.g., '[43.6-47.6] doma. V Ljubljani se'), and Claude
picks segment.start (43.6) or next segment.start (48.2) instead of
the actual word 'V' boundary inside the segment.

Prompt fix:
1. NEW critical rule: 'clip start = TOČNO prva beseda PRVE vrstice'
2. Warning about Soniox merging end-of-verse + start-of-chorus
3. Use word-level timestamps to find chorus start word
4. Warning about long held tones in Soniox segments (15-20s on
   'oba', 'doma', 'srca' due to fade-out instrumental)
5. Cut 1-2s after last sung word, don't wait 20s for tone to die
6. Outro filler: include short outros (yeah/aj-aj), but cut before
   long repeating outros (5+s of 'o o o') as those are fade-out

Added concrete examples in PRIMERI:
- BRAJDE: 28s (already perfect)
- GORENJSKA: explicit warning about 'V Ljubljani se' boundary
- CVETELE: explicit warning about 15-20s held tone segments

This is a prompt-only change. No code logic modified.
LLM still has full autonomy on duration.
2026-04-30 05:19:35 +00:00
sebastjan 22bb3cfe02 Trust LLM: remove forced extension, content-driven prompt
User feedback: 'Tikaj more llm razmislat in ineti filing kaj dat notri'.
With Soniox transcript now accurate, LLM has all info to decide content-wise.

TWO CHANGES:

1. smart_clip_range() — REMOVED forced extension logic:
   Before: if duration < min_duration (20s):
       - extend to next chorus (40% match) ← WRONG! merged with B-chorus
       - extend symmetrically into VERSE ← WRONG! brought in kitica
       - cap at max_duration
   After: trust LLM completely. Only safety: clamp to video bounds.

2. Prompt rewrite — content-driven instead of number-driven:
   Before: 'Skupna dolžina: 12-25 sekund (običajno)' + conflicting '~30s'
           '❌ Drugi/tretji nastop refrena — uporabi PRVI'
   After: '~30 sekund (NAJBOLJŠA opcija = dva zaporedna refrena)'
          'Vključi naravne intro klice (Ajmo Janezi! Hey! Pa-pa!)'
          'BRAJDE primer: 41.8-69.8s = 28s (dva refrena z Ajmo Janezi intro)'
          'NE meša 2 RAZLIČNA refrena (A + B = napaka)'
          'NE razširi v VERZE/KITICE'

For BRAJDE this means:
- Old: Claude picked 57.1-69.8s (12.7s, 2nd chorus, no Ajmo)
       Code forced extension to 57.06-82.5s (mixed with B-chorus + verse)
- New: Claude picks 41.8-69.8s (28s, 2 choruses with 'Ajmo Janezi!' intro)
       Code returns exactly that — no forced extension.
2026-04-30 04:39:26 +00:00
sebastjan dc1cb1ad27 Fix SRT subtitle timing: use word-level timestamps for chunk boundaries
Bug: BRAJDE reel showed subtitles 2-3 seconds out of sync with audio.

Soniox returned correct word timestamps:
- 'Ajmo,' at 41.82s
- 'Janezi!' at 42.18s
- 'Pejd' greva, ajde,' at 43.44-44.40s

But generate_srt_from_segments() ignored word timestamps and split long
segments into evenly-spaced 2.5s chunks based on segment duration:

  chunk_dur = duration / n_parts   ← assumes even pacing
  for i in range(n_parts):
      cs = rel_start + i * chunk_dur

This produces wrong timing because singers don't sing evenly. Real audio
had 'Ajmo, Janezi!' in 0.9s and 'Pejd' greva, ajde, na traktorju od Majde'
in 6s — the 2.5s chunks didn't align with vocals.

Fix: when word-level timestamps are available (Soniox/Scribe), group
words into chunks where each chunk's start/end match the actual first/last
word timestamps. Each chunk is at most MAX_CHUNK_DURATION (2.5s) but
respects natural word boundaries.

Before:
  00:00.000 → 01.900  AJMO, JANEZI! PEJD' GREVA, AJDE, NA TRAKTORJU OD
  00:01.900 → 03.800  MAJDE, NOBEN NAJU NE NAJDE, KO PELJEM TE

After:
  00:00.020 → 02.120  AJMO, JANEZI! PEJD' GREVA,
  00:02.360 → 04.820  AJDE, NA TRAKTORJU OD MAJDE, NOBEN

Subtitles now perfectly align with vocals.
2026-04-30 04:02:09 +00:00
sebastjan 865e21fe1a Integrate Soniox stt-async-v4 as primary STT provider
Test results comparing all providers on Slovenian folk-pop:

CVETELE SO MALINE:
- Scribe: HALLUCINATED ('finančni moduli...') ❌
- Gemini 3 Pro: correct lyrics, ~100s ✅
- Soniox: PERFECT lyrics in 4 seconds ✅✅

PA PA:
- Scribe: 'se mu pomahala' (wrong: missing M) ❌
- Soniox: 'sem mu pomahala' ✅ + caught 'pa-pa-ra-pa' fillers

ŽENA ME TEPE:
- Scribe: hallucinations + word errors
- Soniox: PERFECT 'Žena me tepe, mi prazni žepe, da vidi, kje in s kom sem bil'

Soniox advantages:
- 4x cheaper than Scribe ($0.10/h vs $0.40/h)
- 5x faster (4-15s vs 10-15s for 180s audio)
- 50x cheaper than Gemini 3 Pro
- 25x faster than Gemini
- Slovenian native quality matches Gemini
- Word-level timestamps + diacritics + punctuation

Implementation:

1. transcribe_with_soniox() function:
   - Multipart upload to /v1/files (no SDK dependency)
   - Create transcription with stt-async-v4 model
   - Auto language hint based on filename (NZ → 'sl')
   - Multilingual fallback ['en', 'sl', 'de', 'hr', 'es', 'fr', 'it']
   - Poll status, fetch transcript
   - Group subword tokens into words → segments
   - Auto-cleanup files after transcription

2. New 'soniox_chain' provider mode (default for 'auto'):
   - Soniox primary (fast + cheap + accurate)
   - Scribe fallback (rare cases when Soniox fails)
   - Gemini fallback (last resort, slow but bulletproof)
   - Quality gate: coverage >= 50%, no hallucinations

3. Provider modes: auto, soniox, elevenlabs, gemini, hybrid, local

This makes the pipeline reliable for ALL music genres including
Slovenian narodno-zabavni glasbi which Scribe consistently failed on.
2026-04-30 03:06:38 +00:00
sebastjan ab5424d37b Clip starts EXACTLY on chorus first word (no buffer)
User feedback: 'na refren ne pred na začetek refrena' — the clip should
start right when the chorus begins, not 0.3s before.

Changes:
1. Prompt rule: 'Začetek = TOČNO ko prva beseda refrena začne'
   (was: '~0.3s PRED prvo besedo refrena')
2. Word-level extension: removed -0.15s buffer when extending back
   (now lands exactly on word start)

Reasoning: with no_subs as default, we don't need buffer to avoid
cutting first word during fade-in (fade-in is now 0.05s = imperceptible).
Cleaner cuts directly at chorus onset.
2026-04-29 19:41:56 +00:00
sebastjan 64b0ed1edc Default 'no subtitles' mode: cleaner reels without text issues
User feedback: subtitles have been causing problems (wrong text from STT,
chorus selection issues). Better to default to clean reels without
burned-in subtitles - just video + audio at the chorus moment.

Changes:
- 'Brez podnapisov' checkbox now CHECKED by default
- Removed 'Stil podnapisov' dropdown from UI (kept hidden for compat)
- Updated step label: 'Reframe v 9:16 + podnapisi' → 'Reframe v 9:16'
- Backend already supports --no-subs flag, no logic changes needed

Result: reels are simpler and more reliable. Just clean 9:16 with audio.
User can enable subtitles per-job by unchecking the box if needed.
2026-04-29 19:38:35 +00:00
sebastjan 4788a55643 Clean noise patterns more aggressively + clean already-stored values
User feedback: 'Ansambel UNIKAT — PA PA (offiicial video)' shows the
'(offiicial video)' suffix everywhere (titles, downloads, UI). The user
wants only 'Artist - Title' without any video format markers.

Two fixes:

1. EXPANDED _NOISE_PATTERNS to handle:
   - Typos in 'official': 'offiicial', 'offical', 'oficial' (regex Off[a-z]*icial)
   - Variants: '(Official 4K Video)', '(Official HD Video)', '(Official Music Video)'
   - More versions: (Live), (Cover), (Acoustic), (Extended Mix), (Radio Edit), (Clean), (Explicit)
   - Square brackets: [Official...], [HD], [Lyrics...]
   - Bare words without brackets
   - Trailing year markers '(2024)'

2. NEW clean_noise() function applied at READ TIME:
   Even if a job was saved with 'PA PA (offiicial video)' as parsed_title,
   the new code re-cleans it when serving the job to the UI or building
   the download filename. This means existing jobs get fixed too without
   needing re-processing.

3. Applied to:
   - build_download_filename() — clean before formatting
   - list_jobs() — strip noise when serving job list
   - get_job() — strip noise when serving single job

Result: 'Ansambel UNIKAT - PA PA - REEL.mp4' (no more (offiicial video))
2026-04-29 18:47:19 +00:00
sebastjan 0dd33c16f3 Hybrid transcription: Scribe primary + Gemini 3 Pro fallback
Real-world test confirmed Gemini 3 Pro can transcribe Slovenian folk-pop
songs accurately where ElevenLabs Scribe hallucinates:

Test: FEHTARJI - GORENJSKA LJUBLJENA (120s sample)
- Scribe result: 'finančni moduli...' (total hallucination, wrong content)
- Gemini 3 Pro: 'Zunaj srečo sem iskal, planet prepotoval' (CORRECT lyrics)

Implementation:

1. New transcribe_with_gemini() function:
   - Uploads audio via Gemini Files API (resumable upload)
   - Calls gemini-3-pro-preview with structured prompt
   - Parses JSON response with word-level timestamps
   - Computes coverage_pct and hallucination_count
   - Returns same format as Scribe (compatible)

2. New 'hybrid' provider mode (now the default for 'auto'):
   - Try Scribe first (fast, cheap: 8-10s, $0.013)
   - If quality OK (coverage >= 50%, no hallucinations) → return Scribe
   - Else retry Scribe once
   - If still bad → fallback to Gemini 3 Pro (slow, more expensive: 100s, $0.20)
   - Compare results, return whichever is better

3. Provider modes:
   - 'auto'      → hybrid if both keys, else elevenlabs, else local
   - 'hybrid'    → explicit Scribe + Gemini fallback
   - 'elevenlabs'→ Scribe only (with auto-retry)
   - 'gemini'    → Gemini only
   - 'local'     → faster-whisper on CPU

Cost analysis (10 reels/day):
- Pure Scribe: $0.13/day, ~5-10% reels unusable
- Hybrid: ~$0.55/day, 100% usable
- Pure Gemini: $2/day

Hybrid is the clear winner: +$0.42/day for 100% reliability.
2026-04-29 18:38:27 +00:00
sebastjan df6011c3cf Detect Scribe hallucinations + filter from SRT + auto-retry
Bug found in Žena ME TEPE third re-test:
- Scribe transcribed only verse 1 (0-33s) properly
- Then returned a single 98s segment [34.7-133.2] with just 1 word 'sam'
- This is a known Scribe hallucination on instrumental sections
- Result: SRT showed 'SAM SAM SAM SAM...' 14 times across the chorus
- Looked completely wrong because the chorus audio was correct but
  subtitles showed 'SAM' repeatedly

Three-part fix:

1. SRT GENERATOR: skip segments > 15s with < 5 words.
   These are hallucinations and have no real transcription value.

2. SCRIBE TRANSCRIBE: detect hallucinations in returned segments.
   - Mark segments > 15s with < 5 words as hallucinations
   - Compute true coverage % (excluding hallucinations)
   - Add _hallucination_count and _coverage_pct to result

3. TRANSCRIBE_FULL: auto-retry Scribe if quality is poor.
   - If hallucinations detected OR coverage < 50%, retry once
   - Keep retry result only if it has better stats
   - Otherwise fall back to first attempt (still better than nothing)

This makes the pipeline robust against Scribe's occasional bad transcripts
on songs with long instrumental breaks. Most second attempts succeed
where the first failed (random Scribe variance).
2026-04-29 18:08:35 +00:00
sebastjan d3b71942d2 Word-level extension: 2-word lookback (not full phrase)
Refinement of previous lookback fix - limit to MAX 2 words back.

Reason: with unlimited lookback, the lookback would chain through
words with gaps < 0.5s and keep walking back into the previous verse.
For Žena ME TEPE: 'verjet.' [76.78] → 'Žena' [76.88] gap is 0.10s,
which means lookback would walk back to verses before chorus.

With 2-word limit:
- Clip at 78.19s → 'me' [78.16] is closest preceding word (gap 0.03s)
- Lookback j=i: 'me' → 'Žena' gap 0.14s → captured (i-1)
- Lookback j=i-1: 'Žena' → 'verjet.' gap 0.10s → would be captured
  but MAX_LOOKBACK_WORDS=2 stops here ✓

Result: anchor = 'Žena' at 76.88s → new_start = 76.73s.
Subtitle: 'ŽENA ME TEPE' (full phrase, no verse leakage).
2026-04-29 16:53:29 +00:00
sebastjan d73453fe50 Fix SRT subtitles: word-level clipping for partial segments
Bug found in Žena ME TEPE re-test:
- Clip start: 76.73s (correct, captures full 'Žena' word)
- But SRT subtitle #1 showed: 'SAJ ŠE DOMA MI VEČ NOČJO VERJET.'
- That text is from the PREVIOUS verse, not the chorus!

Why: previous segment (73.9-78.2s) contained 'saj še doma mi več
nočjo verjet. Žena me'. Clip start fell at 76.73s (mid-segment).
Old SRT logic: max(s_start, clip_start) just clipped TIMING but kept
ALL the text from that segment, including text from before the clip.

Fix: when a segment partially falls outside clip range AND has word-level
timestamps (Scribe provides these), reconstruct the segment using only
the words that actually fall within [clip_start, clip_end]. Audio
(clipped at clip_start) only contains those words anyway, so the
subtitle should match.

Result for Žena chorus:
- Old: 'SAJ ŠE DOMA MI VEČ NOČJO VERJET.' (wrong, that text is silent
        in clip)
- New: 'ŽENA ME' (only words actually heard at 76.73-78.16s)
2026-04-29 16:48:39 +00:00
sebastjan 823eb3e91e Use original Scribe transcript for word-level (Claude doesnt return words)
Bug found in Žena ME TEPE re-test:
- Final clip start was 77.2s but word 'Žena' starts at 76.88s
- Word-level extension would have correctly chosen 76.73s
- Why didn't it? Because corrected_segs (Claude output) doesn't contain
  word-level timestamps, only segment start/end. all_words array was empty,
  triggering segment-level fallback (-0.5s) which produced 77.2s instead.

Fix: always use transcript['segments'] (original Scribe output with word
timestamps) for word-level boundary detection, not Claude corrected_segments.

Now: 'Žena' word at 76.88-77.74s will trigger word-level extension to
76.73s (76.88 - 0.15s buffer), capturing the full word.
2026-04-29 16:30:51 +00:00
sebastjan 108f6b1ad3 Fix: live preview blocks right-side preview buttons
Bug: when user clicked Preview in the left 'Analiza pesmi' panel, it
loaded an inline <video> below it. The video element grew to 400px and,
combined with the sticky positioning of the left card and grid layout,
caused the right column (jobs list) preview/download buttons to become
unclickable — the video element was effectively layering over them.

Fix: replace inline preview with the same modal that the right-side
Preview buttons use. Removes the layout conflict entirely:

- Live panel Preview button now opens the centered modal
- Removed inline #live-video element
- Removed liveVideo references from JS (resetLive)
- Job cards now have data-id and data-title attributes so the modal can
  pull title for display

Both left-side (live) and right-side (jobs list) preview now use the
same clean modal experience.
2026-04-29 15:37:51 +00:00
sebastjan e06c3efb8e Add audio amplitude defense (Layer 3) for first-word cut prevention
Žena problem persists: even after word-level extension, some cases where
Scribe doesn't transcribe the very first word still result in clip cutting
the vocal start.

Layer 3 defense: after word-level start extension, probe the FIRST 150ms
of audio at clip start with ffmpeg volumedetect. If mean_volume > -35 dB
(threshold for vocal/music vs silence), extend clip start back 0.5s as a
safety buffer.

This catches cases where:
- Scribe missed the word entirely (no word-level timestamp to extend to)
- LLM picked a start that's already inside vocal energy
- Word-level extension didn't trigger because no nearby word matched

The check is fast (<100ms) and conservative (only triggers if audio is
clearly NOT silent). If it's a true musical break (silence before chorus),
mean_volume will be < -40 dB and extension is skipped.

Three layers of defense now:
1. Claude prompt: 'start ~0.3s before first chorus word'
2. Word-level boundary detection (Scribe word timestamps)
3. Audio amplitude check (catches cases 1-2 missed)
2026-04-29 15:23:37 +00:00
sebastjan 9df58212b2 Two fixes: clip end overflow into instrumental + UI cleanup
1. CLIP END EXTENSION TOO AGGRESSIVE (Avsenika problem):
   Previous logic extended clip end to any segment within 1s pause.
   This caused clip to spill into instrumental break or next chorus.

   New rules (multi-language):
   - Hard cap: original_clip_end + 3s max (prevents long instrumental tails)
   - Pause threshold tightened: 0.7s (was 1.0s)
   - Length check: skip segments longer than 2.5s (likely new verse/chorus)
   - Outro filler regex: only extend if next segment matches
     (la|na|oh|ah|eh|ej|aj|ja|hey|yeah|yo|ho|wo|hu|mm|nn|uu|oo|aa|ee|ii)
   - Universal across languages (works for SLO 'aj ja ja', EN 'yeah',
     ES 'ay ay ay', RO 'hei hei', JP 'la la la')

2. UI CLEANUP:
   - Removed dead pendingFile/pendingArtist/pendingTitle references
     (multi-upload migration left some single-file resets behind)
   - Job watch handler no longer tries to clear single-file state
2026-04-29 15:14:42 +00:00
sebastjan 91cc03658d Multi-upload batch queue + Telegram notifications
Changes:

1. Frontend multi-upload:
   - File input now has 'multiple' attribute, drag-drop accepts multiple
   - File queue list with per-file artist/title preview + remove button
   - 'Pošlji vse' uploads sequentially (one at a time to avoid network saturation)
   - Each file gets same batch_id for Telegram batch summary
   - After upload, queue clears, jobs appear in right sidebar

2. Backend queue worker:
   - New _queue_worker() background thread processes 'queued' jobs sequentially
   - Only 1 job at a time to keep openclaw stable (avoid CPU/RAM thrash)
   - FIFO order by created_at
   - Auto-starts on app startup after job resume

3. Job submission flow change:
   - /api/process and /api/youtube no longer call background.add_task directly
   - Just mark status='queued', queue worker picks up
   - This means upload completes fast, processing happens in background
   - User can close browser, jobs continue

4. Telegram notifications (FOLX Alerts bot):
   - Per-job: 'Reel pripravljen: Lady Gaga - Abracadabra (29s, 30 MB)'
   - Per-job failed: 'Reel ni uspel: <name> + error message'
   - Batch summary: 'Batch končan: 10/10 reels pripravljeni' (only if >1 in batch)
   - Uses existing TELEGRAM_TOKEN + TELEGRAM_CHAT_ID env vars
   - app/telegram.py module with notify_job_done(), notify_job_failed(),
     notify_batch_complete()

5. batch_id field:
   - Added to Job model + StartJobIn pydantic
   - Saved during upload + process
   - Used to count batch progress and trigger summary notification

User experience:
- Drag 20 videos at once
- Click 'Pošlji'
- Close browser, go grab coffee
- Telegram sends 'Reel pripravljen' for each
- After all done: 'Batch končan: 20/20 reels pripravljeni' summary
- Open app to download all
2026-04-29 15:12:38 +00:00
sebastjan 157e6b781e Fix 'Žena' word still cut: word-level start extension instead of segment-level
Previous fix used segment boundaries — required segments <3s for type 1
or <4s for type 2. But Žena was in a 4.3s segment ('saj še doma mi več
noč'jo verjet'. Žena me'), so the condition wasn't met and clip start
stayed at 77.7s, exactly at end of word 'Žena' (76.88-77.70s).

New approach: scan word-level timestamps directly:

1. If clip start falls MID-WORD → extend back to word start - 0.15s
2. If a word ends 0-0.5s BEFORE clip start AND next word is at clip start
   → that word is suspect (may be first word of chorus that Scribe put
   in previous segment), extend back to its start - 0.15s

Word-level timestamps are always available from Scribe (timestamps_granularity=word).
Falls back to segment-level for local Whisper without word timing.

This handles arbitrary segment lengths and is universal — works for any
language where the chorus starts on a word that the STT placed in the
previous segment.
2026-04-29 15:04:18 +00:00
sebastjan a5097c5acc Fix first word being cut at clip start ('Žena' problem)
Real-world failure: 'Ansambel Saša Avsenika - ŽENA ME TEPE'
- Refren starts with 'Žena me tepe' at 78.0s
- Scribe's segment boundary: word 'Žena' was end of previous segment (73.9-78.2s)
  while new segment 'tepe, mi prazni žepe' started at 78.3s
- Claude picked clip start = 78.3s (segment boundary)
- Fade-in 0.4s on vocal start = inaudible 'Že-'
- User hears: '...na me tepe' (cut)

Three-part fix:

1. PROMPT: instruct Claude to start clip ~0.3s BEFORE first chorus word
   (not exactly at it). Concrete example with timing math.

2. POST-LLM EXTENSION: scan corrected_segments for boundary cases:
   - If clip start falls MID-segment → extend back to segment start - 0.2s
   - If a previous segment ended within 0.5s of clip start → check if its
     last word might actually be the first chorus word, extend back to it
   - Uses word-level timestamps when available (Scribe provides these)

3. FADE-IN: was 0.4s when starting on vocal — too long, audibly cuts first
   word. Reduced to 0.05s (just click prevention, not audible). Still 0.2s
   for instrumental intros where fade is musically appropriate.

Now 'Žena' will be heard fully — clip starts at ~77.5-77.7s, word starts
at 78.0s, plenty of buffer.
2026-04-29 14:47:07 +00:00
sebastjan 1cc8e8be35 MXF/MPG broadcast format support: handle multichannel audio properly
Problem: MXF and MPG files (TV broadcast formats) often contain:
- Multiple audio streams (4-8 streams for different language tracks)
- Multichannel layouts (5.1, 7.1) instead of stereo
- Default ffmpeg behavior was -c:a aac without channel limit, which
  meant multichannel got transcoded as multichannel AAC, overwriting
  what should have been clean stereo

Solution:

1. get_audio_streams() helper probes all audio streams with ffprobe
   - Returns codec, channels, sample_rate, language, layout for each

2. build_audio_args() picks best stream + downmix:
   - Prefers first 2-channel stereo stream (usually main mix)
   - Falls back to first stream if none are 2-ch
   - Always: -ac 2 (force stereo downmix), -ar 48000, -c:a aac, -b:a 192k
   - Bitrate raised from 128k to 192k for music quality

3. Smart trim path now detects broadcast formats:
   - .mxf, .mpg, .mpeg, .ts, .m2ts, .mts → transcode (not stream copy)
   - Standard MP4/MOV → stream copy (faster, lossless)

4. Pre-conversion step for broadcast files without trim:
   - Even without --start/--duration, MXF/MPG get converted to MP4
   - Same audio handling as trim path

5. Main render adds explicit -map 0:v:0 -map 0:a:0? -ac 2 to ensure
   only first video and first audio stream get encoded, with stereo

6. ACR recognize also gets -map 0:a:0 -ac 2 for MXF compatibility

7. UI accepts: video/*,.mxf,.mpg,.mpeg,.ts,.m2ts,.mts

8. Upload limit raised: 2GB → 10GB (MXF files are large)

This means a TV broadcast MXF with [SLO/EN/DE language tracks] now
correctly outputs stereo MP4 with the main language track preserved.
2026-04-29 14:38:48 +00:00
sebastjan b543057cee ACRCloud auto-recognition: never block uploads, fall back to fingerprinting
Changes:

1. UI: removed blocking prompt() that asked for artist+title on filename
   that didn't match 'Artist - Title' pattern. Upload always proceeds.
   Instead shows yellow warning saying 'server will try to recognize'.

2. Backend: added scripts/acr_recognize.py — extracts 20s audio sample
   from video (at 15s and 60s offsets for robustness), computes ACRCloud
   fingerprint via native binary (3KB payload), sends to identify API.

3. Pipeline: process_job() now runs ACR recognition step before analysis
   IF parsed_artist or parsed_title is missing. Result is saved to job
   metadata and used for download filename + Scribe/Claude filename hint.

4. Credentials: ACR_HOST + ACR_ACCESS_KEY + ACR_SECRET_KEY env vars
   added to Coolify (using existing keys from openclaw fb-agent metka).

5. requirements.txt: added pyacrcloud==1.0.11 for native fingerprinting.

This unblocks future automation/cron upload pipelines — files don't need
to be perfectly named, ACRCloud will identify them automatically.

Fallback chain:
1. Filename parsing (Artist - Title.mp4)
2. ACRCloud audio fingerprint (works even for '12345.mp4', 'IMG_001.mp4')
3. If both fail: download filename uses 'reel_<id>.mp4' (still works)
2026-04-29 14:24:53 +00:00
sebastjan 3877b822ff Smart download filenames: 'Artist - Title - REEL.mp4' + validation
Two improvements:

1. DOWNLOAD FILENAME: instead of 'reel_<job-id>.mp4' (e.g. reel_25e076af7600.mp4),
   downloads now have descriptive names like:
   - 'Lady Gaga - Abracadabra - REEL.mp4'
   - 'Modrijani - S teboj - REEL.mp4'
   - 'Sarah Connor - FICKA - REEL.mp4'

2. PRE-UPLOAD VALIDATION: when filename doesn't follow 'Artist - Title' format,
   browser prompts user for both fields. Without them, upload is blocked.
   This prevents files with names like '12345.mp4' or 'video_final.mp4' from
   being processed without identifying info.

Implementation:
- parse_artist_title() helper handles common formats:
  - 'Artist - Title.mp4' / 'Artist – Title' (em-dash)
  - 'Artist | Title' / 'Artist : Title'
  - Strips noise: '(Official Music Video)', '(Audio)', '(HD)', '[Lyric Video]'
- Client-side parser mirrors backend (validation before upload)
- Backend accepts artist + title form fields (override parsed)
- Job stored with parsed_artist + parsed_title + has_clean_name fields
- YouTube jobs auto-fetch title via yt-dlp --info-only and parse it
- Filename hint to Scribe/Claude uses parsed values (cleaner than raw filename)
- Download endpoint uses build_download_filename() for content-disposition
- Jobs list shows 'Artist — Title' instead of raw filename

Result: downloaded reels are auto-named correctly for Facebook/Instagram
upload, no more renaming files manually.
2026-04-29 14:15:18 +00:00
sebastjan a30137f1f2 Strict 'chorus only' mode: respect include_prebuild in LLM prompt
Bug: 'Vključi pre-chorus' checkbox in UI was sent to backend but ignored
by Claude/Gemini analysis prompt. Both modes used same lenient rules
saying 'pre-chorus is optional' — Claude often included pre-chorus even
when user wanted just chorus.

Real-world failure: Lady Gaga 'Abracadabra' picked 54.7-84.6s, but actual
chorus 'Abracadabra, amor, ooh-na-na' starts at 85.2s. Claude included
the entire pre-chorus block ('Hold me in your heart tonight', 'Like a
poem said by a lady in red', 'With a haunting dance') and missed the
actual chorus completely.

Fix: include_prebuild parameter now flows all the way to the prompt:
- main.py → analyze.py CLI args → analyze_with_llm() → prompt builder
- Two distinct prompt rule sets:

  CHORUS ONLY (default, include_prebuild=False):
  - Strict: 'clip starts on FIRST WORD of chorus, never before'
  - Length: 12-25s typically
  - Explicit examples for pop songs (Abracadabra, Despacito, Shape of You)
  - List of common mistakes to avoid

  CHORUS + PRE-CHORUS (include_prebuild=True):
  - Optional pre-chorus before chorus, 4-10s
  - Length: 18-35s

This fixes the most common failure mode where Claude rationalizes
including verse/pre-chorus content even when user explicitly wants
just the chorus.
2026-04-29 14:03:40 +00:00
sebastjan 671b512917 Fix Preview button in jobs sidebar not opening modal
Root cause: inline onclick with JSON.stringify(title) broke when title
contained quotes, special chars, or was empty. The HTML attribute parser
got confused by mismatched quotes, so click handler never fired.

Fix:
- Replaced inline onclick handlers with data-action attributes
- Added single delegated click listener at document level
- Title stored in element dataset (no HTML quoting issues)
- Added escapeHtml() helper for safe rendering of titles/errors

Now clicking Preview in the right sidebar opens the fullscreen modal
correctly, regardless of filename characters.
2026-04-29 13:39:04 +00:00
sebastjan 90cdad516b Universal chorus selection: chorus mandatory, pre-chorus only natural extension
User feedback: 'REFREN je obvezen, pre-chorus opcijsko' + 'sistem mora biti
stabilen za vse jezike, tudi španščino in romunščino'.

Two changes:

1. Web search is now MANDATORY first step (was: optional fallback):
   - Even if Claude thinks it knows the song, must search lyrics first
   - Universal lyrics sources by language:
     SLO: besedila.com, lyricstranslate.com
     DE: songtexte.com
     HR/SR/BS: tekstovi.net
     ES: letras.com, musica.com
     RO: versuri.ro
     IT: angolotesti.it
     FR: paroles.net
     EN: genius.com, azlyrics.com
     Universal: lyricstranslate.com (any language)
   - Search strategy: artist+title first, then transcript snippet fallback
   - Without lyrics, Claude cannot reliably identify chorus boundaries

2. Simplified selection rules - chorus is THE priority:
   - Chorus (full first occurrence) = MANDATORY
   - Pre-chorus = ONLY if 1-2 verse lines tightly connected to chorus
   - In doubt: just take chorus alone (12-25s)
   - Outro fillers explicitly multi-language:
     SLO 'aj ja ja' / 'ej ej ej'
     EN 'yeah' / 'oh oh'
     ES 'ay ay ay'
     RO 'hei hei'
     JA 'la la la'
   - 12-35s total range (was 15-35s, now allows shorter chorus-only clips)

This makes the system language-agnostic: works the same way for Slovenian
narodno-zabavna, Spanish reggaeton, Romanian manele, German Schlager, etc.
The lyrics lookup is what makes it stable across languages.
2026-04-29 13:36:34 +00:00
sebastjan 389c26d012 Modal preview: click Preview opens fullscreen video player
Previously: clicking Preview in jobs list showed a small inline video
within the job card row.

Now: clicking Preview opens a centered fullscreen modal with:
- Large video player (up to 95vw × 85vh) — same experience as bottom
  live-preview but accessible from jobs list
- Auto-play, controls, native HTML5 video player
- Title shown below video for context
- Download button + Close button
- Click outside or ESC key to close
- Backdrop blur for focus

Removes the obsolete inline <video> element that was rendered hidden
in each job card. Body scroll locked while modal open.
2026-04-29 13:21:36 +00:00
sebastjan 4efd726176 Extend clip end past chorus to capture outro/sustained notes
Problem: Claude was cutting clip exactly at last transcribed word of chorus,
but in real songs:
- Singer holds last note 1-3s longer (still meaningful)
- Outro 'ej-ej-ej' / 'oh' / 'yeah' may not be transcribed as words
- Result felt like 'incomplete chorus' even though SRT was correct

Fix has two parts:

1. Prompt enhancement:
   - Ask Claude to add 1-2s padding AFTER last chorus word
   - Explicit example with timing math
   - Mention outro fillers (ej-ej-ej, oh, yeah)

2. Post-LLM extension logic:
   - After Claude returns clip range, scan corrected_segments for
     segments overlapping or starting just after current end
   - If next segment is within 1s pause and ends within max_duration+5s,
     extend clip to include it (with 0.3s breathing room)
   - Hard cap at max_duration + 5s to prevent unbounded extension

This ensures chorus naturally trails off rather than being cut mid-emotional-peak.
2026-04-29 13:12:28 +00:00
sebastjan 81bae81401 Fix Scribe stopping mid-song: enable tag_audio_events=true + filter events out
ROOT CAUSE FOUND: tag_audio_events=false caused Scribe to stop transcribing
when instrumental music dominates (polka harmonica taking over from vocals).

Real-world test on Avseniki - Ena bolha za pomoč (186s polka):
- tag_audio_events=false: 20% coverage (37s only) — fails
- tag_audio_events=true:  100% coverage (186s full) — works

When tag_audio_events=true, Scribe inserts placeholder markers like
'(glasba)' / '(plesalna glasba)' for instrumental sections instead of
giving up. We then filter these out so they don't appear in subtitles.

Filtering logic:
- Skip word.type != 'word' (audio_event types)
- Skip parenthesized text legacy fallback like '(music)', '(applause)'

This is the core fix — no longer reliant on filename for transcription
completeness. Even untitled files like '12345.mp4' now get full coverage.
2026-04-29 13:04:19 +00:00
sebastjan 7d00730051 Auto-detect language from filename for Scribe (no manual UI selection needed)
Problem: Scribe was failing on Slovenian narodno-zabavna songs (Avseniki,
Modrijani) because:
- User doesn't manually pick language (everything is auto)
- Scribe auto-detect had low confidence (0.58) on harmonika-heavy polka
- Result: only 37s transcribed instead of full 186s song

Solution: detect_language_from_filename() function:
- Recognizes 60+ Slovenian artists (Avseniki, Modrijani, Veseli Dolenjci, ...)
- Recognizes 30+ German artists (Ben Zucker, Helene Fischer, ...)
- Recognizes 20+ Croatian/Serbian artists (Thompson, Severina, Lepa Brena, ...)
- Falls back to keyword matching (volim, liebe, srce, herz, ...)
- Detects character set (č/ž/š → SL, ä/ö/ü/ß → DE, đ → HR)
- Score-based: 5pts for artist match, 1-2pts for keywords/chars

When detected, sends language_code to Scribe explicitly:
- Avseniki → 'slv' lock → no more half-transcribed songs
- Ben Zucker → 'deu' lock → consistent German transcription
- User still doesn't need to manually pick anything

filename_hint flows: main.py → analyze.py CLI → transcribe_full → Scribe
2026-04-29 12:57:19 +00:00
sebastjan 40acad26f3 Crystal-clear chorus selection rules: pre-chorus build-up + FIRST chorus
Previous rules were ambiguous and Claude was sometimes picking:
- Just the chorus (no build-up)
- Second chorus instance (lower energy than first)
- Random verse + later chorus combinations

New explicit priority order:
1. PRIMARY: pre-chorus verse (build-up) + first chorus (~20-35s total)
2. FALLBACK: just first chorus alone
3. LAST RESORT: dramatic peak section

Strict rules:
- ALWAYS first chorus (highest energy/recognition)
- NEVER second/third chorus instances
- NEVER skip between verses
- NEVER extend over 35 seconds
- Concrete example given: chorus@32s,16s long → pick 20-48s

This fixes Veseli Dolenjci picking second chorus + post-chorus verse
instead of natural pre-chorus build-up + first chorus.
2026-04-29 12:42:54 +00:00
sebastjan 5f90085981 Add Claude web_search tool for lyrics lookup + tighter subtitle timing
1. Claude API web_search tool integration:
   - Claude can now search web for actual lyrics when STT text is wrong
   - Especially useful for SLO/HR/BS/SR songs (Modrijani, Veseli Dolenjci)
     where Claude doesn't know lyrics from training data
   - Agentic loop: tool_use → server-side search → continuation → final text
   - Max 3 searches per job ($0.03 cost limit)
   - Hint sources: besedila.com, lyricstranslate.com, tekstovi.net, songtexte.com

2. Tighter subtitle segmentation from Scribe word timestamps:
   - Phrase boundaries on shorter pauses (0.4s vs 0.6s)
   - Sentence-ending punctuation triggers segment break
   - Max segment 4s (was 6s) for natural readable subtitles
   - Hard cap at 5.5s to prevent very long lines

This fixes 'ples to noč' → 'ples pojoč' for Modrijani songs that
Scribe transcribed phonetically wrong but Claude can fix via web lookup.
2026-04-29 12:24:17 +00:00
sebastjan 68247bb84c Integrate ElevenLabs Scribe (best multilingual STT 2026)
ElevenLabs Scribe replaces local Whisper as default transcription:
- 96.7% accuracy English, 2.4% WER Indonesian (vs Whisper 7.7%)
- 18x faster (200s song = 11s vs 3-5 min on CPU)
- No hallucinations on songs (Whisper invented 'Pony und Kleid' for 'Bonnie und Clyde')
- 99 languages supported, including SLO/HR/BS/SR
- $0.40/h pricing, ~$0.022 per 200s song

Implementation:
- transcribe_with_elevenlabs() function uses Scribe v1
- ISO 639-1 ↔ 639-3 mapping (Scribe needs 'deu' not 'de')
- Word-level timestamps converted to pseudo-segments (close on 0.6s pause or 6s duration)
- 24MB upload limit guard with auto-fallback to local

Default whisper_provider='auto':
- If ELEVENLABS_API_KEY set → use Scribe
- Otherwise → fallback to local faster-whisper
- 'elevenlabs' strict mode: no fallback
- 'local' strict mode: skip Scribe entirely

Tested on Ben Zucker - Ohne dich: Scribe correctly transcribed
'Wir sind Bonnie und Clyde, zu allem bereit' where local Whisper hallucinated.
2026-04-29 12:03:40 +00:00
14 changed files with 6820 additions and 410 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ COPY templates/ ./templates/
RUN mkdir -p ./static RUN mkdir -p ./static
# Data direktorij (Coolify bo prek Persistent Storage UI bind-al volume) # Data direktorij (Coolify bo prek Persistent Storage UI bind-al volume)
RUN mkdir -p /data/uploads /data/outputs /data/jobs /data/cookies RUN mkdir -p /data/uploads /data/outputs /data/jobs /data/cookies /data/qnet
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
+167
View File
@@ -0,0 +1,167 @@
"""
Cleanup module: removes local files older than N hours, *only if they exist in S3*.
Safe by design:
- Never deletes a local file unless its S3 mirror is verified
- Never deletes job metadata (jobs/*.json) — those are tiny
- Default age threshold is conservative (48 h)
- Dry-run mode for verification
Usage (cron):
python3 -m app.cleanup --apply # actual delete
python3 -m app.cleanup --dry-run # preview only (default)
python3 -m app.cleanup --apply --hours 72 # custom age threshold
Suggested cron (every night 03:30):
30 3 * * * cd /app && python3 -m app.cleanup --apply >> /data/cleanup.log 2>&1
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from pathlib import Path
# Konfiguracija — ujema main.py
DATA_DIR = Path(os.environ.get("DATA_DIR", "/data"))
UPLOAD_DIR = DATA_DIR / "uploads"
OUTPUT_DIR = DATA_DIR / "outputs"
DEFAULT_AGE_HOURS = 48
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [cleanup] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
log = logging.getLogger(__name__)
def _file_age_hours(p: Path) -> float:
"""Return file age in hours based on mtime."""
try:
return (time.time() - p.stat().st_mtime) / 3600
except OSError:
return 0.0
def _scan_dir(d: Path, kind: str, min_age_h: float):
"""Yield (local_path, kind, age_hours) for files older than threshold."""
if not d.exists():
return
for p in d.iterdir():
if not p.is_file():
continue
# Skip very small files (probably config/state) — only target real workfiles
if p.stat().st_size < 1024 * 100: # <100 KB
continue
age = _file_age_hours(p)
if age >= min_age_h:
yield p, kind, age
def cleanup(min_age_hours: float, apply: bool) -> dict:
"""Run cleanup pass. Returns stats dict.
Logic:
For each file older than min_age_hours in uploads/ and outputs/:
- Verify S3 mirror exists (s3.exists())
- If verified: delete local
- If not verified: skip (warn) — never delete unverified
"""
from app import s3_storage
if not s3_storage.is_enabled():
log.error("S3 not configured — refusing to run cleanup. Aborting.")
return {"error": "s3_not_configured"}
stats = {
"scanned": 0,
"would_delete": 0,
"deleted": 0,
"skipped_no_s3_mirror": 0,
"freed_mb": 0.0,
"errors": 0,
}
deleted_files = []
skipped_files = []
for d, kind in [(UPLOAD_DIR, "upload"), (OUTPUT_DIR, "output")]:
for p, _, age_h in _scan_dir(d, kind, min_age_hours):
stats["scanned"] += 1
size_mb = p.stat().st_size / 1024 / 1024
folder = "uploads" if kind == "upload" else "outputs"
s3_key = f"{folder}/{p.name}"
# Verify S3 mirror
try:
s3_size = s3_storage.get_object_size(s3_key)
except Exception as e:
log.warning("S3 check failed for %s: %s", s3_key, e)
stats["errors"] += 1
continue
if s3_size is None:
log.warning("SKIP — no S3 mirror: %s (age %.1fh, %.1f MB)",
p.name, age_h, size_mb)
stats["skipped_no_s3_mirror"] += 1
skipped_files.append(p.name)
continue
# Check size match (sanity)
local_size = p.stat().st_size
if abs(s3_size - local_size) > 1024: # >1 KB delta — suspicious
log.warning("SKIP — size mismatch %s: local=%d s3=%d",
p.name, local_size, s3_size)
stats["skipped_no_s3_mirror"] += 1
skipped_files.append(p.name)
continue
stats["would_delete"] += 1
stats["freed_mb"] += size_mb
if apply:
try:
p.unlink()
stats["deleted"] += 1
deleted_files.append(p.name)
log.info("DEL %s (%.1f MB, age %.1fh, S3 verified)",
p.name, size_mb, age_h)
except OSError as e:
log.error("Delete failed %s: %s", p, e)
stats["errors"] += 1
else:
log.info("DRY %s (%.1f MB, age %.1fh, S3 verified)",
p.name, size_mb, age_h)
log.info("=" * 60)
log.info("Cleanup pass: %s", "APPLY" if apply else "DRY-RUN")
log.info(" scanned: %d", stats["scanned"])
log.info(" would-delete: %d", stats["would_delete"])
log.info(" deleted: %d", stats["deleted"])
log.info(" skipped (no S3): %d", stats["skipped_no_s3_mirror"])
log.info(" errors: %d", stats["errors"])
log.info(" freed: %.1f MB", stats["freed_mb"])
return stats
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Actually delete files (default is dry-run)")
ap.add_argument("--dry-run", action="store_true",
help="Preview only, do not delete (default)")
ap.add_argument("--hours", type=float, default=DEFAULT_AGE_HOURS,
help=f"Min file age in hours (default {DEFAULT_AGE_HOURS})")
args = ap.parse_args()
apply = args.apply and not args.dry_run
stats = cleanup(min_age_hours=args.hours, apply=apply)
if stats.get("error"):
sys.exit(2)
if __name__ == "__main__":
main()
+2203 -49
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
"""
Qnet match modul — iz uploaded filename najde matching song v Qnet bazah.
Vrne (artist, title, station, confidence, matched_file).
Strategija:
1. **Exact filename match** (case-insensitive, with normalized punctuation)
— stem only, brez extension. Confidence: 1.0.
2. **Normalized basename match** — odstrani noise besede (Official, HD, 4K,
letnice, številke v oklepajih), normaliziraj presledke, primerjaj.
Confidence: 0.9.
3. **Artist+Title fuzzy match** — najprej parsa filename z obstoječim
parse_artist_title, potem išče v bazi po normalized (artist|title)
bigram-keywords. Confidence: 0.6–0.85 odvisno od ratio.
4. Če ni zadetka → None.
Vrne lookup result dict:
{
"matched": bool,
"method": "exact" | "normalized" | "fuzzy" | None,
"confidence": float, # 0.0–1.0
"artist": str,
"title": str,
"station": str,
"file": str,
}
"""
import json
import re
import os
import time
import unicodedata
from pathlib import Path
from typing import Optional
from difflib import SequenceMatcher
LOOKUP_PATH = Path(os.environ.get("QNET_LOOKUP_PATH", "/data/qnet/songs_lookup.json"))
# In-memory cache — re-load if file is newer than cache
_cache: dict = {"mtime": 0.0, "songs": [], "by_norm_file": {}, "by_norm_stem": {}}
def _norm(s: str) -> str:
"""Aggressive normalize: lowercase, strip diacritics, kill punctuation."""
if not s:
return ""
s = unicodedata.normalize("NFKD", s)
s = "".join(c for c in s if not unicodedata.combining(c))
s = s.lower()
# Replace en-dash / em-dash / various separators with hyphen
s = s.replace("–", "-").replace("—", "-").replace("|", "-")
# Drop everything except alnum and space
s = re.sub(r"[^a-z0-9\s]+", " ", s)
s = re.sub(r"\s+", " ", s).strip()
return s
# Noise words pogosto v "Official Video" / YouTube ipd.
_NOISE_WORDS = {
"official", "officiel", "video", "musikvideo", "musicvideo", "music",
"lyric", "lyrics", "audio", "hd", "4k", "8k", "uhd", "live", "remix",
"remaster", "remastered", "version", "ver", "extended", "edit",
"videoclip", "clip", "premiere", "premiera", "tv", "final", "promo",
"1080p", "720p", "2160p",
}
def _norm_minus_noise(s: str) -> str:
"""Norm + remove noise words + drop standalone digits 2/3/4-digit (years/versions)."""
n = _norm(s)
toks = []
for t in n.split():
if t in _NOISE_WORDS:
continue
if t.isdigit() and len(t) <= 4:
continue
toks.append(t)
return " ".join(toks)
def _load_db():
"""Re-load lookup file if changed. Uses mtime check."""
global _cache
try:
mtime = LOOKUP_PATH.stat().st_mtime
except FileNotFoundError:
return False
if mtime <= _cache["mtime"]:
return True
songs = json.loads(LOOKUP_PATH.read_text(encoding="utf-8"))
by_norm_file = {} # normalized whole filename → song
by_norm_stem = {} # normalized stem (no ext) → song
by_norm_minus = {} # normalized minus noise → song
for s in songs:
f = s.get("file") or ""
if not f:
continue
nf = _norm(f)
ns = _norm(Path(f).stem)
nm = _norm_minus_noise(Path(f).stem)
# Prvi zadetek wins (po prvi station ki ima ta file — kasneje lahko
# menjamo na "all stations match")
by_norm_file.setdefault(nf, s)
by_norm_stem.setdefault(ns, s)
if nm:
by_norm_minus.setdefault(nm, s)
_cache = {
"mtime": mtime,
"songs": songs,
"by_norm_file": by_norm_file,
"by_norm_stem": by_norm_stem,
"by_norm_minus": by_norm_minus,
}
print(f"[qnet_match] loaded {len(songs)} songs from {LOOKUP_PATH} (mtime={mtime})", flush=True)
return True
def _result(song: dict, method: str, confidence: float) -> dict:
return {
"matched": True,
"method": method,
"confidence": round(confidence, 3),
"artist": song.get("artist", ""),
"title": song.get("title", ""),
"station": song.get("station", ""),
"file": song.get("file", ""),
}
def _no_match() -> dict:
return {
"matched": False,
"method": None,
"confidence": 0.0,
"artist": "",
"title": "",
"station": "",
"file": "",
}
def match_filename(filename: str) -> dict:
"""Najdi match za uploaded filename. Glavna API funkcija."""
if not filename:
return _no_match()
if not _load_db():
return _no_match() # baza ni naložena
stem = Path(filename).stem
nf_full = _norm(filename)
nf_stem = _norm(stem)
nm_stem = _norm_minus_noise(stem)
# 1) Exact normalized match z extensionom
if nf_full in _cache["by_norm_file"]:
return _result(_cache["by_norm_file"][nf_full], "exact", 1.0)
# 2) Stem match (brez extension)
if nf_stem in _cache["by_norm_stem"]:
return _result(_cache["by_norm_stem"][nf_stem], "exact_stem", 0.95)
# 3) Stem match brez noise
if nm_stem and nm_stem in _cache["by_norm_minus"]:
return _result(_cache["by_norm_minus"][nm_stem], "normalized", 0.9)
# 4) Fuzzy substring — če se input vsebuje v song.file ali obratno (samo če dovolj dolgo)
# Najprej probaj parse "Artist - Title" iz filename
parsed_a, parsed_t = _parse_artist_title_simple(stem)
if parsed_a and parsed_t:
cand = _fuzzy_artist_title(parsed_a, parsed_t)
if cand:
return cand
# 5) Last-resort fuzzy — sequence ratio na stem proti vsem stem-om (slow but bounded)
# Samo če stem >= 8 znakov, da ne dobimo random match-ev
if len(nm_stem) >= 8:
best = None
best_ratio = 0.0
for cand_stem, song in _cache["by_norm_minus"].items():
ratio = SequenceMatcher(None, nm_stem, cand_stem).ratio()
if ratio > best_ratio:
best_ratio = ratio
best = song
if best and best_ratio >= 0.85:
return _result(best, "fuzzy", best_ratio * 0.85)
return _no_match()
def _parse_artist_title_simple(name: str):
"""Lightweight parser za 'Artist - Title' / 'Artist – Title'."""
if not name:
return (None, None)
# Probaj najprej en/em-dash (najbolj distinctive), potem hyphen
for sep in [" – ", " — ", " - ", "_-_"]:
if sep in name:
parts = name.split(sep, 1)
if len(parts) == 2:
a = parts[0].strip()
t = parts[1].strip()
if a and t and len(a) <= 80 and len(t) <= 100:
return (a, t)
return (None, None)
def _fuzzy_artist_title(artist: str, title: str) -> Optional[dict]:
"""Najdi pesem v bazi po normalized artist+title fuzzy match."""
na = _norm_minus_noise(artist)
nt = _norm_minus_noise(title)
if not na or not nt:
return None
best = None
best_score = 0.0
for song in _cache["songs"]:
sa = _norm_minus_noise(song.get("artist", ""))
st = _norm_minus_noise(song.get("title", ""))
if not sa or not st:
continue
ar = SequenceMatcher(None, na, sa).ratio()
tr = SequenceMatcher(None, nt, st).ratio()
score = (ar + tr) / 2
if score > best_score:
best_score = score
best = song
if best and best_score >= 0.82:
return _result(best, "fuzzy_at", best_score * 0.9)
return None
def db_stats() -> dict:
"""Vrne statistiko baze za health check / admin endpoint."""
_load_db()
if not _cache["songs"]:
return {"loaded": False, "total": 0, "stations": {}}
by_station = {}
for s in _cache["songs"]:
st = s.get("station", "?")
by_station[st] = by_station.get(st, 0) + 1
return {
"loaded": True,
"total": len(_cache["songs"]),
"stations": by_station,
"mtime": _cache["mtime"],
"age_seconds": time.time() - _cache["mtime"],
}
+296
View File
@@ -0,0 +1,296 @@
"""
S3 storage abstraction for reels-app workfiles.
Bucket layout:
s3://folxspeed/reels-app/
uploads/{job_id}_yt.mp4
uploads/{job_id}_yt.info.json
outputs/{job_id}.mp4
outputs/{job_id}_source_low.mp4
outputs/{job_id}.analysis.json
outputs/{job_id}.subtitles.srt
outputs/{job_id}.subtitles.ass
outputs/{job_id}_waveform_2400x72.png
jobs/{job_id}.json
REELS/ prefix (final published reels for cron pickup) is NOT touched here.
ENV (set via Coolify):
S3_ENDPOINT = https://fsn1.your-objectstorage.com
S3_BUCKET = folxspeed
S3_ACCESS_KEY = ...
S3_SECRET_KEY = ...
S3_PREFIX = reels-app/ (trailing slash required)
S3_REGION = fsn1 (optional, default fsn1)
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Iterator, Optional
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Config from environment
# ---------------------------------------------------------------------------
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "https://fsn1.your-objectstorage.com")
S3_BUCKET = os.getenv("S3_BUCKET", "folxspeed")
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "")
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "")
S3_PREFIX = os.getenv("S3_PREFIX", "reels-app/")
S3_REGION = os.getenv("S3_REGION", "fsn1")
# Make sure prefix ends with slash
if S3_PREFIX and not S3_PREFIX.endswith("/"):
S3_PREFIX += "/"
# Feature flag — if creds missing, all calls become no-ops (safe fallback during rollout)
S3_ENABLED = bool(S3_ACCESS_KEY and S3_SECRET_KEY)
# ---------------------------------------------------------------------------
# Lazy client
# ---------------------------------------------------------------------------
_client = None
def get_client():
"""Return a cached boto3 S3 client, or None if S3 is not configured."""
global _client
if not S3_ENABLED:
return None
if _client is None:
_client = boto3.client(
"s3",
endpoint_url=S3_ENDPOINT,
aws_access_key_id=S3_ACCESS_KEY,
aws_secret_access_key=S3_SECRET_KEY,
region_name=S3_REGION,
config=Config(
signature_version="s3v4",
retries={"max_attempts": 3, "mode": "standard"},
connect_timeout=10,
read_timeout=120,
),
)
return _client
def _full_key(key: str) -> str:
"""Prepend S3_PREFIX if not already present."""
key = key.lstrip("/")
if S3_PREFIX and not key.startswith(S3_PREFIX):
return S3_PREFIX + key
return key
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def is_enabled() -> bool:
"""True if S3 credentials are present and client is usable."""
return S3_ENABLED
def upload(local_path: str | Path, key: str, content_type: Optional[str] = None) -> bool:
"""
Upload local file to s3://{bucket}/{prefix}{key}.
Returns True on success, False on failure (logs error, does not raise).
"""
client = get_client()
if client is None:
log.warning("S3 not configured, skipping upload of %s", key)
return False
local = Path(local_path)
if not local.exists():
log.error("S3 upload: local file does not exist: %s", local)
return False
full_key = _full_key(key)
extra: dict = {}
if content_type:
extra["ContentType"] = content_type
try:
client.upload_file(str(local), S3_BUCKET, full_key, ExtraArgs=extra or None)
log.info("S3 upload OK: %s -> s3://%s/%s (%d bytes)",
local.name, S3_BUCKET, full_key, local.stat().st_size)
return True
except ClientError as e:
log.error("S3 upload FAILED %s: %s", full_key, e)
return False
except Exception as e:
log.error("S3 upload UNEXPECTED ERROR %s: %s", full_key, e)
return False
def download(key: str, local_path: str | Path) -> bool:
"""
Download s3://{bucket}/{prefix}{key} to local_path.
Creates parent directories if needed.
Returns True on success.
"""
client = get_client()
if client is None:
log.warning("S3 not configured, cannot download %s", key)
return False
local = Path(local_path)
local.parent.mkdir(parents=True, exist_ok=True)
full_key = _full_key(key)
try:
client.download_file(S3_BUCKET, full_key, str(local))
log.info("S3 download OK: s3://%s/%s -> %s", S3_BUCKET, full_key, local)
return True
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code in ("NoSuchKey", "404"):
log.info("S3 download: key not found: %s", full_key)
else:
log.error("S3 download FAILED %s: %s", full_key, e)
return False
except Exception as e:
log.error("S3 download UNEXPECTED ERROR %s: %s", full_key, e)
return False
def exists(key: str) -> bool:
"""Return True if s3://{bucket}/{prefix}{key} exists."""
client = get_client()
if client is None:
return False
full_key = _full_key(key)
try:
client.head_object(Bucket=S3_BUCKET, Key=full_key)
return True
except ClientError as e:
code = e.response.get("Error", {}).get("Code", "")
if code in ("404", "NoSuchKey", "NotFound"):
return False
log.error("S3 exists check FAILED %s: %s", full_key, e)
return False
def delete(key: str) -> bool:
"""Delete a single object. Returns True if the call succeeded."""
client = get_client()
if client is None:
return False
full_key = _full_key(key)
try:
client.delete_object(Bucket=S3_BUCKET, Key=full_key)
log.info("S3 delete OK: %s", full_key)
return True
except ClientError as e:
log.error("S3 delete FAILED %s: %s", full_key, e)
return False
def presigned_url(key: str, expires_in: int = 3600, method: str = "get_object") -> Optional[str]:
"""
Generate a presigned URL for reading (default) or writing.
method: 'get_object' or 'put_object'
"""
client = get_client()
if client is None:
return None
full_key = _full_key(key)
try:
url = client.generate_presigned_url(
method,
Params={"Bucket": S3_BUCKET, "Key": full_key},
ExpiresIn=expires_in,
)
return url
except ClientError as e:
log.error("S3 presigned URL FAILED %s: %s", full_key, e)
return None
def list_keys(prefix: str = "", max_keys: int = 1000) -> Iterator[str]:
"""
Iterator over keys under S3_PREFIX + prefix.
Returns keys WITHOUT S3_PREFIX (relative).
"""
client = get_client()
if client is None:
return
full_prefix = _full_key(prefix)
paginator = client.get_paginator("list_objects_v2")
try:
for page in paginator.paginate(
Bucket=S3_BUCKET, Prefix=full_prefix, PaginationConfig={"PageSize": max_keys}
):
for obj in page.get("Contents", []):
full_key = obj["Key"]
# Strip prefix to return relative key
if S3_PREFIX and full_key.startswith(S3_PREFIX):
yield full_key[len(S3_PREFIX):]
else:
yield full_key
except ClientError as e:
log.error("S3 list FAILED prefix=%s: %s", full_prefix, e)
def get_object_size(key: str) -> Optional[int]:
"""Return size in bytes, or None if missing/error."""
client = get_client()
if client is None:
return None
full_key = _full_key(key)
try:
resp = client.head_object(Bucket=S3_BUCKET, Key=full_key)
return resp.get("ContentLength")
except ClientError:
return None
# ---------------------------------------------------------------------------
# Convenience helpers tailored to reels-app file layout
# ---------------------------------------------------------------------------
def upload_job_file(job_id: str, kind: str, local_path: str | Path) -> bool:
"""
Upload a workfile by kind:
'upload' -> uploads/{filename}
'output' -> outputs/{filename}
'job_meta' -> jobs/{filename}
Filename comes from local_path.name.
"""
local = Path(local_path)
folder_map = {"upload": "uploads", "output": "outputs", "job_meta": "jobs"}
folder = folder_map.get(kind)
if folder is None:
log.error("upload_job_file: unknown kind %r", kind)
return False
return upload(local, f"{folder}/{local.name}")
def download_upload(filename: str, local_path: str | Path) -> bool:
"""Download from uploads/ prefix."""
return download(f"uploads/{filename}", local_path)
def download_output(filename: str, local_path: str | Path) -> bool:
"""Download from outputs/ prefix."""
return download(f"outputs/{filename}", local_path)
def upload_exists(filename: str) -> bool:
return exists(f"uploads/{filename}")
def output_exists(filename: str) -> bool:
return exists(f"outputs/{filename}")
+119
View File
@@ -0,0 +1,119 @@
"""
telegram.py — Telegram bot helper za reels-app.
Pošilja obvestila o končanih jobih, batch summary, napakah.
Credentials se preberejo iz env vars (TELEGRAM_TOKEN, TELEGRAM_CHAT_ID).
"""
import os
import urllib.request
import urllib.parse
import json
def send_message(text, parse_mode="Markdown", disable_notification=False):
"""Pošlji sporočilo na Telegram. Vrne True ob uspehu, False ob napaki.
text: ne sme biti daljši od 4096 znakov.
parse_mode: 'Markdown' ali 'HTML' ali None.
disable_notification: True za tiho obvestilo (brez zvonca).
"""
token = os.environ.get("TELEGRAM_TOKEN")
chat_id = os.environ.get("TELEGRAM_CHAT_ID")
if not token or not chat_id:
return False
# Telegram limit: 4096 chars
if len(text) > 4090:
text = text[:4087] + "..."
data_dict = {
"chat_id": chat_id,
"text": text,
}
if parse_mode:
data_dict["parse_mode"] = parse_mode
if disable_notification:
data_dict["disable_notification"] = "true"
data = urllib.parse.urlencode(data_dict).encode()
req = urllib.request.Request(
f"https://api.telegram.org/bot{token}/sendMessage",
data=data,
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read().decode())
return result.get("ok", False)
except Exception as e:
print(f"⚠️ Telegram send failed: {e}", flush=True)
return False
def notify_job_done(job, base_url="https://reels.biba.live"):
"""Obvesti o končanem reelu."""
artist = job.get("parsed_artist", "")
title = job.get("parsed_title", "")
job_id = job.get("id", "")
if artist and title:
name = f"*{artist} — {title}*"
elif title:
name = f"*{title}*"
else:
name = f"`{job_id}`"
duration = job.get("duration", 0)
output_size_mb = job.get("output_size_mb") or round((job.get("output_size", 0) / 1024 / 1024), 1)
text = (
f"✅ Reel pripravljen\n\n"
f"{name}\n"
f"⏱ {duration:.0f}s · 📦 {output_size_mb} MB\n\n"
f"[Predogled & Download]({base_url})"
)
return send_message(text)
def notify_job_failed(job, error_msg=""):
"""Obvesti o neuspehu joba."""
artist = job.get("parsed_artist", "")
title = job.get("parsed_title", "")
fname = job.get("filename", "")
if artist and title:
name = f"*{artist} — {title}*"
elif fname:
name = f"`{fname}`"
else:
name = f"`{job.get('id', '?')}`"
err_short = (error_msg or "neznana napaka")[:200]
text = f"⚠️ Reel ni uspel\n\n{name}\n\n```\n{err_short}\n```"
return send_message(text)
def notify_batch_complete(batch_id, total, succeeded, failed, base_url="https://reels.biba.live"):
"""Obvesti o končanem batch-u (več jobov hkrati)."""
if total <= 1:
return False # ne pošiljaj batch summary za en sam job
if failed == 0:
emoji = "🎉"
head = f"Batch končan: vsi {total} reelov pripravljeni"
else:
emoji = "✅"
head = f"Batch končan: {succeeded}/{total} uspelih"
if failed > 0:
head += f", {failed} neuspelih"
text = f"{emoji} {head}\n\n[Vsi reels →]({base_url})"
return send_message(text)
def notify_batch_started(batch_id, total):
"""Obvesti o začetku batch-a (samo če > 1 job)."""
if total <= 1:
return False
text = f"🎬 Batch za {total} datotek dodan v vrsto…"
return send_message(text, disable_notification=True)
+3
View File
@@ -6,3 +6,6 @@ faster-whisper==1.0.3
opencv-python-headless==4.10.0.84 opencv-python-headless==4.10.0.84
numpy==1.26.4 numpy==1.26.4
yt-dlp>=2025.10.0 yt-dlp>=2025.10.0
pyacrcloud==1.0.11
requests==2.32.3
boto3==1.35.49
+202
View File
@@ -0,0 +1,202 @@
"""
acr_recognize.py — Audio recognition prek ACRCloud Audio Fingerprinting API.
Uporabi native binary za fingerprinting (3KB sample namesto cel audio).
Vrne (artist, title) ali (None, None) če pesem ni prepoznana.
Credentials se preberejo iz env vars (ACR_ACCESS_KEY, ACR_SECRET_KEY, ACR_HOST).
"""
import os
import sys
import json
import hmac
import base64
import time
import subprocess
import tempfile
import urllib.request
import urllib.parse
import uuid
def _sign(string_to_sign, secret):
h = hmac.new(secret.encode('ascii'), string_to_sign.encode('ascii'), digestmod='sha1')
return base64.b64encode(h.digest()).decode('ascii')
def _build_multipart(fields, files):
"""Sestavi multipart/form-data body."""
boundary = uuid.uuid4().hex
parts = []
for k, v in fields.items():
parts.append(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n".encode())
for k, (fname, content, ctype) in files.items():
parts.append(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"; filename=\"{fname}\"\r\n"
f"Content-Type: {ctype}\r\n\r\n".encode() + content + b"\r\n"
)
parts.append(f"--{boundary}--\r\n".encode())
body = b"".join(parts)
return body, boundary
def recognize_audio_file(audio_path, timeout=30):
"""Pošlji audio file (ali fingerprint) ACRCloud-u in vrni raw response.
audio_path: pot do MP3/WAV/M4A datoteke.
Vrne dict z odgovorom ali None ob napaki.
"""
host = os.environ.get("ACR_HOST", "identify-eu-west-1.acrcloud.com")
access_key = os.environ.get("ACR_ACCESS_KEY")
secret_key = os.environ.get("ACR_SECRET_KEY")
if not access_key or not secret_key:
print("⚠️ ACR_ACCESS_KEY/SECRET_KEY nista nastavljena", file=sys.stderr)
return None
if not os.path.exists(audio_path):
print(f"⚠️ Audio file ne obstaja: {audio_path}", file=sys.stderr)
return None
# Probaj native binary za fingerprint (manjši payload)
fingerprint = None
try:
from acrcloud import acrcloud_extr_tool as acr
fingerprint = acr.create_fingerprint_by_file(audio_path, 0, 30, 0)
except (ImportError, Exception) as e:
print(f" ℹ️ Fingerprint binary ni na voljo ({e}), uporabljam audio direktno", file=sys.stderr)
timestamp = str(int(time.time()))
http_method = "POST"
http_uri = "/v1/identify"
signature_version = "1"
if fingerprint:
data_type = "fingerprint"
sample_data = fingerprint
sample_name = "sample.fp"
sample_ctype = "application/octet-stream"
else:
data_type = "audio"
with open(audio_path, "rb") as f:
sample_data = f.read()
sample_name = "sample.mp3"
sample_ctype = "audio/mpeg"
string_to_sign = f"{http_method}\n{http_uri}\n{access_key}\n{data_type}\n{signature_version}\n{timestamp}"
signature = _sign(string_to_sign, secret_key)
fields = {
"access_key": access_key,
"sample_bytes": str(len(sample_data)),
"timestamp": timestamp,
"signature": signature,
"data_type": data_type,
"signature_version": signature_version,
}
files = {"sample": (sample_name, sample_data, sample_ctype)}
body, boundary = _build_multipart(fields, files)
url = f"https://{host}/v1/identify"
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except Exception as e:
print(f"⚠️ ACR API napaka: {e}", file=sys.stderr)
return None
def extract_short_audio(video_path, duration=20, start_offset=15):
"""Izloči kratek audio iz videa za ACR fingerprint.
Začnemo 15s v video (preskoči intro) in vzamemo 20s.
Za MXF/multichannel: izberemo prvi audio stream, downmix v stereo.
Vrne pot do tmp MP3 ali None.
"""
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".mp3")
os.close(tmp_fd)
cmd = [
"ffmpeg", "-i", video_path,
"-ss", str(start_offset),
"-t", str(duration),
"-map", "0:a:0", # samo prvi audio stream (varno za MXF z več streami)
"-vn",
"-ac", "2", # downmix v stereo (če multichannel)
"-ar", "44100",
"-b:a", "128k",
"-f", "mp3", tmp_path,
"-y", "-loglevel", "error"
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
os.unlink(tmp_path)
print(f"⚠️ ffmpeg napaka: {proc.stderr[:200]}", file=sys.stderr)
return None
return tmp_path
def recognize_video(video_path):
"""Glavni vstop: prepoznaj pesem v videu, vrni (artist, title) ali (None, None).
Probavamo 2 točki v videu (15s in 60s) za večjo robustnost — če ni intro/instrumental.
"""
if not os.environ.get("ACR_ACCESS_KEY"):
return (None, None)
for start_offset in [15, 60]:
audio = extract_short_audio(video_path, duration=20, start_offset=start_offset)
if not audio:
continue
try:
result = recognize_audio_file(audio)
finally:
try:
os.unlink(audio)
except OSError:
pass
if not result:
continue
status = result.get("status", {}).get("code")
if status != 0:
# 1001 = no result, drugi = napaka
msg = result.get("status", {}).get("msg", "")
print(f" ℹ️ ACR @{start_offset}s: status={status} ({msg})", file=sys.stderr)
continue
# Uspeh — vzemi prvi music match
music = result.get("metadata", {}).get("music", [])
if not music:
continue
first = music[0]
title = first.get("title", "").strip()
artists = first.get("artists", [])
artist = artists[0].get("name", "").strip() if artists else ""
if artist and title:
print(f" ✅ ACR @{start_offset}s prepoznal: {artist} - {title}", file=sys.stderr)
return (artist, title)
print(" ⚠️ ACR pesem ni prepoznana", file=sys.stderr)
return (None, None)
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Uporaba: python3 acr_recognize.py <video.mp4>")
sys.exit(1)
artist, title = recognize_video(sys.argv[1])
if artist and title:
print(json.dumps({"artist": artist, "title": title}, ensure_ascii=False))
sys.exit(0)
else:
print(json.dumps({"artist": None, "title": None}))
sys.exit(1)
+1460 -142
View File
File diff suppressed because it is too large Load Diff
+181 -14
View File
@@ -25,7 +25,12 @@ import numpy as np
def get_video_info(path): def get_video_info(path):
"""Vrni dict z width, height, fps, duration.""" """Vrni dict z width, height, fps, duration.
width/height so SQUARE-PIXEL dimenzije (popravljeno za anamorphic SAR).
Pri broadcast 720x576 SAR 64:45 (PAL DV) bo vrnil 1024x576 (square pixel).
Pri standardnem source z SAR 1:1 ostane nespremenjeno.
"""
cmd = [ cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json", "ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", "-show_format", str(path) "-show_streams", "-show_format", str(path)
@@ -35,14 +40,105 @@ def get_video_info(path):
fps_str = vstream["r_frame_rate"] fps_str = vstream["r_frame_rate"]
num, den = fps_str.split("/") num, den = fps_str.split("/")
fps = float(num) / float(den) fps = float(num) / float(den)
raw_w = int(vstream["width"])
raw_h = int(vstream["height"])
# SAR (sample aspect ratio) — razmerje stranic pikslja
sar = vstream.get("sample_aspect_ratio", "1:1")
try:
sar_n, sar_d = sar.split(":")
sar_n, sar_d = int(sar_n), int(sar_d)
if sar_n == 0 or sar_d == 0:
sar_n, sar_d = 1, 1
except (ValueError, AttributeError):
sar_n, sar_d = 1, 1
# Square-pixel dimenzije: če SAR != 1:1, popravi širino
if sar_n != sar_d:
sq_w = int(round(raw_w * sar_n / sar_d))
# Zaokroži na sodo (libx264 ima rad sode dimenzije)
sq_w = sq_w + (sq_w % 2)
sq_h = raw_h
print(f"📐 Anamorphic source: {raw_w}x{raw_h} SAR {sar_n}:{sar_d} → {sq_w}x{sq_h} (square pixel)", file=sys.stderr)
else:
sq_w = raw_w
sq_h = raw_h
return { return {
"width": int(vstream["width"]), "width": sq_w,
"height": int(vstream["height"]), "height": sq_h,
"raw_width": raw_w,
"raw_height": raw_h,
"sar_n": sar_n,
"sar_d": sar_d,
"fps": fps, "fps": fps,
"duration": float(data["format"]["duration"]), "duration": float(data["format"]["duration"]),
} }
def get_audio_streams(path):
"""Vrni seznam audio streamov z njihovimi metadati.
MXF datoteke imajo pogosto 4-8 audio streamov ali en stream z 8 kanali.
Vrne: [{'index': 1, 'channels': 2, 'codec': 'pcm_s24le', 'language': 'eng'}, ...]
"""
cmd = [
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", "a", str(path)
]
try:
data = json.loads(subprocess.check_output(cmd))
streams = []
for s in data.get("streams", []):
streams.append({
"index": s.get("index"),
"channels": int(s.get("channels", 2)),
"codec": s.get("codec_name", ""),
"sample_rate": int(s.get("sample_rate", 48000)),
"language": s.get("tags", {}).get("language", ""),
"channel_layout": s.get("channel_layout", ""),
})
return streams
except Exception:
return []
def build_audio_args(audio_streams):
"""Sestavi FFmpeg argumente za audio: izberi pravi stream + downmix v stereo.
Strategija:
- Če je samo 1 stream: pretvori ga v stereo (downmix iz multichannel)
- Če je več streamov (MXF z več jezikovnimi kanali):
* preferiraj prvi 2-kanalni stereo stream (običajno glavna audio mix)
* sicer prvi stream
- Vedno: output 2 channels @ 48kHz, codec AAC 192k (boljša kvaliteta za broadcast)
"""
if not audio_streams:
# Brez audia: prazen output
return ["-an"]
# Najdi najboljši stream
chosen = None
# 1. Stereo (2-kanalni) ima prednost
for s in audio_streams:
if s["channels"] == 2:
chosen = s
break
# 2. Sicer prvi stream
if chosen is None:
chosen = audio_streams[0]
args = [
"-map", f"0:{chosen['index']}", # samo izbrani stream
"-ac", "2", # downmix v stereo (če je multichannel)
"-ar", "48000",
"-c:a", "aac",
"-b:a", "192k", # 192k = boljša kvaliteta za glasbo (prej 128k)
]
return args
def detect_face_centers(video_path, sample_fps=5): def detect_face_centers(video_path, sample_fps=5):
""" """
Vzorči video pri sample_fps in vrni seznam (timestamp, x_center_normalized). Vzorči video pri sample_fps in vrni seznam (timestamp, x_center_normalized).
@@ -188,7 +284,7 @@ def build_center_filter(info, target_w, target_h):
return f"scale={scale_w}:{scale_h},crop={target_w}:{target_h}:(in_w-{target_w})/2:0" return f"scale={scale_w}:{scale_h},crop={target_w}:{target_h}:(in_w-{target_w})/2:0"
def build_blur_filter(info, target_w, target_h): def build_blur_filter(info, target_w, target_h, anamorphic_prefix=""):
""" """
9:16 platno: spodaj/zgoraj blur kopija, v sredini originalni 16:9. 9:16 platno: spodaj/zgoraj blur kopija, v sredini originalni 16:9.
""" """
@@ -196,10 +292,11 @@ def build_blur_filter(info, target_w, target_h):
src_w = info["width"] src_w = info["width"]
src_h = info["height"] src_h = info["height"]
fg_h = int(target_w * src_h / src_w) fg_h = int(target_w * src_h / src_w)
pre = (anamorphic_prefix + ",") if anamorphic_prefix else ""
return ( return (
f"[0:v]scale={target_w}:{target_h}:force_original_aspect_ratio=increase," f"[0:v]{pre}scale={target_w}:{target_h}:force_original_aspect_ratio=increase,"
f"crop={target_w}:{target_h},gblur=sigma=30[bg];" f"crop={target_w}:{target_h},gblur=sigma=30[bg];"
f"[0:v]scale={target_w}:{fg_h}[fg];" f"[0:v]{pre}scale={target_w}:{fg_h}[fg];"
f"[bg][fg]overlay=0:(H-h)/2" f"[bg][fg]overlay=0:(H-h)/2"
) )
@@ -227,6 +324,23 @@ def main():
# Če imamo --start/--duration, najprej trim z FFmpeg v temp file (hitreje) # Če imamo --start/--duration, najprej trim z FFmpeg v temp file (hitreje)
work_input = src work_input = src
tmp = None tmp = None
# Probe audio streams iz originala
audio_streams = get_audio_streams(src)
src_ext = src.suffix.lower()
is_broadcast = src_ext in (".mxf", ".mpg", ".mpeg", ".ts", ".m2ts", ".mts")
has_complex_audio = (
len(audio_streams) > 1 or
(audio_streams and audio_streams[0].get("channels", 2) > 2) or
is_broadcast
)
if has_complex_audio:
print(f"🎚 Broadcast format ({src_ext}) — {len(audio_streams)} audio stream(s):", file=sys.stderr)
for s in audio_streams:
print(f" #{s['index']}: {s['codec']} {s['channels']}ch "
f"{s.get('channel_layout', '')} lang={s.get('language', '?')}", file=sys.stderr)
if args.start is not None or args.duration is not None: if args.start is not None or args.duration is not None:
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp.close() tmp.close()
@@ -236,36 +350,83 @@ def main():
cmd += ["-i", str(src)] cmd += ["-i", str(src)]
if args.duration is not None: if args.duration is not None:
cmd += ["-t", str(args.duration)] cmd += ["-t", str(args.duration)]
cmd += ["-c", "copy", tmp.name]
if has_complex_audio:
# Broadcast format ali multichannel: ne kopiraj, transkodiraj v stereo MP4
audio_args = build_audio_args(audio_streams)
cmd += [
"-map", "0:v:0", # samo prvi video stream
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p", # web/mobile compat (broadcast .mpg = yuv422p)
]
cmd += audio_args
else:
# MP4/MOV s standardnim audiom — stream copy je OK in hitrejši
cmd += ["-c", "copy"]
cmd += [tmp.name]
print(f"🔧 TRIM CMD: {' '.join(cmd)}", file=sys.stderr) print(f"🔧 TRIM CMD: {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
print(f"❌ TRIM FAILED: {result.stderr[-500:]}", file=sys.stderr) print(f"❌ TRIM FAILED: {result.stderr[-1000:]}", file=sys.stderr)
sys.exit(1) sys.exit(1)
work_input = Path(tmp.name) work_input = Path(tmp.name)
print(f"✂ Trim → {work_input}") print(f"✂ Trim → {work_input}")
# Verify trim output duration
verify = subprocess.run( verify = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", ["ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nokey=1", str(work_input)], "-of", "default=nw=1:nokey=1", str(work_input)],
capture_output=True, text=True capture_output=True, text=True
) )
print(f"🔍 TRIMMED FILE DURATION: {verify.stdout.strip()}s (expected ~{args.duration}s)", file=sys.stderr) print(f"🔍 TRIMMED FILE DURATION: {verify.stdout.strip()}s (expected ~{args.duration}s)", file=sys.stderr)
elif has_complex_audio:
# Brez trim-a, ampak MXF/MPG → še vedno transkodiraj v MP4 z 2-channel audio
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
tmp.close()
audio_args = build_audio_args(audio_streams)
cmd = ["ffmpeg", "-y", "-i", str(src),
"-map", "0:v:0",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
"-pix_fmt", "yuv420p"] # web/mobile compat
cmd += audio_args + [tmp.name]
print(f"🔧 PRE-CONVERT CMD: {' '.join(cmd)}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ PRE-CONVERT FAILED: {result.stderr[-1000:]}", file=sys.stderr)
sys.exit(1)
work_input = Path(tmp.name)
print(f"🔄 Pre-converted → {work_input}")
info = get_video_info(work_input) info = get_video_info(work_input)
print(f"📹 Vhod: {info['width']}x{info['height']} @ {info['fps']:.2f}fps, {info['duration']:.1f}s") print(f"📹 Vhod: {info['width']}x{info['height']} @ {info['fps']:.2f}fps, {info['duration']:.1f}s")
# Anamorphic correction prefix: če je SAR != 1:1, najprej scale-amo na square-pixel
# in resetiramo SAR=1, šele potem track/crop filter dela na pravilnih dimenzijah.
# Brez tega: 720x576 SAR 64:45 (DAR 16:9) PAL DV se pri scale na 1920 razteguje
# nepravilno (ker filter misli da je 720 širina, dejansko prikazana širina je 1024).
if info.get("sar_n", 1) != info.get("sar_d", 1):
anamorphic_prefix = f"scale={info['width']}:{info['height']}:flags=lanczos,setsar=1,"
print(f"🔧 Anamorphic prefix: {anamorphic_prefix.rstrip(',')}", file=sys.stderr)
else:
anamorphic_prefix = ""
if args.mode == "track": if args.mode == "track":
print("🔍 Detektiram obraze (OpenCV)...") print("🔍 Detektiram obraze (OpenCV)...")
samples, _, _, _, _ = detect_face_centers(work_input, sample_fps=5) samples, _, _, _, _ = detect_face_centers(work_input, sample_fps=5)
n_with_face = sum(1 for _, x in samples if x is not None) n_with_face = sum(1 for _, x in samples if x is not None)
print(f" {n_with_face}/{len(samples)} vzorcev z obrazom") print(f" {n_with_face}/{len(samples)} vzorcev z obrazom")
x_at = smooth_track(samples, info["duration"], smoothing_window=4.0) x_at = smooth_track(samples, info["duration"], smoothing_window=4.0)
vfilter = build_track_filter(info, x_at, args.target_width, args.target_height, info["fps"]) vfilter = anamorphic_prefix + build_track_filter(info, x_at, args.target_width, args.target_height, info["fps"])
elif args.mode == "center": elif args.mode == "center":
vfilter = build_center_filter(info, args.target_width, args.target_height) vfilter = anamorphic_prefix + build_center_filter(info, args.target_width, args.target_height)
elif args.mode == "blur": elif args.mode == "blur":
vfilter = build_blur_filter(info, args.target_width, args.target_height) # blur uporablja filter_complex z [0:v] referenco — anamorphic prefix gre v posebni veji
vfilter = build_blur_filter(info, args.target_width, args.target_height,
anamorphic_prefix=anamorphic_prefix.rstrip(","))
# KONČNI setsar=1: zagotovi 1:1 piksel v output-u (kompenzira morebitne rounding errore
# iz scale/crop filtrov, ki lahko dajo SAR npr. 10240:10239)
if args.mode != "blur":
vfilter = vfilter + ",setsar=1"
preset = {"fast": "veryfast", "medium": "medium", "high": "slow"}[args.quality] preset = {"fast": "veryfast", "medium": "medium", "high": "slow"}[args.quality]
crf = {"fast": "26", "medium": "21", "high": "18"}[args.quality] crf = {"fast": "26", "medium": "21", "high": "18"}[args.quality]
@@ -286,7 +447,10 @@ def main():
"ffmpeg", "-y", "-i", str(work_input), "ffmpeg", "-y", "-i", str(work_input),
"-filter_complex", vfilter, "-filter_complex", vfilter,
"-c:v", "libx264", "-preset", preset, "-crf", crf, "-c:v", "libx264", "-preset", preset, "-crf", crf,
"-c:a", "aac", "-b:a", "128k", "-pix_fmt", "yuv420p", # web/mobile compat (Instagram/FB/web players)
"-map", "0:a:0?", # samo prvi audio stream (če obstaja)
"-ac", "2", # force stereo
"-c:a", "aac", "-b:a", "192k",
] ]
if audio_filter_str: if audio_filter_str:
cmd += ["-af", audio_filter_str] cmd += ["-af", audio_filter_str]
@@ -296,7 +460,10 @@ def main():
"ffmpeg", "-y", "-i", str(work_input), "ffmpeg", "-y", "-i", str(work_input),
"-vf", vfilter, "-vf", vfilter,
"-c:v", "libx264", "-preset", preset, "-crf", crf, "-c:v", "libx264", "-preset", preset, "-crf", crf,
"-c:a", "aac", "-b:a", "128k", "-pix_fmt", "yuv420p", # web/mobile compat (Instagram/FB/web players)
"-map", "0:v:0", "-map", "0:a:0?", # video + samo prvi audio
"-ac", "2", # force stereo
"-c:a", "aac", "-b:a", "192k",
] ]
if audio_filter_str: if audio_filter_str:
cmd += ["-af", audio_filter_str] cmd += ["-af", audio_filter_str]
+3 -1
View File
@@ -255,12 +255,14 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
# Burn-in z ass filtrom (boljši kot subtitles za naš primer) # Burn-in z ass filtrom (boljši kot subtitles za naš primer)
ass_escaped = ass_path.replace("\\", "\\\\").replace(":", "\\:").replace("'", r"\'") ass_escaped = ass_path.replace("\\", "\\\\").replace(":", "\\:").replace("'", r"\'")
vf = f"ass='{ass_escaped}'" # setsar=1 + setdar=9/16: zagotovi 1:1 piksel + 9:16 display ratio v finalnem output-u
vf = f"ass='{ass_escaped}',setsar=1"
cmd = [ cmd = [
"ffmpeg", "-y", "-i", str(video), "ffmpeg", "-y", "-i", str(video),
"-vf", vf, "-vf", vf,
"-c:v", "libx264", "-preset", "medium", "-crf", "21", "-c:v", "libx264", "-preset", "medium", "-crf", "21",
"-pix_fmt", "yuv420p", # web/mobile compat (Instagram/FB/web players)
"-c:a", "copy", "-c:a", "copy",
"-movflags", "+faststart", "-movflags", "+faststart",
str(output), str(output),
+190
View File
@@ -0,0 +1,190 @@
#!/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.
Songs.txt je v CP1250 encoding (Windows Slovenian/CE), NE 1252 (Western).
1252 bi 'Č' (0xC8) interpretiral kot 'È', 'Š' kot 'Š' OK ampak 'Ž' (0xDE) kot 'Þ' itd.
"""
# 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-1250 -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()
+199 -19
View File
@@ -9,10 +9,75 @@ import argparse
import os import os
import subprocess import subprocess
import sys import sys
import time
from pathlib import Path from pathlib import Path
import json import json
YT_API_URL = os.environ.get("YT_API_URL", "https://yt.biba.live")
YT_API_TOKEN = os.environ.get("YT_API_TOKEN", "")
def fetch_cookies_from_api(cache_path="/tmp/yt_cookies_api.txt", max_age_seconds=300):
"""Pull sveže YouTube cookies iz yt.biba.live API.
Cache za 5 min (TTL) — če imamo nedavno fetched, uporabi local copy.
Vrne path do cookies file-a ali None če API ne deluje.
"""
if not YT_API_TOKEN:
return None # ni configured
cache = Path(cache_path)
if cache.exists():
age = time.time() - cache.stat().st_mtime
if age < max_age_seconds and cache.stat().st_size > 100:
return str(cache)
try:
import urllib.request
req = urllib.request.Request(
f"{YT_API_URL}/cookies/raw",
headers={"Authorization": f"Bearer {YT_API_TOKEN}"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
content = resp.read().decode("utf-8")
if len(content) < 100:
print(f"⚠ yt.biba.live vrnil prekratek cookies file ({len(content)} bytes)", file=sys.stderr)
return None
cache.write_text(content, encoding="utf-8")
print(f"🍪 Cookies fetched iz yt.biba.live ({len(content)} bytes)", file=sys.stderr)
return str(cache)
except Exception as e:
print(f"⚠ yt.biba.live cookies fetch failed: {e}", file=sys.stderr)
return None
def get_cookies_file(cookies_file=None):
"""Vrne path do cookies file-a — prioriteta:
1. eksplicitno podano (cookies_file param)
2. yt.biba.live API (sveže, hourly refreshed)
3. /data/cookies/youtube.txt (lokalni fallback)
4. YT_COOKIES_FILE env
"""
if cookies_file and Path(cookies_file).exists():
return cookies_file
# Try API
api_cookies = fetch_cookies_from_api()
if api_cookies:
return api_cookies
# Fallback to local
for candidate in [
"/data/cookies/youtube.txt",
os.environ.get("YT_COOKIES_FILE", ""),
]:
if candidate and Path(candidate).exists():
return candidate
return None
def download(url, output, max_height=1080, format_str=None, cookies_file=None): def download(url, output, max_height=1080, format_str=None, cookies_file=None):
""" """
Download YT video. Privzeto: best mp4 ≤1080p z audiotrackom. Download YT video. Privzeto: best mp4 ≤1080p z audiotrackom.
@@ -33,17 +98,8 @@ def download(url, output, max_height=1080, format_str=None, cookies_file=None):
"-o", str(output), "-o", str(output),
] ]
# Auto-detect cookies file če ni eksplicitno podan cookies_file = get_cookies_file(cookies_file)
if cookies_file is None: if cookies_file:
for candidate in [
"/data/cookies/youtube.txt",
os.environ.get("YT_COOKIES_FILE", ""),
]:
if candidate and Path(candidate).exists():
cookies_file = candidate
break
if cookies_file and Path(cookies_file).exists():
cmd += ["--cookies", str(cookies_file)] cmd += ["--cookies", str(cookies_file)]
print(f"🍪 Using cookies: {cookies_file}", file=sys.stderr) print(f"🍪 Using cookies: {cookies_file}", file=sys.stderr)
@@ -58,14 +114,36 @@ def download(url, output, max_height=1080, format_str=None, cookies_file=None):
def get_info(url, cookies_file=None): def get_info(url, cookies_file=None):
"""Vrni metadata brez prenosa.""" """Vrni metadata brez prenosa.
Strategija:
1. Najprej probaj yt.biba.live API /download/info (residential IP,
sveži cookies, signature solver — manj možnosti za bot detection).
2. Fallback na lokalni yt-dlp --dump-json.
"""
# ─── 1. Try yt.biba.live API ───
if YT_API_TOKEN:
try:
import urllib.request
import urllib.parse
params = urllib.parse.urlencode({"url": url})
req = urllib.request.Request(
f"{YT_API_URL}/download/info?{params}",
headers={"Authorization": f"Bearer {YT_API_TOKEN}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
api_data = json.loads(resp.read().decode("utf-8"))
if api_data.get("ok"):
print(f"ℹ Info via yt.biba.live API: {api_data.get('title','')[:60]}", file=sys.stderr)
# API že vrne flat dict — preimenujmo v yt-dlp obliko za kompatibilnost
return api_data
except Exception as e:
print(f"⚠ yt.biba.live /download/info failed: {e}, fallback to local", file=sys.stderr)
# ─── 2. Fallback: lokalni yt-dlp ───
cmd = ["yt-dlp", "--dump-json", "--no-playlist"] cmd = ["yt-dlp", "--dump-json", "--no-playlist"]
if cookies_file is None: cookies_file = get_cookies_file(cookies_file)
for candidate in ["/data/cookies/youtube.txt", os.environ.get("YT_COOKIES_FILE", "")]: if cookies_file:
if candidate and Path(candidate).exists():
cookies_file = candidate
break
if cookies_file and Path(cookies_file).exists():
cmd += ["--cookies", str(cookies_file)] cmd += ["--cookies", str(cookies_file)]
cmd.append(url) cmd.append(url)
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
@@ -74,6 +152,95 @@ def get_info(url, cookies_file=None):
return json.loads(result.stdout.strip().split("\n")[0]) return json.loads(result.stdout.strip().split("\n")[0])
def get_playlist(url, cookies_file=None):
"""Vrni listo videov v playlistu (samo metadata, ne pobere).
Strategija:
1. Najprej probaj yt.biba.live API /download/playlist (centralizirano,
vedno sveže cookies + signature solver na strežniku).
2. Fallback na lokalni yt-dlp --flat-playlist.
Returns:
{
"is_playlist": bool,
"playlist_title": str,
"items": [
{"id": "VIDEO_ID", "title": "...", "url": "https://...", "duration": 234},
...
]
}
"""
# ─── 1. Try yt.biba.live API ───
if YT_API_TOKEN:
try:
import urllib.request
import urllib.parse
params = urllib.parse.urlencode({"url": url, "max_items": 200})
req = urllib.request.Request(
f"{YT_API_URL}/download/playlist?{params}",
headers={"Authorization": f"Bearer {YT_API_TOKEN}"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
api_data = json.loads(resp.read().decode("utf-8"))
if api_data.get("ok"):
print(f"📋 Playlist resolved via yt.biba.live API: {api_data.get('count', 0)} items", file=sys.stderr)
return {
"is_playlist": api_data.get("is_playlist", False),
"playlist_title": api_data.get("playlist_title", ""),
"items": api_data.get("items", []),
}
else:
print(f"⚠ yt.biba.live API error: {api_data.get('error', 'unknown')[:200]}", file=sys.stderr)
# Fall through to local
except Exception as e:
print(f"⚠ yt.biba.live API failed: {e}, fallback to local yt-dlp", file=sys.stderr)
# ─── 2. Fallback: lokalni yt-dlp ───
cmd = ["yt-dlp", "--flat-playlist", "--dump-json"]
cookies_file = get_cookies_file(cookies_file)
if cookies_file:
cmd += ["--cookies", str(cookies_file)]
cmd.append(url)
print(f"📋 Resolving playlist (lokalno): {url}", file=sys.stderr)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
print(f"❌ yt-dlp playlist napaka:\n{result.stderr[-1500:]}", file=sys.stderr)
return {"is_playlist": False, "playlist_title": "", "items": [], "error": result.stderr[-500:]}
items = []
playlist_title = ""
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
# Playlist header (typ=playlist)
if entry.get("_type") == "playlist":
playlist_title = entry.get("title", "")
continue
# Video entry
vid = entry.get("id")
if not vid:
continue
items.append({
"id": vid,
"title": entry.get("title", "") or "",
"url": entry.get("url") or f"https://www.youtube.com/watch?v={vid}",
"duration": entry.get("duration"),
"uploader": entry.get("uploader") or entry.get("channel") or "",
})
is_playlist = len(items) > 1 or ("list=" in url)
return {
"is_playlist": is_playlist,
"playlist_title": playlist_title or (items[0]["title"] if items else ""),
"items": items,
}
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("url") ap.add_argument("url")
@@ -89,11 +256,24 @@ def main():
info = get_info(args.url, cookies_file=args.cookies) info = get_info(args.url, cookies_file=args.cookies)
if info: if info:
print(json.dumps({ print(json.dumps({
"id": info.get("id"),
"title": info.get("title"), "title": info.get("title"),
"duration": info.get("duration"), "duration": info.get("duration"),
"uploader": info.get("uploader"), "uploader": info.get("uploader"),
"uploader_id": info.get("uploader_id"),
"channel": info.get("channel"),
"channel_id": info.get("channel_id"),
"thumbnail": info.get("thumbnail"), "thumbnail": info.get("thumbnail"),
}, indent=2)) "description": info.get("description"),
"upload_date": info.get("upload_date"),
"view_count": info.get("view_count"),
"like_count": info.get("like_count"),
"categories": info.get("categories"),
"tags": info.get("tags"),
"webpage_url": info.get("webpage_url"),
"age_limit": info.get("age_limit"),
"is_live": info.get("is_live"),
}, indent=2, ensure_ascii=False))
else: else:
print("❌ Ne morem dobiti info", file=sys.stderr) print("❌ Ne morem dobiti info", file=sys.stderr)
sys.exit(1) sys.exit(1)
+1549 -184
View File
File diff suppressed because it is too large Load Diff