(X) Hide this
    • Login
    • Join
      • Say No Bots Generate New Image
        By clicking 'Register' you accept the terms of use .

Uploading and geo-tagging photos on Flickr using Silverlight's HttpWebRequest

(5 votes)
Marcel du Preez
>
Marcel du Preez
Joined Jun 09, 2010
Articles:   3
Comments:   0
More Articles
3 comments   /   posted on Jul 01, 2010
Categories:   Data Access , General

This article is compatible with the latest version of Silverlight.


Introduction

In the previous article (Displaying geo-referenced Flickr images in Silverlight using Bing Maps) we covered how to retrieve photos from Flickr using their API, and displaying them on a map (Microsoft’s Bing Maps control). In this article, I’ll be showing you how to upload your own photos to Flickr, and geo-tagging them.

The FlickrNet library

Sam Judson has a library on Codeplex for accessing Flickr services from .NET, called FlickrNet. There is no Silverlight version of the library available (yet), so I’ll be modifying them to be asynchronous.

Auth, Auth, and Auth again

Flickr exposes services over the internet, so it is understandable that their might be some tight security when accessing these services. According to their API (and common sense), every call requiring a write permission (Uploading a photo, changing a photo’s geo-tag), requires that call to be authenticated. There are three approaches to authentication :

  • Web based authentication
  • Non-web based authentication
  • Mobile authentication

Because we need to access the file system on the client computer (to get the photo to upload), our Silverlight application requires elevated trust; hence we’ll be taking the app from the previous tutorial, and making it out-of-browser (OOB) capable. Then, we’ll be using Non-web based authentication, due to our app running OOB.

Non-web based authentication

To authenticate a non-web based application, there are some steps to follow :

  • Request a frob. A frob is almost like a preliminary key.
  • Allow the application to use your Flickr account (authenticate the frob)
  • Get an authorization token

All three of these calls are required to be signed. A signed Flickr API call involves the following :

  • Sort all of the parameters passed to the service
  • Append the parameters’ key and value properties to your shared secret
  • Calculate the MD5 hash for this string
  • Append it to the service call as the api_sig

WebClient or HttpWebRequest

In contrast to the previous tutorial, for API calls in this tutorial we’ll not be using the WebClient, but the HttpWebRequest. The HttpWebRequest allows us to send data to the service, and receive a response through the same “connection”. For those calls where we only request information, I’ll still be using a WebClient. For the upload call, I’ll be using the HttpWebRequest. See the links for more in-depth explanations.

Starting out

We’ll be using some common methods in our app. For each call, I’ll be using the CalculateUri method from the FlickrNet library to build and sign the Uri :

 /// <summary>
 /// Modified from the FlickrNet library.
 ///
 /// Calculates the Flickr method cal URL based on the passed in parameters, and 
 /// also generates the signature if required.
 /// </summary>
 /// <param name="parameters">A Dictionary containing a list of parameters to add
 /// to the method call.</param>
 /// <param name="includeSignature">Boolean use to decide whether to generate the
 /// api call signature as well.</param>
 /// <returns>The <see cref="Uri"/> for the method call.</returns>
 public static Uri CalculateUri (Dictionary<string, string> parameters, 
     bool includeSignature, Uri baseUri)
 {
     if (includeSignature)
     {
         StringBuilder sb = new StringBuilder(App.sharedSecret);
  
         foreach (KeyValuePair<string, string> pair in (from p in parameters orderby p.Key select p))
         {
             sb.Append(pair.Key);
             sb.Append(pair.Value);
         }
   
         parameters.Add("api_sig", HashString(sb.ToString()));
     }
  
     StringBuilder url = new StringBuilder();
     url.Append("?");
     foreach (KeyValuePair<string, string> pair in parameters)
     {
         url.AppendFormat(System.Globalization.CultureInfo.InvariantCulture, "{0}={1}&", 
             pair.Key, Uri.EscapeDataString(pair.Value));
     }
   
     return new Uri(baseUri, new Uri(url.ToString(), UriKind.Relative));
 }

First, we sort the passed parameters using LINQ, append them with their keys and values to our app’s shared secret (which I stored in the App.xaml), and then generate an MD5 has from it. The System.Cryptography namespace does not include the MD5 algorithm, so I’ve used the one supplied here. Finally, we append all the parameters to the base Uri.

Requesting a frob

First step in authentication is requesting a frob :

 /// <summary>
 /// Requests a Frob
 /// </summary>
 public void AuthGetFrob()
 {
     Dictionary<string, string> parameters = new Dictionary<string, string>();
  
     parameters.Add("api_key", App.apiKey);
     parameters.Add("method", "flickr.auth.getFrob");
              
     WebClient wc = new WebClient();
     wc.DownloadStringCompleted += (sender, args) => 
         AuthFrobDownloadStringCompleted(sender, args);
     wc.DownloadStringAsync(CalculateUri(parameters, true, new Uri(BaseServiceUrl)));            
 }

In this method, we start by creating a Dictionary of parameters. The first is our API key (stored in the App.xaml). The second is the name of the Flickr method we’re calling, flickr.auth.getFrob. Before calling the WebClient’s DownloadStringAsync method, I declare an event handler that will be called when it completes, called AuthFrobDownloadStringCompleted :

 /// <summary>
 /// Event handler for the GetAuthFrob WebClient's DownloadStringAsyncCompleted event
 /// </summary>
 /// <param name="sender"></param>
 /// <param name="e"></param>
 private void AuthFrobDownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
 {
     XDocument xmlDoc = XDocument.Parse(e.Result);
  
     if (e.Error != null || xmlDoc.Element("rsp").Attribute("stat").Value == "fail")
     {
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The frob request was unsuccessful.\r\n\r\nError : " +                    xmlDoc.ToString(), "GetFrob error");
         });
         return;
     }
     else
     {
         authFrob = (string)(from x in xmlDoc.Element("rsp").Descendants().ToList() select x).First().Value;
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The frob request was successful.\r\n\r\nFrob : " + authFrob, 
                     "GetFrob successful");
         });
  
         SetAuthFrobNavigateUri(authFrob);
     }
 }

This method parses the returned string using the XDocument class. Because this method is called in a different thread, I’m using the Dispatcher object’s BeginInvoke methods to update the UI.

If the call is successful, you’ll see a screen like this (I’ve used the Silverlight ChildWindow) :

UploadSS1

If the call is unsuccessful, the XML response will be displayed. For an explanation of error codes, see the flickr.auth.getFrob method’s page.

Authenticating the frob

Before we can get an authorization token, we need to authenticate the frob with a Flickr account. When you have a frob, you have to send the user to a Flickr page, where they can specify whether your app has access permissions to your files as specified by the perms paramater. The perms parameter can be :

  • read – permission to read private information
  • write – permission to add, edit and delete photo metadata
  • delete – permission to delete photos

The write permission includes all permissions granted by read, and delete includes all permissions granted by write.

In my app, I’ve added a Hyperlink that opens a browser, and navigates to the Uri set in the SetAuthFromNavigateUri method :

 private string AuthServiceUrl = "http://api.flickr.com/services/auth/";
  
 /// <summary>
 /// Sets the NavigateUri property of the Authenticate Frob hyperlink
 /// </summary>
 /// <param name="authFrob"></param>
 private void SetAuthFrobNavigateUri(string authFrob)
 {
     Dictionary<string, string> parameters = new Dictionary<string, string>();
     parameters.Add("api_key", App.apiKey);
     parameters.Add("frob", authFrob);
     parameters.Add("perms", "write");
   
     lnkAuthenticateFrob.NavigateUri = CalculateUri(parameters, true, new Uri(AuthServiceUrl));
     lnkAuthenticateFrob.IsEnabled = true;
     cmdGetToken.IsEnabled = true;
 }

Here I specify that I require the write permission. Clicking on the link, you should be provided with the following page :

UploadSS2

By clicking “OK, I’ll authorize it”, you’re authenticating the frob. 

Getting an authentication token

Once the frob is authorized, we can request an authentication token :

 /// <summary>
 /// Requests an authentication token.
 /// 
 /// Note that the frob first needs to be authenticated before a token will be granted.
 /// </summary>
 public void AuthGetToken()
 {
     Dictionary<string, string> parameters = new Dictionary<string, string>();
  
     parameters.Add("api_key", App.apiKey);
     parameters.Add("frob", authFrob);
     parameters.Add("method", "flickr.auth.getToken");
   
     WebClient wc = new WebClient();
     wc.DownloadStringCompleted += (sender, args) => AuthTokenDownloadStringCompleted(sender, args);
     wc.DownloadStringAsync(CalculateUri(parameters, true, new Uri(BaseServiceUrl)));
 }

The DownloadStringCompleted event for the WebClient is handled in the AuthTokenDownloadStringCompleted event :

 /// <summary>
 /// Event handler for the GetAuthToken WebClient's DownloadStringAsyncCompleted event
 /// </summary>
 /// <param name="sender"></param>
 /// <param name="e"></param>
 private void AuthTokenDownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
 {
     XDocument xmlDoc = XDocument.Parse(e.Result);
  
     if (e.Error != null || xmlDoc.Element("rsp").Attribute("stat").Value == "fail")
     {
         string results = e.Result;
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The token request was not unsuccessful.\r\n\r\nError : " + 
                 xmlDoc.ToString(), "GetToken error");
         });
         return;
     }
     else
     {
         authToken = (string)(from x in xmlDoc.Element("rsp").Element("auth").Descendants().ToList() 
                                 select x).First().Value;
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The token request was successful.\r\n\r\nToken : " + 
                 authToken, "GetToken successful");
         });
         cmdUpload.IsEnabled = true;
     }
 }

Which should display this message :

UploadSS4

Once again, if an error message is displayed, you can find it’s meaning on the flickr.auth.getToken method’s page.

 

Uploading (finally)

Once we have an authentication token, we can start uploading photos. As stated, we’ll be using the HttpWebRequest to do the service calls.

Firstly, I have a method that generates my parameters :

 /// <summary>
 /// Modified method from the FlickrNet library.
 /// 
 /// Sets up the picture upload, and initiates the UploadData method.
 /// </summary>
 /// <param name="stream">A stream for the photo to be uploaded.</param>
 /// <param name="fileName">The file to be uploaded's name.</param>
 /// <param name="title">The photo's title.</param>
 /// <param name="description">A description for the photo.</param>
 /// <param name="tags">Tags for the photo.</param>
 /// <param name="isPublic">Determines whether this photo is accessible by everyone</param>
 /// <param name="isFamily">Determines whether this photo is accessible by family</param>
 /// <param name="isFriend">Determines whether this photo is accessible by friends</param>
 /// <param name="contentType">The photo's type</param>
 /// <param name="safetyLevel">The photo's safety level</param>
 /// <param name="hiddenFromSearch">Determines whether this photo is excluded from searches</param>
 public void UploadPicture(Stream stream, string fileName, string title, string description, string tags, 
     bool isPublic, bool isFamily, bool isFriend, int contentType, int safetyLevel, int hiddenFromSearch)
 {
     //Authentication checking omitted
   
     Uri uploadUri = new Uri(UploadServiceUrl);
   
     Dictionary<string, string> parameters = new Dictionary<string, string>();
   
     if (title != null && title.Length > 0)
     {
         parameters.Add("title", title);
     }
     if (description != null && description.Length > 0)
     {
         parameters.Add("description", description);
     }
     if (tags != null && tags.Length > 0)
     {
         parameters.Add("tags", tags);
     }
   
     parameters.Add("is_public", isPublic ? "1" : "0");
     parameters.Add("is_friend", isFriend ? "1" : "0");
     parameters.Add("is_family", isFamily ? "1" : "0");
   
     if (safetyLevel != 0)
     {
         parameters.Add("safety_level", safetyLevel.ToString());
     }
     if (contentType != 0)
     {
         parameters.Add("content_type", contentType.ToString());
     }
     if (hiddenFromSearch != 0)
     {
         parameters.Add("hidden", hiddenFromSearch.ToString());
     }
   
     parameters.Add("api_key", App.apiKey);
     parameters.Add("auth_token", authToken);
   
     UploadData(stream, fileName, uploadUri, parameters, App.sharedSecret);
  
 }

The UploadData method from the FlickrNet library was modified to allow for asynchronous calls. It builds the request, and then initiates the the HttpWebRequest’s GetRequestStream :

 /// <summary>
 /// Modified method from the FlickrNet library
 /// 
 /// Uploads the image data
 /// </summary>
 /// <param name="imageStream">A stream for the file to upload.</param>
 /// <param name="fileName">The filename.</param>
 /// <param name="uploadUri">The service's Uri.</param>
 /// <param name="parameters">Parameters to include in the Uri.</param>
 /// <param name="sharedSecret">Your application's shared secret.</param>
 private void UploadData(Stream imageStream, string fileName, Uri uploadUri, 
      Dictionary<string, string> parameters, string sharedSecret)
 {
      string[] keys = new string[parameters.Keys.Count];
      parameters.Keys.CopyTo(keys, 0);
      Array.Sort(keys);
   
      StringBuilder hashStringBuilder = new StringBuilder(sharedSecret, 2 * 1024);
      StringBuilder contentStringBuilder = new StringBuilder();
      string boundary = "FLICKR_MIME_" + DateTime.Now.ToString("yyyyMMddhhmmss", 
          System.Globalization.DateTimeFormatInfo.InvariantInfo);
   
      foreach (string key in keys)
      {
          hashStringBuilder.Append(key);
          hashStringBuilder.Append(parameters[key]);
          contentStringBuilder.Append("--" + boundary + "\r\n");
          contentStringBuilder.Append("Content-Disposition: form-data; name=\"" + key + "\"\r\n");
          contentStringBuilder.Append("\r\n");
          contentStringBuilder.Append(parameters[key] + "\r\n");
      }
   
      contentStringBuilder.Append("--" + boundary + "\r\n");
      contentStringBuilder.Append("Content-Disposition: form-data; name=\"api_sig\"\r\n");
      contentStringBuilder.Append("\r\n");
      contentStringBuilder.Append(HashString(hashStringBuilder.ToString()) + "\r\n");
   
      fileName = Path.GetFileName(fileName);
   
      // Photo
      contentStringBuilder.Append("--" + boundary + "\r\n");
      contentStringBuilder.Append("Content-Disposition: form-data; name=\"photo\"; filename=\"" +          fileName + "\"\r\n");
      contentStringBuilder.Append("Content-Type: image/jpeg\r\n");
      contentStringBuilder.Append("\r\n");
   
      UTF8Encoding encoding = new UTF8Encoding();
   
      Dispatcher.BeginInvoke(() =>
      {
          txtUploadResult.Text = "Buffering data...";
      });
   
      byte[] postContents = encoding.GetBytes(contentStringBuilder.ToString());
   
      byte[] photoContents = new byte[imageStream.Length];
      imageStream.Read(photoContents, 0, photoContents.Length);
      imageStream.Close();
   
      byte[] postFooter = encoding.GetBytes("\r\n--" + boundary + "--\r\n");
   
      byte[] dataBuffer = new byte[postContents.Length + photoContents.Length + postFooter.Length];
      Buffer.BlockCopy(postContents, 0, dataBuffer, 0, postContents.Length);
      Buffer.BlockCopy(photoContents, 0, dataBuffer, postContents.Length, photoContents.Length);
      Buffer.BlockCopy(postFooter, 0, dataBuffer, postContents.Length + photoContents.Length, 
          postFooter.Length);
   
      HttpWebRequest req = (HttpWebRequest)HttpWebRequest.Create(uploadUri);
      req.Method = "POST";
      req.ContentType = "multipart/form-data; boundary=" + boundary + "";
   
      req.ContentLength = dataBuffer.Length;
   
      Dispatcher.BeginInvoke(() =>
      {
         txtUploadResult.Text = "Waiting for request stream...";
      });
   
      req.BeginGetRequestStream(new AsyncCallback(GotRequestStreamForUpload), 
          new SendPhotoAsyncState(req, dataBuffer));
   
 }

Up to line 67, all this code does is build the request, its Uri, and the buffer. Then, it creates an HttpWebRequest, and passes to its BeginGetRequestStream method an object of type SendPhotoAsyncState, which is just a wrapper that contains the request and the buffer.

When a request stream is received, we start uploading data :

 /// <summary>
 /// Called when the HttpWebRequest's BeginGetRequest completes
 /// </summary>
 /// <param name="result">A SendPhotoAsyncState object containing the buffer and the request</param>
 private void GotRequestStreamForUpload(IAsyncResult result)
 {
     Dispatcher.BeginInvoke(() =>
     {
         txtUploadResult.Text = "Uploading photo...";
     });
   
     HttpWebRequest request = ((SendPhotoAsyncState)result.AsyncState).Request;
     byte[] data = ((SendPhotoAsyncState)result.AsyncState).Data;
   
     Stream writeStream = request.EndGetRequestStream(result);
  
     foreach (byte b in data)
     {
         writeStream.WriteByte(b);
     }
   
     writeStream.Close();
   
     Dispatcher.BeginInvoke(() =>
     {
         txtUploadResult.Text = "Waiting for response stream...";
     });
   
     request.BeginGetResponse(new AsyncCallback(GotResponseStreamForUpload), request);
 }

After iterating over the buffer, and writing each byte to the stream, I then invoke the BeginGetResponse method, so we can read the results :

 /// <summary>
 /// Called when the HttpWebRequest's BeginGetResponse completes
 /// </summary>
 /// <param name="result"></param>
 private void GotResponseStreamForUpload(IAsyncResult result)
 {
     Dispatcher.BeginInvoke(() =>
     {
         spProgress.Visibility = System.Windows.Visibility.Collapsed;
     });
  
     HttpWebRequest request = (HttpWebRequest)result.AsyncState;
     HttpWebResponse response = (HttpWebResponse)request.EndGetResponse(result);
  
     using (StreamReader streamReader1 = new StreamReader(response.GetResponseStream()))
     {
         string resultString = streamReader1.ReadToEnd();
  
         XDocument xmlDoc = XDocument.Parse(resultString);
         if (xmlDoc.Element("rsp").Attribute("stat").Value == "fail")
         {
             Dispatcher.BeginInvoke(() =>
             {
                 CommonMessageWindow.CreateNew("The photo was not uploaded.\r\n\r\nError : " + xmlDoc.ToString(), 
                    "Upload error");
             });
             return;
         }
         else
         {
             mostRecentlyUploadedPhoto = (from x in XDocument.Parse(resultString).Element("rsp").Descendants().ToList()
                 select x).First().Value;
  
             Dispatcher.BeginInvoke(() =>
             {
                 CommonMessageWindow.CreateNew("The photo was successfully uploaded.\r\n\r\nIt's photo id is : " 
                     + mostRecentlyUploadedPhoto, "Upload successful");
                 cmdSetGeoLocation.IsEnabled = true;
             });
         }
     }
 }

If all goes well, you should see this message :

UploadSS5

At this point, the photo has been uploaded to your Flickr account. Browse to your account to confirm that the title, description, tags and so forth have been set correctly.

Geo-tagging the photo

Even though the photo has been uploaded, it’s geo-location has not yet been set. To do that, we have to send a call to another service method, flickr.photos.geo.setLocation.

To get the location from the map, I added a button to my UI that specifies that I am now selecting a point, and not panning the map :

 /// <summary>
 /// Event handler for the Set Location button's click event
 /// </summary>
 /// <param name="sender"></param>
 /// <param name="e"></param>
 private void cmdSetLocation_Click(object sender, RoutedEventArgs e)
 {
     IsSettingLocation = true;
 }

Then, set the MouseClick event handler for the map :

 <maps:Map CredentialsProvider="YourKeyHere" Margin="4" 
 x:Name="mapMain" MouseClick="mapMain_MouseClick" />

and handle it like this :

 /// <summary>
 /// Event handler for the Bing map control's MouseClick event
 /// </summary>
 /// <param name="sender"></param>
 /// <param name="e"></param>
 private void mapMain_MouseClick(object sender, MapMouseEventArgs e)
 {
     if (IsSettingLocation)
     {
         if (pinNewPhotoAddedAt != null)
         {
             if (mapMain.Children.Contains(pinNewPhotoAddedAt))
             {
                 mapMain.Children.Remove(pinNewPhotoAddedAt);
             }
         }
  
         if (PhotoLocation == null)
         {
             PhotoLocation = new Location();
         }
  
         mapMain.TryViewportPointToLocation(e.ViewportPoint, out PhotoLocation);
         txtLatitude.Text = PhotoLocation.Latitude.ToString();
         txtLongitude.Text = PhotoLocation.Longitude.ToString();
  
         pinNewPhotoAddedAt = new Pushpin();
         pinNewPhotoAddedAt.Location = PhotoLocation;
         mapMain.Children.Add(pinNewPhotoAddedAt);
  
         IsSettingLocation = false;
     }
 }

When setting the location, I add a pushpin at the clicked location for reference.

We’ll be using the WebClient again for this call :

 /// <summary>
 /// Sets the location of the photo specified by the supplied photo ID
 /// </summary>
 /// <param name="PhotoID">The photoID of the photo.</param>
 /// <param name="geoLocation">The location to set to.</param>
 private void SetUploadedPhotoGeoLocation(string PhotoID, Location geoLocation)
 {
     Dictionary<string, string> parameters = new Dictionary<string, string>();
     parameters.Add("api_key", App.apiKey);
     parameters.Add("auth_token", authToken);
     parameters.Add("lat", geoLocation.Latitude.ToString(System.Globalization.NumberFormatInfo.InvariantInfo));
     parameters.Add("lon", geoLocation.Longitude.ToString(System.Globalization.NumberFormatInfo.InvariantInfo));
     parameters.Add("method", "flickr.photos.geo.setLocation");
     parameters.Add("photo_id", PhotoID);
   
  
     WebClient wc = new WebClient();
     wc.DownloadStringCompleted += (s, args) => SetLocationDownloadStringCompleted(s, args);
     wc.DownloadStringAsync(CalculateUri(parameters, true, new Uri(BaseServiceUrl)));
 }

Keeping the same approach as the other methods, I first set some parameters, and then pass the Uri to the WebClient. Once again, we handle the completed event :

 /// <summary>
 /// Called when the SetGeoLocation WebClient's DownloadStringAsync completes.
 /// </summary>
 /// <param name="sender"></param>
 /// <param name="e"></param>
 private void SetLocationDownloadStringCompleted(object sender, DownloadStringCompletedEventArgs e)
 {
     XDocument xmlDoc = XDocument.Parse(e.Result);
  
     if (e.Error != null || xmlDoc.Element("rsp").Attribute("stat").Value == "fail")
     {
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The SetLocation request was not unsuccessful.\r\n\r\nError : " +
                 xmlDoc.ToString(), "SetLocation error");
         });
         return;
     }
     else
     {
         Dispatcher.BeginInvoke(() =>
         {
             CommonMessageWindow.CreateNew("The SetLocation request was successful.", "SetLocation successful");
         });
         cmdUpload.IsEnabled = true;
     }
 }

This call does not return anything when it succeeds, only when it fails. For error codes, see the flickr.photos.geo.setLocation page.

Your photo on Flickr should now display the “Map” link underneath it :

UploadSS6

Clicking on this link will display the location that you’ve set.

Conclusion

This tutorial demonstrated how to firstly authenticate your app with Flickr’s web services, upload a picture to your account and set its geo-location to a location selected on a Bing Map Silverlight control.

Other features that can be added is displaying the photo you added on your own map, adding more parameters to methods (i.e. setting the Context parameter on the setLocation method etc.

Source code

Download source code

Ciao!
Marcel du Preez
marcel@inivit.com


Subscribe

Comments

  • -_-

    RE: Uploading and geo-tagging photos on Flickr using Silverlight 4's HttpWebRequest


    posted by Sam Judson on Jul 05, 2010 16:30

    The WebClient.UploadDataAsync and WebClient.UploadString methods not only allow you to upload data but also to read the response. They would be fine for uploading photos to Flickr I believe (the only reason I don't use them is that the WebClient class is not available in the Compact Framework).

  • -_-

    RE: Uploading and geo-tagging photos on Flickr using Silverlight 4's HttpWebRequest


    posted by Marcel on Jul 05, 2010 20:22

    Hi Sam

    Thanks! I'll clarify that paragraph.

  • -_-

    RE: Uploading and geo-tagging photos on Flickr using Silverlight 4's HttpWebRequest


    posted by Marcel on Aug 29, 2010 12:49

    Sam has released a Silverlight version of his Flickr.Net API... go check it out at http://flickrnet.codeplex.com/

Add Comment

Login to comment:
  *      *