1/*
2* Copyright (c) 2010-2013 Yorba Foundation
3*
4* This program is free software; you can redistribute it and/or
5* modify it under the terms of the GNU Lesser General Public
6* License as published by the Free Software Foundation; either
7* version 2.1 of the License, or (at your option) any later version.
8*
9* This program is distributed in the hope that it will be useful,
10* but WITHOUT ANY WARRANTY; without even the implied warranty of
11* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
12* General Public License for more details.
13*
14* You should have received a copy of the GNU General Public
15* License along with this program; if not, write to the
16* Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
17* Boston, MA 02110-1301 USA
18*/
19
20public errordomain VideoError {
21    FILE,          // there's a problem reading the video container file (doesn't exist, no read
22    // permission, etc.)
23
24    CONTENTS,      // we can read the container file but its contents are indecipherable (no codec,
25    // malformed data, etc.)
26}
27
28public class VideoImportParams {
29    // IN:
30    public File file;
31    public ImportID import_id = ImportID ();
32    public string? md5;
33    public int64 exposure_time_override;
34
35    // IN/OUT:
36    public Thumbnails? thumbnails;
37
38    // OUT:
39    public VideoRow row = new VideoRow ();
40
41    public VideoImportParams (File file, ImportID import_id, string? md5,
42                              Thumbnails? thumbnails = null, int64 exposure_time_override = 0) {
43        this.file = file;
44        this.import_id = import_id;
45        this.md5 = md5;
46        this.thumbnails = thumbnails;
47        this.exposure_time_override = exposure_time_override;
48    }
49}
50
51public class VideoReader {
52    private const double UNKNOWN_CLIP_DURATION = -1.0;
53    private const uint THUMBNAILER_TIMEOUT = 10000; // In milliseconds.
54
55    // File extensions for video containers that pack only metadata as per the AVCHD spec
56    private const string[] METADATA_ONLY_FILE_EXTENSIONS = { "bdm", "bdmv", "cpi", "mpl" };
57
58    private double clip_duration = UNKNOWN_CLIP_DURATION;
59    private Gdk.Pixbuf preview_frame = null;
60    private File file = null;
61    private GLib.Pid thumbnailer_pid = 0;
62    public int64 timestamp {
63        get;
64        private set;
65        default = -1;
66    }
67
68    public VideoReader (File file) {
69        this.file = file;
70    }
71
72    public static bool is_supported_video_file (File file) {
73        return is_supported_video_filename (file.get_basename ());
74    }
75
76    public static bool is_supported_video_filename (string filename) {
77        string mime_type;
78        mime_type = ContentType.guess (filename, new uchar[0], null);
79        if (mime_type.length >= 6 && mime_type[0:6] == "video/") {
80            string? extension = null;
81            string? name = null;
82            disassemble_filename (filename, out name, out extension);
83
84            if (extension == null)
85                return true;
86
87            foreach (string s in METADATA_ONLY_FILE_EXTENSIONS) {
88                if (utf8_ci_compare (s, extension) == 0)
89                    return false;
90            }
91
92            return true;
93        } else {
94            return false;
95        }
96    }
97
98    public static ImportResult prepare_for_import (VideoImportParams params) {
99#if MEASURE_IMPORT
100        Timer total_time = new Timer ();
101#endif
102        File file = params.file;
103
104        FileInfo info = null;
105        try {
106            info = file.query_info (DirectoryMonitor.SUPPLIED_ATTRIBUTES,
107                                    FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
108        } catch (Error err) {
109            return ImportResult.FILE_ERROR;
110        }
111
112        if (info.get_file_type () != FileType.REGULAR)
113            return ImportResult.NOT_A_FILE;
114
115        if (!is_supported_video_file (file)) {
116            message ("Not importing %s: file is marked as a video file but doesn't have a" +
117                     "supported extension", file.get_path ());
118
119            return ImportResult.UNSUPPORTED_FORMAT;
120        }
121
122        var timestamp = info.get_modification_date_time ().to_unix ();
123
124        // make sure params has a valid md5
125        assert (params.md5 != null);
126
127        int64 exposure_time = params.exposure_time_override;
128        string title = "";
129        string comment = "";
130
131        VideoReader reader = new VideoReader (file);
132        bool is_interpretable = true;
133        double clip_duration = 0.0;
134        Gdk.Pixbuf preview_frame = reader.read_preview_frame ();
135        try {
136            clip_duration = reader.read_clip_duration ();
137        } catch (VideoError err) {
138            if (err is VideoError.FILE) {
139                return ImportResult.FILE_ERROR;
140            } else if (err is VideoError.CONTENTS) {
141                is_interpretable = false;
142                clip_duration = 0.0;
143            } else {
144                error ("can't prepare video for import: an unknown kind of video error occurred");
145            }
146        }
147
148        try {
149            VideoMetadata metadata = reader.read_metadata ();
150            MetadataDateTime? creation_date_time = metadata.get_creation_date_time ();
151
152            if (creation_date_time != null && creation_date_time.get_timestamp () != 0)
153                exposure_time = creation_date_time.get_timestamp ();
154
155            string? video_title = metadata.get_title ();
156            string? video_comment = metadata.get_comment ();
157            if (video_title != null)
158                title = video_title;
159            if (video_comment != null)
160                comment = video_comment;
161        } catch (Error err) {
162            warning ("Unable to read video metadata: %s", err.message);
163        }
164
165        if (exposure_time == 0) {
166            // Use time reported by Gstreamer, if available.
167            exposure_time = (reader.timestamp > 0 ? reader.timestamp : 0);
168        }
169
170        params.row.video_id = VideoID ();
171        params.row.filepath = file.get_path ();
172        params.row.filesize = info.get_size ();
173        params.row.timestamp = timestamp;
174        params.row.width = preview_frame.width;
175        params.row.height = preview_frame.height;
176        params.row.clip_duration = clip_duration;
177        params.row.is_interpretable = is_interpretable;
178        params.row.exposure_time = exposure_time;
179        params.row.import_id = params.import_id;
180        params.row.event_id = EventID ();
181        params.row.md5 = params.md5;
182        params.row.time_created = 0;
183        params.row.title = title;
184        params.row.comment = comment;
185        params.row.backlinks = "";
186        params.row.time_reimported = 0;
187        params.row.flags = 0;
188
189        if (params.thumbnails != null) {
190            params.thumbnails = new Thumbnails ();
191            ThumbnailCache.generate_for_video_frame (params.thumbnails, preview_frame);
192        }
193
194#if MEASURE_IMPORT
195        debug ("IMPORT: total time to import video = %lf", total_time.elapsed ());
196#endif
197        return ImportResult.SUCCESS;
198    }
199
200    private void read_internal () throws VideoError {
201        if (!does_file_exist ())
202            throw new VideoError.FILE ("video file '%s' does not exist or is inaccessible".printf (
203                file.get_path ()));
204
205        try {
206            Gst.PbUtils.Discoverer d = new Gst.PbUtils.Discoverer ((Gst.ClockTime) (Gst.SECOND * 5));
207            Gst.PbUtils.DiscovererInfo info = d.discover_uri (file.get_uri ());
208
209            clip_duration = ((double) info.get_duration ()) / 1000000000.0;
210
211            // Get creation time.
212            // TODO: Note that TAG_DATE can be changed to TAG_DATE_TIME in the future
213            // (and the corresponding output struct) in order to implement #2836.
214            Date? video_date = null;
215            if (info.get_tags () != null && info.get_tags ().get_date (Gst.Tags.DATE, out video_date)) {
216
217                timestamp = new DateTime.local (video_date.get_year (), video_date.get_month (),
218                video_date.get_day (), 0, 0, 0).to_unix ();
219            }
220        } catch (Error e) {
221            debug ("Video read error: %s", e.message);
222            throw new VideoError.CONTENTS ("GStreamer couldn't extract clip information: %s"
223            .printf (e.message));
224        }
225    }
226
227    // Used by thumbnailer () to kill the external process if need be.
228    private bool on_thumbnailer_timer () {
229        debug ("Thumbnailer timer called");
230        if (thumbnailer_pid != 0) {
231            debug ("Killing thumbnailer process: %d", thumbnailer_pid);
232            Posix.kill (thumbnailer_pid, Posix.Signal.KILL);
233        }
234        return false; // Don't call again.
235    }
236
237    // Performs video thumbnailing.
238    // Note: not thread-safe if called from the same instance of the class.
239    private Gdk.Pixbuf? thumbnailer (string video_file) {
240        int[] pipefd = {0, 0};
241
242        if (Posix.pipe (pipefd) < 0) {
243            warning ("Error: unable to open pipe.");
244            return null;
245        }
246        Posix.close (pipefd[1]); // Close the write end of the pipe.
247
248        // Use Photos' thumbnailer, redirect output to stdout.
249        debug ("Launching thumbnailer process: %s", AppDirs.get_thumbnailer_bin ().get_path ());
250        string[] argv = {AppDirs.get_thumbnailer_bin ().get_path (), video_file};
251        try {
252            GLib.Process.spawn_async_with_pipes (null, argv, null, GLib.SpawnFlags.SEARCH_PATH |
253                                                 GLib.SpawnFlags.DO_NOT_REAP_CHILD, null, out thumbnailer_pid, null, out pipefd[0], null);
254            debug ("Spawned thumbnailer, child pid: %d", (int) thumbnailer_pid);
255        } catch (Error e) {
256            debug ("Error spawning process: %s", e.message);
257            if (thumbnailer_pid != 0)
258                GLib.Process.close_pid (thumbnailer_pid);
259            return null;
260        }
261
262        // Start timer.
263        Timeout.add (THUMBNAILER_TIMEOUT, on_thumbnailer_timer);
264
265        // Read pixbuf from stream.
266        Gdk.Pixbuf? buf = null;
267        try {
268            GLib.UnixInputStream unix_input = new GLib.UnixInputStream (pipefd[0], true);
269            buf = new Gdk.Pixbuf.from_stream (unix_input, null);
270        } catch (Error e) {
271            warning ("Error creating pixbuf: %s", e.message);
272            buf = null;
273        }
274
275        // Make sure process exited properly.
276        int child_status = 0;
277        int ret_waitpid = Posix.waitpid (thumbnailer_pid, out child_status, 0);
278        if (ret_waitpid < 0) {
279            debug ("waitpid returned error code: %d", ret_waitpid);
280            buf = null;
281        } else if (0 != posix_wexitstatus (child_status)) {
282            debug ("Thumbnailer exited with error code: %d", posix_wexitstatus (child_status));
283            buf = null;
284        }
285
286        Posix.close (pipefd[0]);
287        GLib.Process.close_pid (thumbnailer_pid);
288        thumbnailer_pid = 0;
289        return buf;
290    }
291
292    private bool does_file_exist () {
293        return FileUtils.test (file.get_path (), FileTest.EXISTS | FileTest.IS_REGULAR);
294    }
295
296    public Gdk.Pixbuf? read_preview_frame () {
297        if (preview_frame != null)
298            return preview_frame;
299
300        if (!does_file_exist ())
301            return null;
302
303        // Get preview frame from thumbnailer.
304        preview_frame = thumbnailer (file.get_path ());
305        if (null == preview_frame)
306            preview_frame = Resources.get_noninterpretable_badge_pixbuf ();
307
308        return preview_frame;
309    }
310
311    public double read_clip_duration () throws VideoError {
312        if (clip_duration == UNKNOWN_CLIP_DURATION)
313            read_internal ();
314
315        return clip_duration;
316    }
317
318    public VideoMetadata read_metadata () throws Error {
319        VideoMetadata metadata = new VideoMetadata ();
320        metadata.read_from_file (File.new_for_path (file.get_path ()));
321
322        return metadata;
323    }
324}
325
326public class Video : VideoSource, Flaggable, Monitorable, Dateable {
327    public const int NO_VIDEO_INTERPRETER_STATE = -1;
328    public const string TYPENAME = "video";
329
330    public const uint64 FLAG_TRASH = 0x0000000000000001;
331    public const uint64 FLAG_OFFLINE = 0x0000000000000002;
332    public const uint64 FLAG_FLAGGED = 0x0000000000000004;
333
334    public class InterpretableResults {
335        internal Video video;
336        internal bool update_interpretable = false;
337        internal bool is_interpretable = false;
338        internal Gdk.Pixbuf? new_thumbnail = null;
339
340        public InterpretableResults (Video video) {
341            this.video = video;
342        }
343
344        public void foreground_finish () {
345            if (update_interpretable)
346                video.set_is_interpretable (is_interpretable);
347
348            if (new_thumbnail != null) {
349                try {
350                    ThumbnailCache.replace (video, ThumbnailCache.Size.BIG, new_thumbnail);
351                    ThumbnailCache.replace (video, ThumbnailCache.Size.MEDIUM, new_thumbnail);
352
353                    video.notify_thumbnail_altered ();
354                } catch (Error err) {
355                    message ("Unable to update video thumbnails for %s: %s", video.to_string (),
356                             err.message);
357                }
358            }
359        }
360    }
361
362    private static bool interpreter_state_changed;
363    private static int current_state;
364    private static bool normal_regen_complete;
365    private static bool offline_regen_complete;
366    private static GLib.Settings video_settings;
367    public static VideoSourceCollection global;
368
369    private VideoRow backing_row;
370
371    public Video (VideoRow row) {
372        this.backing_row = row;
373
374        // normalize user text
375        this.backing_row.title = prep_title (this.backing_row.title);
376
377        if (((row.flags & FLAG_TRASH) != 0) || ((row.flags & FLAG_OFFLINE) != 0))
378            rehydrate_backlinks (global, row.backlinks);
379    }
380
381    public static void init (ProgressMonitor? monitor = null) {
382        // Must initialize static variables here.
383        // TODO: set values at declaration time once the following Vala bug is fixed:
384        //       https://bugzilla.gnome.org/show_bug.cgi?id=655594
385        interpreter_state_changed = false;
386        current_state = -1;
387        normal_regen_complete = false;
388        offline_regen_complete = false;
389        video_settings = new GLib.Settings (GSettingsConfigurationEngine.VIDEO_SCHEMA_NAME);
390
391        // initialize GStreamer, but don't pass it our actual command line arguments -- we don't
392        // want our end users to be able to parameterize the GStreamer configuration
393        string[] fake_args = new string[0];
394        unowned string[] fake_unowned_args = fake_args;
395        Gst.init (ref fake_unowned_args);
396
397        int saved_state = video_settings.get_int ("interpreter-state-cookie");
398        current_state = (int) Gst.Registry.get ().get_feature_list_cookie ();
399        if (saved_state == NO_VIDEO_INTERPRETER_STATE) {
400            message ("interpreter state cookie not found; assuming all video thumbnails are out of date");
401            interpreter_state_changed = true;
402        } else if (saved_state != current_state) {
403            message ("interpreter state has changed; video thumbnails may be out of date");
404            interpreter_state_changed = true;
405        }
406
407        global = new VideoSourceCollection ();
408
409        Gee.ArrayList < VideoRow?> all = VideoTable.get_instance ().get_all ();
410        Gee.ArrayList<Video> all_videos = new Gee.ArrayList<Video> ();
411        Gee.ArrayList<Video> trashed_videos = new Gee.ArrayList<Video> ();
412        Gee.ArrayList<Video> offline_videos = new Gee.ArrayList<Video> ();
413        int count = all.size;
414        for (int ctr = 0; ctr < count; ctr++) {
415            Video video = new Video (all.get (ctr));
416
417            if (interpreter_state_changed)
418                video.set_is_interpretable (false);
419
420            if (video.is_trashed ())
421                trashed_videos.add (video);
422            else if (video.is_offline ())
423                offline_videos.add (video);
424            else
425                all_videos.add (video);
426
427            if (monitor != null)
428                monitor (ctr, count);
429        }
430
431        global.add_many_to_trash (trashed_videos);
432        global.add_many_to_offline (offline_videos);
433        global.add_many (all_videos);
434    }
435
436    public static bool has_interpreter_state_changed () {
437        return interpreter_state_changed;
438    }
439
440    public static void notify_normal_thumbs_regenerated () {
441        if (normal_regen_complete)
442            return;
443
444        message ("normal video thumbnail regeneration completed");
445
446        normal_regen_complete = true;
447        if (normal_regen_complete && offline_regen_complete)
448            save_interpreter_state ();
449    }
450
451    public static void notify_offline_thumbs_regenerated () {
452        if (offline_regen_complete)
453            return;
454
455        message ("offline video thumbnail regeneration completed");
456
457        offline_regen_complete = true;
458        if (normal_regen_complete && offline_regen_complete)
459            save_interpreter_state ();
460    }
461
462    private static void save_interpreter_state () {
463        if (interpreter_state_changed) {
464            message ("saving video interpreter state to configuration system");
465
466            video_settings.set_int ("interpreter-state-cookie", current_state);
467            interpreter_state_changed = false;
468        }
469    }
470
471    public static void terminate () {
472    }
473
474    public static ExporterUI? export_many (Gee.Collection<Video> videos, Exporter.CompletionCallback done,
475                                           bool export_in_place = false) {
476        if (videos.size == 0)
477            return null;
478
479        // in place export is relatively easy -- provide a fast, separate code path for it
480        if (export_in_place) {
481            ExporterUI temp_exporter = new ExporterUI (new Exporter.for_temp_file (videos,
482                    Scaling.for_original (), ExportFormatParameters.unmodified ()));
483            temp_exporter.export (done);
484            return temp_exporter;
485        }
486
487        // one video
488        if (videos.size == 1) {
489            Video video = null;
490            foreach (Video v in videos) {
491                video = v;
492                break;
493            }
494
495            File save_as = ExportUI.choose_file (video.get_basename ());
496            if (save_as == null)
497                return null;
498
499            try {
500                AppWindow.get_instance ().set_busy_cursor ();
501                video.export (save_as);
502                AppWindow.get_instance ().set_normal_cursor ();
503            } catch (Error err) {
504                AppWindow.get_instance ().set_normal_cursor ();
505                export_error_dialog (save_as, false);
506            }
507
508            return null;
509        }
510
511        // multiple videos
512        File export_dir = ExportUI.choose_dir (_ ("Export Videos"));
513        if (export_dir == null)
514            return null;
515
516        ExporterUI exporter = new ExporterUI (new Exporter (videos, export_dir,
517                                              Scaling.for_original (), ExportFormatParameters.unmodified ()));
518        exporter.export (done);
519
520        return exporter;
521    }
522
523    protected override void commit_backlinks (SourceCollection? sources, string? backlinks) {
524        try {
525            VideoTable.get_instance ().update_backlinks (get_video_id (), backlinks);
526            lock (backing_row) {
527                backing_row.backlinks = backlinks;
528            }
529        } catch (DatabaseError err) {
530            warning ("Unable to update link state for %s: %s", to_string (), err.message);
531        }
532    }
533
534    protected override bool set_event_id (EventID event_id) {
535        lock (backing_row) {
536            bool committed = VideoTable.get_instance ().set_event (backing_row.video_id, event_id);
537
538            if (committed)
539                backing_row.event_id = event_id;
540
541            return committed;
542        }
543    }
544
545    public static bool is_duplicate (File? file, string? full_md5) {
546        assert (file != null || full_md5 != null);
547#if !NO_DUPE_DETECTION
548        return VideoTable.get_instance ().has_duplicate (file, full_md5);
549#else
550        return false;
551#endif
552    }
553
554    public static ImportResult import_create (VideoImportParams params, out Video video) {
555        video = null;
556
557        // add to the database
558        try {
559            if (VideoTable.get_instance ().add (params.row).is_invalid ())
560                return ImportResult.DATABASE_ERROR;
561        } catch (DatabaseError err) {
562            return ImportResult.DATABASE_ERROR;
563        }
564
565        // create local object but don't add to global until thumbnails generated
566        video = new Video (params.row);
567
568        return ImportResult.SUCCESS;
569    }
570
571    public static void import_failed (Video video) {
572        try {
573            VideoTable.get_instance ().remove (video.get_video_id ());
574        } catch (DatabaseError err) {
575            AppWindow.database_error (err);
576        }
577    }
578
579    public override BackingFileState[] get_backing_files_state () {
580        BackingFileState[] backing = new BackingFileState[1];
581        lock (backing_row) {
582            backing[0] = new BackingFileState (backing_row.filepath, backing_row.filesize,
583                                               backing_row.timestamp, backing_row.md5);
584        }
585
586        return backing;
587    }
588
589    public override Gdk.Pixbuf? get_thumbnail (int scale) throws Error {
590        return ThumbnailCache.fetch (this, scale);
591    }
592
593    public override string get_master_md5 () {
594        lock (backing_row) {
595            return backing_row.md5;
596        }
597    }
598
599    public override Gdk.Pixbuf get_preview_pixbuf (Scaling scaling) throws Error {
600        Gdk.Pixbuf pixbuf = get_thumbnail (ThumbnailCache.Size.BIG);
601
602        return scaling.perform_on_pixbuf (pixbuf, Gdk.InterpType.NEAREST, true);
603    }
604
605    public override Gdk.Pixbuf? create_thumbnail (int scale) throws Error {
606        VideoReader reader = new VideoReader (get_file ());
607        Gdk.Pixbuf? frame = reader.read_preview_frame ();
608
609        return (frame != null) ? frame : Resources.get_noninterpretable_badge_pixbuf ().copy ();
610    }
611
612    public override string get_typename () {
613        return TYPENAME;
614    }
615
616    public override int64 get_instance_id () {
617        return get_video_id ().id;
618    }
619
620    public override ImportID get_import_id () {
621        lock (backing_row) {
622            return backing_row.import_id;
623        }
624    }
625
626    public override PhotoFileFormat get_preferred_thumbnail_format () {
627        return PhotoFileFormat.get_system_default_format ();
628    }
629
630    public override string? get_title () {
631        lock (backing_row) {
632            return backing_row.title;
633        }
634    }
635
636    public override void set_title (string? title) {
637        string? new_title = prep_title (title);
638
639        lock (backing_row) {
640            if (backing_row.title == new_title)
641                return;
642
643            try {
644                VideoTable.get_instance ().set_title (backing_row.video_id, new_title);
645            } catch (DatabaseError e) {
646                AppWindow.database_error (e);
647                return;
648            }
649            // if we didn't short-circuit return in the catch clause above, then the change was
650            // successfully committed to the database, so update it in the in-memory row cache
651            backing_row.title = new_title;
652        }
653
654        notify_altered (new Alteration ("metadata", "name"));
655    }
656
657    public override string? get_comment () {
658        lock (backing_row) {
659            return backing_row.comment;
660        }
661    }
662
663    public override bool set_comment (string? comment) {
664        string? new_comment = prep_title (comment);
665
666        lock (backing_row) {
667            if (backing_row.comment == new_comment)
668                return true;
669
670            try {
671                VideoTable.get_instance ().set_comment (backing_row.video_id, new_comment);
672            } catch (DatabaseError e) {
673                AppWindow.database_error (e);
674                return false;
675            }
676            // if we didn't short-circuit return in the catch clause above, then the change was
677            // successfully committed to the database, so update it in the in-memory row cache
678            backing_row.comment = new_comment;
679        }
680
681        notify_altered (new Alteration ("metadata", "comment"));
682
683        return true;
684    }
685
686    public override bool is_trashed () {
687        return is_flag_set (FLAG_TRASH);
688    }
689
690    public override bool is_offline () {
691        return is_flag_set (FLAG_OFFLINE);
692    }
693
694    public override void mark_offline () {
695        add_flags (FLAG_OFFLINE);
696    }
697
698    public override void mark_online () {
699        remove_flags (FLAG_OFFLINE);
700
701        if ((!get_is_interpretable ()) && has_interpreter_state_changed ())
702            check_is_interpretable ().foreground_finish ();
703    }
704
705    public override void trash () {
706        add_flags (FLAG_TRASH);
707    }
708
709    public override void untrash () {
710        remove_flags (FLAG_TRASH);
711    }
712
713    public bool is_flagged () {
714        return is_flag_set (FLAG_FLAGGED);
715    }
716
717    public void mark_flagged () {
718        add_flags (FLAG_FLAGGED, new Alteration ("metadata", "flagged"));
719    }
720
721    public void mark_unflagged () {
722        remove_flags (FLAG_FLAGGED, new Alteration ("metadata", "flagged"));
723    }
724
725    public override EventID get_event_id () {
726        lock (backing_row) {
727            return backing_row.event_id;
728        }
729    }
730
731    public override string to_string () {
732        lock (backing_row) {
733            return "[%s] %s".printf (backing_row.video_id.id.to_string (), backing_row.filepath);
734        }
735    }
736
737    public VideoID get_video_id () {
738        lock (backing_row) {
739            return backing_row.video_id;
740        }
741    }
742
743    public override int64 get_exposure_time () {
744        lock (backing_row) {
745            return backing_row.exposure_time;
746        }
747    }
748
749    public void set_exposure_time (int64 time) {
750        lock (backing_row) {
751            try {
752                VideoTable.get_instance ().set_exposure_time (backing_row.video_id, time);
753            } catch (Error e) {
754                debug ("Warning - %s", e.message);
755            }
756            backing_row.exposure_time = time;
757        }
758
759        notify_altered (new Alteration ("metadata", "exposure-time"));
760    }
761
762    public Dimensions get_frame_dimensions () {
763        lock (backing_row) {
764            return Dimensions (backing_row.width, backing_row.height);
765        }
766    }
767
768    public override Dimensions get_dimensions (Photo.Exception disallowed_steps = Photo.Exception.NONE) {
769        return get_frame_dimensions ();
770    }
771
772    public override uint64 get_filesize () {
773        return get_master_filesize ();
774    }
775
776    public override uint64 get_master_filesize () {
777        lock (backing_row) {
778            return backing_row.filesize;
779        }
780    }
781
782    public override int64 get_timestamp () {
783        lock (backing_row) {
784            return backing_row.timestamp;
785        }
786    }
787
788    public void set_master_timestamp (FileInfo info) {
789        var mod_time = info.get_modification_date_time ().to_unix ();
790
791        try {
792            lock (backing_row) {
793                if (backing_row.timestamp == mod_time)
794                    return;
795
796                VideoTable.get_instance ().set_timestamp (backing_row.video_id, mod_time);
797                backing_row.timestamp = mod_time;
798            }
799        } catch (DatabaseError err) {
800            AppWindow.database_error (err);
801
802            return;
803        }
804
805        notify_altered (new Alteration ("metadata", "master-timestamp"));
806    }
807
808    public string get_filename () {
809        lock (backing_row) {
810            return backing_row.filepath;
811        }
812    }
813
814    public override File get_file () {
815        return File.new_for_path (get_filename ());
816    }
817
818    public override File get_master_file () {
819        return get_file ();
820    }
821
822    public void export (File dest_file) throws Error {
823        File source_file = File.new_for_path (get_filename ());
824        source_file.copy (dest_file, FileCopyFlags.OVERWRITE | FileCopyFlags.TARGET_DEFAULT_PERMS,
825        null, null);
826    }
827
828    public double get_clip_duration () {
829        lock (backing_row) {
830            return backing_row.clip_duration;
831        }
832    }
833
834    public bool get_is_interpretable () {
835        lock (backing_row) {
836            return backing_row.is_interpretable;
837        }
838    }
839
840    private void set_is_interpretable (bool is_interpretable) {
841        lock (backing_row) {
842            if (backing_row.is_interpretable == is_interpretable)
843                return;
844
845            backing_row.is_interpretable = is_interpretable;
846        }
847
848        try {
849            VideoTable.get_instance ().update_is_interpretable (get_video_id (), is_interpretable);
850        } catch (DatabaseError e) {
851            AppWindow.database_error (e);
852        }
853    }
854
855    // Intended to be called from a background thread but can be called from foreground as well.
856    // Caller should call InterpretableResults.foreground_process () only from foreground thread,
857    // however
858    public InterpretableResults check_is_interpretable () {
859        InterpretableResults results = new InterpretableResults (this);
860
861        double clip_duration = -1.0;
862        Gdk.Pixbuf? preview_frame = null;
863
864        VideoReader backing_file_reader = new VideoReader (get_file ());
865        try {
866            clip_duration = backing_file_reader.read_clip_duration ();
867            preview_frame = backing_file_reader.read_preview_frame ();
868        } catch (VideoError e) {
869            // if we catch an error on an interpretable video here, then this video is
870            // non-interpretable (e.g. its codec is not present on the users system).
871            results.update_interpretable = get_is_interpretable ();
872            results.is_interpretable = false;
873
874            return results;
875        }
876
877        // if already marked interpretable, this is only confirming what we already knew
878        if (get_is_interpretable ()) {
879            results.update_interpretable = false;
880            results.is_interpretable = true;
881
882            return results;
883        }
884
885        debug ("video %s has become interpretable", get_file ().get_basename ());
886
887        // save this here, this can be done in background thread
888        lock (backing_row) {
889            backing_row.clip_duration = clip_duration;
890        }
891
892        results.update_interpretable = true;
893        results.is_interpretable = true;
894        results.new_thumbnail = preview_frame;
895
896        return results;
897    }
898
899    public override void destroy () {
900        VideoID video_id = get_video_id ();
901
902        ThumbnailCache.remove (this);
903
904        try {
905            VideoTable.get_instance ().remove (video_id);
906        } catch (DatabaseError err) {
907            error ("failed to remove video %s from video table", to_string ());
908        }
909
910        base.destroy ();
911    }
912
913    protected override bool internal_delete_backing () throws Error {
914        bool ret = delete_original_file ();
915
916        // Return false if parent method failed.
917        return base.internal_delete_backing () && ret;
918    }
919
920    private void notify_flags_altered (Alteration? additional_alteration) {
921        Alteration alteration = new Alteration ("metadata", "flags");
922        if (additional_alteration != null)
923            alteration = alteration.compress (additional_alteration);
924
925        notify_altered (alteration);
926    }
927
928    public uint64 add_flags (uint64 flags_to_add, Alteration? additional_alteration = null) {
929        uint64 new_flags;
930        lock (backing_row) {
931            new_flags = internal_add_flags (backing_row.flags, flags_to_add);
932            if (backing_row.flags == new_flags)
933                return backing_row.flags;
934
935            try {
936                VideoTable.get_instance ().set_flags (get_video_id (), new_flags);
937            } catch (DatabaseError e) {
938                AppWindow.database_error (e);
939                return backing_row.flags;
940            }
941
942            backing_row.flags = new_flags;
943        }
944
945        notify_flags_altered (additional_alteration);
946
947        return new_flags;
948    }
949
950    public uint64 remove_flags (uint64 flags_to_remove, Alteration? additional_alteration = null) {
951        uint64 new_flags;
952        lock (backing_row) {
953            new_flags = internal_remove_flags (backing_row.flags, flags_to_remove);
954            if (backing_row.flags == new_flags)
955                return backing_row.flags;
956
957            try {
958                VideoTable.get_instance ().set_flags (get_video_id (), new_flags);
959            } catch (DatabaseError e) {
960                AppWindow.database_error (e);
961                return backing_row.flags;
962            }
963
964            backing_row.flags = new_flags;
965        }
966
967        notify_flags_altered (additional_alteration);
968
969        return new_flags;
970    }
971
972    public bool is_flag_set (uint64 flag) {
973        lock (backing_row) {
974            return internal_is_flag_set (backing_row.flags, flag);
975        }
976    }
977
978    public void set_master_file (File file) {
979        string new_filepath = file.get_path ();
980        string? old_filepath = null;
981        try {
982            lock (backing_row) {
983                if (backing_row.filepath == new_filepath)
984                    return;
985
986                old_filepath = backing_row.filepath;
987
988                VideoTable.get_instance ().set_filepath (backing_row.video_id, new_filepath);
989                backing_row.filepath = new_filepath;
990            }
991        } catch (DatabaseError err) {
992            AppWindow.database_error (err);
993
994            return;
995        }
996
997        assert (old_filepath != null);
998        notify_master_replaced (File.new_for_path (old_filepath), file);
999
1000        notify_altered (new Alteration.from_list ("backing:master,metadata:name"));
1001    }
1002
1003    public VideoMetadata read_metadata () throws Error {
1004        return (new VideoReader (get_file ())).read_metadata ();
1005    }
1006}
1007
1008public class VideoSourceCollection : MediaSourceCollection {
1009    public enum State {
1010        UNKNOWN,
1011        ONLINE,
1012        OFFLINE,
1013        TRASH
1014    }
1015
1016    public override TransactionController transaction_controller {
1017        get {
1018            if (_transaction_controller == null)
1019                _transaction_controller = new MediaSourceTransactionController (this);
1020
1021            return _transaction_controller;
1022        }
1023    }
1024
1025    private TransactionController _transaction_controller = null;
1026    private Gee.MultiMap < uint64?, Video > filesize_to_video =
1027        new Gee.TreeMultiMap < uint64?, Video > (uint64_compare);
1028
1029    public VideoSourceCollection () {
1030        base ("VideoSourceCollection", get_video_key);
1031
1032        get_trashcan ().contents_altered.connect (on_trashcan_contents_altered);
1033        get_offline_bin ().contents_altered.connect (on_offline_contents_altered);
1034    }
1035
1036    protected override MediaSourceHoldingTank create_trashcan () {
1037        return new MediaSourceHoldingTank (this, is_video_trashed, get_video_key);
1038    }
1039
1040    protected override MediaSourceHoldingTank create_offline_bin () {
1041        return new MediaSourceHoldingTank (this, is_video_offline, get_video_key);
1042    }
1043
1044    public override MediaMonitor create_media_monitor (Workers workers, Cancellable cancellable) {
1045        return new VideoMonitor (cancellable);
1046    }
1047
1048    public override bool holds_type_of_source (DataSource source) {
1049        return source is Video;
1050    }
1051
1052    public override string get_typename () {
1053        return Video.TYPENAME;
1054    }
1055
1056    public override bool is_file_recognized (File file) {
1057        return VideoReader.is_supported_video_file (file);
1058    }
1059
1060    private void on_trashcan_contents_altered (Gee.Collection<DataSource>? added,
1061            Gee.Collection<DataSource>? removed) {
1062        trashcan_contents_altered ((Gee.Collection<Video>? ) added,
1063                                   (Gee.Collection<Video>? ) removed);
1064    }
1065
1066    private void on_offline_contents_altered (Gee.Collection<DataSource>? added,
1067            Gee.Collection<DataSource>? removed) {
1068        offline_contents_altered ((Gee.Collection<Video>? ) added,
1069                                  (Gee.Collection<Video>? ) removed);
1070    }
1071
1072    protected override MediaSource? fetch_by_numeric_id (int64 numeric_id) {
1073        return fetch (VideoID (numeric_id));
1074    }
1075
1076    public static int64 get_video_key (DataSource source) {
1077        Video video = (Video) source;
1078        VideoID video_id = video.get_video_id ();
1079
1080        return video_id.id;
1081    }
1082
1083    public static bool is_video_trashed (DataSource source) {
1084        return ((Video) source).is_trashed ();
1085    }
1086
1087    public static bool is_video_offline (DataSource source) {
1088        return ((Video) source).is_offline ();
1089    }
1090
1091    public Video fetch (VideoID video_id) {
1092        return (Video) fetch_by_key (video_id.id);
1093    }
1094
1095    public override Gee.Collection<string> get_event_source_ids (EventID event_id) {
1096        return VideoTable.get_instance ().get_event_source_ids (event_id);
1097    }
1098
1099    public Video? get_state_by_file (File file, out State state) {
1100        Video? video = (Video? ) fetch_by_master_file (file);
1101        if (video != null) {
1102            state = State.ONLINE;
1103
1104            return video;
1105        }
1106
1107        video = (Video? ) get_trashcan ().fetch_by_master_file (file);
1108        if (video != null) {
1109            state = State.TRASH;
1110
1111            return video;
1112        }
1113
1114        video = (Video? ) get_offline_bin ().fetch_by_master_file (file);
1115        if (video != null) {
1116            state = State.OFFLINE;
1117
1118            return video;
1119        }
1120
1121        state = State.UNKNOWN;
1122
1123        return null;
1124    }
1125
1126    private void compare_backing (Video video, FileInfo info, Gee.Collection<Video> matching_master) {
1127        if (video.get_filesize () != info.get_size ())
1128            return;
1129
1130        if (video.get_timestamp () == info.get_modification_time ().tv_sec)
1131            matching_master.add (video);
1132    }
1133
1134    public void fetch_by_matching_backing (FileInfo info, Gee.Collection<Video> matching_master) {
1135        foreach (DataObject object in get_all ())
1136            compare_backing ((Video) object, info, matching_master);
1137
1138        foreach (MediaSource media in get_offline_bin_contents ())
1139            compare_backing ((Video) media, info, matching_master);
1140    }
1141
1142    protected override void notify_contents_altered (Gee.Iterable<DataObject>? added,
1143            Gee.Iterable<DataObject>? removed) {
1144        if (added != null) {
1145            foreach (DataObject object in added) {
1146                Video video = (Video) object;
1147
1148                filesize_to_video.set (video.get_master_filesize (), video);
1149            }
1150        }
1151
1152        if (removed != null) {
1153            foreach (DataObject object in removed) {
1154                Video video = (Video) object;
1155
1156                filesize_to_video.remove (video.get_master_filesize (), video);
1157            }
1158        }
1159
1160        base.notify_contents_altered (added, removed);
1161    }
1162
1163    public VideoID get_basename_filesize_duplicate (string basename, uint64 filesize) {
1164        foreach (Video video in filesize_to_video.get (filesize)) {
1165            if (utf8_ci_compare (video.get_master_file ().get_basename (), basename) == 0)
1166                return video.get_video_id ();
1167        }
1168
1169        return VideoID (); // the default constructor of the VideoID struct creates an invalid
1170        // video id, which is just what we want in this case
1171    }
1172
1173    public bool has_basename_filesize_duplicate (string basename, uint64 filesize) {
1174        return get_basename_filesize_duplicate (basename, filesize).is_valid ();
1175    }
1176}
1177