Last active
October 2, 2024 07:12
-
-
Save kxn4t/f9e56c284b32313974790cc60df0195c to your computer and use it in GitHub Desktop.
iwaSync(リストタブも対応)、YamaPlayerのプレイリスト内で削除された動画がないかチェックするEditor拡張
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using UnityEngine; | |
using UnityEditor; | |
using System.Collections.Generic; | |
using System.Net.Http; | |
using System.Net.Http.Headers; | |
using System.Threading.Tasks; | |
using System.Linq; | |
using System.Threading; | |
using System; | |
using System.Reflection; | |
using System.Text.RegularExpressions; | |
using Newtonsoft.Json.Linq; | |
namespace kxn4t.gist | |
{ | |
internal class YouTubeLinkChecker : EditorWindow, IDisposable | |
{ | |
class YouTubeLinkInfo | |
{ | |
public string Title { get; set; } | |
public string Url { get; set; } | |
} | |
private Vector2 scrollPosObjects; | |
private Vector2 scrollPosLinks; | |
private List<GameObject> gameObjects = new List<GameObject>(); | |
private List<YouTubeLinkInfo> brokenLinks = new List<YouTubeLinkInfo>(); | |
private bool isChecking = false; | |
private float progress = 0f; | |
private string progressMessage = ""; | |
private bool checkCompleted = false; | |
private DateTime lastRepaintTime; | |
private HttpClient httpClient; | |
private CancellationTokenSource cancellationTokenSource; | |
// 同時実行数の上限を設定 | |
private static readonly int MaxConcurrentRequests = 10; | |
// YouTube API 関連のフィールド | |
private bool useYouTubeAPI = false; | |
private string apiKey = ""; | |
[MenuItem("Tools/YouTube Link Checker")] | |
public static void ShowWindow() | |
{ | |
GetWindow<YouTubeLinkChecker>("YouTube Link Checker"); | |
} | |
private void OnEnable() | |
{ | |
httpClient = new HttpClient(); | |
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; UnityEditor)"); | |
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("ja")); | |
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US", 0.9)); | |
httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.8)); | |
} | |
private void OnDisable() | |
{ | |
// HttpClientの破棄 | |
Dispose(); | |
} | |
public void Dispose() | |
{ | |
httpClient?.Dispose(); | |
cancellationTokenSource?.Dispose(); | |
} | |
private void OnGUI() | |
{ | |
DrawHeader(); | |
DrawAPIOptions(); | |
DrawDragAndDropArea(); | |
DrawObjectList(); | |
DrawCheckButton(); | |
DrawProgress(); | |
DrawResults(); | |
} | |
private void DrawHeader() | |
{ | |
EditorGUILayout.LabelField("iwaSync(リストタブも対応), YamaPlayerのプレイリスト内で削除された動画をリストアップします。", EditorStyles.wordWrappedLabel); | |
} | |
private void DrawAPIOptions() | |
{ | |
EditorGUILayout.Space(); | |
useYouTubeAPI = EditorGUILayout.Toggle("YouTube APIを使用する", useYouTubeAPI); | |
if (useYouTubeAPI) | |
{ | |
EditorGUI.indentLevel++; | |
apiKey = EditorGUILayout.TextField("YouTube APIキー", apiKey); | |
EditorGUI.indentLevel--; | |
} | |
} | |
private void DrawDragAndDropArea() | |
{ | |
EditorGUILayout.Space(); | |
Rect dropArea = GUILayoutUtility.GetRect(0.0f, 50.0f, GUILayout.ExpandWidth(true)); | |
GUI.Box(dropArea, "ここにヒエラルキーからプレイリストをドラッグ&ドロップ"); | |
HandleDragAndDrop(dropArea); | |
} | |
private void DrawObjectList() | |
{ | |
GUILayout.Label("選択されたプレイリスト:"); | |
scrollPosObjects = EditorGUILayout.BeginScrollView(scrollPosObjects, GUILayout.Height(100)); | |
for (int i = 0; i < gameObjects.Count; i++) | |
{ | |
EditorGUILayout.BeginHorizontal(); | |
GUILayout.Label(gameObjects[i].name); | |
if (GUILayout.Button("-", GUILayout.Width(20))) | |
{ | |
gameObjects.RemoveAt(i); | |
i--; | |
lock (brokenLinks) | |
{ | |
brokenLinks.Clear(); | |
} | |
checkCompleted = false; | |
} | |
EditorGUILayout.EndHorizontal(); | |
} | |
EditorGUILayout.EndScrollView(); | |
} | |
private void DrawCheckButton() | |
{ | |
EditorGUILayout.BeginHorizontal(); | |
EditorGUI.BeginDisabledGroup(gameObjects.Count == 0 || isChecking); | |
if (GUILayout.Button("YouTubeリンクをチェック")) | |
{ | |
_ = CheckYouTubeLinksAsync(); | |
} | |
EditorGUI.EndDisabledGroup(); | |
// キャンセルボタン | |
EditorGUI.BeginDisabledGroup(!isChecking); | |
if (GUILayout.Button("キャンセル", GUILayout.Width(100))) | |
{ | |
cancellationTokenSource.Cancel(); | |
} | |
EditorGUI.EndDisabledGroup(); | |
EditorGUILayout.EndHorizontal(); | |
} | |
private void DrawProgress() | |
{ | |
if (isChecking) | |
{ | |
GUILayout.Space(10); | |
EditorGUILayout.LabelField("進行中...", EditorStyles.boldLabel); | |
Rect progressRect = GUILayoutUtility.GetRect(50, 20, GUILayout.ExpandWidth(true)); | |
EditorGUI.ProgressBar(progressRect, progress, progressMessage); | |
} | |
} | |
private void DrawResults() | |
{ | |
if (checkCompleted) | |
{ | |
List<YouTubeLinkInfo> localBrokenLinks; | |
lock (brokenLinks) | |
{ | |
localBrokenLinks = new List<YouTubeLinkInfo>(brokenLinks); | |
} | |
if (localBrokenLinks.Count > 0) | |
{ | |
GUILayout.Label("リンク切れの動画:", EditorStyles.boldLabel); | |
scrollPosLinks = EditorGUILayout.BeginScrollView(scrollPosLinks); | |
foreach (var linkInfo in localBrokenLinks) | |
{ | |
EditorGUILayout.BeginHorizontal(); | |
if (GUILayout.Button(linkInfo.Title, EditorStyles.boldLabel)) | |
{ | |
Application.OpenURL(linkInfo.Url); | |
} | |
if (GUILayout.Button(linkInfo.Url, EditorStyles.linkLabel)) | |
{ | |
Application.OpenURL(linkInfo.Url); | |
} | |
EditorGUILayout.EndHorizontal(); | |
} | |
EditorGUILayout.EndScrollView(); | |
} | |
else | |
{ | |
GUILayout.Label("リンク切れのURLは見つかりませんでした。", EditorStyles.boldLabel); | |
} | |
} | |
} | |
private void HandleDragAndDrop(Rect dropArea) | |
{ | |
Event evt = Event.current; | |
// ドロップエリア内でない場合は何もしない | |
if (!dropArea.Contains(evt.mousePosition)) | |
return; | |
// イベントタイプに応じて処理を分岐 | |
switch (evt.type) | |
{ | |
case EventType.DragUpdated: | |
case EventType.DragPerform: | |
DragAndDrop.visualMode = DragAndDropVisualMode.Copy; | |
if (evt.type == EventType.DragPerform) | |
{ | |
DragAndDrop.AcceptDrag(); | |
AddGameObjects(DragAndDrop.objectReferences); | |
evt.Use(); | |
} | |
break; | |
} | |
} | |
private void AddGameObjects(UnityEngine.Object[] objects) | |
{ | |
foreach (var obj in objects) | |
{ | |
if (obj is GameObject go && !gameObjects.Contains(go)) | |
{ | |
gameObjects.Add(go); | |
lock (brokenLinks) | |
{ | |
brokenLinks.Clear(); | |
} | |
checkCompleted = false; | |
} | |
} | |
} | |
private async Task CheckYouTubeLinksAsync() | |
{ | |
if (useYouTubeAPI && string.IsNullOrWhiteSpace(apiKey)) | |
{ | |
EditorUtility.DisplayDialog("エラー", "YouTube APIキーを入力してください。", "OK"); | |
return; | |
} | |
isChecking = true; | |
lock (brokenLinks) | |
{ | |
brokenLinks.Clear(); | |
} | |
progress = 0f; | |
progressMessage = "リンクを収集しています..."; | |
checkCompleted = false; | |
lastRepaintTime = DateTime.Now; | |
Repaint(); | |
cancellationTokenSource = new CancellationTokenSource(); | |
CancellationToken cancellationToken = cancellationTokenSource.Token; | |
List<YouTubeLinkInfo> allYouTubeLinks = new List<YouTubeLinkInfo>(); | |
try | |
{ | |
foreach (var go in gameObjects) | |
{ | |
List<YouTubeLinkInfo> youtubeLinks = ExtractYouTubeLinksFromGameObject(go); | |
allYouTubeLinks.AddRange(youtubeLinks); | |
} | |
int totalLinks = allYouTubeLinks.Count; | |
int checkedLinks = 0; | |
if (totalLinks == 0) | |
{ | |
isChecking = false; | |
progress = 0f; | |
progressMessage = ""; | |
checkCompleted = true; | |
Repaint(); | |
EditorUtility.DisplayDialog("結果", "YouTubeリンクが見つかりませんでした。", "OK"); | |
return; | |
} | |
using (SemaphoreSlim semaphore = new SemaphoreSlim(MaxConcurrentRequests)) | |
{ | |
var tasks = allYouTubeLinks.Select(async linkInfo => | |
{ | |
await semaphore.WaitAsync(cancellationToken); | |
try | |
{ | |
// 0.5~3秒のランダムな遅延を追加 | |
float delaySeconds = UnityEngine.Random.Range(0.5f, 3.0f); | |
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken); | |
bool isBroken = false; | |
if (useYouTubeAPI) | |
{ | |
isBroken = await IsYouTubeLinkBrokenWithAPI(linkInfo.Url, cancellationToken); | |
} | |
else | |
{ | |
isBroken = await IsYouTubeLinkBroken(linkInfo.Url, cancellationToken); | |
} | |
if (isBroken) | |
{ | |
lock (brokenLinks) | |
{ | |
brokenLinks.Add(linkInfo); | |
} | |
} | |
} | |
catch (OperationCanceledException) | |
{ | |
// キャンセルされた場合は何もしない | |
} | |
catch (Exception ex) | |
{ | |
Debug.LogError($"エラーが発生しました ({linkInfo.Url}): {ex.Message}"); | |
} | |
finally | |
{ | |
semaphore.Release(); | |
} | |
Interlocked.Increment(ref checkedLinks); | |
progress = (float)checkedLinks / totalLinks; | |
progressMessage = $"チェック中: {checkedLinks}/{totalLinks}"; | |
// Repaintの頻度を制限 | |
if ((DateTime.Now - lastRepaintTime).TotalSeconds > 0.1) | |
{ | |
EditorApplication.delayCall += () => Repaint(); | |
lastRepaintTime = DateTime.Now; | |
} | |
}).ToList(); | |
await Task.WhenAll(tasks); | |
} | |
} | |
catch (OperationCanceledException) | |
{ | |
Debug.Log("チェックがキャンセルされました。"); | |
} | |
catch (Exception e) | |
{ | |
Debug.LogError($"エラーが発生しました: {e.Message}"); | |
} | |
finally | |
{ | |
isChecking = false; | |
progress = 0f; | |
progressMessage = ""; | |
checkCompleted = true; | |
cancellationTokenSource.Dispose(); | |
cancellationTokenSource = null; | |
Repaint(); | |
} | |
if (brokenLinks.Count == 0 && !cancellationToken.IsCancellationRequested) | |
{ | |
EditorUtility.DisplayDialog("結果", "リンク切れのURLは見つかりませんでした。", "OK"); | |
} | |
} | |
private List<YouTubeLinkInfo> ExtractYouTubeLinksFromGameObject(GameObject go) | |
{ | |
var youtubeLinks = new List<YouTubeLinkInfo>(); | |
foreach (MonoBehaviour script in go.GetComponents<MonoBehaviour>()) | |
{ | |
switch (script.GetType().ToString()) | |
{ | |
case "Yamadev.YamaStream.Script.PlayList": | |
youtubeLinks.AddRange(ReadPlaylistFromYamaPlayer(script)); | |
break; | |
case "HoshinoLabs.IwaSync3.Playlist": | |
youtubeLinks.AddRange(ReadPlaylistFromIwaSync3(script)); | |
break; | |
case "HoshinoLabs.IwaSync3.ListTab": | |
youtubeLinks.AddRange(ReadPlaylistsFromIwaSync3(script)); | |
break; | |
} | |
} | |
return youtubeLinks; | |
} | |
private List<YouTubeLinkInfo> ReadPlaylistFromYamaPlayer(object playlist) | |
{ | |
var results = new List<YouTubeLinkInfo>(); | |
try | |
{ | |
var tracksField = playlist.GetType().GetField("tracks", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); | |
if (tracksField != null) | |
{ | |
var tracks = tracksField.GetValue(playlist) as IEnumerable<object>; | |
if (tracks != null) | |
{ | |
foreach (var track in tracks) | |
{ | |
var titleField = track.GetType().GetField("Title"); | |
var urlField = track.GetType().GetField("Url"); | |
if (titleField != null && urlField != null) | |
{ | |
string title = titleField.GetValue(track) as string; | |
string url = urlField.GetValue(track) as string; | |
if (IsYouTubeUrl(url)) | |
{ | |
results.Add(new YouTubeLinkInfo { Title = title, Url = url }); | |
} | |
} | |
} | |
} | |
} | |
} | |
catch (Exception ex) | |
{ | |
Debug.LogError($"YamaPlayerのプレイリストの読み込みに失敗しました: {ex.Message}"); | |
} | |
return results; | |
} | |
private List<YouTubeLinkInfo> ReadPlaylistFromIwaSync3(object playlist) | |
{ | |
var results = new List<YouTubeLinkInfo>(); | |
try | |
{ | |
var tracksField = playlist.GetType().GetField("tracks", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); | |
if (tracksField != null) | |
{ | |
var tracks = tracksField.GetValue(playlist) as IEnumerable<object>; | |
if (tracks != null) | |
{ | |
foreach (var track in tracks) | |
{ | |
var titleField = track.GetType().GetField("title"); | |
var urlField = track.GetType().GetField("url"); | |
if (titleField != null && urlField != null) | |
{ | |
string title = titleField.GetValue(track) as string; | |
string url = urlField.GetValue(track) as string; | |
if (IsYouTubeUrl(url)) | |
{ | |
results.Add(new YouTubeLinkInfo { Title = title, Url = url }); | |
} | |
} | |
} | |
} | |
} | |
} | |
catch (Exception ex) | |
{ | |
Debug.LogError($"IwaSync3のプレイリストの読み込みに失敗しました: {ex.Message}"); | |
} | |
return results; | |
} | |
private List<YouTubeLinkInfo> ReadPlaylistsFromIwaSync3(object listTab, HashSet<object> processedTabs = null) | |
{ | |
var results = new List<YouTubeLinkInfo>(); | |
if (processedTabs == null) | |
processedTabs = new HashSet<object>(); | |
if (processedTabs.Contains(listTab)) | |
return results; | |
processedTabs.Add(listTab); | |
try | |
{ | |
var tabsField = listTab.GetType().GetField("tabs", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); | |
if (tabsField != null) | |
{ | |
var tabs = tabsField.GetValue(listTab) as IEnumerable<object>; | |
if (tabs != null) | |
{ | |
foreach (var tab in tabs) | |
{ | |
var listField = tab.GetType().GetField("list", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); | |
if (listField != null) | |
{ | |
var list = listField.GetValue(tab); | |
var listType = list.GetType().ToString(); | |
if (listType == "HoshinoLabs.IwaSync3.Playlist") | |
{ | |
results.AddRange(ReadPlaylistFromIwaSync3(list)); | |
} | |
else if (listType == "HoshinoLabs.IwaSync3.ListTab") | |
{ | |
results.AddRange(ReadPlaylistsFromIwaSync3(list, processedTabs)); | |
} | |
} | |
} | |
} | |
} | |
} | |
catch (Exception ex) | |
{ | |
Debug.LogError($"IwaSync3のリストタブの読み込みに失敗しました: {ex.Message}"); | |
} | |
return results; | |
} | |
private bool IsYouTubeUrl(string url) | |
{ | |
if (string.IsNullOrEmpty(url)) | |
return false; | |
string pattern = @"^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|embed\/|shorts\/)|youtu\.be\/)[\w\-]+(&[\w=&]+)*$"; | |
return Regex.IsMatch(url, pattern); | |
} | |
private async Task<bool> IsYouTubeLinkBroken(string url, CancellationToken cancellationToken) | |
{ | |
int maxRetries = 2; | |
for (int attempt = 1; attempt <= maxRetries; attempt++) | |
{ | |
try | |
{ | |
HttpResponseMessage response = await httpClient.GetAsync(url, cancellationToken); | |
int statusCode = (int)response.StatusCode; | |
if (!response.IsSuccessStatusCode) | |
{ | |
Debug.LogWarning($"リンクが無効です (ステータスコード: {statusCode}): {url}"); | |
return true; | |
} | |
string content = await response.Content.ReadAsStringAsync(); | |
if (content.Contains("Video unavailable") || content.Contains("動画は利用できません") || | |
content.Contains("動画は削除されました") || content.Contains("This video has been removed") || | |
content.Contains("動画が削除されました") || content.Contains("動画を再生できません")) | |
{ | |
Debug.LogWarning($"動画が利用できません: {url}"); | |
return true; | |
} | |
return false; | |
} | |
catch (OperationCanceledException) | |
{ | |
// キャンセルされた場合は例外を再スロー | |
throw; | |
} | |
catch (HttpRequestException e) | |
{ | |
if (attempt == maxRetries) | |
{ | |
Debug.LogError($"HTTPリクエストエラー ({url}): {e.Message}"); | |
return true; | |
} | |
else | |
{ | |
await Task.Delay(1000, cancellationToken); // 1秒待機してリトライ | |
} | |
} | |
catch (Exception e) | |
{ | |
Debug.LogError($"予期せぬエラーが発生しました ({url}): {e.Message}"); | |
return true; | |
} | |
} | |
return true; | |
} | |
private async Task<bool> IsYouTubeLinkBrokenWithAPI(string url, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
string videoId = ExtractVideoId(url); | |
if (string.IsNullOrEmpty(videoId)) | |
{ | |
Debug.LogWarning($"無効なYouTube URLです: {url}"); | |
return true; | |
} | |
var videoInfo = await GetYouTubeVideoStatusAsync(videoId, cancellationToken); | |
if (videoInfo == null) | |
{ | |
Debug.LogWarning($"動画が見つかりません: {url}"); | |
return true; | |
} | |
if (!IsVideoAvailable(videoInfo, url)) | |
{ | |
return true; | |
} | |
return false; | |
} | |
catch (OperationCanceledException) | |
{ | |
// キャンセルされた場合は例外を再スロー | |
throw; | |
} | |
catch (Exception e) | |
{ | |
Debug.LogError($"YouTube APIのリクエスト中にエラーが発生しました ({url}): {e.Message}"); | |
return true; | |
} | |
} | |
private string ExtractVideoId(string url) | |
{ | |
var regex = new Regex(@"(?:youtu\.be\/|youtube\.com\/(?:watch\?(?:.*&)?v=|embed\/|v\/|shorts\/))([a-zA-Z0-9_-]{11})"); | |
var match = regex.Match(url); | |
if (match.Success && match.Groups.Count > 1) | |
{ | |
return match.Groups[1].Value; | |
} | |
return null; | |
} | |
private async Task<JObject> GetYouTubeVideoStatusAsync(string videoId, CancellationToken cancellationToken) | |
{ | |
string requestUrl = $"https://www.googleapis.com/youtube/v3/videos?part=contentDetails,status&id={videoId}&key={apiKey}"; | |
HttpResponseMessage response = await httpClient.GetAsync(requestUrl, cancellationToken); | |
if (!response.IsSuccessStatusCode) | |
{ | |
Debug.LogError($"YouTube APIのリクエストに失敗しました (ステータスコード: {(int)response.StatusCode}): {response.ReasonPhrase}"); | |
return null; | |
} | |
string content = await response.Content.ReadAsStringAsync(); | |
var json = JObject.Parse(content); | |
var items = json["items"] as JArray; | |
if (items == null || items.Count == 0) | |
{ | |
return null; | |
} | |
return items[0] as JObject; | |
} | |
private bool IsVideoAvailable(JObject videoInfo, string url) | |
{ | |
var status = videoInfo["status"]; | |
var contentDetails = videoInfo["contentDetails"]; | |
string uploadStatus = status.Value<string>("uploadStatus"); | |
if (uploadStatus != "processed") | |
{ | |
Debug.LogWarning($"動画が利用できません (uploadStatus: {uploadStatus}): {url}"); | |
return false; | |
} | |
string rejectionReason = status.Value<string>("rejectionReason"); | |
if (!string.IsNullOrEmpty(rejectionReason)) | |
{ | |
Debug.LogWarning($"動画のアップロードが拒否されています (rejectionReason: {rejectionReason}): {url}"); | |
return false; | |
} | |
string failureReason = status.Value<string>("failureReason"); | |
if (!string.IsNullOrEmpty(failureReason)) | |
{ | |
Debug.LogWarning($"動画のアップロードに失敗しています (failureReason: {failureReason}): {url}"); | |
return false; | |
} | |
string privacyStatus = status.Value<string>("privacyStatus"); | |
if (privacyStatus == "private") | |
{ | |
Debug.LogWarning($"動画は非公開です (privacyStatus: {privacyStatus}): {url}"); | |
return false; | |
} | |
var regionRestriction = contentDetails["regionRestriction"]; | |
if (regionRestriction != null) | |
{ | |
var blockedRegions = regionRestriction["blocked"] as JArray; | |
if (blockedRegions != null && blockedRegions.Any(region => region.ToString() == "JP")) | |
{ | |
Debug.LogWarning($"この動画は日本では視聴できません: {url}"); | |
return false; | |
} | |
} | |
return true; | |
} | |
} | |
} |
YouTubeの判定が適当なので要修正
Youtube APIに対応。APIの方が多分正確。
誰かyt-dlpでやれるようにしてほしい…
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
ヒエラルキー上からチェックしたいプレイリストをドラッグアンドドロップしてチェック。
【対応プレイヤー】
・YamaPlayer
・iwaSync3(リストタブも対応)