The Movie Tracker app is a simple HTA application written mostly in JavaScript (and partly in VBscript) that tracks a list of movies and/or series that are sorted by their release date. It gets the movie info by parsing an IMDb HTML response based on the URL entered by the user. The data is extracted from the IMDb's JSON script and is stored inside the HTML code itself, also as JSON script.
Introduction
Here is a screenshot of how the Movie Tracker app looks like:
The 3rd column (days from/until release) is updated each time the app is opened, and the movies are always sorted by their release date. This lets the users keep track of all the movies/series that they want to see once they are released - whatever is on the top of the list and colored green has already been released; the still-unreleased movies/series remain at the bottom of the list and are colored white.
The idea was to make the app as simple as possible and maintainable in only one file – thus I created an HTA app which is keeping the list of movies/series in a table inside the HTML code as JSON script. Once the app is closed, the list is updated - new JSON is created - and it is saved inside the .hta file on the filesystem. This is additionally the reason a part of the code is in VBscript - because Javascript does not support direct access to the filesystem.
The tables are built from scratch each time the app is loaded, from the list of JSON elements that are saved within the HTML code.
Using the App
The basic app consists of an empty table, an "Add" button, 2 buttons to switch between Movies and Series, and, of course, 2 empty tables - one for each - and, of course, the script part.
Only one table is visible at a time; the active table can be switched by using the two buttons under the "Add" button. Whichever button is larger, this is the currently selected (visible) table.
By clicking on the "Add" button, a dialog opens asking for an IMDb URL – so the user should input something like this:
After the user adds a new movie or TV show, it will appear as a new row in the appropriate table. If the active table is different from the type of item that was added, the active table shall automatically be changed to the one where the item was added. The movies/series that have already been released (release date in the past) will be colored green, and they will all be sorted by their release date.
Each table row also contains 2 buttons - a button to refresh the row (the row is deleted and info from IMDb is fetched and parsed again) and a button to remove it. The buttons "Refresh all" and "Remove all" in the table header are used to batch refresh or remove all of the items in the currently active table.
Code Execution
Whenever the app is opened, first the JSON scripts are read, parsed and the tables are populated appropriately.
The JSON objects contain the entire HTML elements that are contained by the <td>
elements of the tables. First, a <tr>
element is created, then for each JSON pair a <td>
element is created, and then the <td>
elements are appended into the <tr>
element in a given order - the order of the columns is kept in an array:
var colmap = [];
colmap[json_imgurl]=0;
colmap[json_date]=1;
colmap[json_days_left]=2;
colmap[json_url]=3;
colmap[json_description]=4;
colmap[json_button_refreshRow]=5;
colmap[json_button_remove]=6;
This is an example of a JSON object:
{"id":"20191023_tt6450804",
"image":"<img style=\"height: 100px;\" onclick=\"imgClicked()\"
alt=\"Terminator: Dark Fate\"
src=\"https://m.media-amazon.com/images/M/MV5BNzhlYjE5MjMtZDJmYy00MGZmLTgwN2MtZGM0NT
k2ZTczNmU5XkEyXkFqcGdeQXVyMTkxNjUyNQ@@._V1_.jpg\">",
"datePublished":"23 October 2019",
"daysLeft":"Released 87 day(s) ago!",
"url":"<a href=\"https://www.imdb.com/title/tt6450804/\">Terminator: Dark Fate</a>",
"description":"Terminator: Dark Fate is a movie starring Linda Hamilton,
Arnold Schwarzenegger, and Mackenzie Davis. An augmented human and Sarah Connor
must stop an advanced liquid Terminator, from hunting down a young girl, whose fate is...",
"buttonrefreshRow":"<button onclick=\"refreshRow('20191023_tt6450804')\">Refresh</button>",
"buttonRemove":"<button onclick=\"deleteRow('20191023_tt6450804')\">Remove</button>"}
The ID of each item consists of a release date formatted as "yyyymmdd" and IMDb ID (ie. tt6450804); this is to achieve the possibility of sorting by release date, but also to make sure that the IDs are unique.
The objects from JSON are first read into array slist
consisting of 2-element arrays, with the ID as the first element, and JSON object as the second element. slist
is then sorted by the IDs (elements with index 0). After that, slist
is read in a loop, and JSON objects are parsed and appended (in the sorted order) into the corresponding HTML table.
var slist = [];
getJsonObjects(json).forEach(function(item) {
if (item!='') {
id=getJsonValue(item,'id');
slist.push([id, item]);
}
});
slist.sort(function(a,b) {return a[0]<b[0]});
Parsing JSON
for(i = 0; i<slist.length; i++) {
id=slist[i][0];
obj=slist[i][1];
tr=document.createElement('tr');
tr.className = trclass;
tr.setAttribute('id', id);
getJsonPairs(obj).forEach(function(pair) {
if (pair!='') {
namevalue=getJsonNameValue(pair);
if (namevalue[0]=='daysLeft') {
yyyymmdd=convertDate(tds[colmap[json_date]].innerHTML);
namevalue[1]=getTimeRemaining(yyyymmdd);
if (dateDiff(getDate(yyyymmdd), new Date())<0) {
tr.setAttribute('style','background-color: #adff2f;');
}
}
td=document.createElement('td');
td.className=unescapeJSON(namevalue[0]);
td.innerHTML=unescapeJSON(namevalue[1]);
tds[colmap[td.className]]=td;
}
});
for(j = 0; j <tds.length; j++) {
tr.appendChild(tds[j]);
}
tbody.appendChild(tr);
}
JSON objects are parsed in the following order.
First, all the JSON objects are retrieved by calling function getJsonObjects
:
function getJsonObjects(json) {
var objs=[];
var obj='';
var inside=0;
var c;
var json2=Trim2(json);
do {
if(json2.length>0) {
c = json2.substring(0,1);
json2 = json2.substring(1,json2.length);
} else {
break;
}
if(c=='}') {
inside--;
if(inside==0) {
objs.push(obj);
obj='';
}
}
if(inside>0) {
obj=obj+c;
} else if(inside<0) {
throw('Invalid JSON syntax!');
return [];
}
if(c=='{') inside++;
} while(true);
return objs;
}
Next, for each object, pairs are extracted by calling the function getJsonPairs
:
function getJsonPairs(json) {
var pairs=[];
var pair='';
var inside=0;
var inpar=false;
var c;
var cp='';
var json2=Trim2(json);
if(json2.substring(0,1)=='{' && json2.substring(json2.length-1)=='}')
{
json2=json2.substring(1,json2.length);
json2=json2.substring(0, json2.length-1);
}
do {
if(json2.length > 0) {
c = json2.substring(0,1);
json2 = json2.substring(1, json2.length);
} else {
break;
}
if(c=='{' || c=='[') {
inside++;
} else if (c=='}' || c==']') {
inside=inside-1;
} else if (c=='"' && cp!='\\') {
inpar=!inpar;
if(json2.trim()=='') {
pair+=c;
c=',';
}
}
if(c==',' && inside==0 && !inpar) {
pairs.push(Trim2(pair));
pair='';
} else {
pair+=c;
}
cp=c;
} while(true);
return pairs;
}
Then, for each pair, the function getJsonNameValue
is called, which returns an array of 2, where the first element is the name, and the second element is the value:
function getJsonNameValue(json) {
var namevalue=['',''];
var c;
var cp='';
var inpar=false;
var json2=Trim2(json);
do {
if(json2.length > 0) {
c = json2.substring(0,1);
json2 = json2.substring(1);
} else {
break;
}
if(c=='"' && cp!='\\') {
inpar=!inpar;
} else if (c==':' && !inpar) {
namevalue[1]=json2;
break;
}
namevalue[0]=namevalue[0]+c;
cp=c;
} while(true);
namevalue[0]=Trim2(namevalue[0]);
namevalue[1]=Trim2(namevalue[1]);
if(namevalue[0].substring(0,1)=='"') namevalue[0]=namevalue[0].substring(1);
if(namevalue[1].substring(0,1)=='"') namevalue[1]=namevalue[1].substring(1);
if(namevalue[0].substring(namevalue[0].length-1)=='"')
namevalue[0]=namevalue[0].substring(0, namevalue[0].length-1);
if(namevalue[1].substring(namevalue[1].length-1)=='"')
namevalue[1]=namevalue[1].substring(0, namevalue[1].length-1);
return namevalue;
}
Adding a New Item
When "Add" button is clicked, function add(url)
is called.
function add(url) {
if(dragCheck) return;
if(url=='') {
var url=prompt('Enter IMDb URL:','');
}
if(url==null || url=='') return;
var id=getImdbId(url);
var imdbid=id;
if(id=='') {
alert('Invalid URL!');
writeLog('Invalid URL!');
return;
}
var trs;
var tb;
for(cnt=0;cnt<=1;cnt++)
{
if(i==0) tb=tbody_m;
else tb=tbody_s;
trs=tb.getElementsByClassName(trclass);
for(i=0;i<trs.length;i++) {
if (trs[i].getAttribute('id').length!=trs[i].getAttribute('id').
replace(id,'').length) {
alert('Item \"'+id+'\" already exists!');
return;
}
}
}
writeLog('add: Reading JSON from IMDb');
writeLog('---------------------------');
var imdbJSON;
try {
imdbJSON=HttpSearch(url, String.fromCharCode(60)+
'script type=\"application/ld+json\"'+String.fromCharCode(62),
String.fromCharCode(60)+'/script'+String.fromCharCode(62));
}
catch (err) {
writeLog('ERROR: '+err);
alert("Not found!");
return;
}
var ms=getJsonValue(imdbJSON,json_type);
if (ms==json_type_m) {
if(active_tbody==tbody_s) change_tab(b_m_id);
} else {
if(active_tbody==tbody_m) change_tab(b_s_id);
}
writeLog(' type='+ms);
var namevalue;
var tr, td;
tr=document.createElement('tr');
tr.className = trclass;
var tds = [colmap.length];
var name, thumbnailUrl;
getJsonPairs(imdbJSON).forEach(function(pair) {
namevalue=getJsonNameValue(pair);
namevalue[0]=unescapeJSON(unicodeToChar(namevalue[0]));
namevalue[1]=unescapeJSON(unicodeToChar(namevalue[1]));
switch(namevalue[0]) {
case json_name:
name=namevalue[1];
break;
case json_url:
url="https://www.imdb.com"+namevalue[1];
break;
case json_description:
td=document.createElement('td');
td.className=json_description;
td.innerHTML=namevalue[1];
tds[colmap[json_description]]=td;
if(ms==json_type_s) {
var episodeDesc=getNextEpisode(imdbid);
var dt;
if(episodeDesc[0]=='' && episodeDesc[1]==0) episodeDesc=episodeDesc[3];
else {
if(episodeDesc[0]!='') dt=episodeDesc[0].substring(6,8) +
'.' + episodeDesc[0].substring(4,6) + '.' +
episodeDesc[0].substring(0,4);
else dt='Unknown';
episodeDesc='Episode #' + episodeDesc[1].toString() +
' <b>' + episodeDesc[2] + '</b><br><i>Date: ' + dt +
'</i><br><br>' + episodeDesc[3];
}
tds[colmap[json_description]].innerHTML += '<br>' +
createCollapsible(episodeDesc);
var coll = tds[colmap[json_description]].
getElementsByClassName('collapsible')[0];
coll.addEventListener('click', function() {
this.classList.toggle('collapsed');
var div = this.nextElementSibling;
if (div.style.maxHeight){
div.style.maxHeight = null;
} else {
div.style.maxHeight = div.scrollHeight + 'px';
}
});
}
writeLog(' '+td.className+'='+td.innerHTML);
break;
case json_imgurl:
thumbnailUrl=namevalue[1];
break;
case json_date:
namevalue[1]=namevalue[1].replace(/-/g,'');
id=namevalue[1]+'_'+id;
tr.setAttribute('id', id);
td=document.createElement('td');
td.className=json_date;
td.innerHTML=convertDate(namevalue[1]);
tds[colmap[json_date]]=td;
if(dateDiff(getDate(namevalue[1]), new Date())<0) {
tr.setAttribute('style','background-color: #adff2f;');
}
writeLog(' '+td.className+'='+td.innerHTML);
td=document.createElement('td');
td.className=json_days_left;
td.innerHTML=getTimeRemaining(namevalue[1]);
tds[colmap[json_days_left]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
break;
default:
break;
}
});
td=document.createElement('td');
td.className=json_url;
td.innerHTML=createNameUrl(url, name);
tds[colmap[json_url]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
td=document.createElement('td');
td.className=json_imgurl;
td.innerHTML=createPosterImg(thumbnailUrl,name);
tds[colmap[json_imgurl]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
td=document.createElement('td');
td.className=json_button_refreshRow;
td.innerHTML=createButtonRefresh(id);
tds[colmap[json_button_refreshRow]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
td=document.createElement('td');
td.className=json_button_remove;
td.innerHTML=createButtonRemove(id);
tds[colmap[json_button_remove]]=td;
writeLog(' '+td.className+'='+td.innerHTML);
for(j = 0; j<tds.length; j++) {
tr.appendChild(tds[j]);
}
var r_next = null;
var rows=active_tbody.getElementsByClassName('row');
for(i=0;i<rows.length;i++) {
if(rows[i].getAttribute('id') > tr.getAttribute('id')) {
r_next = rows[i];
break;
}
}
if(r_next==null) {
active_tbody.appendChild(tr);
writeLog('Added '+tr.getAttribute('id'));
} else {
r_next.parentNode.insertBefore(tr, r_next);
writeLog('Added '+tr.getAttribute('id')+' before '+r_next.getAttribute('id'));
}
writeLog(' ');
}
This function is the centerpiece of the entire code. It needs as input an IMDb URL - this URL can be either passed as an argument, or, if that is not the case, is asked from the user with the prompt
function.
The add function
will first attempt to retrieve the entire IMDb HTML document, using the http request/response (XMLHttpRequest
object). Then it will extract the JSON script from the response, parse the JSON, take the necessary name-value pairs, build the <tr>
and <td>
elements, and add the <tr>
into the appropriate table.
Here is a description of some of the functions used in the add
function:
ImdbSearch(url, startstring, endstring)
– This function opens a GET
request using the provided URL, looks for the first instance of startstring
in the returned stream, then looks for the first subsequent instance of endstring
, and returns everything in between. In the previous version, this function was used several times to retrieve each particular info for a movie, now it is used only to retrieve the JSON script; after that, everything else is retrieved by parsing the JSON GetImdbId(url)
– Extracts IMDb ID from the URL convertDate(datestring)
– Converts between numeric (yyyymmdd) and string (dd monthname yyyy) date representation GetKey(datenum, imdbid)
– Creates a key from numeric date (yyyymmdd) and IMDb ID getNextEpisode(imdbid)
- Gets next episode info (for series) - see next paragraph! createCollapsible(description)
- Creates a collapsible div
element which is added to the description column of the series table
Parsing Next Episode Info
There is also an additional piece of information extracted for series - the next episode info. This information is retrieved and extracted in function getNextEpisode
, which is called from the add
function.
This function retrieves the HTML content from the episode guide from IMDb, i.e.,
By using a DOMParser object, it takes all the necessary information by parsing div
elements of class 'info
'. It extracts airdate, episode number, title and description for the next episode. It returns an array of strings.
function getNextEpisode(id) {
writeLog('getNextEpisode for ' + id);
var parser=new DOMParser();
var url=getImdbUrl(id) + 'episodes';
var text=HttpSearch(url,'','');
var doc=parser.parseFromString(text,'text/html');
var episodes = [];
var divs=doc.getElementsByClassName('info');
var item;
var defaultDate='99990101';
var airdate, episodeNumber, title, description;
for(i=0;i<divs.length;i++) {
item = divs[i];
airdate='';
try {
airdate=item.getElementsByClassName('airdate')[0].innerHTML.trim();
if(!isNaN(airdate) && airdate.length==4) {
airdate=defaultDate;
}
else {
airdate=airDateYYYYMMDD(airdate);
}
}
catch(err) {
}
episodeNumber=0;
try {
episodeNumber=parseInt(item.getElementsByTagName
('meta')[0].getAttribute('content'),10);
}
catch(err) {
}
title='';
try {
title=item.getElementsByTagName('strong')[0].getElementsByTagName
('a')[0].getAttribute('title');
}
catch(err) {
}
description='';
try {
description=item.getElementsByClassName('item_description')[0];
if(description.getElementsByTagName('a').length>0)
description.removeChild(description.getElementsByTagName('a')[0]);
description=description.innerHTML.trim();
}
catch(err) {
}
episodes.push([airdate,episodeNumber,title,description]);
}
writeLog(' Found ' + episodes.length + ' episodes');
if(episodes==null || episodes.length==0) {
description='Unable to find last episode!';
writeLog(' ' + description);
return ['',0,'',description];
}
var today=(new Date()).toISOString().substring(0,10).replace(/-/g,'');
var new_episodes=episodes.filter(function(e) {
return e[0]>=today;
});
writeLog(' ' + episodes.length + ' in future');
if(new_episodes.length>0)
{
new_episodes.sort(function(a,b) {return a[0]<b[0]});
var maxdate=[];
for(i=0;i<new_episodes.length;i++) {
if(new_episodes[i][0]==new_episodes[0][0]) maxdate.push(new_episodes[i]);
}
if(maxdate.length==0) maxdate.push(['',0,'','']);
if(maxdate.length>1) {
maxdate.sort(function(a,b) {return a[1]<b[1]});
}
if(maxdate[0][0]==defaultDate) maxdate[0][0]='';
writeLog(' date='+maxdate[0][0]);
writeLog(' episodeNumber='+maxdate[0][1]);
writeLog(' name='+maxdate[0][2]);
writeLog(' description='+maxdate[0][3]);
return maxdate[0];
}
else {
description='No more episodes for this season...';
writeLog(' ' + description);
return ['',0,'',description];
}
}
Deleting an Item
Each tr
element created will contain a "Remove" button. If this button is clicked, function deleteRow
is called with the id of that particular row. The function simply removes the <tr>
child with the given id from the <tbody>
.
function deleteRow(id) {
var tr=document.getElementById(id);
tr.parentNode.removeChild(tr);
}
Refreshing an Item
Each tr
element will also contain a "Refresh" button. This button, when clicked, shall call a function refreshRow
. This action is supposed to refresh the data for the particular item - in case something was changed in the meantime, since when it was added or last refreshed. The sub does not do refresh per se, but will rather delete the existing row, and add it again, with fresh data from IMDb.
function refreshRow(id) {
var tr=document.getElementById(id);
var url=tr.getElementsByClassName(json_url)[0].getElementsByTagName
('a')[0].getAttribute('href');
deleteRow(id);
add(url);
}
Save Changes
tbody
elements have an event listener for DOMSubtreeModified
event - this means that in case anything is changed in the DOM structure of the tbody, function showsave
will be called. This function makes the savebutton
visible, so that the users are able to save (if they want) the changes (i.e., save what was added, deleted or refreshed).
Even though the savebutton
event handler itself is located in the JavaScript part (save
function), the actual saving part is being done in the saveChanges
sub in the VBscript part. VBscript had to be used for this because JavaScript does not permit direct access to the client filesystem.
The items are saved inside the HTA application itself, inside the corresponding script
tags:
<script id="json_movies" type="application/ld+json"></script>
<script id="json_series" type="application/ld+json"></script>
The function that creates JSON:
Function makeJSON(ms)
makeJSON = ""
Dim tbody
If ms=json_type_m Then
Set tbody=tbody_m
Else
Set tbody=tbody_s
End If
For Each tr In tbody.getelementsbyclassname(trclass)
makeJSON=makeJSON&"{""id"":"""&tr.getattribute("id")&""","
For Each td In tr.getelementsbytagname("td")
makeJSON=makeJSON & """" & escapeJSON(td.className) & """:"""
makeJSON=makeJSON & escapeJSON(Trim(td.innerhtml))
makeJSON=makeJSON & ""","
Next
makeJSON=Left(makeJSON,Len(makeJSON)-1)
makeJSON=makeJSON & "}," & Chr(13) & Chr(10)
Next
If makeJSON <> "" Then makeJSON=Left(makeJSON,Len(makeJSON)-3)&Chr(13) & Chr(10)
End Function
The HTA app will open its own file from the filesystem, and rewrite its own code. This way, everything is always conveniently kept in a single file - both the app and the data.
The path to the file is obtained by using the document.location.pathname
attribute:
path = Right(document.location.pathname,Len(document.location.pathname)-1)
Optional Feature - Read Input File
There is an optional feature built in the app - one can specify an external input file, which, if set up, shall be read each time the app is called.
The input file should contain IMDb IDs in rows, like this:
tt4520988
tt8946378
tt5180504
tt9173418
This feature was meant to falicitate the adding of items to the app "remotely" - since the app is not hosted on a server and thus not available from anywhere, but locally, one could set up a txt file on a cloud service, for example Dropbox, and edit this file whenever and from wherever. Then the app would, once it is opened, pick up anything that was written in this txt.
This code is, obviously, also written in VBscript, since it requires access to the local filesystem.
Optional Feature - Debug Mode
There is also a debug mode built into the app which can be turned on by changing the var debugmode
to true
. In this case, during the execution of the code, the app will write out some info in a special paragraph at the end of the HTML body.
<P id=dbg style="FONT-SIZE: 10px; FONT-FAMILY: courier; COLOR: red"> </P>
History
- 23rd June, 2019: Initial version
- 18th January, 2020: 2nd version - Complete code re-design, added refresh option and TV show support, lists stored in JSON
- 26th January, 2020 - Most of the code transcoded into JavaScript,
savebutton
added, draggable buttons functionality added, optional functionality external input file added - 8th March, 2020 - Added the next episode description (parsing and collapsible
div
)