Jarloo

Menu

Sojurn – The TV App

Fork me on GitHub

THIS IS OUT OF DATE.

Version 2.2.1.0 is now available on GitHub that contains many changes. Caliburn.micro and MEF were removed and the UI was changed around.  The full source is available there as well as the binaries.

A MVVM application written using Caliburn.Micro and MahApps.metro, that gets TV data from TVMaze and displays it in a slick interface. If your new to Caliburn.Micro this is a great way to get your feet wet.

This application requires both Caliburn.Micro and MahApps.metro. Both are included in the download package. It also features amazing icons from the WindowsIcons project.

Please grab a copy of the full source code and follow along:

Download Source Code on GitHub

If you just want to run the app get the binaries on GitHub here.

(video is older and shows V1. Quite a bit has changed since, but the basics are the same.)

What is Caliburn.Micro?

Caliburn.Micro is a framework that helps with doing Model-View-ViewModel style development. It provides ways to connect the View to the ViewModel without having to do any work in the code-behind file. It also contains a Window Manager that helps define the life-cycle of “windows” and how they are created.

 

What is MahApps.metro?

MahApps.metro is a library you can download that contains, controls and resources to make your applications look like the Microsoft Metro UI. (Or Visual Studio’s dark theme if you prefer.)

 

TVMaze

TVMaze provides some easy to use REST API’s for TV information. This application uses those to provide content. You can see the API documentation here: TVMaze API

 

Getting Started

NOTE: Since this is open source it’s been modified by others since my original posting. The code shown is no longer just my my original work. Please see the GitHub to see whom wrote what as I’ve updated this since the original version.

The bootstrapper is where it all begins.

using System;
using System.ComponentModel.Composition;
using System.ComponentModel.Composition.Hosting;
using System.ComponentModel.Composition.Primitives;
using System.Linq;
using Caliburn.Micro;
using Jarloo.Sojurn.ViewModels;

namespace Jarloo.Sojurn
{
    public class SojurnBootstrapper : Bootstrapper<MainViewModel>
    {
        private CompositionContainer container;

        protected override void Configure()
        {
            container = new CompositionContainer(new AggregateCatalog(AssemblySource.Instance.Select(x => new AssemblyCatalog(x)).OfType<ComposablePartCatalog>()));

            CompositionBatch batch = new CompositionBatch();

            batch.AddExportedValue<IWindowManager>(new AppWindowManager());
            batch.AddExportedValue<IEventAggregator>(new EventAggregator());
            batch.AddExportedValue(container);

            container.Compose(batch);
        }

        protected override object GetInstance(Type serviceType, string key)
        {
            string contract = string.IsNullOrEmpty(key) ? AttributedModelServices.GetContractName(serviceType) : key;
            var exports = container.GetExportedValues<object>(contract);

            if (exports.Count() > 0)
            {
                return exports.First();
            }

            throw new Exception(string.Format("Could not locate any instances of contract {0}.", contract));
        }

    }
}

The bootstrapper is the first thing Caliburn.Micro calls, and in this case it runs the MainViewModel.

Caliburn.Micro will use the custom Window Manager in the project called AppWindowManager to determine how to display the window:

using System.Windows;
using Caliburn.Micro;
using Jarloo.Sojurn.Windows;

namespace Jarloo.Sojurn
{
    internal class AppWindowManager : WindowManager
    {
        protected override Window EnsureWindow(object model, object view, bool isDialog)
        {
            Window window = view as BaseWindow;

            if (window == null)
            {
                if (isDialog)
                {
                    window = new BaseDialogWindow
                    {
                        Content = view,
                        SizeToContent = SizeToContent.WidthAndHeight
                    };
                }
                else
                {
                    window = new BaseWindow
                        {
                            Content = view,
                            SizeToContent = SizeToContent.Manual
                        };
                }

                window.SetValue(View.IsGeneratedProperty, true);
            }
            else
            {
                Window owner2 = InferOwnerOf(window);
                if (owner2 != null && isDialog)
                {
                    window.Owner = owner2;
                }
            }
            return window;
        }
    }
}

The AppWindowManager class returns a BaseWindow or BaseDialogWindow. The reason I added these is so I could have more control over templating the window. MahApps.metro lets us add items to the window chrome. In this case I’ve added a control that displays a push-pin, and allows the user to toggle the control topmost.

 

The Main ViewModel

This is where all the real action is. It exposes the TV information via the Models: Show, Season and Episode. When you click the “+” button it calls the AddShowViewModel and injects an IInformationProvider instance into it. The default one is the TvMazeInformationProvider but you can provide any implementation as long as it fits the interface. (for example there is another for TvRage which used to be the default)

 

Information Providers

The TVMazeInformationProvider handles downloading and parsing the data into the Models mentioned, which are then returned to the MainViewModel. Since the TVMaze API is REST, it is very easy to work with.

To download and parse the full episode information for a show it boils down to this:

public Show GetFullDetails(int showId)
{
 try
 {
 var requestShowDetailUri = $"{BASE_URL}shows/{HttpUtility.HtmlEncode(showId)}";
 var shdata = GetJsonData(requestShowDetailUri);

 var show = new Show
 {
 ShowId = shdata.id,
 Name = shdata.name,
 Started = GetDate(shdata.premiered),
 Ended = null,
 Country = GetCountryCode(shdata),
 Status = shdata.status,
 ImageUrl = GetImage(shdata.image),
 AirTimeHour = GetTime(shdata.schedule.time, 'H'),
 AirTimeMinute = GetTime(shdata.schedule.time, 'M')
 };

 var requestShowEpisodsUri = $"{BASE_URL}shows/{HttpUtility.HtmlEncode(showId)}/episodes";
 var epdata = GetJsonData(requestShowEpisodsUri);

 //I could not use linq becuase the json data is dynamic
 //use old reliable foreach
 DateTime? lastEpisodeAirDate = null;
 var seasonNumber = 0;
 Season season = null;
 foreach (var ep in epdata)
 {
 if (ep.season != seasonNumber)
 {
 season = new Season {SeasonNumber = ep.season};
 show.Seasons.Add(season);
 seasonNumber = ep.season;
 }
 //the season can't be null because the ep.season starts from 1 in TvMaze API
 //and the 'if' statment above initialize the vavriable
 season?.Episodes.Add(new Episode
 {
 EpisodeNumber = ep.number,
 AirDate = GetDate(ep.airdate),
 Title = ep.name,
 Link = ep.url,
 ImageUrl = GetImage(ep.image),
 ShowName = shdata.name,
 SeasonNumber = ep.season,
 Summary = RemoveHtmlTags(ep.summary.ToString())
 });
 
 //if needed (check by status) get the value for the last Episode AirDate as the show's end date 
 if (show.Status == "Ended")
 lastEpisodeAirDate = GetDate(ep.airdate);
 }
 show.Ended = lastEpisodeAirDate;

 //check if there are seasons
 //if not return null for an exception
 if (season == null)
 return null;

 foreach (var t in show.Seasons)
 {
 for (var e = 0; e < t.Episodes.Count; e++)
 {
 t.Episodes[e].EpisodeNumberThisSeason = e + 1;
 }
 }

 show.LastUpdated = DateTime.Now;

 return show;
 }
 catch
 {
 return null;
 }
}

 

Handling Images

The ImageHelper class is used to download and process all the images for each episode, as well as the artwork for each show. The URL’s to the images for the shows and episodes are stored in the corresponding models. All image downloading is done on background threads to ensure the application stays responsive. Once each image is downloaded it’s saved (see Persistence) and then displayed.

public static void GetEpisodeImages(Show show)
{
    foreach (var episode in show.Seasons.SelectMany(season => season.Episodes))
    {
        episode.IsLoading = true;
    }

    Task.Factory.StartNew(() =>
        {
            foreach (var season in show.Seasons.OrderByDescending(w=>w.SeasonNumber))
            {
                foreach (var episode in season.Episodes.OrderByDescending(w=>w.EpisodeNumber))
                {
                    Episode e = episode;

                    if (episode.ImageUrl != null)
                    {
                        string extension = Path.GetExtension(e.ImageUrl);
                        string file = string.Format("{0}_{1}_{2}{3}", show.ShowId, season.SeasonNumber, e.EpisodeNumber, extension);
                        string folder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ConfigurationManager.AppSettings["IMAGE_CACHE"]);

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

                        string filename = Path.Combine(folder, file);

                        if (!File.Exists(filename))
                        {
                            using (WebClient web = new WebClient())
                            {
                                web.DownloadFile(e.ImageUrl, filename);
                            }
                        }

                        Execute.BeginOnUIThread(() =>
                            {
                                if (extension.ToUpper() == ".PNG")
                                {
                                    Stream imageStreamSource = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.Read);
                                    PngBitmapDecoder decoder = new PngBitmapDecoder(imageStreamSource, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.Default);
                                    e.ImageSource = decoder.Frames[0];
                                }
                                else
                                {
                                    try
                                    {
                                        e.ImageSource = new BitmapImage(new Uri(filename));
                                    }
                                    catch
                                    {
                                        //File most likely corrupted
                                        File.Delete(filename);
                                    }
                                }
                            });
                    }

                    Execute.BeginOnUIThread(() => e.IsLoading = false);
                }
            }
        });
}

 

Persistence

The LocalJsonPersistanceManager class is used to save and load information from a data store. In this case the hard-disk. This class uses one main file that can be found in your Application Folder’s “Data” directory. It’s called index.json. This is where all the show and episode data is stored. The Images that are downloaded are all stored in the Application Folder’s “ImageCache” folder.

 

Extending the project

Sojurn has a few extendable parts.

  1. Information Providers: This is the bit of code that takes user requests for TV shows and contacts an external API to provide the necessary information in the format Sojurn expects.
  2. Persistance Managers: This is responsible for saving information to the disk.
  3. Stream Providers: This provides a link to a streaming site to quickly watch the show.

These are all defined in the config file like so:

<add key="InformationProvider" value="Jarloo.Sojurn.InformationProviders.TvMazeInformationProvider"/>
<add key="PersistanceManager" value="Jarloo.Sojurn.Data.LocalJsonPersistenceManager"/>
<add key="StreamProvider" value="Jarloo.Sojurn.StreamProviders.PutLockerStreamProvider"/>

If you write your own class for any of these, you can replace the default one by modifying the App.config and entering your own class. The MainViewModel will dynamically create your class in the base constructor and use dependence injection to pass it to another constructor.

[ImportingConstructor]
public MainViewModel(IWindowManager windowManager)
: this(
windowManager,
(IInformationProvider)Activator.CreateInstance(Type.GetType(ConfigurationManager.AppSettings["InformationProvider"])),
(IPersistenceManager)Activator.CreateInstance(Type.GetType(ConfigurationManager.AppSettings["PersistanceManager"])),
(IStreamProvider)Activator.CreateInstance(Type.GetType(ConfigurationManager.AppSettings["StreamProvider"])))
{
}

 

Conclusion

This was a fun application to build and I’m happy with the result, hopefully you’ll find it useful.

The app uses several icons for the user interface. These are all free created by the wonderfull WindowsIcons project. (license file included in the download)

Categories:   Code

Comments

Sorry, comments are closed for this item.