-->

dev rss

I have a few old podcast series that are not available online anymore. Every now and then, I enjoy listening to an old episode. I keep them in a hard drive connected to a Raspberry Pi, which serves them over the local network. Then I connect to this share on my mobile device and consume the content. It works fine most of the time. The problem is it’s hard to remember the last episode I listened to since everything is treated as files with no history. I thought I could leverage my podcast app on my phone if I served these files via an RSS feed. This tutorial will show how to generate the RSS feed using C# and serve the content over your local network. If this sounds like a problem you would like to solve, let’s get started.

Prerequisites

To follow this tutorial, you need the following software installed:

Set up the Web Server

Since you will host only static files (RSS feed which is an XML file and some audio files), an Nginx instance running in a Docker container is sufficient.

First, designate a local directory on your computer to put the files. In the tutorial, I will use the following path: ~/Temp/webroot. Modify this to match your environment.

Run the following command to start your podcast server:

docker run --name podcast-server -p 9876:80 -v ~/Temp/webroot:/usr/share/nginx/html:ro -d nginx

The command above;

  • Maps port 9876 on your machine to the internal port 80 in the container. (-p 9876:80)

  • Runs the container in the background as a daemon (-d)

  • Mounts the ~/Temp/webroot directory on your machine to the /usr/share/nginx/html directory on the container. This means when Nginx serves content in its HTML directory, it looks into the ~/Temp/webroot directory. This way, you can manage the content without going into the container’s file system.

If you open a browser tab and go to http://localhost:9876, you should get a 403 Forbidden response from the web server. This is expected because you haven’t put any files to serve yet.

Set up Content

To test the application, let’s start with a small amount of content. Go to file-examples.com and download 2 MP3 files and rename them as “episode1.mp3” and “episode2.mp3”. So the root of your web server should look like this:

Contents of the root directory showing webroot directory, content directory under it and two files named episode1.mp3 and episode2.mp3

Now, if you request one of these files in your browser (e.g. http://localhost:9876/content/episode1.mp3), you should be able to hear the MP3 playing. In the next section, you will implement the application that creates the RSS feed so that you can consume the feed via your podcatcher too.

Implement the RSS Generator

An RSS feed is simply an XML file. It includes the name and description of the show, as well as the titles and URLs of the individual episodes. If this feed were meant to be published publicly, you would add more details such as icons, categories, iTunes-specific tags etc., but for personal consumption, the following format is sufficient:

<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
  <channel>
    <title>{Show Title}</title>
    <description>{Show Description}</description>
    <category>{Category}</category>
    <item>
      <title>{Episode Title}</title>
      <description>{Episode Description}</description>
      <enclosure url="{Episode URL}" type="audio/mpeg"/>
    </item>
  </channel>
</rss>

Create a new dotnet console application:

dotnet new console --name RssFeedGenerator --output .

Open the project with your IDE.

To serialize to the XML above, you will need a data structure. There are tools that can generate C# classes from a sample XML, so you don’t have to manually create the classes yourself. I generated the following at Xml2Charp. The result looks like this (after a bit of formatting):

/* 
 Licensed under the Apache License, Version 2.0
 
 http://www.apache.org/licenses/LICENSE-2.0
 */

using System.Xml.Serialization;

namespace RssFeedGenerator
{
    [XmlRoot(ElementName="enclosure")]
    public class Enclosure
    {
        [XmlAttribute(AttributeName="url")]
        public string Url { get; set; }
        [XmlAttribute(AttributeName="type")]
        public string Type { get; set; }
    }

    [XmlRoot(ElementName="item")]
    public class Item
    {
        [XmlElement(ElementName="title")]
        public string Title { get; set; }
        [XmlElement(ElementName="description")]
        public string Description { get; set; }
        [XmlElement(ElementName="enclosure")]
        public Enclosure Enclosure { get; set; }
    }

    [XmlRoot(ElementName="channel")]
    public class Channel
    {
        [XmlElement(ElementName="title")]
        public string Title { get; set; }
        [XmlElement(ElementName="description")]
        public string Description { get; set; }
        [XmlElement(ElementName="category")]
        public string Category { get; set; }
        [XmlElement(ElementName="item")]
        public List<Item> Item { get; set; }
    }

    [XmlRoot(ElementName="rss")]
    public class Rss
    {
        [XmlElement(ElementName="channel")]
        public Channel Channel { get; set; }
        [XmlAttribute(AttributeName="version")]
        public string Version { get; set; }
    }
}

Create a file called Rss.cs in your project and paste the above code. The biggest change I made to the auto-generated version is to replace the single Item property in the Channel class with a **List**, as you will need multiple entries per podcast.

Now, update Program.cs with the code below:

using System.Xml.Serialization;
using RssFeedGenerator;

string serverIPAddress = "192.168.1.20";
int serverPort = 9876;
var contentFullPath = "/Temp/webroot/content";
var feedFullPath = "/Temp/webroot/feed.rss";
var audioRootUrl = $"http://{serverIPAddress}:{serverPort}";

var rss = new Rss
{
    Version = "2.0",
    Channel = new Channel
    {
        Title = "[Local] Test Podcast",
        Description = "Testing generating RSS feed from local MP3 files",
        Category = "test",
        Item = new List<Item>()
    }
};

var allMp3s = new DirectoryInfo(contentFullPath)
    .GetFiles("*.mp3", SearchOption.AllDirectories)
    .OrderBy(x => x.Name);

foreach (var mp3 in allMp3s)
{
    rss.Channel.Item.Add(new Item
    {
        Description = mp3.Name,
        Title = mp3.Name,
        Enclosure = new Enclosure
        {
            Url = $"{audioRootUrl}/{mp3.Directory.Name}/{mp3.Name}",
            Type = "audio/mpeg"
        }
    });
}

var serializer = new XmlSerializer(typeof(Rss));
using (var writer = new StreamWriter(feedFullPath))
{
    serializer.Serialize(writer, rss);
}

Make sure to update the settings at the top of the file before you run the application.

Run the application by running the following command in the terminal:

dotnet run

Under your web server’s root directory, you should see the feed.rss file that looks like this:

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" version="2.0">
  <channel>
    <title>[Local] Test Podcast</title>
    <description>Testing generating RSS feed from local MP3 files</description>
    <category>test</category>
    <item>
      <title>episode1.mp3</title>
      <description>episode1.mp3</description>
      <enclosure url="http://192.168.1.20:9876/content/episode1.mp3" type="audio/mpeg" />
    </item>
    <item>
      <title>episode2.mp3</title>
      <description>episode2.mp3</description>
      <enclosure url="http://192.168.1.20:9876/content/episode2.mp3" type="audio/mpeg" />
    </item>
  </channel>
</rss>

At this point, you have your RSS feed and all your content under your web server. The final step is to consume this content using your podcast app.

Add Your Podcast to your Podcast App

My podcast app of choice is Overcast. I’m very happy with it and have been using it for many years. The following might be a limitation of Overcast, but apparently, it cannot access feeds over the local network. So to tackle this issue, I used NGrok to tunnel web traffic to my local web server.

If you are having the same issue, install ngrok and run the following command:

ngrok http 9876

This should generate a public URL and route traffic to your local server. In my case, it looks like this:

ngrok output showing the traffic is routed to localhost:9876

Now you can access your feed via the {public URL}/feed.rss.

In Overcast, I add the URL by clicking the + button on the top right and then clicking Add URL link.

After it fetches and parses the RSS feed, it should appear in your podcast list.

The only thing that’s left is to open the podcast and play the episodes:

Even though Overcast cannot fetch the RSS feed over the local network, it can still play the episodes locally. You can stop ngrok and continue to play the episodes. The downside of this approach is if this podcast is an active one and you want to refresh the feed, you will need to delete and re-add the feed because the ngrok address will have changed the next time you try to get the updates.

Conclusion

I love hosting my own content in my own network. Even though having some offline podcasts stored locally is not a common use case, I had to implement my solution to solve the issue and decided to make it public and share it in case anyone else would like to use the same approach or build on it and make it better. The final source code can be found in my GitHub repository.

dev google_sheets

Spreadsheets are quite powerful tools. They can also act as a simple database with an intuitive UI. You can use the spreadsheet as a temporary database and GSheets API as a CRUD API while you prototype your own application. This article will teach you how to manage Google Sheets using your C# application.

Prepare your spreadsheet

The sample application will be a simple shopping list manager console application. It will insert/update/delete items in the shopping list and keep a log of each event in another sheet.

You will need a Google Account to follow along. You can sign up for free here if you don’t have one.

While logged in to your Google account, open your Google Drive.

Click the New button on the top left, then click Google Sheets in the menu.

New menu shows available google services such as Google Docs, Google Sheets, Google Slides, Google Forms and a more link at the end

Name your spreadsheet Shopping List.

At the bottom of the screen, right-click on the sheet name (Sheet1) and rename it to Cart.

Set the value of A1 to Item and B1 to Quantity. You can style the cells the way you like.

Spreadsheet showing the title Shopping List, the value of A1 cell as "Item" and the value of B1 cell as "Quantity"

Now that the “database” is ready, move on to the next section to set up the permissions.

Generate Credentials

Go to Google API Console.

Click APIs & Services and Library

Search sheets and click Google Sheets API in the search results.

In the API settings, click the Enable button.

Click the Create Credentials button.

In the credential type, select Application Data.

It will ask if you’re planning to use it with Kubernetes etc. Select “No, I’m not using them”.

Click Next.

In the service account settings, enter shopping-list-service-account as the service account name and click Create and Continue.

Click Continue to proceed.

Click Done to finish the account creation.

Click Credentials and the new service account.

Click Keys and Add Key.

Click Create new key, keep JSON as the selected option and click Create.

A download should automatically start with your credentials. You will need this file later on.

Finally, you need to share your spreadsheet with the new service account. Go to your sheet and click the Share button.

Copy the email address generated for your service account and paste it into the Add people and groups textbox.

Click the Share button and close the dialog.

Now you can proceed to create the application.

Implement the sample application

In a terminal, navigate to the root directory that you want to create the project in and run:

dotnet new console --name ShoppingList --output .

Add the necessary Google Sheets SDK, via NuGet:

dotnet add package Google.Apis.Sheets.v4

Copy the downloaded credentials to the project folder and open the project with your IDE.

To access your spreadsheet fro myour program, you will need the id of the sheet which you can find in the URL:

First things first: Confirm your access to your spreadsheet. To achieve that, replace the code in Program.cs with the following code:

using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;

var spreadsheetId = "{ YOUR SPREADSHEET'S ID }";
var range = "Cart!A1:B";

GoogleCredential credential;
using (var stream = new FileStream("credentials.json", FileMode.Open, FileAccess.Read))
{
    credential = GoogleCredential.FromStream(stream).CreateScoped(new string[] { SheetsService.Scope.Spreadsheets });
}

var sheetsService = new SheetsService(new BaseClientService.Initializer()
{
    HttpClientInitializer = credential,
    ApplicationName = "ShoppingList"
});

SpreadsheetsResource.ValuesResource.GetRequest getRequest = sheetsService.Spreadsheets.Values.Get(spreadsheetId, range);
       
var getResponse = await getRequest.ExecuteAsync();
IList<IList<Object>> values = getResponse.Values;
if (values != null && values.Count > 0)
{
    foreach (var row in values)
    {
        Console.WriteLine(row[0]);
        Console.WriteLine(row[1]);
    }
}

Replace { YOUR SPREADSHEET'S ID } with the actual value and run the application. The column titles (Item and Quantity) should be displayed in your terminal.

You can add some items to your shopping list and test again.

Before going further, refactor the code. You will encapsulate all GSheets-related functions in a called GSheetsHelper.cs. Create the file and update the code as below:

using Google.Apis.Auth.OAuth2;
using Google.Apis.Services;
using Google.Apis.Sheets.v4;

namespace ShoppingList;

public class GSheetsHelper
{
    private SheetsService _sheetsService;
    private string _spreadsheetId = "{ YOUR SPREADSHEET'S ID }";
    private string _range = "Cart!A2:B";
    
    public GSheetsHelper()
    {
        GoogleCredential credential;
        using (var stream = new FileStream("credentials.json", FileMode.Open, FileAccess.Read))
        {
            credential = GoogleCredential.FromStream(stream).CreateScoped(SheetsService.Scope.Spreadsheets);
        }

        _sheetsService = new SheetsService(new BaseClientService.Initializer()
        {
            HttpClientInitializer = credential,
            ApplicationName = "ShoppingList"
        });        
    }

    public async Task PrintCartItems()
    {
        SpreadsheetsResource.ValuesResource.GetRequest getRequest = _sheetsService.Spreadsheets.Values.Get(_spreadsheetId, _range);
       
        var getResponse = await getRequest.ExecuteAsync();
        IList<IList<Object>> values = getResponse.Values;
        if (values != null && values.Count > 0)
        {
            Console.WriteLine("Item\t\t\tQuantity");
            
            foreach (var row in values)
            {
                Console.WriteLine($"{row[0]}\t\t\t{row[1]}");
            }
        }
    }
}

Run the application now and you should see your items in your cart printed in your terminal:

Convert the application to a CLI

You now have the functionality to get your cart, but it will do the same thing every time. To add more commands, convert your application into a CLI. First, add the System.CommandLine package to your project:

dotnet add package System.CommandLine --version 2.0.0-beta4.22272.1

If you are interested in developing your own CLIs with C#, make sure to check out these articles as well: Develop your own CLI with C# and How to Develop an Interactive CLI with C# and dotnet 6.0

Create your first command, the same functionality as above, to print the cart items. Replace Program.cs with the following code:

using System.CommandLine;
using ShoppingList;

var rootCommand = new RootCommand("Manage your shopping cart");

var printCartCommand = new Command("print", "Print the items in the cart");
printCartCommand.SetHandler(async () =>
{
    var gsheetsHelper = new GSheetsHelper();
    try
    {
        await gsheetsHelper.PrintCartItems();
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
});
rootCommand.AddCommand(printCartCommand);

return rootCommand.InvokeAsync(args).Result;

Run the application with dotnet run command and you should now see an info message explaining the supported commands:

You now have to specify the command name to print the items. Run it as dotnet run print to pass the command name and you should see the contents of your cart again.

Add Items yo your Cart

The program can now be enhanced simply by adding more commands.

Add the following function to GSheetsHelper.cs:

public async Task AddItem(string itemName, decimal quantity)
{
    var valuesToInsert = new List<object>
    {
        itemName,
        quantity
    };

    SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum valueInputOption = SpreadsheetsResource.ValuesResource.AppendRequest.ValueInputOptionEnum.RAW;
    SpreadsheetsResource.ValuesResource.AppendRequest.InsertDataOptionEnum insertDataOption = SpreadsheetsResource.ValuesResource.AppendRequest.InsertDataOptionEnum.INSERTROWS;

    var requestBody = new ValueRange();
    requestBody.Values = new List<IList<object>>();
    requestBody.Values.Add(valuesToInsert);

    SpreadsheetsResource.ValuesResource.AppendRequest appendRequest = _sheetsService.Spreadsheets.Values.Append(requestBody, _spreadsheetId, _range);
    appendRequest.ValueInputOption = valueInputOption;
    appendRequest.InsertDataOption = insertDataOption;
    
    await appendRequest.ExecuteAsync();
}

To invoke this method, add the command to Program.cs:

var itemNameOption = new Option<string>(
    new[] {"--item-name", "-n"},
    description: "The name of the item"
);
itemNameOption.IsRequired = true;

var quantityOption = new Option<decimal>(
    new[] {"--quantity", "-q"},
    description: "The quantity of the item"
);
quantityOption.IsRequired = true;

var addItemCommand = new Command("add", "Add an item to the cart")
{
    itemNameOption,
    quantityOption
};
addItemCommand.SetHandler(async (itemName, quantity) =>
{
    var gsheetsHelper = new GSheetsHelper();
    try
    {
        await gsheetsHelper.AddItem(itemName, quantity);
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, itemNameOption, quantityOption);
rootCommand.AddCommand(addItemCommand);

Run the command with some item like this:

dotnet run add --item-name Steaks --quantity 2

You should see the new item in your cart:

Remove Items From Your Cart

To have remove functionality, add the following method to GSheetsHelper:

public async Task RemoveItem(string itemName)
{
    SpreadsheetsResource.ValuesResource.GetRequest getRequest = _sheetsService.Spreadsheets.Values.Get(_spreadsheetId, _range);
    
    var getResponse = await getRequest.ExecuteAsync();
    IList<IList<Object>> values = getResponse.Values;
    if (values != null && values.Count > 0)
    {
        for (int i = 0; i < values.Count; i++)
        {
            if (values[i][0].ToString() == itemName)
            {
                var request = new Request
                {
                    DeleteDimension = new DeleteDimensionRequest
                    {
                        Range = new DimensionRange
                        {
                            SheetId = 0,
                            Dimension = "ROWS",
                            StartIndex = i + 1,
                            EndIndex = i + 2
                        }
                    }
                };
                
                var deleteRequest = new BatchUpdateSpreadsheetRequest {Requests = new List<Request> {request}};
                var batchUpdateRequest = new SpreadsheetsResource.BatchUpdateRequest(_sheetsService, deleteRequest, _spreadsheetId);
                await batchUpdateRequest.ExecuteAsync();
            }
        }
    }
}

Similar to add command, define it in Program.cs by adding the following code block:

var removeItemCommand = new Command("remove", "Remove an item from the cart")
{
    itemNameOption
};
removeItemCommand.SetHandler(async (itemName) =>
{
    var gsheetsHelper = new GSheetsHelper();
    try
    {
        await gsheetsHelper.RemoveItem(itemName);
    }
    catch (Exception e)
    {
        Console.Error.WriteLine(e.Message);
    }
}, itemNameOption);
rootCommand.AddCommand(removeItemCommand);

Run the application as dotnet run remove -n Milk, and you should see the item removed from your cart.

Conclusion

This article covered the basics of setting up a new Google Sheets spreadsheet, creating API credentials and granting access to the sheet. It also showed how to develop a basic CLI to list, add and remove items from the spreadsheet. You can get the final version of the application from my GitHub repo.

Even though it’s a simple project, I hope it helped you learn the basics of managing a Google Sheets spreadsheet.

dev magpi, downloader, dotnet

There is a slightly different version of this article published recently on Twilio Blog: Get notified of new magazine issues using web scraping and SMS with C# .NET. That version uses a worker service rather than a scheduled console application and uses SMS as the notification channel. The article you’re about to read has the extra step of importing the PDFs into Calibre. If you’re interested in the topic, I recommend checking out both versions.

As a Raspberry PI fan, I like to read The MagPi Magazine, which is freely available as PDFs. The problem is I tend to forget to download it manually every month, so I decided to automate the process. I use Calibre as my ebook management software (I blogged about my setup here).

The MagPi Magazine Tracker Architecture and Workflow

I wanted this project to periodically check the latest issue, download it when it’s available and import it into Calibre and send me a notification email so that I can connect to my ebook library and check out the issue. So here’s the architecture to achieve this goal:

The MagPi Magazine Tracker Architecture and Workflow diagram

Here’s the workflow:

  1. The application is triggered based on a schedule. Since MagPi is a monthly magazine, it should be fine to run it every week.
  2. The application fetches the MagPi page and parses the HTML to find out the latest issue.
  3. It then checks its database (a flat file or a JSON would suffice for this project).
  4. If it’s a new issue, it then downloads the PDF to the local file system.
  5. It imports the PDF into Calibre using Calibre’s CLI.
  6. It sends a notification telling the user that a new issue is available.
  7. The user (which is me!) connects to calibre-web (the web frontend I used to view my Calibre libraries) and reads the magazine.

Prerequisites

The full source code is freely available on my GitHub repo if you’re just interested in getting a copy of the application and playing around with it. If you’re new to GitHub, you might want to have a look at this article: How to clone a GitHub repository.

To follow along and implement the project, you will need the following:

  • Calibre
  • A Twilio SendGrid account (with an API key and sender address set up. The beginning of this article may be useful to set up your account)

Implementation

For a scheduled task, you generally have two options:

  • External scheduler: This is generally part of the operating system (such as Task Scheduler for Windows and Crontab for macOS/Linux)
  • Internal scheduler: Run the main application in an infinite loop with sleeping the amount of time you want to wait for the next run.

I think an internal scheduler works better if your application needs to run quite often, like every hour or so. Using an external scheduler makes more sense to me for longer cycles, like running an application once a week, such as this project. Therefore, I’m going to implement it using a Console Application and schedule it using Crontab. If you are on Windows, you can take a look at this article or search for scheduling tasks on Windows.

To create a Console Application, run the following commands at the root level of your project:

mkdir MagPiTracker
cd MagPiTracker
dotnet new console

Persistence

Let’s start with the persistence layer. All you need to read/write is the latest issue you saved in your Calibre library, so create a folder called Persistence and add an interface named IMagPiRepository.cs that looks like this:

namespace MagPiTracker.Persistence;

public interface IMagPiRepository
{
    Task<int> GetLastSavedIssueNumber();
    Task SaveLastSavedIssueNumber(int newIssueNumber);
}

The actual implementation is going to be a simple JSON reader/writer for this project. You can choose to implement a SQLite database or a simple txt file. For JSON, add the Newtonsoft.Json package to your project by running:

dotnet add package NewtonSoft.Json

Then, create a file called db.json and set the initial value to 0:

{
  "lastSavedIssueNumber": 0
}

Also, make sure that it is going to be copied to the output directory. Right-click on properties and set Copy to output directory to Copy always. I prefer Copy always to Copy if newer because it’s more straightforward and predictable.

Create a new file named JsonMagPiRepository.cs under the Persistence folder. Update the code as below:

using Newtonsoft.Json.Linq;

namespace MagPiTracker.Persistence;

public class JsonMagPiRepository : IMagPiRepository
{
    private const string DB_PATH = "./persistence/db.json";

    public async Task<int> GetLastSavedIssueNumber()
    {
        var rawContents = await File.ReadAllTextAsync(DB_PATH);
        return JObject.Parse(rawContents).GetValue("lastSavedIssueNumber").Value<int>();
    }

    public async Task SaveLastSavedIssueNumber(int newIssueNumber)
    {
        var newValue = new { lastSavedIssueNumber = newIssueNumber };
        await File.WriteAllTextAsync(DB_PATH, Newtonsoft.Json.JsonConvert.SerializeObject(newValue));
    }
}

To test the data layer, update Program.cs with the following code:

using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

// Test write and read back
await repository.SaveLastSavedIssueNumber(120);
Console.WriteLine($"[Test] Last Saved Issue Number: {await repository.GetLastSavedIssueNumber()}");

The output should look like this:

Running The MagPi Tracker...
Last Saved Issue Number: 0
Test Last Saved Issue Number: 120

Issue Checker

The next task is to implement the service to check the latest available issue on The MagPi Magazine page. What we need to get out of this service is:

  • The latest issue number
  • The link to the PDF
  • The link to the cover image (to make the notification email prettier)

So create an interface called IMagPiService.cs that looks like this:

namespace MagPiTracker.MagPi;

public interface IMagPiService
{
    Task<int> GetLatestIssueNumber();
    Task<string> GetIssuePdfUrl(int issueNumber);
    Task<string> GetLatestIssueCoverUrl();
}

Create a class called MagPiService that implements the interface, and that looks like this initially:

namespace MagPiTracker.MagPi;

public class MagPiService : IMagPiService
{
    public async Task<int> GetLatestIssueNumber()
    {
        throw new NotImplementedException();
    }

    public async Task<string> GetIssuePdfUrl(int issueNumber)
    {
        throw new NotImplementedException();
    }

    public async Task<string> GetLatestIssueCoverUrl()
    {
        throw new NotImplementedException();
    }
}

Now, let’s focus on getting the latest issue number. The easiest way to find the latest issue number is by going to the issues page, which looks like this at the time of this writing:

The MagPI Magazine issues page showing the latest issue

We’re going to utilize some web scraping to get the job done. In this project, I used a library called AngleSharp to achieve this. Run the following command to add it to your project:

dotnet add package AngleSharp

Then, update your GetLatestIssueNumber implementation as shown below:

using AngleSharp;

namespace MagPiTracker.MagPi;

public class MagPiService : IMagPiService
{
    private const string MAGPI_ROOT_URL = "https://magpi.raspberrypi.com";
    
    public async Task<int> GetLatestIssueNumber()
    {
        var config = AngleSharp.Configuration.Default.WithDefaultLoader();
        var context = BrowsingContext.New(config);
        var document = await context.OpenAsync($"{MAGPI_ROOT_URL}/issues/");
        var latestCoverLinkSelector = ".c-latest-issue > .c-latest-issue__cover > a";
        var latestCoverLink = document.QuerySelector(latestCoverLinkSelector);
        var rawLink = latestCoverLink.Attributes.GetNamedItem("href").Value;
        return int.Parse(rawLink.Substring(rawLink.LastIndexOf('/') + 1));
    }

    public async Task<string> GetIssuePdfUrl(int issueNumber)
    {
        throw new NotImplementedException();
    }

    public async Task<string> GetLatestIssueCoverUrl()
    {
        throw new NotImplementedException();
    }
}

To put this to the test, update your Program.cs as below and run the application:

using MagPiTracker.MagPi;
using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var magpiService = new MagPiService();

var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

var latestIssueNumber = await magpiService.GetLatestIssueNumber();
Console.WriteLine($"Latest Issue Number: {latestIssueNumber}");

You should see an output similar to this:

Running The MagPi Tracker...
Last Saved Issue Number: 120
Latest Issue Number: 121

Your latest issue will probably be different depending on when you are running it.

Download the PDF

The next challenge is to find the direct link to the PDF. If you click the Download Free PDF link, the page does not start the download automatically. Instead, you land on a donation page that looks like this:

The MagPi Magazine donation page

I’d strongly recommend everybody to consider donating. This is a great magazine with professional quality, and it’s full of valuable knowledge about everything Raspberry Pi.

Since they are allowing free downloads and Raspberry Pi is mostly a favourite among maker-community, I’m hoping they wouldn’t mind this little project.

If you click on the “No thanks, take me to the free PDF” link, the PDF download starts automatically. This is actually done by a redirect that contains an iframe with the src property set to the URL of the PDF. So to download the PDF, you need to parse the URL.

Create a new project directory called Downloader and add a new interface named IDownloadService.cs that looks like this:

namespace MagPiTracker.Downloader;

public interface IDownloadService
{
    Task DownloadFile(string url, string localPath);
}

As you can tell from the method name and its arguments, this service is going to download the file at the given URL and save it to the local file system.

For the actual implementation, create a class called DownloadService implementing the interface and update the code with this:

namespace MagPiTracker.Downloader;

public class DownloadService : IDownloadService
{
    public async Task DownloadFile(string url, string localPath)
    {
        using HttpClient client = new HttpClient(); // use HttpClient factory in production
        using HttpResponseMessage response = await client.GetAsync(url);
        using Stream downloadedFileStream = await response.Content.ReadAsStreamAsync();
        
        using (var localFileStream = new FileStream(localPath, FileMode.Create, FileAccess.Write))
        {
            await downloadedFileStream.CopyToAsync(localFileStream);
        }
    }
}

To test these changes, update the Program.cs file with the following code:

using MagPiTracker.Downloader;
using MagPiTracker.MagPi;
using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var magpiService = new MagPiService();
var downloadService = new DownloadService();

var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

var latestIssueNumber = await magpiService.GetLatestIssueNumber();
Console.WriteLine($"Latest Issue Number: {latestIssueNumber}");

if (latestIssueNumber > lastSavedIssueNumber)
{
    var pdfUrl = await magpiService.GetIssuePdfUrl(latestIssueNumber);
    var localPath = $"TheMagPiMagazine_{latestIssueNumber.ToString().PadLeft(3, '0')}.pdf";
    await downloadService.DownloadFile(pdfUrl, localPath);
    Console.WriteLine($"Latest Issue PDF has been saved to {localPath}");
}
else
{
    Console.WriteLine($"No new issue found. Exiting.");
}

The final version of the application checks its database and compares it to the latest issue. If the latest one is newer, then it downloads the PDF. Run the application, and you should see the new PDF downloaded to your local machine. Your output should look like this:

Running The MagPi Tracker...
Last Saved Issue Number: 120
Latest Issue Number: 121
Latest Issue PDF has been saved to TheMagPiMagazine_121.pdf

Importing to Calibre

The next step is to import this file into Calibre. An easy way to wrap external CLIs is the CliWrap library. Add it to your project via NuGet by running the command below:

dotnet add package CliWrap

Create a new folder called Calibre and a new interface called ICalibreService.cs under it.

Update the interface with this code:

namespace MagPiTracker.Calibre;

public interface ICalibreService
{
    Task ImportMagPiMagazine(int issueNumber, string pdfPath);
}

This method is going to be tailored for The MagPi Magazine. The MagPi-related information can be stripped out of the method and put somewhere else, like a config file, but since I’m not aiming to make this a generic downloader, for the time being, it should do the job.

Create the implementation class named CalibreService and implement the interface like this:

using System.Text;
using CliWrap;

namespace MagPiTracker.Calibre;

public class CalibreService : ICalibreService
{
    private const string LIBRARY_PATH = "{PATH TO YOUR CALIBRE LIBRARY}";
    
    public async Task ImportMagPiMagazine(int issueNumber, string pdfPath)
    {
        var issueTitle = $"The MagPi Issue {issueNumber.ToString().PadLeft(3, '0')}";
        var authors = "Raspberry Pi Press";
        var series = "The MagPi Magazine";
        
        var stdOutBuffer = new StringBuilder();
        var stdErrBuffer = new StringBuilder();
        
        await Cli.Wrap("/Applications/calibre.app/Contents/MacOS/calibredb")
            .WithArguments($"add --title \"{issueTitle}\" --with-library \"{LIBRARY_PATH}\" --authors \"{authors}\" --series \"{series}\" \"{pdfPath}\"")
            .WithStandardOutputPipe(PipeTarget.ToStringBuilder(stdOutBuffer))
            .WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
            .ExecuteAsync();
        
        var stdOut = stdOutBuffer.ToString();
        var stdErr = stdErrBuffer.ToString();
        
        Console.WriteLine(stdOut);
        Console.WriteLine(stdErr);
    }
}

Make sure to update LIBRARY_PATH. Also, update the application path depending on your operating system.

Then, update the Program.cs like this:

using MagPiTracker.Calibre;
using MagPiTracker.Downloader;
using MagPiTracker.MagPi;
using MagPiTracker.Notifications;
using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var magpiService = new MagPiService();

var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

var latestIssueNumber = await magpiService.GetLatestIssueNumber();
Console.WriteLine($"Latest Issue Number: {latestIssueNumber}");

if (latestIssueNumber > lastSavedIssueNumber)
{
    var pdfUrl = await magpiService.GetIssuePdfUrl(latestIssueNumber);
    var localPath = $"TheMagPiMagazine_{latestIssueNumber.ToString().PadLeft(3, '0')}.pdf";
 
    var downloadService = new DownloadService();
    await downloadService.DownloadFile(pdfUrl, localPath);
    Console.WriteLine($"Latest Issue PDF has been saved to {localPath}");
    
    var calibreService = new CalibreService();
    await calibreService.ImportMagPiMagazine(latestIssueNumber, new FileInfo(localPath).FullName);
    Console.WriteLine($"Latest Issue has been imported into Calibre");
}
else
{
    Console.WriteLine($"No new issue found. Exiting.");
}

Run the application, and you should see an output like this:

Running The MagPi Tracker...
Last Saved Issue Number: 120
Latest Issue Number: 121
Latest Issue PDF has been saved to TheMagPiMagazine_121.pdf
    /Users/.../scheduled-magpi-magazine-tracker/src/TheMagPiMagazine_121.pdf

The following books were not added as they already exist in the database (see --duplicates option or --automerge option):
  The MagPi Issue 121

Latest Issue has been imported into Calibre

You can go ahead and open your Calibre application, and you should see the newly imported issue in your library:

The latest MagPi issue shown in Calibre

Cover Image URL

In the previous section, we left out implementing the third method. This is not strictly necessary, but having the cover image would make your notification email look nicer. Also, from a practical point of view, if you’re not interested in the topics covered in that issue, you may delay looking at that issue.

To get the cover URL, revisit MagPiService class and update the GetLatestIssueCoverUrl method’s implementation as below:

public async Task<string> GetLatestIssueCoverUrl()
{
    var config = AngleSharp.Configuration.Default.WithDefaultLoader();
    var context = BrowsingContext.New(config);
    var document = await context.OpenAsync($"{MAGPI_ROOT_URL}/issues/");
    var latestCoverImageSelector = ".c-latest-issue > .c-latest-issue__cover > a > img";
    var latestCoverImage = document.QuerySelector(latestCoverImageSelector);
    var latestCoverImageUrl = latestCoverImage.Attributes.GetNamedItem("src").Value;
    
    return latestCoverImageUrl;
}

This will come in handy in the next section.

Notifications

It would be nice to know when a new issue is imported into your library, so the next step is to add a notification mechanism to the application. In this example, I will use email notifications as that’s the cheapest and simplest method. I will use SendGrid as my SMTP provider.

To store the API key, initialize dotnet user-secrets and create a new secret by running the following commands:

dotnet user-secrets init
dotnet user-secrets set SendGrid:ApiKey {YOUR API KEY}

In the project, create a new project directory called Notifications and a new interface called INewIssueNotificationService.cs with the following code:

namespace MagPiTracker.Notifications;

public interface INewIssueNotificationService
{
    Task SendNewIssueNotification(int issueNumber, string coverUrl);
}

Before implementing the class, add SendGrid SDK by running:

dotnet add package SendGrid

Now add a new class called EmailNotificationService.cs and update its code with this:

using System.Reflection;
using Microsoft.Extensions.Configuration;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace MagPiTracker.Notifications;

public class EmailNotificationService : INewIssueNotificationService
{
    public async Task SendNewIssueNotification(int issueNumber, string coverUrl)
    {
        IConfiguration config = new ConfigurationBuilder()
            .AddUserSecrets(Assembly.GetExecutingAssembly(), optional: true, reloadOnChange: false)
            .Build();
        
        var sendGridClient = new SendGridClient(apiKey: config["SendGrid:ApiKey"]);

        var from = new EmailAddress("{YOUR VERIFIED SENDER EMAIL ADDRESS}", "The MagPi Magazine Issue Checker");
        var to = new EmailAddress("{YOUR RECIPIENT EMAIL ADDRESS}", "{YOUR DISPLAY NAME}");

        var htmlContent = await File.ReadAllTextAsync("./notifications/email-template.html");
        var htmlWithData = htmlContent.Replace("%{COVER_URL}", coverUrl);
        
        var msg = MailHelper.CreateSingleEmail(from, to, "The MagPi Magazine New Issue", htmlWithData, htmlWithData);
        await sendGridClient.SendEmailAsync(msg);
    }
}

This code reads the API key from user secrets so that it’s never accidentally pushed to source control. Also, it reads the email template from an HTML file. Create a new file named email-template.html, and set it to be copied to the output always (as you did with db.json) and update its contents as shown below:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
<h1>New MagPi Magazine is out!</h1>
<p>
    <img src="%{COVER_URL}" />
</p>
</body>
</html>

There are better ways for variable replacement (using a templating engine such as Handlebars, Razor etc.), but to keep things simple, I just put a placeholder and replaced the string. Update the Program.cs to reflect the latest changes and test:

using MagPiTracker.Calibre;
using MagPiTracker.Downloader;
using MagPiTracker.MagPi;
using MagPiTracker.Notifications;
using MagPiTracker.Persistence;

Console.WriteLine("Running The MagPi Tracker...");

var repository = new JsonMagPiRepository();
var magpiService = new MagPiService();

var lastSavedIssueNumber = await repository.GetLastSavedIssueNumber();
Console.WriteLine($"Last Saved Issue Number: {lastSavedIssueNumber}");

var latestIssueNumber = await magpiService.GetLatestIssueNumber();
Console.WriteLine($"Latest Issue Number: {latestIssueNumber}");

if (latestIssueNumber > lastSavedIssueNumber)
{
    var pdfUrl = await magpiService.GetIssuePdfUrl(latestIssueNumber);
    var localPath = $"TheMagPiMagazine_{latestIssueNumber.ToString().PadLeft(3, '0')}.pdf";
 
    var downloadService = new DownloadService();
    await downloadService.DownloadFile(pdfUrl, localPath);
    Console.WriteLine($"Latest Issue PDF has been saved to {localPath}");
    
    var calibreService = new CalibreService();
    await calibreService.ImportMagPiMagazine(latestIssueNumber, new FileInfo(localPath).FullName);
    Console.WriteLine($"Latest Issue has been imported into Calibre");

    var latestIssueCoverUrl = await magpiService.GetLatestIssueCoverUrl();
    var notificationService = new EmailNotificationService();
    await notificationService.SendNewIssueNotification(latestIssueNumber, latestIssueCoverUrl);

    await repository.SaveLastSavedIssueNumber(latestIssueNumber);
    File.Delete(localPath);
}
else
{
    Console.WriteLine($"No new issue found. Exiting.");
}

In addition to the previous tasks, you are now sending the notification email. Once everything is done successfully, the code updates the local JSON file with the latest issue number so that it doesn’t download the same issue over and over again. Also, it deletes the local PDF to keep things nice and tidy.

Run the application, and you should receive a notification that looks like this:

Screenshot of the final notification email showing MagPi cover

You can add more stuff like the link to the PDF, issue number etc., but just to ping myself this much information is enough for me.

Scheduling

Let’s bring this home by scheduling the application so that it does its thing in an automated fashion.

As mentioned before, on Windows, I’d recommend using the built-in Task Scheduler. On macOS/Linux systems, crontab does the job.

Firstly, build your application and place the deployment package wherever you want to run the application. To edit cron jobs, run

crontab -e

I will run the application every Friday at 5 AM and will use this cron expression: 0 5 * * 5

To find where the dotnet executable is located, you can use the which command:

which dotnet

Also, the cron job will be run in a different working directory. To avoid path issues, it’s best to change to our application directory before running it. So the cron job looks like this:

0 5 * * 5 cd /Users/.../Deployment/MagPiTracker && /usr/local/share/dotnet/dotnet MagPiTracker.dll

Crontab uses the following syntax, and you can customize your schedule based on this:

* * * * * command
* - minute (0-59)
* - hour (0-23)
* - day of the month (1-31)
* - month (1-12)
* - day of the week (0-6, 0 is Sunday)

Why No Docker?

Normally I try to run everything in Docker containers. In this project, I chose to run the application on bare metal. The reason for this is to be able to import the PDFs into my Calibre library, which is also running on bare metal. If I were to run this application in Docker, I wouldn’t be able to run Calibre CLI on the host computer. If I didn’t have this constraint, I would have definitely Dockerized the application.

Conclusion

I hope you enjoyed this little project. As a reminder, please consider donating to the Raspberry Pi Press and use their own mechanism, but if you cannot afford it and since the PDFs are already available out there, you can go ahead and use this project and hopefully learn some new technologies along the way.