Tweet image download agent.

【追記】2017/09/19 現在「いいね」画像のTL取得に失敗しているみたい。原因究明中(デバッグ中)使用しているアカウントがロックされたので、ちょっとお手上げ。ログインするととたんにロックされるのでビルド済みのファイルは公開中止。アカウントを永久凍結されると困るので、これにて終了(^^;; チキンハートw

ちょうどGUIをWinFomsからWPFに移行中でそろそろ完了しつつあったのでちょい残念。でもまぁ、WPFのバインディングやら非同期やら勉強できたので、いい教材だったかなと。


【追記】 2017/08/27 二段階認証に対応。画面をちょっと変更。ファイル拡張子の判別がちゃんと機能してなかった・・・他色々修正しまくり。


【追記】 2017/08/20 set-cookieヘッダの取得方法を若干変更。 現在2段階認証には対応しておりません。今度対応させます。


【追記】 2017/08/19 ログイン後の画像取得が失敗する不具合を修正。


【追記】2017/08/18 ↓の原因をほぼ特定。暫定的に修正したものを更新予定。


【追記】2017/08/17 ログインのプロセスが変更になった関係で、現在、ログインが成功しても画像の取得に失敗します。ログイン自体が失敗している模様です。また調査次第修正できるようであれば、修正します m(__)m


【追記】2017/07/09 取得するファイルタイプ(拡張子)、ファイルサイズの上下限を設定できるようにした。コードのリファクタリング実施。


【追記】2016/09/06 テキストボックスのところをコンボボックスに張り替え。新しいものから10個まで入力履歴を記憶できるように修正。他、諸々修正。


【追記】2016/04/27 GW前に、ちまちま修正他、検索ワードに対応。これで画像収集が捗るわい(^^;


ちょっと前に書いたツイッターの画像を一括ダウンロードするやつ、のGUI版を 非同期タスクのお勉強も兼ねて日曜プログラミング(^^;

コンソールでコマンドを打つ方が慣れているのでわざわざGUIみたいなクソめんどくさいもん書く必要もなかったんですが・・・。

とくに最近、C#でアシンクやらアウェイトやらウェイタブルとか今まで見たこともないキーワードがガンガン出てくるようなコードを見る機会が多くなったので、勉強がてら練習で作ってみました。・・・・というレベルです(m_m)
とはいいつつ、awaitとかasyncとかまだよく分からないので、Task.Run()をふんだんにちりばめてみました(^_^;;; ま、自分で使うツールだから動けばいいや。
検索とかはまだ実装していません、というか、自分には必要ないので、やるつもり無し。時間があればまたブラウザで解析して・・・時間があれば・・・。
そもそもこの手のツールは他にもっと便利なソフトが窓の杜とかVectorとか探せばあると思うので。。。
動画とかも落とせればいいかなー、とか思って昼休みにchromeの開発者ツールでちょっと調べてみたけど、結構めんどくさそうですね・・・。

twitterimageagent-1 twitterAgent2

Twitter APIじゃないので、ブラウザでの表示方法が変更されると誤動作するでしょう。

ビルド済みのファイルとあまり綺麗では無いソースファイルは、OSDN.JPの作業部屋 ↓↓↓から。(使い方よく分かってません・・・)
https://osdn.jp/users/earlgreyx/pf/TwitterImageDownloadAgent/wiki/FrontPage

twitterの「いいね」画像をダウンロード C#版

2017年7月15日
こっちに移動 → https://ptsv.jp/2016/04/20/tweet-image-download-agent/

2016年4月29日 Windows フォームを利用したGUI版追加
検索ワード対応とスレッドプールにダウンロード処理を投げるように、ソース本体も改編した版

2015年11月27日・29日 コード修正・他
コード修正 画像ファイルの正規表現の間違い修正及びcommandを追加。
コード修正 URL を favorites => likes に変更。文章をちょろっと変更。

2015年11月23日 コード修正
2回目以降のtimeline URLの取得方法が間違ってました。ごめんなさい。(m_m)


perl で組んだものを C# に書き直したコードです。perlで組んだもので僕がやりたかったことは全部できた訳ですが・・・ま、今仕事暇なんで(^^;

エラーチェックとか全然してません。このコードは参考テスト程度のものなので、あしからず。
間違ったtargetとか入力してサーバーが404エラー返すと例外吐いて死にます(^_^;; エラー処理をコードに盛り込むとコードの要点が見えずらくなるので・・・いないとは思いますが、コードを使う時は、エラー処理(例外捕捉とか)を入れてね。

また、Twitter API使った方法ではないので、twitter側がブラウザ表示の方法を変更してしまうと、たぶんエラーで落ちると思う。まぁ、いつまでこの方法で使えんのかな、という感じです。

コンソールプログラムで良ければ、コンパイルしたものを置いときます。[ getTweetImage.exe ]
※使用方法は下記コードのコメントを参照してください。
(EXEファイルをダウンロードすると、たぶんセキュリティーチェックで引っかかると思いますが、信用できない方は、下記コードをご自身でコンパイルしてください。 )

たぶん Windows以外のOSでも、monoが入っていたら 上記 exe ファイルがそのまま動くんじゃないかなー。
[追記]
CentOS6 + mono で実行してみましたが、https通信でセキュリティ?かなんかの例外が発生してしまいました。
デフォルトでインストールしたまんまのmonoの場合はルート証明書をインポートする等しないといけないみたいですね。mozroots.exeをインストールして解決しました。下記ページを参考にさせていただきましたm(__)m。
~Monoで、Secure Socket Layer (SSL) / Transport Layer Security (TLS)通信を行うには~
http://qiita.com/takanemu/items/129acc13d8b7ce088b92

[追記ここまで]

これにて終了。

/*******************************************************************************

  saving image file on twitter.(getTweetImage.cs)

   build: Visual Studio 2015 Community.( also Express Edition) or Windows SDK
     IDE使うほどでもないので、コマンドラインツールでcsc.exeでバイトコンパイルしてください。
     >> csc.exe getTweetImage.cs

  howto: Console command
     >> getTweetImage.exe command target [username password]
     
     command  : ターゲットが"いいね"した画像 => 'favo' 
                ターゲットがアップした画像   => 'profile'
                ターゲットのTLの画像        => 'timeline'
     target   : ターゲットとなる取得したい画像のアカウント名 
     username : 自分のログインアカウント
     password : 自分のパスワード

     usernameとpasswordは、「いいね」画像の取得及び、
     ターゲットとなるアカウントが非公開アカの場合に必要かと思います。

*******************************************************************************/
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using System.IO;
using System.Net;
using System.Linq;

namespace ptsv.jp
{
  public class ConsoleApplication1
    {
      private static Dictionary<string,string> TIMELINEURL = new Dictionary<string,string>
        {
          { "profile","https://twitter.com/i/profiles/show/{0}/media_timeline"},
          { "favo","https://twitter.com/{0}/likes/timeline"},
          { "timeline","https://twitter.com/i/profiles/show/{0}/timeline"}
        };

      /// <summary>
      /// スタートアップ
      /// </summary>
      /// <param name="args">コマンドライン引数</param>
      public static void Main(string [] args)
        {
          if(args.Length < 2)
            {
              Console.WriteLine("USAGE: getTweetImage.exe command target [username password]");
              Console.WriteLine("USAGE: command is 'favo' or 'profile'");
              Console.WriteLine("require username and password\n when command is 'favo',or target is private account.");
              return;
            }
          string command  = args[0];
          string target = args[1];
          string username_or_email = null;
          string password = null;

          if(args.Length == 4)
            {
               username_or_email = args[2];
               password = args[3];
            }

          if(command == "favo" && (string.IsNullOrEmpty(username_or_email) || string.IsNullOrEmpty(password)))
            {
               Console.WriteLine("when command is 'favo', username and password is required.");
               Console.WriteLine("USAGE: getTweetImage.exe command target [username password]");
               return;
            }

          string timeline_url = TIMELINEURL.ContainsKey(command) ? string.Format(TIMELINEURL[command],target) : null;

          var twitter = new TwitterDownload((type,v) =>
                                            {
                                              switch(type)
                                                {
                                                case "get-timeline":
                                                  Console.WriteLine("---- getting and parsing\n{0} ...\n----",v);
                                                  break;
                                                case "getting-image":
                                                  Console.WriteLine("fetching: {0}",v);
                                                  break;
                                                case "saved-image":
                                                  Console.WriteLine("saved {0}",v);
                                                  break;
                                                case "file-exists":
                                                  Console.WriteLine("{0} is already exists.",v);
                                                  break;
                                                case "fail-login":
                                                  Console.WriteLine("Auth code not found.","");
                                                  break;
                                                case "fail-auth":
                                                  Console.WriteLine("username or password were refused.","");
                                                  break;
                                                case "try-login":
                                                  Console.WriteLine("Trying to login by '{0}' for twitter.com", v);
                                                  break;
                                                }
                                            });

          if(!string.IsNullOrEmpty(timeline_url))
            {
              twitter.Agent(timeline_url,username_or_email,password);
            }
          else
            {
              Console.WriteLine("[INVALID COMMAND] command should be 'favo' or 'profile'");
            }
        }
    }

  /// <summary>
  /// 画像ダウンロード本体クラス
  /// </summary>
  public class TwitterDownload
    {
      /*------------------------------------------------------------------------------

        Const

      ------------------------------------------------------------------------------*/
      public const string URL_LOGIN    = "https://twitter.com/login";
      public const string URL_SESSIONS = "https://twitter.com/sessions";
      public const string USER_AGENT   = "Mozilla/5.0 (compatible; MSIE 11.0; Windows NT 6.0; Trident/5.0)"; //適当
      public const string REFERER      = "https://twitter.com/";
      private const string RE_MEDIA_URL = @"https://pbs\.twimg\.com/media/([\w\-]+\.\w{3,4})(:large)?";
      private const string RE_TWEET_ID  = @"data-tweet-id=\\""(\d+)\\""";
      private const string RE_AUTH_CODE = @"<input type=""hidden"" value=""([\d\w]+?)"" name=""authenticity_token""(?:\s*/)?>";

      /*------------------------------------------------------------------------------

        Instance

      ------------------------------------------------------------------------------*/
      private string OutputDirectory = Path.Combine(".", "tmp");
      private TwitterWebClient webClient;
      private Action<string,string> Callback;

      public TwitterDownload(Action<string,string> callback, string cookieFile)
        {
         this.Callback = callback;
         this.webClient =  new TwitterWebClient(cookieFile);
        }

      public TwitterDownload(Action<string,string> callback)
        {
          this.Callback = callback;
          this.webClient = new TwitterWebClient();
         }

      public string directory
        {
          set
            {
              this.OutputDirectory = value;
            }
          get
            {
              return this.OutputDirectory;
            }
        }

      public void Agent(string timelineURL,string username,string password)
        {
          if(String.IsNullOrEmpty(timelineURL))
            return;

          if(!string.IsNullOrEmpty(username) && !string.IsNullOrEmpty(password))
            {
                if (!GetLogin(username, password))
                 {
                    Callback("fail-auth", "");
                    return;
                 }
            }

          if (!Directory.Exists(OutputDirectory))
            Directory.CreateDirectory(OutputDirectory);

          string param = "?include_available_features=1&include_entities=1";
          string url;
          do
            {
              url = timelineURL + param;
              Callback("get-timeline",url);

              string result = webClient.DownloadString(url);
              param = GetImage(result);

            } while(!string.IsNullOrEmpty(param));
        }

      private bool GetLogin(string username,string password)
        {
          Callback("try-login", username);
          webClient.Headers[HttpRequestHeader.UserAgent] = USER_AGENT;
          webClient.Headers[HttpRequestHeader.Referer] = REFERER;

          string loginResult = webClient.DownloadString(URL_LOGIN);
          Match authMatch = Regex.Match(loginResult,RE_AUTH_CODE);
          if(!authMatch.Success)
            return false;

          var param = new NameValueCollection();
          param.Add("session[username_or_email]",username);
          param.Add("session[password]",password);
          param.Add("authenticity_token",authMatch.Groups[1].Value);
          param.Add("remember_me","1");
          param.Add("redirect_after_login","/");

          return Encoding.UTF8.GetString(webClient.UploadValues(URL_SESSIONS,param)).IndexOf("error") < 0;
        }

      private string GetImage(string json)
        {
          json = json.Replace(@"\/", "/");

          foreach(Match match in Regex.Matches(json,RE_MEDIA_URL))
            {
              string url = match.Value;
              string basename = match.Groups[1].Value;
              string filename = Path.Combine(OutputDirectory,basename);

              url = string.IsNullOrEmpty(match.Groups[2].Value) ? url + ":orig" : url.Replace(":large",":orig");

              if(File.Exists(filename))
                {
                  Callback("file-exists",filename);
                }
              else
                {
                  Callback("getting-image",url);
                  webClient.DownloadFile(url,filename);
                  Callback("saved-image",filename);
                }
            }

          var ids = new List<string>();
          foreach(Match match in Regex.Matches(json,RE_TWEET_ID))
            {
              ids.Add(match.Groups[1].Value);
            }

          return ids.Count > 0 ? string.Format("?max_position={0}&include_available_features=1&include_entities=1",ids.Last()) : null;
        }
    }

  /// <summary>
  /// クッキーの読書に対応するためだけのWebClientクラス。
  /// </summary>
  internal class TwitterWebClient : WebClient
    {
      private CookieContainer cookieContainer = new CookieContainer();
      private bool isAutoRedirectEnabled = false;

      public TwitterWebClient() : this("") {}

      /// <summary>
      /// もしクッキーファイルが存在したらクッキーファイルを読み込んでCookieContainerに追加する。
      /// </summary>
      /// <param name="cookieFile">ブラウザからエクスポートしたクッキーファイル</param>
      public TwitterWebClient(string cookieFile)
        {
          if(File.Exists(cookieFile))
            {
              using(var sr = new StreamReader(cookieFile))
              {
                string line;
                var reCRLF = new Regex(@"[\r\n]$");
                var reEmpty = new Regex("^$");
                var reComment = new Regex("^#");
                var reSpace = new Regex(@"\s+");

                while ((line = sr.ReadLine()) != null) 
                  {
                    line = reCRLF.Replace(line,"");
                    if(reEmpty.Match(line).Success || reComment.Match(line).Success)
                      continue;
                    string[] ar =  reSpace.Split(line);

                    cookieContainer.Add(new Cookie(ar[5],ar[6],ar[2],ar[0]));
                  }
              }
            }
        }

      /// <summary>
      /// WebClient.GetWebRequestメソッドをオーバーライド
      /// </summary>
      protected override WebRequest GetWebRequest(Uri address)
        {
          WebRequest request = base.GetWebRequest(address);

          if (request is HttpWebRequest)
            {
              var httpWebRequest = request as HttpWebRequest;
              if(httpWebRequest != null)
                {
                  httpWebRequest.CookieContainer = this.cookieContainer;
                  httpWebRequest.AllowAutoRedirect = this.isAutoRedirectEnabled;
                }
            }

          return request;
        }
      /// <summary>
      /// 自動リダイレクトするか?
      /// </summary>
      public bool AutoRedirect
         {
            set { isAutoRedirectEnabled = value; }
            get { return isAutoRedirectEnabled; }
         }
    }

}

packer(.NET版)を勝手に改造

これ、すごい。こんなスマートなビルの壊し方があるんすね。。。ビル爆破より地味だけど、なんかお金かかりそう。
http://www.bbc.co.uk/news/world-asia-21406927

それはともかく。

javascriptコードを圧縮するのに packer (http://dean.edwards.name/download/#packer) の.NET版をよく使っています。
が、.NET版は(たぶん)メンテされておらず?ソースコードも .NET Framework 1.0時代のものなので、見栄えがかなり悪いし、テキストボックスの入力が32KBに制限されていたり、とちょいと不満がくすぶっていました。
最近Javascriptコードを書くことが多くなって、頻繁に使い出すと、どうもこのUI自体が(僕にとっては)使いにくい。

で、あまり見た目を触らずボタン類の配置、ビジュアルスタイルの適用などを含めて改造。
自分用の書庫の意味もかねているので、ここにアップロード。

左がオリジナル。右がUI改造版
jspacker myjspacker

改造点は、

  • ボタン配置・レイアウトを変更した。
  • ビジュアルスタイルを適用するようにした。
  • ラベルを日本語化した。.configファイルでラベル編集可能。
  • テキストボックスの32KB制限を外した。
  • ファイルからの読込(Loadボタン)時に文字コードを判別するようにした。
    (文字判定にInternet Explorerのモジュールを使っているのでIEを削除している方(いないと思うけど)の環境では文字判定できません

ユーザーインターフェイスの部分(Visual C#のIDE上で編集できる部分)のみ修正したのでpacker本体のアルゴリズムには一切タッチせず。packer自体のバージョンは上がっていると思うので、たぶん古いアルゴリズムのままのはず。分からんけど。

プロジェクトのプラットフォームを.NET Framework 3.5にして、フォームを定義しているソースファイルでIDE機能のデザイナーで編集される部分のコードを分離( partial class化)してます。

<< Download from here if you need.>> もし必要ならダウンロードはこちら
(改造後のVisual C# 2008のプロジェクトソースも同梱してます。)
(なんか、chromeだと、危険だ!とか出てダウンロードできませんが、別になんも仕込んではいないです。)

使い方は・・・説明しなくても・・・(^^;;;
ラベルを英語にしたい場合は、Javascript packer.exe.config ファイルを開いて、

&lt;setting name=&quot;Lang&quot; serializeAs=&quot;String&quot;&gt;
&lt;value&gt;ja&lt;/value&gt;
&lt;/setting&gt;

の中の、ja の部分を ja以外、たとえば、en とかにしてください。
ラベルを修正したい場合は、configファイルの該当箇所を修正してください。ただ、ボタン幅は固定なので長さによってははみ出るかも?

でわでわ。

C#でWordPressのエクスポートファイルを取得する

とある業務ソフトのヘルプをWordpressのブログシステム上で作成しよう、ということになってまして、まぁ、フツーはそのWordpressをユーザー認証をかけて、そのまま公開すれば話は済むんですが・・・普通はね・・・。でもクローズドな環境にも対応しなければいけません! とかいう、メンドクサイ仕様になってまして・・・。
それなら、Wordpressなんか使う必要あらへん、ホームページビルダなり、なんなり、使いやすいソフトにすればいいじゃねーの? とフツーはなるんですが・・・。
まぁ、ヘルプ作成について色んな方法を僕がいくら提案しても受け入れてくれないのは分かっているので、グダグダ文句を言っても始まりません。 結局は、ヘルプファイル(HTML)はローカルに置けるように、Wordpressの記事をすべてHTMLに落とすという全くもって本末転倒&意味不明のプログラムを組むことで落ち着きました。

はじめは、そんなの誰かがもうやってるだろう・・・とググったけど、探し方がまずいのか、ヒットしません。ま、そりゃ、そーだよな、わざわざWordpressの記事を全部HTMLに落とすなんてこと・・・する必要もねーし、そんなヘルプの作成の仕方しているの素人集団のウチだけだよな。

ま、グチを言ってても始まりません(笑)

WordPressはXML-RPCをサポートしてますので、XML-RPCで・・・と思ったんですが、Wordpressが吐き出すWXR形式のエクスポートファイル(XML)をダウンロードしてパースした方が早いんじゃねーの? という根拠のない脳内ベンチを信じて、C#で組み始めることに。

エクスポートファイルをパースしてHTMLファイルを一気に吐き出すコードは簡単にできたんですが、エクスポートファイルをWordpressから排出させるところで躓いた。

サーバー上のWordpressへのアクセスに System.Net.WebClient クラスを使ったんですが・・・ログイン処理、具体的にはクッキー処理がうまく行かず、ちょっと時間がかかってしまいました。ネットで検索して出てくる、WebClientを継承して、GetRequestメソッドをオーバーライドしてCookieを保存できるようにカスタマイズしたWebClientを試してみたんですが、どういうわけか、Wordpressへのログインは成功するんだけど、クッキーが保存されないのでログイン画面にリダイレクトされてしまう。
結局、HttpWebRequestの自動リダイレクトを無効/AllowAutoRedirectプロパティをfalseにすることで、解決しました。

/*
  エラー処理・タイムアウト処理はしていない。
*/
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Text;
using System.Text.RegularExpressions;
using System.Linq;
using System.Net;
using System.Threading;

///<summary>
/// 自動リダイレクトを無効にするため、WebClientを継承します。
///</summary>
class XmlWebClient : WebClient
{
  protected override WebRequest GetWebRequest(Uri address)
    {
      WebRequest request = base.GetWebRequest(address);
      
      if (request is HttpWebRequest)
        ((HttpWebRequest)request).AllowAutoRedirect = false;
      
      return request;
    }
  
  ///<summary>
  ///エクスポートファイルを新規スレッドでダウンロードする。
  ///非同期APIを使えばいいんですが、ややこしいので、スレッドを一本起こしてます。
  ///</summary>
  public static Thread GetWXRFileAsync(string url,string user,string phrase,string ofilepath,Action cb)
    {
      try
        {
          var thread = new Thread(o =>
                                  {
                                    GetWXRFile(url, user, phrase, ofilepath);
                                    cb();
                                  });
          
          thread.Start();
          return thread;
        }
      catch(Exception exception)
        {
          throw exception;
        }
    }
  
  ///<summary>
  ///エクスポートファイルをダウンロードする。
  ///</summary>
  public static void GetWXRFile(string url,string user,string phrase,string ofilepath)
    {
      var xwc = new XmlWebClient { Encoding = Encoding.UTF8 };
      
      xwc.Headers[HttpRequestHeader.UserAgent] = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)";
      xwc.Headers[HttpRequestHeader.Referer] = url + "wp-login.php";
      
      var param = new NameValueCollection();
      param.Add("log", user);
      param.Add("pwd", phrase);
      param.Add("rememberme","forever");
      param.Add("wp-submit","login");
      param.Add("redirect_to",url + "wp-admin/");
      param.Add("testcookie","1");
      
      xwc.UploadValues(url + "wp-login.php",param);
      
      string setCookie = xwc.ResponseHeaders[HttpResponseHeader.SetCookie];
      string[] cookies = Regex.Split(setCookie, "(?<!expires=.{3}),")
        .Select(s => s.Split(';').First().Split('='))
          .Select(xs => new { Name = xs.First(), Value = string.Join("=", xs.Skip(1).ToArray()) })
            .Select(a => a.Name + "=" + a.Value)
              .ToArray();
      
      xwc.Headers[HttpRequestHeader.Cookie] = string.Join(";", cookies);
      xwc.DownloadFile(url + "wp-admin/export.php?content=all&download=true", ofilepath);
    }
}

クッキー処理のところは、たまたま検索したページ(サイト失念(m_m))のコピペ。SetCookieヘッダーの処理って面倒なんですよね・・・助かる(^^)
あとは、下記のように WordPressのURLとログイン名・パスワード・ダウンロードするエクスポートファイルを保存するファイルパスを指定してコールするだけ。

//ダウンロード
XmlWebClient.GetWXRFile("ワードプレスのURL","ユーザー名","パスワード","ファイルパス");

//おまけで、コールバック関数を指定して別スレッドで動かす「なんちゃって非同期バージョン」
//Windowsフォームを使うアプリだと、非同期は必須っすね。
XmlWebClient.GetWXRFileAsync("ワードプレスのURL",
                             "ユーザー名",
                             "パスワード",
                             "ファイルパス",
                             () => {Console.WriteLine("エクスポートファイルの取得が終了しました。");});

iTunesで曲名の列挙

ちょっとメモ。

iTunesに登録した動画・音楽の一覧をプログラムかスクリプトから取得したくて・・・。
はじめに思いついたのは「ライブラリのエクスポート」で得られるXMLファイルから取得する方法。
これは単純にXMLをパースするだけなんで、C# (.NET Framework)で使えるようにラップするクラスを作った。
これを使って、下記のようなコードで曲名を列挙できた。・・・が、

/*
using System;
using System.Collections.Generic;
using System.Text;
using System.Xml;
using System.Linq;
using System.Xml.Linq;
*/

public static void Main(string [] args)
{
  if(args.Length <= 0)
    {
      Console.WriteLine("XMLファイルを指定してください。");
      return;
    }

  string xmlfile = args[0];

  var albums = new Dictionary<string,Album>();

  var xDict = new XDict(xmlfile,"/dict");
  var Tracks = xDict["Tracks"] as XDict;

  foreach(var key in Tracks.Keys)
    {
      var xDictTrack = Tracks[key] as XDict;
      
      //アルバム名がない場合、アーティスト名で作成する。
      if(xDictTrack["Album"] == null)
        xDictTrack["Album"] = xDictTrack["Artist"] ?? "No Album";
      
      var tune = new Tune(xDictTrack);
      
      if(albums.ContainsKey(tune.Album))
        albums[tune.Album].Tunes.Add(tune);
      else
        albums[tune.Album] = new Album(tune);
    }

  foreach(var album in albums.Values)
    {
      Console.WriteLine("---{0}-",album.Name);
      album.Sort(SortBy.Track);
      album.Tunes.ForEach((tune) => Console.WriteLine("{0:D2}:{1}",tune.Track,tune.Name));
    }
}

これを書いてたとき、たまたまGoogleで検索してたら、iTunesアプリケーション自体がCOMオートメーションサーバーを実装していてドキュメントが公開されているのを今更発見しました(笑) なんだ、スクリプトから簡単に曲名からプレイリストの編集までできるんじゃないですか!(^^;;
さっそく https://developer.apple.com/downloads/ で無料のデベロッパー登録?して “iTunes COM for Windows SDK”をダウンロードしドキュメントをゲット。

/*
 wscriptで動かすと延々メッセージボックスがポップアップするので注意。
*/
var	iTunes = WScript.CreateObject("iTunes.Application");
var	tracks = iTunes.LibraryPlaylist.Tracks;
var	num = tracks.Count;
for(var i = 1;i <= num;i++)
 WScript.Echo(tracks.Item(i).Name);

簡単!

最初のXMLから取得する方法は無駄になった・・・けど、C#でXMLを操作する勉強をしたと思えば・・・ま、いいか(^^;