Learn Docker With My Newest Course

Dive into Docker takes you from "What is Docker?" to confidently applying Docker to your own projects. It's packed with best practices and examples. Start Learning Docker →

Formatting Seconds into HH:MM:SS with Elixir and Python

formatting-seconds-into-hh-mm-ss-with-elixir-and-python.jpg

This ended up being a fun exercise to learn Elixir a little better and I also realized how easy it was to do in Python too.

Quick Jump:

In the video course platform I’m currently developing, I’m saving the duration of each video as seconds in the database but I wanted to be able to display a more human readable format such as 8:42 for an 8 minute and 42 second video in the UI.

What we’re about to go over will work for both video and audio use cases because it boils down to accepting seconds as input and returning a string that’s formatted in a human readable way.

# Getting the Time Formatting Rules Squared Away

The format I wanted to go with is exactly how Youtube displays the current time and total length of a video in their video player.

The seconds get converted into HH:MM:SS but it’s not as straight forward as that by itself.

Here’s a few examples of how I wanted things to look like:

  • 7 seconds should be 0:07
  • 30 seconds should be 0:30
  • 1 minute should be 1:00
  • 12 minutes and 12 seconds should be 12:12
  • 1 hour, 23 minutes and 4 seconds should be 1:23:04

The rules are basically:

  • The left most value should not be zero padded
  • Every other value should be zero padded
  • If the seconds is less than 1 minute, add a 0 minute

In the end this creates visually appealing times without getting bogged down by unnecessary padding. For example, 1:05 is a lot nicer to read than 00:01:05 and it’s also slightly nicer than 01:05.

Not knowing if it’s 1 minute and 5 seconds or 1 hour and 5 minutes is a non-issue in this case because 99.9% of my videos are less than 1 hour, plus as soon as you see a video that’s greater than an hour you’ll understand the pattern.

Usually I prefer explicit over implicit but this is one case where I’m ok with going with a tiny bit of ambiguity for the sake of readability since the UI pattern here isn’t uncommon or weird to begin with. I think folks will immediately get it.

# Starting with Writing It in Python

Since Python is my primary language I decided to write it out in Python first by just brute forcing the problem until it worked. I wanted to see if I could solve the problem without any external libraries.

The code wasn’t pretty. It was filled with all sorts of math.floor calls, modulo mayhem and more. It was about 35 lines of code in the end. That was my first pass at solving the problem. It worked, so that’s good at least!

I knew I was going to throw that code away so I didn’t bother trying to refactor it into something nicer. But I did Google for a solution to the specific problem and found an answer on StackOverflow that used the divmod() function which I didn’t even know existed.

I modified the code a bit and what you see below is the end result. If you create a format_seconds.py file you can run it with python3 format_seconds.py 120 where 120 is whatever seconds you want to format.

import sys


one_minute = 60
one_hour = 3600


def seconds_to_hh_mm_ss(seconds):
    m, s = divmod(seconds, one_minute)
    h, m = divmod(m, one_minute)

    if seconds >= one_hour:
        return f"{h:d}:{m:02d}:{s:02d}"
    else:
        return f"{m:d}:{s:02d}"


seconds = int(sys.argv[1])

print(seconds_to_hh_mm_ss(seconds))

I’m pretty happy with that solution. It’s readable, quite short and if I wanted to solve the problem “for real” in a Python based web app that needed to display human readable times in the way I wanted then I would use this code.

# Rewriting It in Elixir

Elixir doesn’t have a divmod() function so for a first pass I pretty much ported my original Python code over line by line.

After a quick chat on the Elixir Slack channel folks pointed out that I was unnecessarily using Float.floor/2 so what we see below is a cleaned up version of my first pass.

If you create a format_seconds.exs file, you can run it with elixir format_seconds.exs 120:

defmodule Seconds do
  @one_minute 60
  @one_hour 3600

  def to_hh_mm_ss(seconds) when seconds >= @one_hour do
    h = div(seconds, @one_hour)

    m =
      seconds
      |> rem(@one_hour)
      |> div(@one_minute)
      |> pad_int()

    s =
      seconds
      |> rem(@one_hour)
      |> rem(@one_minute)
      |> pad_int()

    "#{h}:#{m}:#{s}"
  end

  def to_hh_mm_ss(seconds) do
    m = div(seconds, @one_minute)

    s =
      seconds
      |> rem(@one_minute)
      |> pad_int()

    "#{m}:#{s}"
  end

  defp pad_int(int, padding \\ 2) do
    int
    |> Integer.to_string()
    |> String.pad_leading(padding, "0")
  end
end

System.argv()
|> hd()
|> Integer.parse()
|> elem(0)
|> Seconds.to_hh_mm_ss()
|> IO.puts()

Also, Elixir does not have the modulo (%) operator. Instead, it has a rem/2 function to get the remainder of dividing 2 integers. Then there’s div/2 to do integer based division.

What’s neat about the above code is it ended up being about the same lines as my original Python implementation but thanks to pattern matching and pipes, I think this version is a bit more readable.

In case you’re brand new to Elixir, the pipe operator |> allow you to pass the return value in as the first argument to the next function in the pipe.

For example, at the bottom of the code example, we’re doing:

  • Grab all of the arguments passed in from the command line
  • Only get the first item in the list (the head of the list)
  • Parse it into an integer (the seconds in this case)
  • Grab the first element of that (Integer.parse/1 returns a tuple)
  • Convert the seconds into HH:MM:SS
  • Print the output to the terminal

That translates to:

IO.puts(Seconds.to_hh_mm_ss(elem(Integer.parse(hd(System.argv())), 0)))

I think it’s pretty clear on which one is easier to follow!

In languages without pipes you would likely unwind some of that into “temporary” variables to make it more readable. I put temporary in quotes because those variables would really only exist to pass information to the next function that’s calling it.

As for the rest of the code. I’m not sure if it’s 100% idiomatic code but it does pass a test suite where I set up ~30 different seconds amounts and it passed every case. I also feel like I’ll be able to understand the code in a couple of weeks from now.

# Someone Else Rewriting It in Elixir

I’m still very new to Elixir in the grand scheme of things, but Greg Vaughn provided his version of the code. Greg has given a bunch of talks at various Elixir conferences and he gave me permission to post the code here.

If you create a format_seconds_2.exs file, you can run it with elixir format_seconds_2.exs 120:

defmodule Seconds do
  def to_hh_mm_ss(0), do: "0:00"

  def to_hh_mm_ss(seconds) do
    units = [3600, 60, 1]
    [h | t] =
      Enum.map_reduce(units, seconds, fn unit, val -> {div(val, unit), rem(val, unit)} end)
      |> elem(0)
      |> Enum.drop_while(&match?(0, &1))

    {h, t} = if length(t) == 0, do: {0, [h]}, else: {h, t}

    "#{h}:#{
      t |> Enum.map_join(":", fn x -> x |> Integer.to_string() |> String.pad_leading(2, "0") end)
    }"
  end
end

System.argv()
|> hd()
|> Integer.parse()
|> elem(0)
|> Seconds.to_hh_mm_ss()
|> IO.puts()

The only modification I made to Greg’s code was adding a function head to support not crashing when 0 was input as the seconds. Pattern matching on function arguments is another neat feature of Elixir.

As for the code itself, as of today it still looks like Klingon to me, but it’s not because Greg wrote poor code. It’s because I don’t think like this yet when it comes to functional programming.

I hope one day I’ll be able to understand it at a glance. I haven’t done it yet but at some point I will sit down for a few hours and try to reverse everything being done to better understand it.

That’s why I think code examples are so valuable to have as a learning example. The code is there, you can run it and you can see what it produces. You have everything you need to mess around with it until it makes sense.

Lastly, in case you’re wondering, the Python version and both Elixir versions all pass the same test suite of 30 different seconds amounts so feel free to use them in real projects.

How would you solve this problem in your language of choice? Let me know below!

Never Miss a Tip, Trick or Tutorial

Like you, I'm super protective of my inbox, so don't worry about getting spammed. You can expect a few emails per year (at most), and you can 1-click unsubscribe at any time. See what else you'll get too.



Comments