文章內插入音檔 Cloudflare R2
如何把黃金泡菜之歌插在網頁內呢?先把音檔放在Cloudflare R2空間,再把超連結貼入文章內節省Github的空間;最後寫個Shortcode,就有簡潔的播放器可以使用了。
🎧 Hugo + Cloudflare R2 = 發布雲端音檔
這麼做的原因無它,同樣是為了節省Github的空間,只有1GB,每個bit都珍貴。
上一回 是把圖片放在Imagekit.io圖床,接著把圖片的超連結寫進文章內;這回是把音樂放在Cloudflare R2物件儲存空間,再把 音樂 的超連結放進文章裡省空間,最後寫個Shortcode,網頁上就有簡潔的 播放器 可以使用了。
🧩 一、系統概述
這是一套完整的音檔自動上傳與發布系統,運行在 Linux 終端機 好得無比!
- 拖曳音檔»»自動轉檔並壓縮成 160 kbps MP3
- 上傳至 Cloudflare R2(S3 介面)
- 自動產生 Hugo shortcode 並自動複製到剪貼簿(我覺得這很神奇又很懶,不用寫程式碼了)
- 直接貼進 Markdown文章 ,即可播放
⚙️ 二、環境需求
| 套件 | 用途 | 安裝指令 |
|---|---|---|
| rclone | 與 Cloudflare R2 連線 | sudo apt install -y rclone |
| ffmpeg | 音檔轉檔(壓縮 MP3) | sudo apt install -y ffmpeg |
| xclip / wl-copy | 複製 shortcode 到剪貼簿 | sudo apt install -y xclip |
☁️ 三、Cloudflare R2 設定
- 登入 Cloudflare »» R2 »» Create Bucket
名稱:
media - 進入 Manage R2 API Tokens»»Create API Token
- Permissions:Edit
- Buckets:All buckets 或 指定
media - 建立後記下:
- Account ID
- Access Key ID
- Secret Access Key
- 在終端機建立 rclone 設定:
rclone config
# name: r2
# provider: Other
# access_key_id: <你的ID>
# secret_access_key: <你的密鑰>
# endpoint: https://<你的AccountID>.r2.cloudflarestorage.com
# region: auto
- 測試:
rclone mkdir r2:media
echo hi > /tmp/hi.txt
rclone copyto /tmp/hi.txt r2:media/hi.txt --progress
🧰 四、主要腳本 ~/upload_to_r2.sh
這是整個工作流的核心! 功能:
- 自動轉 MP3 160 kbps
- 自動上傳 R2 並設定 metadata
- 產生 Hugo shortcode 並複製到剪貼簿
- 支援 dry-run 與分類選單
📜 完整腳本內容
#!/usr/bin/env bash
set -euo pipefail
# ===== 基本設定 =====
REMOTE_NAME="r2" # rclone 遠端名稱
BUCKET_NAME="media" # R2 bucket 名稱
CUSTOM_DOMAIN="https://media.jujublog.idv.tw" # 自訂網域
CACHE_CONTROL_DEFAULT="public, max-age=31536000, immutable"
LAST_CATEGORY_FILE="$HOME/.upload_to_r2_last_category"
# ===== 顏色 =====
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[0;33m'; NC='\033[0m'
ok(){ echo -e "${GREEN}[OK]${NC} $*"; }
warn(){ echo -e "${YELLOW}[WARN]${NC} $*"; }
err(){ echo -e "${RED}[ERR]${NC} $*"; }
# ===== 需求檢查 =====
command -v rclone >/dev/null || { err "請先安裝 rclone:sudo apt install -y rclone"; exit 1; }
command -v ffmpeg >/dev/null || warn "未安裝 ffmpeg,將無法壓縮音檔。"
command -v python3 >/dev/null || { err "缺少 python3(用於 URL 編碼)"; exit 1; }
# ===== 剪貼簿 =====
copy_to_clipboard() {
local text="$1"
if [[ "${XDG_SESSION_TYPE:-}" == "x11" || -n "${DISPLAY:-}" ]]; then
command -v xclip >/dev/null 2>&1 && printf '%s' "$text" | xclip -selection clipboard && ok "已複製到剪貼簿(X11)" && return
fi
if [[ "${XDG_SESSION_TYPE:-}" == "wayland" || -n "${WAYLAND_DISPLAY:-}" ]]; then
command -v wl-copy >/dev/null 2>&1 && printf '%s' "$text" | wl-copy && ok "已複製到剪貼簿(Wayland)" && return
fi
warn "無法自動複製到剪貼簿。"
}
# ===== URL 編碼 =====
urlencode() {
local input="$1"
python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$input"
}
# ===== 工具 =====
human_size() { awk -v b="$1" 'function p(x){s="B KB MB GB TB";split(s,a," ");i=1;while(x>=1024&&i<5){x/=1024;i++}printf("%.1f %s",x,a[i])}BEGIN{p(b)}'; }
filesize() { stat -c%s "$1"; }
normalize_path() {
local p="$1"
[[ "$p" == \"*\" ]] && p="${p#\"}" && p="${p%\"}"
[[ "$p" == \'*\' ]] && p="${p#\'}" && p="${p%\'}"
if [[ "$p" == ~* ]]; then eval "p=$p"; fi
echo "$p"
}
guess_mime() {
local ext="${1##*.}"; ext=$(echo "$ext" | tr '[:upper:]' '[:lower:]')
case "$ext" in
mp3) echo "audio/mpeg" ;;
wav) echo "audio/wav" ;;
m4a|aac) echo "audio/mp4" ;;
flac) echo "audio/flac" ;;
ogg|oga) echo "audio/ogg" ;;
opus) echo "audio/opus" ;;
mp4) echo "video/mp4" ;;
webm) echo "video/webm" ;;
jpg|jpeg) echo "image/jpeg" ;;
png) echo "image/png" ;;
webp) echo "image/webp" ;;
pdf) echo "application/pdf" ;;
*) echo "application/octet-stream" ;;
esac
}
check_bucket() {
if ! rclone lsd "${REMOTE_NAME}:${BUCKET_NAME}" >/dev/null 2>&1; then
err "找不到 bucket '${BUCKET_NAME}'。請在 Cloudflare R2 建立並開啟 Public Access。"
exit 1
fi
}
transcode_to_mp3_160k() {
local input_path="$1" tmpdir="$2"
local base="$(basename "${input_path%.*}")"
local out="${tmpdir}/${base}-160k.mp3"
ffmpeg -y -i "$input_path" -vn -c:a libmp3lame -b:a 160k -ar 44100 -ac 2 "$out" </dev/null >/dev/null 2>&1
echo "$out"
}
pick_category() {
local default_cat=""
[[ -f "$LAST_CATEGORY_FILE" ]] && default_cat=$(<"$LAST_CATEGORY_FILE")
echo
echo "請選擇分類(將成為 bucket 下的子目錄)"
[[ -n "$default_cat" ]] && echo -e "上次使用分類:${GREEN}${default_cat}${NC}"
echo "1) audio 2) podcast 3) video 4) images 5) docs 6) custom"
read -rp "輸入數字或直接 Enter 使用上次分類: " choice
case "$choice" in
1) DEST_PREFIX="audio" ;;
2) DEST_PREFIX="podcast" ;;
3) DEST_PREFIX="video" ;;
4) DEST_PREFIX="images" ;;
5) DEST_PREFIX="docs" ;;
6) read -rp "輸入自訂頂層目錄(例如 worship 或 assets/audio): " custom_dir
DEST_PREFIX="${custom_dir%/}" ;;
"") DEST_PREFIX="${default_cat:-audio}" ;;
*) DEST_PREFIX="audio" ;;
esac
echo "$DEST_PREFIX" > "$LAST_CATEGORY_FILE"
}
# ===== 主程式 =====
check_bucket
CACHE_CONTROL="$CACHE_CONTROL_DEFAULT"
read -rp "是否 dry-run (Y/n): " yn
[[ "${yn,,}" == "n" ]] && DRY_RUN=0 || DRY_RUN=1
read -rp "Cache-Control(預設: ${CACHE_CONTROL_DEFAULT}): " cc
[[ -n "$cc" ]] && CACHE_CONTROL="$cc"
COMPRESS=0
if command -v ffmpeg >/dev/null 2>&1; then
read -rp "將非 MP3 音檔轉為 MP3 160 kbps 再上傳?(Y/n): " ync
[[ -z "${ync}" || "${ync,,}" == "y" ]] && COMPRESS=1
fi
pick_category
FILES=()
while :; do
echo
echo "貼上或拖曳檔案到此(可多個;以空白分隔):"
read -er -a FILES
[[ ${#FILES[@]} -gt 0 ]] && break
echo "⚠️ 沒有輸入任何檔案,請再試一次。"
done
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
URLS=()
for RAW in "${FILES[@]}"; do
FILE="$(normalize_path "$RAW")"
[[ ! -f "$FILE" ]] && { err "找不到檔案:$RAW"; continue; }
SRC="$FILE"
SRC_EXT="${SRC##*.}"; SRC_EXT="${SRC_EXT,,}"
ORIG_SIZE=$(filesize "$SRC"); ORIG_HS=$(human_size "$ORIG_SIZE")
NEED_TRANSCODE=0
[[ "$SRC_EXT" != "mp3" ]] && NEED_TRANSCODE=1
if [[ $COMPRESS -eq 1 && $NEED_TRANSCODE -eq 1 ]]; then
OUT="$(transcode_to_mp3_160k "$SRC" "$TMPDIR")"
MIME="audio/mpeg"
BASENAME="$(basename "$OUT")"
NEW_SIZE=$(filesize "$OUT"); NEW_HS=$(human_size "$NEW_SIZE")
echo -e "轉檔完成:${ORIG_HS} → ${NEW_HS}"
else
OUT="$SRC"; MIME="$(guess_mime "$OUT")"; BASENAME="$(basename "$OUT")"
fi
DEST_KEY="${DEST_PREFIX}/${BASENAME}"
echo
echo "== 上傳資訊 =="
echo "來源:$SRC"
echo "目的:${REMOTE_NAME}:${BUCKET_NAME}/${DEST_KEY}"
echo "模式:$([[ $DRY_RUN -eq 1 ]] && echo 'dry-run' || echo '實際上傳')"
if [[ $DRY_RUN -eq 1 ]]; then
warn "[dry-run] 將上傳:$BASENAME"
else
rclone mkdir "${REMOTE_NAME}:${BUCKET_NAME}/${DEST_PREFIX}" >/dev/null 2>&1 || true
rclone copyto "$OUT" "${REMOTE_NAME}:${BUCKET_NAME}/${DEST_KEY}" --progress \
--header-upload "Content-Type:${MIME}" \
--header-upload "Cache-Control:${CACHE_CONTROL}"
fi
ENCBASENAME="$(urlencode "$BASENAME")"
URLS+=("${CUSTOM_DOMAIN}/${DEST_PREFIX}/${ENCBASENAME}")
done
# ===== 總結 =====
if [[ ${#URLS[@]} -gt 0 ]]; then
echo
echo "====== 公開網址 ======"
printf '%s\n' "${URLS[@]}"
echo "======================"
echo
echo "====== Hugo Shortcodes ======"
SHORTCODES=""
for url in "${URLS[@]}"; do
filename="$(basename "${url%%\?*}")"
title="${filename%.*}"
title="${title%-160k}"
shortcode="{{< audio_cdn src=\"${url}\" title=\"${title}\" >}}"
echo "$shortcode"
SHORTCODES+="$shortcode"$'\n'
done
echo "=============================="
copy_to_clipboard "$SHORTCODES" || true
ok "所有 shortcode 已複製到剪貼簿,可直接貼入 Hugo 文章。"
fi
ok "全部完成。"
🎨 五、寫一支 Hugo Shortcode
把以下程式碼放在這目錄並存成.htmllayouts/shortcodes/audio_cdn.html
寫在Markdown裡的shortcode會讀取audio_cdn.html裡的程式碼,進而產生音檔播放器及相關設定。
{{/* 通用 CDN 音檔播放器(支持任何可直連 URL) */}}
{{- $src := .Get "src" -}}
{{- $title := .Get "title" -}}
{{- if not $src -}}
<p style="color:#b91c1c">audio_cdn:缺少 src。</p>
{{- else -}}
<div class="gdrive-audio-player" style="margin:1em 0;">
{{- if $title -}}
<div style="font-weight:600;margin-bottom:0.5em;">🎵 {{ $title }}</div>
{{- end -}}
<audio controls preload="none" style="width:100%;max-width:720px;">
<source src="{{ $src }}" type="audio/mpeg">
您的瀏覽器不支援音訊播放。
</audio>
</div>
{{- end -}}
💡 六、使用範例
在 Hugo 文章內加入:
{{< audio_cdn src="https://media.jujublog.idv.tw/audio/goldpickle-160k.mp3" title="黃金泡菜(浪漫版)" >}}
✅ 七、成了
- Cloudflare R2 上傳與 CDN 網域整合
- 自動壓縮與轉檔
- 自動產生 shortcode和音檔播放器
- 自動複製shortcode至剪貼簿
- Hugo 音樂播放器整合
上次修改於 2025-10-15