# [メタ情報] # 識別子: bunny更新パイプライン_exe # 補足: # [/メタ情報] 要約: このPythonスクリプトは、GoogleスプレッドシートとBunny CDN(動画ストリーム・ストレージ)の自動連携ツールです。環境変数を読み込み、サービスアカウントでスプレッドシートにアクセスします。メイン処理では、スプレッドシートのマスターON/OFFスイッチを確認し、有効な場合のみ実行。シート上の各行を巡回し、「処理待ち」の項目を検出すると、ローカルの動画や音楽ファイルをBunny CDNにアップロード。アップロード後は、Bunny IDやURLなどの情報をスプレッドシートに書き戻し、ステータスを「完了」に更新します。古いBunny上のファイルは削除します。また、「削除待ち」の項目に対しては、Bunny CDNからファイルを完全に削除し、スプレッドシートの関連情報をクリア(一部維持)して「未登録」に更新します。スプレッドシート更新はリトライとエラーログを備えた安全な関数で行われます。 付属のplistファイルは、macOSのLaunchAgents設定で、このPythonスクリプトを自動実行するために使用されます。ユーザーログイン時にロードされ、60秒ごとにスクリプトを繰り返し実行します。標準出力とエラー出力は指定されたログファイルにリダイレクトされます。この設定により、Bunny CDNへのコンテンツ管理とスプレッドシートでの情報管理がmacOS上で定期的に自動同期されるシステムが構築されています。 他ファイルに存在する関係するコード類: マイライブラリ_生成更新処理.txtに存在する、 mediaLibrary.gs AppSheetの設定: Actions Bunnyファイル生成更新 sheet1 bn低画質動画単独生成="TRUE" bnステータス="処理待ち" Behavior Only if this condition is true IN([拡張子], {"mp4", "mov", "m4v", "avi", "wmv", "MP4", "MOV"}) /Users/XXXXXX/python_scripts/bunny_upd/f1_bunny_factory.py ``` # -*- coding: utf-8 -*- import os import requests import gspread import csv import random import string import json import sys import urllib.parse import unicodedata import time from datetime import datetime from dotenv import load_dotenv from oauth2client.service_account import ServiceAccountCredentials # 1. 環境設定の読み込み load_dotenv() BUNNY_API_KEY = os.getenv("BUNNY_API_KEY") BUNNY_LIBRARY_ID = os.getenv("BUNNY_LIBRARY_ID") BUNNY_PULL_ZONE_NAME = os.getenv("BUNNY_PULL_ZONE_NAME") BUNNY_STORAGE_ZONE_NAME = os.getenv("BUNNY_STORAGE_ZONE_NAME") BUNNY_STORAGE_API_KEY = os.getenv("BUNNY_STORAGE_API_KEY") SHEET_ID = "" JSON_KEY_FILE = "/Users/XXXXXX/scripts/credentials/vimeo-worker-61651aba0e02.json" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE_PATH = os.path.join(SCRIPT_DIR, "bunny_deletion_history.csv") CACHE_FILE = os.path.join(SCRIPT_DIR, "bunny_mtime_cache.json") LOCAL_ERROR_LOG = os.path.join(SCRIPT_DIR, "fatal_sheet_errors.log") def get_spreadsheet(): scope = ['https://spreadsheets.google.com/feeds', 'https://www.googleapis.com/auth/drive'] creds = ServiceAccountCredentials.from_json_keyfile_name(JSON_KEY_FILE, scope) client = gspread.authorize(creds) return client.open_by_key(SHEET_ID) # --------------------------------------------------------- # ▼ 安全な書き込み(Y列を廃止し、K列のパイプラインを管理) # --------------------------------------------------------- def safe_sheet_update(row_index, updates=None, final_w_status=None, is_error=False, error_msg=""): for attempt in range(3): try: spreadsheet = get_spreadsheet() sheet = spreadsheet.sheet1 if not is_error: # O列〜X列(15〜24列目)にデータを書き戻す # ※gspreadの仕様変更によるエラーを防ぐため、範囲と値を位置引数で指定します sheet.update(f"O{row_index}:X{row_index}", [updates]) # W列(23列目)を最終ステータスに更新 sheet.update_cell(row_index, 23, final_w_status) # 【重要】処理完了後、K列(11列目)を「FALSE」にしてパイプラインを空ける sheet.update_cell(row_index, 11, "FALSE") else: # エラー時はW列にエラーを書き込み、K列はそのまま残す # スプレッドシートの書き込みエラーを防ぐため文字数を制限 safe_error_msg = f"エラー: {error_msg}"[:100] sheet.update_cell(row_index, 23, safe_error_msg) return except Exception as e: if attempt == 2: with open(LOCAL_ERROR_LOG, mode='a', encoding='utf-8') as f: f.write(f"[{datetime.now()}] Row:{row_index} - Google Update Failed. Error: {str(e)} | Data: {updates}\n") time.sleep(5) # --------------------------------------------------------- # ▼ Bunny API 関数(動画・Stream用) # --------------------------------------------------------- def upload_to_bunny(file_path, video_title): url_create = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos" headers = {"accept": "application/json", "content-type": "application/json", "AccessKey": BUNNY_API_KEY} for attempt in range(3): try: response = requests.post(url_create, json={"title": video_title}, headers=headers, timeout=30) if response.status_code != 200: raise Exception(f"Video Create Error: {response.text}") video_id = response.json().get("guid") url_upload = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos/{video_id}" with open(file_path, 'rb') as f: upload_res = requests.put(url_upload, data=f, headers={"AccessKey": BUNNY_API_KEY}, timeout=(30, 3600)) if upload_res.status_code != 200: raise Exception(f"Video Upload Error: {upload_res.text}") return video_id except Exception as e: if attempt == 2: raise Exception(f"Bunnyへの動画送信に3回失敗しました: {str(e)}") time.sleep(10) def delete_old_video(old_id, movid): clean_id = str(old_id).strip() if not clean_id or len(clean_id) < 10: return "Skip" url = f"https://video.bunnycdn.com/library/{BUNNY_LIBRARY_ID}/videos/{clean_id}" headers = {"AccessKey": BUNNY_API_KEY} try: res = requests.delete(url, headers=headers, timeout=30) status = "Success" if (res.status_code == 200 or res.status_code == 404) else f"Error({res.status_code})" except Exception as e: status = f"FatalError({str(e)})" file_exists = os.path.isfile(LOG_FILE_PATH) with open(LOG_FILE_PATH, mode='a', encoding='utf-8', newline='') as f: writer = csv.writer(f) if not file_exists: writer.writerow(["Time", "動画id", "Old_ID", "Status"]) writer.writerow([datetime.now().strftime("%Y-%m-%d %H:%M:%S"), movid, clean_id, status]) return status # --------------------------------------------------------- # ▼ Bunny API 関数(音楽 Storage用) # --------------------------------------------------------- def upload_to_bunny_storage(file_path, file_name, target_folder): nfc_name = unicodedata.normalize('NFC', file_name) safe_name = urllib.parse.quote(nfc_name) url_upload = f"https://sg.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE_NAME}/{target_folder}/{safe_name}" headers = {"AccessKey": BUNNY_STORAGE_API_KEY, "Content-Type": "application/octet-stream"} for attempt in range(3): try: with open(file_path, 'rb') as f: response = requests.put(url_upload, data=f, headers=headers, timeout=(30, 3600)) if response.status_code != 201: raise Exception(f"Storage Upload Error: {response.text}") return safe_name except Exception as e: if attempt == 2: raise Exception(f"Bunny({target_folder})への送信に3回失敗しました: {str(e)}") time.sleep(10) def delete_from_bunny_storage(old_file_name, movid, target_folder): clean_name = str(old_file_name).strip() if not clean_name: return "Skip" nfc_name = unicodedata.normalize('NFC', clean_name) safe_old_name = urllib.parse.quote(nfc_name) url_delete = f"https://sg.storage.bunnycdn.com/{BUNNY_STORAGE_ZONE_NAME}/{target_folder}/{safe_old_name}" headers = {"AccessKey": BUNNY_STORAGE_API_KEY} try: res = requests.delete(url_delete, headers=headers, timeout=30) status = "Success" if (res.status_code == 200 or res.status_code == 404) else f"Error({res.status_code})" except Exception as e: status = f"FatalError({str(e)})" file_exists = os.path.isfile(LOG_FILE_PATH) with open(LOG_FILE_PATH, mode='a', encoding='utf-8', newline='') as f: writer = csv.writer(f) if not file_exists: writer.writerow(["Time", "動画id", "Old_ID", "Status"]) writer.writerow([datetime.now().strftime("%Y-%m-%d %H:%M:%S"), movid, clean_name, f"Storage({target_folder}): {status}"]) return status # --------------------------------------------------------- # 補助関数・メイン処理 # --------------------------------------------------------- def generate_proper_wpidex(extn): chars = string.ascii_letters + string.digits random_id = ''.join(random.choice(chars) for _ in range(8)) return f"{random_id}.{extn.strip().lstrip('.')}" def main(): spreadsheet = get_spreadsheet() try: settings_sheet = spreadsheet.worksheet("F_システム設定") settings_records = settings_sheet.get_all_records() master_switch = 'OFF' for row in settings_records: if row.get('設定項目名') == 'Bunny自動更新マスター': master_switch = row.get('ステータス', 'OFF') break except: sys.exit() if master_switch != 'ON': sys.exit() sheet = spreadsheet.sheet1 records = sheet.get_all_values() COL_B, COL_C, COL_F, COL_G, COL_H = 1, 2, 5, 6, 7 COL_O, COL_P, COL_Q, COL_R = 14, 15, 16, 17 COL_S, COL_W = 18, 22 audio_exts = ['mp3', 'aac', 'wav', 'm4a', 'flac', 'm4r'] for i, row in enumerate(records[1:], start=2): if len(row) <= COL_W: continue # O列(Index 14)とW列(Index 22)の状態を確認 col_o_val = row[COL_O].strip().upper() if len(row) > COL_O else "" status = row[COL_W].strip() # O列が TRUE かつ W列が 処理待ち の場合のみ実行 if col_o_val == "TRUE" and status == "処理待ち": try: # 最初にW列を「処理中」にする sheet.update_cell(i, COL_W + 1, "処理中") except Exception as e: with open(LOCAL_ERROR_LOG, mode='a', encoding='utf-8') as f: f.write(f"[{datetime.now()}] Row:{i} - Failed to set '処理中'. Error: {str(e)}\n") continue # --- メイン処理 --- try: if not os.path.exists(row[COL_F]): raise Exception(f"ファイルが見つかりません: {row[COL_F]}") extn = str(row[COL_H]).lower().strip() low_wpidex = row[COL_S] or generate_proper_wpidex(row[COL_H]) wp_url = f"https://XXXXXX.com/rd.php?id={low_wpidex}" now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") if extn in audio_exts: safe_file_name = upload_to_bunny_storage(row[COL_F], row[COL_G], "music") high_url = f"https://XXXXXX-music.b-cdn.net/music/{safe_file_name}" updates = ["FALSE", row[COL_G], "", "", low_wpidex, high_url, high_url, wp_url, "完了", now_str] safe_sheet_update(row_index=i, updates=updates, final_w_status="完了") if row[COL_P] and row[COL_P] != row[COL_G]: delete_from_bunny_storage(row[COL_P], row[COL_C] or row[COL_B], "music") else: new_id = upload_to_bunny(row[COL_F], row[COL_G]) high_p, low_p = row[COL_Q] or "1080", row[COL_R] or "360" updates = [ "FALSE", new_id, high_p, low_p, low_wpidex, f"https://{BUNNY_PULL_ZONE_NAME}/{new_id}/playlist.m3u8", f"https://{BUNNY_PULL_ZONE_NAME}/{new_id}/play_{low_p}p.mp4", wp_url, "完了", now_str ] safe_sheet_update(row_index=i, updates=updates, final_w_status="完了") if row[COL_P] and row[COL_P] != new_id: delete_old_video(row[COL_P], row[COL_C] or row[COL_B]) except Exception as e: # 処理中にエラーが発生した場合は確実にW列にエラーを書き込む safe_sheet_update(row_index=i, is_error=True, error_msg=str(e)) # ▼▼ ここから新しく追加する「削除処理」 ▼▼ elif status == "削除待ち": try: # 最初にW列を「処理中」にする sheet.update_cell(i, COL_W + 1, "処理中") except Exception as e: with open(LOCAL_ERROR_LOG, mode='a', encoding='utf-8') as f: f.write(f"[{datetime.now()}] Row:{i} - Failed to set '処理中' for Deletion. Error: {str(e)}\n") continue try: extn = str(row[COL_H]).lower().strip() now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 1. Bunnyサーバーから実体ファイルを完全に削除 if row[COL_P]: if extn in audio_exts: delete_from_bunny_storage(row[COL_P], row[COL_C] or row[COL_B], "music") else: delete_old_video(row[COL_P], row[COL_C] or row[COL_B]) # 2. S列(低画質wpidex)とV列(WPURL)は「維持」し、他のBunny関連項目をクリアする updates = [ "FALSE", "", "", "", row[COL_S], # S列:リンク切れを防ぐため維持 "", "", row[21], # V列:WPURLも維持 (★0始まりで21) "未登録", now_str ] safe_sheet_update(row_index=i, updates=updates, final_w_status="未登録") except Exception as e: safe_sheet_update(row_index=i, is_error=True, error_msg=str(e)) # ▲▲ 追加はここまで ▲▲ if __name__ == "__main__": main() ``` /Users/XXXXXX/Library/LaunchAgents/com.XXXXXX.bunny_factory.plist ``` Label com.XXXXXX.bunny_factory ProgramArguments /usr/bin/python3 /Users/XXXXXX/python_scripts/bunny_upd/f1_bunny_factory.py RunAtLoad StartInterval 60 StandardOutPath /Users/XXXXXX/Library/Logs/bunny_factory.log StandardErrorPath /Users/XXXXXX/Library/Logs/bunny_factory_err.log ```