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