VCR Program Manager (#101)

by Fran├žois Beausoleil

The goal of this Ruby Quiz is to make a Video Cassette Recorder program manager. The user is responsible for saying what times to record, and then the VCR will query the program manager regularly to determine if it should be recording, and if so, on which channel.

The interesting bit in this quiz is the conflict resolution.

Normally, users specify recording schedules such as this:

Monday to Friday, 3 PM to 4 PM, channel 8

Specific programs might overlap. Assuming the above program is active, if the user added this program:

Wednesday Nov 8, 3:30 PM to 5 PM, channel 12

We should record from 3 to 3:30 channel 8, then switch to channel 12, and record until 5 PM.

Another variation might be:

Thursday Nov 9, 3:30 PM to 4:30 PM, channel 8

In this case, the channel didn't change, so we should just keep on recording.

Interesting, optional features: fuzzy time (start a bit before and end a bit after the specific times, to catch shows starting early / ending late) and taking care of DST.

Your program manager must implement the following interface:

ruby
# Query to determine if we should be recording at any particular
# moment. It can be assumed that the VCR will query the program
# manager at most twice per minute, and with always increasing minutes.
# New programs may be added between two calls to #record?.
#
# This method must return either a +nil+, indicating to stop recording,
# or don't start, or an +Integer+, which is the channel number we should
# be recording.
def record?(time); end

# Adds a new Program to the list of programs to record.
def add(program_details); end

Your task is to provide an implementation for the ProgramManager.

You can see the unit tests I used at:

Program Manager Unit Tests


Quiz Summary

When I used the term VCR in my latest book, my editor complained that it "dates me." (Ouch. I'm only 30!) I'm told we all use Tivo to record our shows now. I'm not on that bandwagon yet, so the rest of you will need to tell me if we need a Tivo Program Manager quiz or if this same code will work.

First, let's examine a super straight forward solution to the task to see what's involved. When we've done that, I'll make note of a few variations that caught my eye.

We will begin with some code by Peter Severin. Here's a helper method Peter added to the Time class:

ruby
class Time
def seconds
(hour * 60 + min) * 60 + sec
end
end

This just calculates the number of seconds since midnight. That measurement is used by weekly programs in the quiz, to specify when to record on a given day.

Let's take our first steps into the Program objects now:

ruby
class Program
attr_reader :channel

def initialize(program_details)
@program_start = program_details[:start]
@program_end = program_details[:end]
@channel = program_details[:channel]
end
end

I think Peter has a very clean and correct OO design in these Program classes.

Here we see the base class handling only the initialization that applies to all subclasses. Also note that only channel() is exposed to the outside world, since that's all the ProgramManager really needs.

Here's the subclass for one-shot recording:

ruby
class SpecificProgram < Program
def record?(time)
time.between?(@program_start, @program_end)
end
end

All Peter adds here is the ability for the Program to determine if it is scheduled for the passed time. This keeps the Program logic in the Program classes where it belongs.

The other subclass is for repeat programming:

ruby
class RepeatingProgram < Program
WEEKDAYS = %w(mon tue wed thu fri sat sun)

def initialize(program_details)
super
@days = program_details[:days].map {|day| WEEKDAYS.index(day) + 1}
end

def record?(time)
@days.include?(time.wday) &&
time.seconds.between?(@program_start, @program_end)
end
end

Here initialization is modified to handle the :days parameter that only applies to this type of Program. From there, another record?() method is created to examine both the day and time.

I don't see as much of this traditional OO design in solutions to the quizzes, but here I found it quite elegant. With each Program able to answer the right questions about itself, the ProgramManager is almost trivial to construct:

ruby
class ProgramManager
def initialize()
@programs = []
end

def add(program_details)
case program_details[:start]
when Numeric
@programs << RepeatingProgram.new(program_details)
when Time
@programs[0, 0] = SpecificProgram.new(program_details)
end

self
end

def record?(time)
program = @programs.find {|program| program.record?(time)}
program ? program.channel : nil
end
end

Peter begins by constructing an Array to hold the @programs in initialize(). Conflict management was pretty easy for this quiz in that the last defined Program wins out. You can deal with that by keeping them all in one list and making sure you order them correctly at insertion time.

The insertions are handled by the add() method. It determines the type of Program object to create and adds it to our list of @programs. The second addition is the tricky one, if you're not familiar with how Array.[]=() works. Let's see what that does in IRb:

ruby
>> programs = Array.new
=> []
>> programs << "repeating 1" << "repeating 2"
=> ["repeating 1", "repeating 2"]
>> programs[0, 0] = "specific 1"
=> "specific 1"
>> programs
=> ["specific 1", "repeating 1", "repeating 2"]

Put another way, the assignment to index zero, length zero adds the element to the front of the Array. It's equivalent to the more common:

ruby
@programs.unshift(SpecificProgram.new(program_details))

The final method of Peter's ProgramManager, record?(), just forwards the record?() calls to the Array of Program objects via the find() iterator. The first one to claim the time is selected and that Program's channel() is returned.

There was an interesting element in Dema's Program class that's probably worth a quick look:

ruby
class Program

# ...

def initialize(program)
@start = program[:start]
@end = program[:end]
@channel = program[:channel]
@days = program[:days]

raise "Missing start or end" \
if @start.nil? || @end.nil?
raise "Wrong start or end types" \
unless (@start.is_a?(Time) && @end.is_a?(Time)) ||
(@start.is_a?(Integer) && @end.is_a?(Integer))
raise "Invalid program" \
if weekly? && (@start.is_a?(Time) || @end.is_a?(Time))
raise "End must come after Start" \
if !weekly? && @start > @end
raise "Missing channel" \
if !@channel.is_a?(Integer)
raise "Invalid weekday" \
if @days.is_a?(Array) && @days.any? { |day| WEEKDAYS[day] == nil }
end

# ...

end

The only difference here is that Dema does a fair amount of error checking when Programs are constructed. Ordinarily, I'm not a big fan of this kind of lock-down coding, but this seems like a good case where it might just be worth the effort. Programs are going to come from users and of course users are going to make mistakes. Isolating these at Program construction time would allow the machine to respond to those errors intelligently at the time when it matters, instead of running into trouble down the road and recording at the wrong times.

The focus of this quiz was not to implement a complete VCR, of course, and most solvers just assumed they would receive correct input. I just thought it worth mentioning that the safeguards employed by Dema do need to be in place somewhere in the system.

I won't show the code here, but as a final point of interest I want to recommend everyone take a peek at Gordon Thiesfeld's solution. It uses a library called Runt to handle the majority of the scheduling. I wasn't aware of this resource and Gordon's code got me to look into it.

Runt is Temporal Expression library designed with things like recurring events and scheduling in mind. The documentation is pretty good and the project worth a look:

Runt

My thanks to all the solvers who always manage to teach me things, even when we do these simple problems. I love that aspect of the quiz.

Tomorrow we will take a shot at bringing Literate Programming to our fair Ruby...