Files
scripts/podcast/scripts/schedule-episode.sh
T
ealmeida 6035542b67 feat: scripts de projectos vindos do Hub (podcast, alojadamaria, clip, ocr, etc.)
Movidos do vault Hub para centralizar scripts. Hub mantem symlinks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:53:29 +01:00

293 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# schedule-episode.sh — Agenda episódio completo no WordPress
# Envia MP3 + capa via SCP, importa media, cria post com todos os metas
#
# Uso: ./scripts/schedule-episode.sh <ep_num> <YYYY-MM-DD> [--dry-run]
#
# Requisitos locais:
# - MP3 em Episodios/Audios/final/ep_NNN_*.mp3
# - Capa em banco-media: capas-geradas/podcast/podcast-epNNN-*.png
# - ffprobe (para duração)
#
# Requisitos servidor:
# - wp-cli com --allow-root
# - SSH porta 9443, chave ~/.ssh/id_ed25519
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/lib.sh"
# SSH config
SSH_KEY="${HOME}/.ssh/id_ed25519"
SSH_PORT=9443
SSH_HOST="server.descomplicar.pt"
SSH_USER="root"
SSH_OPTS="-o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
WP_PATH="/home/ealmeida/public_html"
WP_OWNER="ealmeida:ealmeida"
SITE_URL="https://descomplicar.pt"
SERIES_SLUG="podcast-descomplicar-digital"
# Banco de media (capas)
CAPAS_DIR="/media/ealmeida/Dados/Hub/06-Operacoes/Conteúdos/banco-media/capas-geradas/podcast"
DRY_RUN=false
usage() {
echo "Usage: $0 <ep_num> <YYYY-MM-DD> [--dry-run]"
echo " Agenda episodio completo no WordPress (audio + capa + metas + SEO)"
exit 1
}
ssh_cmd() {
SSH_AUTH_SOCK= ssh -p "${SSH_PORT}" -i "${SSH_KEY}" ${SSH_OPTS} "${SSH_USER}@${SSH_HOST}" "$@"
}
scp_file() {
SSH_AUTH_SOCK= scp -P "${SSH_PORT}" -i "${SSH_KEY}" ${SSH_OPTS} "$1" "${SSH_USER}@${SSH_HOST}:$2"
}
[[ $# -lt 2 ]] && usage
EP_NUM="$1"
SCHED_DATE="$2"
[[ "${3:-}" == "--dry-run" ]] && DRY_RUN=true
EP_PAD="$(pad_number "$EP_NUM")"
# === 1. Encontrar ficheiros locais ===
AUDIO_FILE=""
for f in "${PROJECT_ROOT}/Episodios/Audios/final/ep_${EP_PAD}_"*.mp3; do
[[ -f "$f" ]] && AUDIO_FILE="$f" && break
done
COVER_FILE=""
for f in "${CAPAS_DIR}/podcast-ep${EP_PAD}-"*.png; do
[[ -f "$f" ]] && COVER_FILE="$f" && break
done
GUIDE_FILE=""
for f in "${PROJECT_ROOT}/Episodios/Episodio_${EP_PAD}_"*.txt; do
[[ -f "$f" ]] && GUIDE_FILE="$f" && break
done
# Validar
[[ -z "$AUDIO_FILE" ]] && log_error "EP${EP_PAD}: MP3 nao encontrado em Episodios/Audios/final/" && exit 1
[[ -z "$COVER_FILE" ]] && log_error "EP${EP_PAD}: Capa PNG nao encontrada em ${CAPAS_DIR}/" && exit 1
[[ -z "$GUIDE_FILE" ]] && log_warn "EP${EP_PAD}: Guiao .txt nao encontrado (conteudo WP ficara vazio)"
# === 2. Extrair metadata do audio ===
DURATION_RAW=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "$AUDIO_FILE")
DUR_SEC_TOTAL=${DURATION_RAW%.*}
DUR_MIN=$((DUR_SEC_TOTAL / 60))
DUR_SEC=$((DUR_SEC_TOTAL % 60))
DURATION_FMT="$(printf '%d:%02d' "$DUR_MIN" "$DUR_SEC")"
FILESIZE_H="$(du -h "$AUDIO_FILE" | cut -f1)"
FILESIZE_RAW="$(stat -c%s "$AUDIO_FILE")"
AUDIO_BASENAME="$(basename "$AUDIO_FILE")"
# Extrair titulo do nome do ficheiro do guiao
TITLE=""
if [[ -n "$GUIDE_FILE" ]]; then
TITLE="$(basename "$GUIDE_FILE" .txt | sed 's/^Episodio_[0-9]*_//' | tr '_' ' ')"
fi
# Fallback do audio
[[ -z "$TITLE" ]] && TITLE="$(basename "$AUDIO_FILE" .mp3 | sed 's/^ep_[0-9]*_//' | tr '-' ' ')"
# Upload paths
YEAR="$(date -d "$SCHED_DATE" '+%Y')"
MONTH="$(date -d "$SCHED_DATE" '+%m')"
AUDIO_REMOTE_DIR="${WP_PATH}/wp-content/uploads/podcast/${YEAR}/${MONTH}"
AUDIO_URL="${SITE_URL}/wp-content/uploads/podcast/${YEAR}/${MONTH}/${AUDIO_BASENAME}"
log_info "EP${EP_PAD}: '${TITLE}' | ${DURATION_FMT} | ${FILESIZE_H} | ${SCHED_DATE} 07:00"
if $DRY_RUN; then
log_info "[DRY-RUN] Audio: ${AUDIO_FILE}"
log_info "[DRY-RUN] Capa: ${COVER_FILE}"
log_info "[DRY-RUN] URL audio: ${AUDIO_URL}"
log_info "[DRY-RUN] Titulo: ${TITLE}"
exit 0
fi
# === 3. Enviar MP3 para o servidor ===
log_info "EP${EP_PAD}: Enviar MP3..."
ssh_cmd "mkdir -p '${AUDIO_REMOTE_DIR}'"
scp_file "$AUDIO_FILE" "${AUDIO_REMOTE_DIR}/"
ssh_cmd "chown ${WP_OWNER} '${AUDIO_REMOTE_DIR}/${AUDIO_BASENAME}'"
log_info "EP${EP_PAD}: MP3 enviado -> ${AUDIO_REMOTE_DIR}/${AUDIO_BASENAME}"
# === 4. Enviar capa e importar no WP ===
log_info "EP${EP_PAD}: Enviar capa..."
COVER_BASENAME="$(basename "$COVER_FILE")"
ssh_cmd "mkdir -p /tmp/podcast-upload"
scp_file "$COVER_FILE" "/tmp/podcast-upload/${COVER_BASENAME}"
ssh_cmd "chown ${WP_OWNER} '/tmp/podcast-upload/${COVER_BASENAME}'"
ATTACH_ID=$(ssh_cmd "cd '${WP_PATH}' && wp media import '/tmp/podcast-upload/${COVER_BASENAME}' --title='${COVER_BASENAME%.png}' --porcelain --allow-root 2>/dev/null")
ssh_cmd "rm -f '/tmp/podcast-upload/${COVER_BASENAME}'"
if [[ -z "$ATTACH_ID" ]]; then
log_error "EP${EP_PAD}: Falha ao importar capa no WP"
exit 1
fi
log_info "EP${EP_PAD}: Capa importada (attach_id: ${ATTACH_ID})"
# === 5. Criar post podcast agendado ===
log_info "EP${EP_PAD}: Criar post..."
POST_ID=$(ssh_cmd "cd '${WP_PATH}' && wp post create \
--post_type=podcast \
--post_title='$(echo "$TITLE" | sed "s/'/'\\\\''/g")' \
--post_status=future \
--post_date='${SCHED_DATE} 07:00:00' \
--porcelain \
--allow-root 2>/dev/null")
if [[ -z "$POST_ID" ]]; then
log_error "EP${EP_PAD}: Falha ao criar post"
exit 1
fi
log_info "EP${EP_PAD}: Post criado (ID: ${POST_ID})"
# === 6. Associar serie e featured image ===
ssh_cmd "cd '${WP_PATH}' && \
wp post term set ${POST_ID} series '${SERIES_SLUG}' --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} _thumbnail_id ${ATTACH_ID} --allow-root 2>/dev/null"
# === 7. Metas SSP (Seriously Simple Podcasting) ===
ssh_cmd "cd '${WP_PATH}' && \
wp post meta update ${POST_ID} episode_type audio --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} audio_file '${AUDIO_URL}' --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} duration '${DURATION_FMT}' --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} filesize '${FILESIZE_H}' --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} filesize_raw '${FILESIZE_RAW}' --allow-root 2>/dev/null && \
wp post meta update ${POST_ID} date_recorded '${SCHED_DATE} 07:00:00' --allow-root 2>/dev/null"
# === 8. Aplicar conteudo WP + Rank Math + tags (se _wp.json existir) ===
WP_JSON=""
for f in "${PROJECT_ROOT}/Episodios/Episodio_${EP_PAD}_"*_wp.json; do
[[ -f "$f" ]] && WP_JSON="$f" && break
done
if [[ -n "$WP_JSON" ]]; then
# Auto-reparar JSON se necessário
if ! python3 -c "import json; json.load(open('$WP_JSON'))" 2>/dev/null; then
log_warn "EP${EP_PAD}: JSON inválido — a tentar reparação automática..."
python3 - "$WP_JSON" << 'PYFIX'
import sys, re, json
filepath = sys.argv[1]
with open(filepath, 'r') as f:
raw = f.read()
marker = '"content_html": "'
start = raw.find(marker)
if start == -1: sys.exit(1)
content_start = start + len(marker)
end_pattern = re.search(r'",\s*\n\s*"hashtags"', raw[content_start:]) or re.search(r'",\s*\n\s*"wp_tags"', raw[content_start:])
if not end_pattern: sys.exit(1)
content_end = content_start + end_pattern.start()
fixed = raw[:content_start] + re.sub(r'(?<!\\)"', '\\"', raw[content_start:content_end]) + raw[content_end:]
json.loads(fixed)
with open(filepath, 'w') as f: f.write(fixed)
PYFIX
log_info "EP${EP_PAD}: JSON reparado"
fi
fi
if [[ -n "$WP_JSON" ]] && python3 -c "import json; json.load(open('$WP_JSON'))" 2>/dev/null; then
log_info "EP${EP_PAD}: Aplicar conteudo WP de $(basename "$WP_JSON")"
WP_CONTENT="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('content_html',''))" 2>/dev/null)"
WP_META="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('meta_description',''))" 2>/dev/null)"
WP_KEYWORD="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('keyword',''))" 2>/dev/null)"
WP_TAGS="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(','.join(d.get('wp_tags',[])))" 2>/dev/null)"
WP_HASHTAGS="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(' '.join(d.get('hashtags',[])))" 2>/dev/null)"
# Excerpt = primeira linha do meta + hashtags
WP_EXCERPT=""
if [[ -n "$WP_META" && -n "$WP_HASHTAGS" ]]; then
WP_EXCERPT="${WP_META}
${WP_HASHTAGS}"
fi
# Aplicar conteudo HTML
if [[ -n "$WP_CONTENT" ]]; then
ESCAPED_CONTENT="$(echo "$WP_CONTENT" | sed "s/'/'\\\\''/g")"
ssh_cmd "cd '${WP_PATH}' && wp post update ${POST_ID} --post_content='${ESCAPED_CONTENT}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: post_content aplicado"
fi
# Aplicar excerpt
if [[ -n "$WP_EXCERPT" ]]; then
ESCAPED_EXCERPT="$(echo "$WP_EXCERPT" | sed "s/'/'\\\\''/g")"
ssh_cmd "cd '${WP_PATH}' && wp post update ${POST_ID} --post_excerpt='${ESCAPED_EXCERPT}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: post_excerpt aplicado"
fi
# Aplicar tags
if [[ -n "$WP_TAGS" ]]; then
ssh_cmd "cd '${WP_PATH}' && wp post term set ${POST_ID} post_tag ${WP_TAGS} --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: tags aplicadas"
fi
# Rank Math: meta description + focus keyword
if [[ -n "$WP_META" ]]; then
ESCAPED_META="$(echo "$WP_META" | sed "s/'/'\\\\''/g")"
ssh_cmd "cd '${WP_PATH}' && wp post meta update ${POST_ID} rank_math_description '${ESCAPED_META}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: rank_math_description aplicado"
fi
if [[ -n "$WP_KEYWORD" ]]; then
ESCAPED_KW="$(echo "$WP_KEYWORD" | sed "s/'/'\\\\''/g")"
ssh_cmd "cd '${WP_PATH}' && wp post meta update ${POST_ID} rank_math_focus_keyword '${ESCAPED_KW}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: rank_math_focus_keyword aplicado"
fi
# Rank Math: SEO title (preferir seo_title do JSON, fallback para title + sufixo)
WP_SEO_TITLE="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('seo_title',''))" 2>/dev/null)"
if [[ -z "$WP_SEO_TITLE" ]]; then
WP_TITLE_FALLBACK="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('title',''))" 2>/dev/null)"
[[ -n "$WP_TITLE_FALLBACK" ]] && WP_SEO_TITLE="${WP_TITLE_FALLBACK} | Podcast Descomplicar Digital"
fi
if [[ -n "$WP_SEO_TITLE" ]]; then
ESCAPED_SEO_TITLE="$(echo "$WP_SEO_TITLE" | sed "s/'/'\\\\''/g")"
ssh_cmd "cd '${WP_PATH}' && wp post meta update ${POST_ID} rank_math_title '${ESCAPED_SEO_TITLE}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: rank_math_title aplicado"
fi
# Slug optimizado (preferir slug do JSON)
WP_SLUG="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('slug',''))" 2>/dev/null)"
if [[ -n "$WP_SLUG" ]]; then
ssh_cmd "cd '${WP_PATH}' && wp post update ${POST_ID} --post_name='${WP_SLUG}' --allow-root 2>/dev/null"
log_info "EP${EP_PAD}: slug actualizado para ${WP_SLUG}"
fi
else
log_info "EP${EP_PAD}: PENDENTE — WP JSON nao encontrado, gerar via generate-content.sh"
fi
# === 9. Corrigir permissoes uploads ===
ssh_cmd "chown -R ${WP_OWNER} '${AUDIO_REMOTE_DIR}/' '${WP_PATH}/wp-content/uploads/${YEAR}/${MONTH}/' 2>/dev/null" || true
log_info "EP${EP_PAD}: Agendado para ${SCHED_DATE} 07:00 (post ${POST_ID})"
# Actualizar pipeline-state.json
EP_TITLE="$(python3 -c "import json; d=json.load(open('$WP_JSON')); print(d.get('title',''))" 2>/dev/null || echo "")"
AUDIO_BASENAME="$(basename "$AUDIO_FILE")"
jq --argjson n "$EP_NUM" --arg t "$EP_TITLE" --arg a "Episodios/Audios/final/${AUDIO_BASENAME}" --arg s "$SCHED_DATE" \
'if [.episodes[] | select(.num == $n)] | length > 0
then (.episodes[] | select(.num == $n)) |= . + {status: "ready", title: $t, audio: $a, scheduled: $s}
else .episodes += [{num: ($n | tonumber), title: $t, audio: $a, scheduled: $s, status: "ready"}]
end | .last_updated = (now | todate)' \
"${STATE_FILE}" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "${STATE_FILE}"
log_info "EP${EP_PAD}: pipeline-state.json actualizado"
echo "${POST_ID}"