Add IMDb poster downloader
[cinaest] / src / poster / imdb-poster-downloader.vala
1 /* This file is part of Cinaest.
2  *
3  * Copyright (C) 2010 Philipp Zabel
4  *
5  * Cinaest is free software: you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation, either version 3 of the License, or
8  * (at your option) any later version.
9  *
10  * Cinaest is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License
16  * along with Cinaest. If not, see <http://www.gnu.org/licenses/>.
17  */
18
19 namespace Hildon {
20         public const int MARGIN_DOUBLE = 16;
21         public const int MARGIN_HALF = 4;
22 }
23
24 namespace Poster {
25         public const int SMALL_WIDTH = (800 - 2*Hildon.MARGIN_DOUBLE - 4*Hildon.MARGIN_HALF)/5;
26         public const int SMALL_HEIGHT = (420 - Hildon.MARGIN_HALF)/2;
27         public const int ICON_WIDTH = 46;
28         public const int ICON_HEIGHT = 64;
29 }
30
31 // A single IMDb poster image search (parsing and retrieval of the poster image URI)
32 public class IMDbMessage : Soup.Message {
33         public string title;
34         public int year;
35
36         public string poster_uri = null;
37         private bool thumbnail;
38
39         public IMDbMessage (string title_, int year_, bool thumbnail_ = false) {
40                 Object (method: "GET");
41
42                 title = title_;
43                 year = year_;
44                 thumbnail = thumbnail_;
45                 string url = "http://www.imdb.com/find?s=tt&q=%s (%d)".printf (convert (title, -1, "iso8859-1", "utf-8"), year);
46                 uri = new Soup.URI (url);
47         }
48 }
49
50 // Encapsulation of a single poster download (IMDb title search query and image file download)
51 public class IMDbPosterDownload : Object {
52         private IMDbPosterDownloader downloader;
53         private Soup.Session session;
54         private string cache_dir;
55         private string cache_filename;
56         private bool thumbnail;
57         private bool cancelled = false;
58
59         public int handle;
60
61         public IMDbPosterDownload (string title, string year, bool thumbnail_, int _handle, IMDbPosterDownloader _downloader) {
62                 handle = _handle;
63                 downloader = _downloader;
64                 session = downloader.session;
65                 thumbnail = thumbnail_;
66
67                 var message = new IMDbMessage (title, year.to_int (), thumbnail);
68                 session.queue_message (message, title_page_callback);
69
70                 if (thumbnail) {
71                         // FIXME
72                         cache_dir = Path.build_filename (Environment.get_tmp_dir(), "cinaest-thumbnails");
73                 } else {
74                         cache_dir = Path.build_filename (Environment.get_user_cache_dir(), "media-art");
75                 }
76                 cache_filename = "movie-" +
77                                  Checksum.compute_for_string (ChecksumType.MD5, (title).down ()) + "-" +
78                                  Checksum.compute_for_string (ChecksumType.MD5, (year).down ()) + ".jpeg";
79         }
80
81         private void title_page_callback (Soup.Session session, Soup.Message message) {
82                 if (cancelled ||
83                     message.status_code != Soup.KnownStatusCode.OK) {
84                         print ("[%d] NO POSTER FOR %s (CODE %u)\n", handle, message.uri.to_string (false), message.status_code);
85                         downloader.finished (this, null);
86                         return;
87                 }
88
89                 if (message.uri.path == "/find") {
90                         print ("[%d] AMBIGUOUS RESULTS: %s\n", handle, message.uri.to_string (false));
91
92                         var msg = (IMDbMessage) message;
93
94                         var re3 = new Regex ("<a href=\"(/title/[^\"]*/)\"[^>]*>([^<]*)</a> *\\(([0-9]*)");
95
96                         MatchInfo match;
97                         if (re3.match ((string) message.response_body.data, 0, out match)) {
98                                 do {
99                                         print ("[%d] POTENTIAL RESULT: %s (%s)\n", handle, match.fetch (2), match.fetch (3));
100                                         if (msg.title.down () == match.fetch (2).down () &&
101                                             msg.year == match.fetch (3).to_int ()) {
102                                                 string url = "http://www.imdb.com" + match.fetch (1);
103                                                 print ("[%d] CHOSE RESULT URL: %s\n", handle, url);
104                                                 message.uri = new Soup.URI (url);
105                                                 session.queue_message (message, this.title_page_callback);
106                                                 return;
107                                         }
108                                 } while (match.next ());
109                         }
110                         print ("[%d] NO MATCH\n", handle);
111                         downloader.finished (this, null);
112                         return;
113                 }
114
115                 print ("[%d] GOT TITLE PAGE FOR %s\n", handle, message.uri.path);
116
117                 MatchInfo match;
118                 if (downloader.re1.match ((string) message.response_body.data, 0, out match)) {
119                         string url = "http://www.imdb.com" + match.fetch (1);
120                         print ("[%d] FOUND PHOTO PAGE URL: %s\n", handle, url);
121                         message.uri = new Soup.URI (url);
122                         session.queue_message (message, this.photo_page_callback);
123                 } else {
124                         print ("[%d] NO POSTER AVAILABLE\n", handle);
125                         downloader.finished (this, null);
126                 }
127         }
128
129         private void photo_page_callback (Soup.Session session, Soup.Message message) {
130                 if (cancelled ||
131                     message.status_code != Soup.KnownStatusCode.OK) {
132                         downloader.finished (this, null);
133                         return;
134                 }
135
136                 print ("[%d] GOT PHOTO PAGE %s\n", handle, message.uri.path);
137
138                 MatchInfo match;
139                 if (downloader.re2.match ((string) message.response_body.data, 0, out match)) {
140                         string url = match.fetch (1);
141                         print ("[%d] FOUND IMAGE URL: %s\n", handle, url);
142                         message.uri = new Soup.URI (url);
143                         session.queue_message (message, this.image_callback);
144                 }
145         }
146
147         private void image_callback (Soup.Session session, Soup.Message message) {
148                 if (cancelled ||
149                     message.status_code != Soup.KnownStatusCode.OK) {
150                         downloader.finished (this, null);
151                         return;
152                 }
153
154                 print ("[%d] Downloaded poster: %s\n", handle, message.uri.to_string (false));
155
156                 // Define cache path according to the Media Art Storage Spec (http://live.gnome.org/MediaArtStorageSpec)
157                 string cache_path = Path.build_filename (cache_dir, cache_filename);
158
159                 // Make sure the directory .album_arts is available
160                 DirUtils.create_with_parents (cache_dir, 0770);
161
162                 if (FileUtils.set_contents (cache_path + ".part",
163                                             (string) message.response_body.data,
164                                             (ssize_t) message.response_body.length)) {
165                         if (!thumbnail) {
166                                 FileUtils.rename (cache_path + ".part", cache_path);
167                         } else {
168                                 // Scale and crop
169                                 var p1 = new Gdk.Pixbuf.from_file (cache_path + ".part");
170                                 var p2 = new Gdk.Pixbuf (Gdk.Colorspace.RGB, true, 8, Poster.SMALL_WIDTH, Poster.SMALL_HEIGHT);
171                                 double xscale = (double)Poster.SMALL_WIDTH / (double)p1.width;
172                                 double yscale = (double)Poster.SMALL_HEIGHT / (double)p1.height;
173                                 double scale;
174                                 int ofs_x = 0;
175                                 int ofs_y = 0;
176                                 if (xscale > yscale) {
177                                         scale = xscale;
178                                         ofs_y = -(int) (p1.height*scale - Poster.SMALL_HEIGHT)/2;
179                                 } else {
180                                         scale = yscale;
181                                         ofs_x = -(int) (p1.width*scale - Poster.SMALL_WIDTH)/2;
182                                 }
183                                 p1.scale (p2, 0, 0, Poster.SMALL_WIDTH, Poster.SMALL_HEIGHT, ofs_x, ofs_y, scale, scale, Gdk.InterpType.BILINEAR);
184                                 print ("[%d] Scaled %d x %d --> %d x %d (%+d,%+d)\n", handle, p1.width, p1.height, p2.width, p2.height, ofs_x, ofs_y);
185                                 p2.save (cache_path, "png", null);
186                                 FileUtils.unlink (cache_path + ".part");
187                         }
188                         print ("[%d] Stored as: %s\n", handle, cache_path);
189                 } else {
190                         stdout.printf ("[%d] Failed to store poster\n", handle);
191                 }
192
193                 downloader.finished (this, cache_path);
194         }
195
196         public void cancel () {
197                 print ("[%d] Cancelled\n", handle);
198                 cancelled = true;
199         }
200 }
201
202 // The D-Bus service to manage poster downloads
203 public class IMDbPosterDownloader : Object, PosterDownloader {
204         private MainLoop loop;
205         private int fetch_handle = 1;
206         private List<IMDbPosterDownload> downloads = null;
207         private uint source_id;
208
209         public Soup.SessionAsync session;
210         public Regex re1;
211         public Regex re2;
212
213         public IMDbPosterDownloader () {
214                 loop = new MainLoop (null);
215
216                 session = new Soup.SessionAsync ();
217                 session.max_conns = 40;
218                 session.max_conns_per_host = 20;
219                 try {
220                         re1 = new Regex ("\"(/rg/action-box-title/primary-photo/media/[^\"]*)\"");
221                         re2 = new Regex ("\"(http://ia.media-imdb.com/images[^\"]*)\"");
222                 } catch (RegexError e) {
223                 }
224         }
225
226         public void timeout_quit () {
227                 // With every change we reset the timer to 3min
228                 if (source_id != 0) {
229                         Source.remove (source_id);
230                 }
231                 source_id = Timeout.add_seconds (180, quit);
232         }
233
234         private bool quit () {
235                 loop.quit ();
236
237                 print ("Timeout. Quitting with %u remaining downloads.\n", downloads.length ());
238                 foreach (IMDbPosterDownload download in downloads)
239                         failed (download.handle);
240
241                 // One-shot only
242                 return false;
243         }
244
245         public void run () {
246                 loop.run ();
247         }
248
249         public void finished (IMDbPosterDownload download, string? cache_path) {
250                 if (cache_path != null)
251                         fetched (download.handle, cache_path);
252                 else
253                         failed (download.handle);
254                 downloads.remove (download);
255                 timeout_quit ();
256         }
257
258         // Implement the PosterDownloader interface
259         public int Fetch (string title, string year, string kind) throws DBus.Error {
260                 print ("Fetch (\"%s\", \"%s\", \"%s\") = %d\n", title, year, kind, fetch_handle+1);
261                 var download = new IMDbPosterDownload (title, year, false, ++fetch_handle, this);
262
263                 downloads.append (download);
264
265                 return fetch_handle;
266         }
267
268         public int FetchThumbnail (string title, string year, string kind) throws DBus.Error {
269                 print ("FetchThumbnail (\"%s\", \"%s\", \"%s\") = %d\n", title, year, kind, fetch_handle+1);
270                 var download = new IMDbPosterDownload (title, year, true, ++fetch_handle, this);
271
272                 downloads.append (download);
273
274                 return fetch_handle;
275         }
276
277         public void Unqueue (int handle) throws DBus.Error {
278                 print ("Unqueue (%d)\n", handle);
279                 IMDbPosterDownload download = null;
280                 foreach (IMDbPosterDownload d in downloads) {
281                         if (d.handle == handle) {
282                                 download = d;
283                                 d.cancel ();
284                                 break;
285                         }
286                 }
287                 if (download != null) {
288                         downloads.remove (download);
289                 }
290         }
291
292         static void main () {
293                 try {
294                         var conn = DBus.Bus.get (DBus.BusType.SESSION);
295                         dynamic DBus.Object bus = conn.get_object ("org.freedesktop.DBus",
296                                                                    "/org/freedesktop/DBus",
297                                                                    "org.freedesktop.DBus");
298
299                         // Try to register service in session bus
300                         uint res = bus.request_name ("org.maemo.movieposter.IMDb", (uint) 0);
301                         if (res == DBus.RequestNameReply.PRIMARY_OWNER) {
302                                 // Start server
303                                 var server = new IMDbPosterDownloader ();
304                                 conn.register_object ("/org/maemo/movieposter/IMDb", server);
305
306                                 server.timeout_quit ();
307                                 server.run ();
308                         }
309                 } catch (Error e) {
310                         error ("Oops: %s\n", e.message);
311                 }
312         }
313 }