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