Jarloo

Menu

Sojurn – The TV App

Fork me on GitHub

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

  • Posted: July 28, 2013 14:09

    Simon

    Fantastic code even though it is far beyond my capabilities (I am quite new to coding but a fast learner. Tell me, how would you do to get a picture from a URL streamed to an imagesource without saving it to disk as I am dealing with thousands and it would just consume far too much space in the end. Regards, Simon
    • Posted: July 28, 2013 17:45

      Kelly Elias

      Your looking for the MemoryStream. Take this code from Sojurn and replace the FileStream with a MemoryStream and it will not write to disk but still load into the ImageSource. Keep in mind each image consumes memory so you can't show thousands at once. 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];
  • Posted: July 31, 2013 01:41

    Simon

    Okay, I am in awe of your coding there and I can't help but suspect it was easy for you. WOudl you be able to tell me where (you went or)I can go to learn to use Json in my wpf project and especially how to use those Persistance managers you implemented in your project (I suspect that is not the proper name of it since google is not helping here). Seriously, hoping I can learn a new way of data usage by analysing your project if you don't mind.
    • Posted: July 31, 2013 08:47

      Kelly Elias

      For this project I decided to store the data in JSON format, but it could have been binary, XML or any other format really. That's why I ensured I created an interface the IPersistanceManager class so that a different PersistanceManager could be swapped in instead if desired. JSON has a few things going for it over XML. 1. It's smaller so it takes less time to transfer large amounts of data in JSON then in XML. 2. The DataContractJsonSerializer can serialize a dictionary!! (can't do that with the XML one out of the box) As for learning JSON I've been working with it for sometime, but there really isn't one place I can point you at to learn it. http://www.json.org/ is a decent resource if your trying to understand how the format works, but really I would just recommend you build a few objects and serialize them to json and look at the results. One thing of note is you'll notice the attributes on the models such as the Episode class. The DataContract, DataMemeber, IgnoreDataMember etc provide information to the DataContractJsonSerializer to tell it how to create the JSON.
  • Posted: August 25, 2013 20:22

    Anthony

    Can you show me how to show a window
  • Posted: August 25, 2013 22:27

    johnny

    I need help showing a window
  • Posted: September 3, 2014 12:44

    cablehead

    Just noticed that when all shows are deleted - or you delete one show- the image cache always keeps a copy
    • Posted: September 3, 2014 20:29

      Kelly Elias

      You are correct. I've updated the code, it now scans the image cache folder on startup and removes any unused images. (Get the code off GitHub.) I would have preferred to remove them immediately but because of the relationship between the View and ViewModel the image files were locked because they were still being used by the View. I could have got around that issue by queuing the delete and handling it later in a more lazy fashion but that just seemed to bother me. So for now it does it on startup.
      • Posted: September 3, 2014 21:03

        cablehead

        Thanks - one more thing - Im trying to fiddle with your control and add a flyout at add show - any ideas
      • Posted: September 4, 2014 18:24

        Kelly Elias

        Sorry I've never tried out the flyout functionality of MahApps.metro before so I don't think I can be of much help there.
  • Posted: September 6, 2014 08:29

    cablehead

    I managed to get a FlyOut to contain the AddShowView xaml - everyrthing looks great but I cannot figure out how to add the AddShowViewModel to the FlyOut - is it possible
  • Posted: September 16, 2014 13:01

    cablehead

    One question - How does the Add button become Enabled on the ListBox selection_change - I cannot find any code in the ViewModel that would do it Thanks
    • Posted: September 18, 2014 18:22

      Kelly Elias

      A bit of Caliburn.micro wizardry. In the ViewModel there is a property named CanAddShow. The button is named AddShow. Calburn will look for something with the same name and the word "Can" before it to determine the items state. Can see it here: https://caliburnmicro.codeplex.com/wikipage?title=Basic%20Configuration%2C%20Actions%20and%20Conventions
  • Posted: October 29, 2014 09:44

    Andrew

    Ok, dumb question, but how do I actually open up this app and use it?
    • Posted: October 29, 2014 19:43

      Kelly Elias

      The code is hosted on GitHub. If your just looking for the binaries to run the app go here: https://github.com/kelias/Jarloo.Sojurn/releases and download it then run the EXE. If you want the code it's also there on GitHub you can download it, fork it whatever you like. Keep in mind the data comes from TVRage so can sometimes be off, but the vast majority of time is fine. I use the app daily to track my shows and see whats coming up in my queue.
      • Posted: September 16, 2015 06:25

        Jack

        Hi Kelly, I realised that TVRage seems to be down. Any alternatives ? Thanks
      • Posted: September 21, 2015 10:19

        Kelly Elias

        Appears to be back up, but it is spotty. TheMovieDb is one that could serve as a replacement, but I haven't worked with it enough yet.
  • Posted: December 31, 2014 10:57

    David Daly

    Hi, Is there a tutorial document showing how to load the app on your PC? For example, after downloading the source files can you open a index file in a browser? I'm trying to open this on my PC.
  • Posted: December 31, 2014 11:01

    David Daly

    Hi, I'm sorry, I've seen my question was answered. My PC is not allowing me to open the app. Windows 8.1 says 'Windows protected your PC from opening a potentially harmful program' Can you help please?