Skip to content

API Documentation

This API is still in development and may change without notice.

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

CuemsDBMedia

Bases: StringSanitizer

Media file CRUD, thumbnail / waveform generation, and video pre-indexing.

On upload (via :meth:new) the following sidecar files are created:

  • Movie — JPEG thumbnail (ffmpeg) + .idx video index (cuems-videoindexer).
  • Audio — PNG waveform image (audiowaveform) + JSON waveform data.
  • Image — JPEG thumbnail (ffmpeg).

All mutating operations are wrapped in Peewee atomic transactions with rollback; filesystem changes are reversed on failure where possible.

User-supplied name strings are sanitised via the inherited StringSanitizer methods.

Example

db_media = CuemsDBMedia(settings_dict, database) dest_name = db_media.new("/tmp/cuems/upload.tmp123456", "my-audio.wav") media_list = db_media.list() meta = db_media.load_meta(uuid_str) db_media.delete(uuid_str) # soft-delete → trash db_media.restore(uuid_str) # restore from trash db_media.delete_from_trash(uuid_str) # permanent delete

Source code in src/cuemseditor/CuemsDBMedia.py
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
class CuemsDBMedia(StringSanitizer):
    """Media file CRUD, thumbnail / waveform generation, and video pre-indexing.

    On upload (via :meth:`new`) the following sidecar files are created:

    * **Movie** — JPEG thumbnail (ffmpeg) + ``.idx`` video index (cuems-videoindexer).
    * **Audio** — PNG waveform image (audiowaveform) + JSON waveform data.
    * **Image** — JPEG thumbnail (ffmpeg).

    All mutating operations are wrapped in Peewee atomic transactions with
    rollback; filesystem changes are reversed on failure where possible.

    User-supplied name strings are sanitised via the inherited
    ``StringSanitizer`` methods.

    Example:
        >>> db_media = CuemsDBMedia(settings_dict, database)
        >>> dest_name = db_media.new("/tmp/cuems/upload.tmp123456", "my-audio.wav")
        >>> media_list = db_media.list()
        >>> meta = db_media.load_meta(uuid_str)
        >>> db_media.delete(uuid_str)       # soft-delete → trash
        >>> db_media.restore(uuid_str)      # restore from trash
        >>> db_media.delete_from_trash(uuid_str)  # permanent delete
    """

    def __init__(self, settings_dict, db_connection):
        """Initialise paths from *settings_dict*.

        Args:
            settings_dict: Mapping consumed from ``settings.xml``.  Required
                keys: ``library_path``, ``tmp_path``, ``media_folder_name``,
                ``trash_folder_name``, ``thumbnail_folder_name``,
                ``waveform_folder_name``, ``thumbnail_extension``,
                ``waveform_extension``, ``thumbnail_size``.
            db_connection: The shared Peewee ``SqliteDatabase`` instance owned
                by ``CuemsDBManager``.
        """
        self.db = db_connection
        try:
            self.library_path = settings_dict['library_path']
            self.tmp_path = settings_dict['tmp_path']
            self.media_path = os.path.join(self.library_path, settings_dict['media_folder_name'])
            self.trash_path = os.path.join(self.library_path, settings_dict['trash_folder_name'], settings_dict['media_folder_name'])
            self.thumbnail_path = os.path.join(self.media_path, settings_dict['thumbnail_folder_name'])
            self.waveform_path = os.path.join(self.media_path, settings_dict['waveform_folder_name'])
            self.thumbnail_trash_path = os.path.join(self.trash_path, settings_dict['thumbnail_folder_name'])
            self.waveform_trash_path = os.path.join(self.trash_path, settings_dict['waveform_folder_name'])
            self.thumbnail_extension = settings_dict['thumbnail_extension']
            self.waveform_extension = settings_dict['waveform_extension']
            self.thumbnail_size = settings_dict['thumbnail_size']
        except KeyError as e:
            Logger.error(f'can not read settings {e}')

    def _free_filename(self, filename):
        """Return *filename*, or ``<base>-NNN<ext>``, free on disk AND in the DB.

        ``CopyMoveVersioned`` only looks at ``media_path``, but a trashed
        media keeps its DB row (and the unique ``name``/``unix_name``
        constraints) while its file sits in the trash directory, so an
        upload with the same name passed the disk check and then failed
        ``Media.create`` with an ``IntegrityError``.
        """
        (base, ext) = os.path.splitext(filename)
        candidate = filename
        i = 0
        while (os.path.exists(os.path.join(self.media_path, candidate))
               or Media.select().where((Media.unix_name == candidate) | (Media.name == candidate)).exists()):
            i += 1
            candidate = base + "-{:03d}".format(i) + ext
        return candidate

    def new(self, tmp_file_path, filename, md5=None):
        """Intake a newly uploaded file: move it into the media library and create sidecars.

        Steps performed atomically:

        1. ``CopyMoveVersioned.move`` from ``tmp_file_path`` to
           ``<media_path>/<filename>`` (versioned if *filename* exists).
        2. Detect ``MediaType`` from the file extension.
        3. Extract duration via ``ffprobe`` (audio/video).
        4. Generate thumbnail (ffmpeg), waveform (audiowaveform for audio), and
           video index (cuems-videoindexer for movies).
        5. Insert the ``Media`` DB row.

        On any failure the transaction is rolled back and any partially created
        sidecar files are removed.

        Args:
            tmp_file_path: Absolute path to the file in the tmp upload directory.
            filename: Sanitised target filename (no directory component).
            md5: the file's MD5 as the upload verified it (``None`` when the
                caller has none); stored as ``file_md5`` (869fat84r, D18).

        Returns:
            The actual destination filename (may differ from *filename* if
            ``CopyMoveVersioned`` added a suffix to avoid collision).

        Raises:
            Exception: Re-raises after rollback and cleanup on any failure.
        """
        with self.db.atomic() as transaction:
            trash_state = False
            try:
                dest_filename = None
                dest_filename = CopyMoveVersioned.move(tmp_file_path, self.media_path, self._free_filename(filename))

                try:
                    _type = self.get_type(dest_filename)
                except Exception as e:
                    Logger.warning(f'could not get media type; error : {e}')
                    _type = None
                    raise e

                try:
                    if _type in (MediaType.MOVIE, MediaType.AUDIO):
                        media_duration = self.get_duration(dest_filename)
                    else:
                        media_duration = None
                except Exception as e:
                    Logger.warning(f'could not get media duration; error : {e}')
                    media_duration = None

                try:
                    if _type is MediaType.MOVIE:
                        Logger.debug(f'creating thumbnail for movie: {dest_filename}')
                        dest_thumbnail_filename = None
                        dest_thumbnail_filename = self.create_video_thumbnail(dest_filename, media_duration)
                        self.create_video_index(dest_filename)
                    elif _type is MediaType.AUDIO:
                        Logger.debug(f'creating thumbnail for audio: {dest_filename}')
                        dest_thumbnail_filename = None
                        dest_waveform_filename = None
                        if media_duration is not None:
                            dest_thumbnail_filename = self.create_audio_thubnail(dest_filename, media_duration)
                        else:
                            Logger.warning(f'no duration for {dest_filename}; skipping audio waveform thumbnail')
                        dest_waveform_filename = self.create_audio_waveform(dest_filename)
                    elif _type is MediaType.IMAGE:
                        Logger.debug(f'creating thumbnail for image: {dest_filename}')
                        dest_thumbnail_filename = None
                        dest_thumbnail_filename = self.create_video_thumbnail(dest_filename, None)
                except Exception as e:
                    Logger.error(f'could not generate {_type} thumbnail or waveform; error : {e}')
                    media_thumbnail_binary_data = None

                # Stored like the duration (869fat84r): a movie's pixel size, so
                # the engine does not probe the file when it arms a cue (a
                # failed probe stores nothing; never fatal); and, for every
                # media type, the file's size and the MD5 the upload verified.
                dest_path = self.get_file_path(dest_filename)
                pixel_width = pixel_height = None
                if _type is MediaType.MOVIE:
                    width, height = probe_dimensions(dest_path)
                    if width and height:
                        pixel_width, pixel_height = width, height
                file_size = media_file_size(dest_path)
                file_md5 = md5.lower() if isinstance(md5, str) and len(md5) == 32 else None

                media_uuid = new_uuid()
                Media.create(uuid=str(media_uuid), name=dest_filename, unix_name=dest_filename, created=new_datetime(), modified=new_datetime(), duration=media_duration, media_type=_type.name, in_trash=False, pixel_width=pixel_width, pixel_height=pixel_height, file_size=file_size, file_md5=file_md5)
                Logger.debug(f'new media created: {media_uuid} {dest_filename}')
                return dest_filename
            except Exception as e:
                Logger.error("error: {} {} trying to move new file, rolling back database insert".format(type(e), e))
                transaction.rollback()
                if dest_filename is None and dest_thumbnail_filename is None:  # if move or copy where not successful we don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                    if _type is MediaType.AUDIO:
                        if dest_waveform_filename is None:
                            raise e
                    else:
                        raise e
                if os.path.exists(self.get_file_path(dest_filename)):
                    os.remove(self.get_file_path(dest_filename))

                if os.path.exists(self.get_thumbnail_path(dest_filename)):
                    os.remove(self.get_thumbnail_path(dest_filename))

                if os.path.exists(self.get_waveform_path(dest_filename)):
                    os.remove(self.get_waveform_path(dest_filename))

                raise e

    def list(self):
        """Return all live (non-trashed) media files with project usage counts.

        Returns:
            List of single-key dicts ``{uuid_str: {name, unix_name, created,
            modified, duration, type, in_projects, in_projects_list,
            in_trash_projects}}``, ordered by ``created`` descending.
            ``duration`` is the ``CTimecode`` string (or ``None``); the frontend
            copies it into cue ``Media.duration`` on save.  ``in_projects_list``
            is a list of ``{uuid, name, in_trash}`` dicts for each referencing
            project.
        """
        media_list = list()

        medias = (Media
                  .select(Media,
                          fn.COUNT(Case(Project.in_trash, (('0', 1),), None)).alias('in_project_count'),
                          fn.COUNT(Case(Project.in_trash, (('1', 1),), None)).alias('in_project_trash_count'))
                  .join(ProjectMedia, JOIN.LEFT_OUTER)
                  .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
                  .where((Media.in_trash == False))
                  .group_by(Media).order_by(Media.created.desc()))

        medias = prefetch(
            medias,
            ProjectMedia,
            Project)

        for media in medias:
            project_list = []  # Reset list for each media item
            for project in media.projects():
                project_dict = {'uuid': str(project.uuid), 'name': project.name, 'in_trash': project.in_trash}
                project_list.append(project_dict)

            media_dict = {str(media.uuid): {'name': media.name, 'unix_name': media.unix_name, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, "in_projects": media.in_project_count, "in_projects_list": project_list, "in_trash_projects": media.in_project_trash_count}}
            media_list.append(media_dict)

        return media_list

    def list_trash(self):
        """Return all trashed media files with project usage counts.

        Returns:
            Same shape as :meth:`list` but for media where
            ``in_trash == True``.  ``in_projects_list`` is not included.
        """
        media_list = list()

        medias = (Media
                  .select(Media.uuid, Media.name, Media.unix_name, Media.created, Media.modified, Media.duration, Media.media_type,
                          fn.COUNT(Case(Project.in_trash, (('0', 1),), None)).alias('in_project_count'),
                          fn.COUNT(Case(Project.in_trash, (('1', 1),), None)).alias('in_project_trash_count'))
                  .join(ProjectMedia, JOIN.LEFT_OUTER)
                  .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
                  .where(Media.in_trash == True)
                  .group_by(Media.uuid).order_by(Media.created.desc()))
        for media in medias:
            media_dict = {str(media.uuid): {'name': media.name, 'unix_name': media.unix_name, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, "in_projects": media.in_project_count, "in_trash_projects": media.in_project_trash_count}}
            media_list.append(media_dict)

        return media_list

    def save(self, uuid, data):
        """Update the ``name`` and ``description`` fields of a live media record.

        Args:
            uuid: Media UUID string.
            data: Dict with structure ``{uuid: {name, description}}``.

        Returns:
            ``'updated'`` on success.

        Raises:
            NonExistentItemError: If the media record does not exist or is in
                the trash.
        """
        try:
            media = Media.get((Media.uuid == uuid) & (Media.in_trash == False))
            with self.db.atomic() as transaction:
                try:
                    media.update(name=StringSanitizer.sanitize_name(data['uuid']['name']), description=StringSanitizer.sanitize_text_size(data['uuid']['description']), modified=new_datetime()).execute()
                    return 'updated'
                except Exception as e:
                    Logger.error("error: {} {} trying to update  media data, rolling back database update".format(type(e), e))
                    transaction.rollback()
                    raise e

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def load_meta(self, uuid):
        """Return full metadata for a media file, including project usage.

        Args:
            uuid: Media UUID string (live or trashed).

        Returns:
            Single-key dict ``{uuid: {name, unix_name, description, created,
            modified, duration, type, in_trash, in_projects, in_trash_projects}}``.
            ``in_projects`` is a list of live project UUID strings;
            ``in_trash_projects`` is a list of trashed project UUID strings.

        Raises:
            NonExistentItemError: If no media record with *uuid* exists.
        """
        try:
            media = Media.get(Media.uuid == uuid)
            file_meta = dict()
            project_list = list()
            project_trash_list = list()
            media_projects_query = media.projects()
            for project in media_projects_query:
                if project.in_trash == False:
                    project_list.append(str(project.uuid))
                else:
                    project_trash_list.append(str(project.uuid))

            file_meta[uuid] = {'name': media.name, 'unix_name': media.unix_name, 'description': media.description, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, 'in_trash': media.in_trash, 'in_projects': project_list, 'in_trash_projects': project_trash_list}
            return file_meta

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def load_thumbnail(self, uuid):
        """Read and return the thumbnail file as a length-prefixed binary blob.

        The returned bytes start with a 36-byte UUID header (see
        :meth:`add_binary_header`) so the frontend can correlate the binary
        WebSocket frame to the originating request.

        Args:
            uuid: Media UUID string.

        Returns:
            ``bytes`` — 36-byte UUID header + raw thumbnail file contents.

        Raises:
            NonExistentItemError: If the media record or its thumbnail file
                cannot be found.
        """
        try:
            media_filename = Media.get(Media.uuid == uuid).unix_name
            thumbnail_file_path = self.get_thumbnail_path(media_filename)
            try:
                with open(thumbnail_file_path, 'rb') as file:
                    media_thumbnail_binary_data = file.read()
                return self.add_binary_header(media_thumbnail_binary_data, uuid, 1)

            except Exception as e:
                raise NonExistentItemError("item with uuid: {} error reading thumbnail ; {}, {}".format(uuid, type(e), e))

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def load_waveform(self, uuid):
        """Read and return the waveform data file as a length-prefixed binary blob.

        The returned bytes start with a 36-byte UUID header (see
        :meth:`add_binary_header`) so the frontend can correlate the binary
        WebSocket frame to the originating request.

        Args:
            uuid: Media UUID string.

        Returns:
            ``bytes`` — 36-byte UUID header + raw waveform file contents.

        Raises:
            NonExistentItemError: If the media record or its waveform file
                cannot be found.
        """
        try:
            media_filename = Media.get(Media.uuid == uuid).unix_name
            waveform_file_path = self.get_waveform_path(media_filename)
            try:
                with open(waveform_file_path, 'rb') as file:
                    media_waveform_binary_data = file.read()
                return self.add_binary_header(media_waveform_binary_data, uuid, 2)

            except Exception as e:
                raise NonExistentItemError("item with uuid: {} error reading  waveform ; {}, {}".format(uuid, type(e), e))

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def _move_keeping_name(self, src_path, dest_dir, moves):
        """Move *src_path* into *dest_dir* under the same name, logging it in *moves*.

        Projects reference media by filename (``ProjectMedia.media_filename``,
        the cue XML), so a trashed or restored file must keep its
        ``unix_name``: ``CopyMoveVersioned`` would rename the moved file
        instead and leave the DB row pointing at a path that does not exist.
        ``unix_name`` is unique, so a file already at the destination is not
        tracked by any row; it is moved aside to ``<base>-NNN<ext>``.
        """
        dest_path = os.path.join(dest_dir, os.path.basename(src_path))
        if os.path.exists(dest_path):
            aside = CopyMoveVersioned.move(dest_path, dest_dir)
            moves.append((dest_path, os.path.join(dest_dir, aside)))
            Logger.warning(f'{dest_path} is not tracked by the media library; moved it aside to {aside}')
        shutil.move(src_path, dest_path)
        moves.append((src_path, dest_path))

    def _undo_moves(self, moves):
        """Reverse the moves logged by ``_move_keeping_name``, newest first."""
        for src_path, dest_path in reversed(moves):
            try:
                if os.path.exists(dest_path) and not os.path.exists(src_path):
                    shutil.move(dest_path, src_path)
            except Exception as e:
                Logger.error(f'error: {type(e)} {e}; trying to move {dest_path} back to {src_path}')

    def delete(self, uuid):
        """Soft-delete a media file by moving it and its sidecars to the trash.

        Moves the media file, thumbnail, and (for audio) waveform to the
        corresponding trash sub-directories via ``_move_keeping_name``, so
        ``unix_name`` stays valid.  Sets ``Media.in_trash = True``
        atomically.  Reverses filesystem moves on transaction rollback.

        Args:
            uuid: UUID of the live media file to trash.

        Raises:
            NonExistentItemError: If no live media file with *uuid* exists.
        """
        try:
            trash_state = False
            media = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

            with self.db.atomic() as transaction:
                moves = []
                try:
                    file_thumbnail_path = self.get_thumbnail_path(media.unix_name)
                    if os.path.exists(file_thumbnail_path):
                        self._move_keeping_name(file_thumbnail_path, self.thumbnail_trash_path, moves)

                    if self.is_audio(media):
                        file_waveform_path = self.get_waveform_path(media.unix_name)
                        if os.path.exists(file_waveform_path):
                            self._move_keeping_name(file_waveform_path, self.waveform_trash_path, moves)

                    self._move_keeping_name(self.get_file_path(media.unix_name), self.trash_path, moves)
                    media.in_trash = True
                    media.save()
                    Logger.debug('updating instance in db: {}'.format(media))
                except Exception as e:
                    Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    Logger.debug("moving files back to media folder")
                    self._undo_moves(moves)
                    raise e

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def restore(self, uuid):
        """Restore a trashed media file and its sidecars back to the media library.

        Moves files from the trash sub-directories back to the live media
        directories via ``_move_keeping_name``, so ``unix_name`` stays valid.
        Sets ``Media.in_trash = False`` atomically.  Reverses filesystem
        moves on transaction rollback.

        A live file already at ``<media_path>/<unix_name>`` is not tracked by
        the library, but cues may play it by name, so the restore is refused
        rather than moving it aside.

        Args:
            uuid: UUID of a trashed media file to restore.

        Raises:
            NonExistentItemError: If no trashed media file with *uuid* exists.
            FileExistsError: If an untracked file already holds the name in
                the media directory.
        """
        try:
            trash_state = True
            media_trash = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

            if os.path.exists(self.get_file_path(media_trash.unix_name)):
                raise FileExistsError(f'cannot restore {media_trash.unix_name}: a file with that name, not tracked by the media library, '
                                      f'is already in {self.media_path}; move it away first')

            with self.db.atomic() as transaction:
                moves = []
                try:
                    file_thumbnail_path = self.get_thumbnail_path(media_trash.unix_name, trash_state=True)
                    if os.path.exists(file_thumbnail_path):
                        self._move_keeping_name(file_thumbnail_path, self.thumbnail_path, moves)

                    if self.is_audio(media_trash):
                        file_waveform_path = self.get_waveform_path(media_trash.unix_name, trash_state=True)
                        if os.path.exists(file_waveform_path):
                            self._move_keeping_name(file_waveform_path, self.waveform_path, moves)

                    self._move_keeping_name(self.get_file_path(media_trash.unix_name, trash_state=True), self.media_path, moves)
                    media_trash.in_trash = False
                    media_trash.save()
                    Logger.debug('updating instance in db: {}'.format(media_trash))
                except Exception as e:
                    Logger.error("error: {} {}; trying to move file from trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    self._undo_moves(moves)
                    raise e
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def delete_from_trash(self, uuid):
        """Permanently delete a trashed media file and its sidecars.

        Removes the ``Media`` DB row (and its ``ProjectMedia`` join rows via
        cascade) and deletes the media file, thumbnail, and waveform from the
        trash directories.

        Args:
            uuid: UUID of a trashed media file.

        Raises:
            NonExistentItemError: If no trashed media file with *uuid* exists.
        """
        try:
            trash_state = True
            media = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

            with self.db.atomic() as transaction:
                try:
                    file_path = self.get_file_path(media.unix_name, trash_state=True)
                    file_thumbnail_path = self.get_thumbnail_path(media.unix_name, trash_state=True)

                    if os.path.exists(file_thumbnail_path):
                        os.remove(file_thumbnail_path)

                    if self.is_audio(media):
                        file_waveform_path = self.get_waveform_path(media.unix_name, trash_state=True)
                        if os.path.exists(file_waveform_path):
                            os.remove(file_waveform_path)

                    media.delete_instance(recursive=True)
                    os.remove(file_path)
                    Logger.debug('modifing instance in table: {}'.format(media))
                except Exception as e:
                    Logger.error("error: {} {}; trying to delete file from trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    raise e

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def get_type(self, filename):
        """Detect the ``MediaType`` of *filename* from its extension.

        Extension matching is case-insensitive.

        Args:
            filename: Filename string (no directory component needed).

        Returns:
            ``MediaType.MOVIE``, ``MediaType.AUDIO``, or ``MediaType.IMAGE``;
            ``None`` if the extension is not in any known list.
        """
        movie_list = ('.mov', '.avi', '.mkv', '.mpg', '.mp4', '.webm', '.m4v', '.flv', '.wmv', '.ogv', '.3gp')
        audio_list = ('.aif', '.aiff', '.wav', '.mp3', '.m4a', '.ogg', '.oga', '.flac', '.aac', '.wma', '.opus', '.weba')
        image_list = ('.png', '.jpg', '.jpeg', '.tga', '.webp', '.gif', '.bmp', '.heic', '.heif', '.tiff', '.tif', '.ico')
        name_root, file_extension = os.path.splitext(filename)
        file_extension_lower = file_extension.lower() if file_extension else ''
        _type = None

        if file_extension_lower in movie_list:
            _type = MediaType.MOVIE
        elif file_extension_lower in audio_list:
            _type = MediaType.AUDIO
        elif file_extension_lower in image_list:
            _type = MediaType.IMAGE

        return _type

    def is_audio(self, db_record):
        """Return ``True`` if *db_record* has ``media_type == 'AUDIO'``.

        Args:
            db_record: A ``Media`` ORM instance.

        Returns:
            ``bool``
        """
        if db_record.media_type == MediaType.AUDIO.name:
            return True
        else:
            return False

    def get_duration(self, filename):
        """Extract a media file's duration and return a ``CTimecode``.

        Thin wrapper around the module-level :func:`probe_duration`, resolving
        *filename* against ``media_path``. Kept as a method so callers with a
        ``CuemsDBMedia`` instance need not know the media directory layout.

        Args:
            filename: Filename of the media file inside ``<media_path>``.

        Returns:
            ``CTimecode`` instance representing the media duration.

        Raises:
            NotTimeCodeError: If the duration cannot be probed (see
                :func:`probe_duration`).
        """
        return probe_duration(os.path.join(self.media_path, filename))

    @logged
    def create_video_thumbnail(self, filename, duration):
        """Generate a JPEG thumbnail for a video or image file using ``ffmpeg``.

        Seeks to 200 ms when *duration* is provided to avoid all-black frames
        at the start of some video files.  Writes to
        ``<thumbnail_path>/<stem>_<ext>.jpg``.

        Args:
            filename: Filename of the source file inside ``<media_path>``.
            duration: ``CTimecode`` duration or ``None`` (images have no
                duration; the first frame is used unconditionally).

        Returns:
            Absolute path to the created thumbnail file, or ``None`` if
            ``ffmpeg`` did not produce an output file.
        """
        # ffmpeg -y -hide_banner -loglevel warning -i input.mov -vf "scale=240:-1" -frames:v 1 -update true out.png
        file_path = self.get_file_path(filename)
        thumbnail_file_path = self.get_thumbnail_path(filename)
        try:
            if duration is None:
                result = subprocess.run(['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-i', file_path, '-vf', f'scale={str(self.thumbnail_size[0])}:-1', '-frames:v', '1', '-update', 'true', thumbnail_file_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
            else:
                time_option = "-ss"
                timecode = f'200ms'
                result = subprocess.run(['ffmpeg', time_option, timecode, '-y', '-hide_banner', '-loglevel', 'warning', '-i', file_path, '-vf', f'scale={str(self.thumbnail_size[0])}:-1', '-vframes', '1', '-update', 'true', thumbnail_file_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
        except subprocess.TimeoutExpired:
            Logger.warning(f'ffmpeg thumbnail timed out for {filename}')
            return None
        if result.returncode != 0:
            Logger.warning(f'ffmpeg thumbnail rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
        if os.path.exists(thumbnail_file_path):
            Logger.debug(f'thumbnail file created for {filename}, output: {result.stdout.decode("utf8")}')
            return thumbnail_file_path
        else:
            Logger.debug(f'thumbnail file not created for {filename}, output: {result.stdout.decode("utf8")}')

    @logged
    def create_audio_thubnail(self, filename, duration):
        """Generate a waveform overview image for an audio file using ``audiowaveform``.

        Produces a PNG-format thumbnail at ``thumbnail_size`` pixels using
        8-bit sample data.  The ``-e`` (end time) argument is
        ``duration.milliseconds_rounded / 1000`` seconds.

        Args:
            filename: Filename of the source audio file inside ``<media_path>``.
            duration: ``CTimecode`` duration of the audio file.

        Returns:
            Absolute path to the created thumbnail file, or ``None`` if
            ``audiowaveform`` did not produce an output file.
        """
        # audiowaveform -i sample.wav -o sample.dat -b 8
        file_path = self.get_file_path(filename)
        thumbnail_file_path = self.get_thumbnail_path(filename)
        # TODO: support 24-bit data
        try:
            result = subprocess.run(['audiowaveform', '-i', file_path, '-o', thumbnail_file_path, '-e', str(duration.milliseconds_rounded / 1000), '-w', str(self.thumbnail_size[0]), '-h', str(self.thumbnail_size[1]), '--no-axis-labels', '--amplitude-scale', '0.9'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
        except subprocess.TimeoutExpired:
            Logger.warning(f'audiowaveform thumbnail timed out for {filename}')
            return None
        if result.returncode != 0:
            Logger.warning(f'audiowaveform thumbnail rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
        if os.path.exists(thumbnail_file_path):
            return thumbnail_file_path
        else:
            Logger.debug(f'thumbnail file not created for {filename}, output: {result.stdout.decode("utf8")}')

    def create_audio_waveform(self, filename):
        """Generate an 8-bit audiowaveform JSON data file for an audio file.

        Writes to ``<waveform_path>/<stem>_<ext>.json``.

        Args:
            filename: Filename of the source audio file inside ``<media_path>``.

        Returns:
            Absolute path to the created waveform file, or ``None`` if
            ``audiowaveform`` did not produce an output file.
        """
        # audiowaveform -i sample.wav -o sample.dat -b 8
        file_path = self.get_file_path(filename)
        waveform_file_path = self.get_waveform_path(filename)
        try:
            result = subprocess.run(['audiowaveform', '-i', file_path, '-o', waveform_file_path, '-b', '8'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
        except subprocess.TimeoutExpired:
            Logger.warning(f'audiowaveform data timed out for {filename}')
            return None
        if result.returncode != 0:
            Logger.warning(f'audiowaveform data rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
        if os.path.exists(waveform_file_path):
            Logger.debug(f'waveform file created for {filename}, output: {result.stdout.decode("utf8")}')
            return waveform_file_path
        else:
            Logger.debug(f'waveform file not created for {filename}, output: {result.stdout.decode("utf8")}')

    def create_video_index(self, filename):
        """Run ``cuems-videoindexer`` to pre-build the frame index sidecar (``.idx``) for a video file.

        The ``.idx`` file is created in ``<media_path>/indexes/`` so that
        ``videocomposer`` can call ``loadCachedIndex`` on first arm instead of
        building the index at playback time.

        Args:
            filename: Filename of the video file inside ``<media_path>``.
        """
        file_path = self.get_file_path(filename)
        try:
            result = subprocess.run(
                ['cuems-videoindexer', file_path],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                timeout=300
            )
            idx_dir = os.path.join(self.media_path, 'indexes')
            idx_path = os.path.join(idx_dir, os.path.basename(file_path) + '.idx')
            if os.path.exists(idx_path):
                Logger.debug(f'video index created for {filename}')
            else:
                Logger.warning(f'video index not created for {filename}: {result.stdout.decode("utf8")}')
        except Exception as e:
            Logger.warning(f'video index failed for {filename}: {e}')

    def get_file_path(self, filename, trash_state=False):
        """Return the absolute path for a media file in the live or trash directory.

        Args:
            filename: Media ``unix_name``.
            trash_state: ``True`` to resolve inside the trash directory.
                Defaults to ``False``.

        Returns:
            Absolute path string.
        """
        if trash_state is False:
            return os.path.join(self.media_path, filename)
        else:
            return os.path.join(self.trash_path, filename)

    def get_thumbnail_filename(self, filename):
        """Derive the thumbnail filename for *filename*.

        Convention: ``<stem>_<ext_without_dot><thumbnail_extension>``
        e.g. ``concert.mp4`` → ``concert_mp4.jpg``.

        Args:
            filename: Source media filename.

        Returns:
            Thumbnail filename string (no directory component).
        """
        name_root, file_extension = os.path.splitext(filename)
        thumbnail_file_name = f'{name_root}_{file_extension[1:]}{self.thumbnail_extension}'
        return thumbnail_file_name

    def get_thumbnail_path(self, filename, trash_state=False):
        """Return the absolute path for a thumbnail file.

        Args:
            filename: Source media ``unix_name``.
            trash_state: ``True`` to resolve inside the trash thumbnail
                directory.  Defaults to ``False``.

        Returns:
            Absolute path string.
        """
        thumbnail_file_name = self.get_thumbnail_filename(filename)
        if trash_state is False:
            thumbnail_file_path = os.path.join(self.thumbnail_path, thumbnail_file_name)
        else:
            thumbnail_file_path = os.path.join(self.thumbnail_trash_path, thumbnail_file_name)
        return thumbnail_file_path

    def get_waveform_filename(self, filename):
        """Derive the waveform filename for *filename*.

        Convention: ``<stem>_<ext_without_dot><waveform_extension>``
        e.g. ``concert.wav`` → ``concert_wav.json``.

        Args:
            filename: Source media filename.

        Returns:
            Waveform filename string (no directory component).
        """
        name_root, file_extension = os.path.splitext(filename)
        waveform_file_name = f'{name_root}_{file_extension[1:]}{self.waveform_extension}'
        return waveform_file_name

    def get_waveform_path(self, filename, trash_state=False):
        """Return the absolute path for a waveform file.

        Args:
            filename: Source media ``unix_name``.
            trash_state: ``True`` to resolve inside the trash waveform
                directory.  Defaults to ``False``.

        Returns:
            Absolute path string.
        """
        waveform_file_name = self.get_waveform_filename(filename)
        if trash_state is False:
            waveform_file_path = os.path.join(self.waveform_path, waveform_file_name)
        else:
            waveform_file_path = os.path.join(self.waveform_trash_path, waveform_file_name)
        return waveform_file_path

    def add_binary_header(self, binary_data, uuid_string, type_number):
        """Prepend a 36-byte UUID header to *binary_data*.

        The frontend identifies the media item from this header when it
        receives the binary WebSocket frame without an out-of-band correlation
        identifier.

        Args:
            binary_data: Raw bytes of the thumbnail or waveform file.
            uuid_string: 36-character UUID string to embed.
            type_number: Integer payload type tag (1 = thumbnail, 2 = waveform).

        Returns:
            ``bytes`` — 36-byte UUID header followed by *binary_data*.
        """
        # 36 bytes; first 36 positions, char = uuid
        return struct.pack('<36s', str.encode(uuid_string)) + binary_data

    def check_if_media_existed_in_projects(self, filename):
        """Query ``ProjectMedia`` rows referencing *filename* regardless of media FK.

        Used by ``CuemsUpload`` after a successful upload to detect re-uploads
        of files that projects already reference by filename.

        Args:
            filename: ``unix_name`` to look up in ``ProjectMedia.media_filename``.

        Returns:
            Peewee ``ModelSelect`` of matching ``ProjectMedia`` rows, or
            ``False`` if none exist.
        """
        projectmedia_mproject_refs = ProjectMedia.select(ProjectMedia.project_id, ProjectMedia.media_filename).where(ProjectMedia.media_filename == filename)

        if projectmedia_mproject_refs:
            return projectmedia_mproject_refs
        else:
            return False

__init__(settings_dict, db_connection)

Initialise paths from settings_dict.

Parameters:

Name Type Description Default
settings_dict

Mapping consumed from settings.xml. Required keys: library_path, tmp_path, media_folder_name, trash_folder_name, thumbnail_folder_name, waveform_folder_name, thumbnail_extension, waveform_extension, thumbnail_size.

required
db_connection

The shared Peewee SqliteDatabase instance owned by CuemsDBManager.

required
Source code in src/cuemseditor/CuemsDBMedia.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def __init__(self, settings_dict, db_connection):
    """Initialise paths from *settings_dict*.

    Args:
        settings_dict: Mapping consumed from ``settings.xml``.  Required
            keys: ``library_path``, ``tmp_path``, ``media_folder_name``,
            ``trash_folder_name``, ``thumbnail_folder_name``,
            ``waveform_folder_name``, ``thumbnail_extension``,
            ``waveform_extension``, ``thumbnail_size``.
        db_connection: The shared Peewee ``SqliteDatabase`` instance owned
            by ``CuemsDBManager``.
    """
    self.db = db_connection
    try:
        self.library_path = settings_dict['library_path']
        self.tmp_path = settings_dict['tmp_path']
        self.media_path = os.path.join(self.library_path, settings_dict['media_folder_name'])
        self.trash_path = os.path.join(self.library_path, settings_dict['trash_folder_name'], settings_dict['media_folder_name'])
        self.thumbnail_path = os.path.join(self.media_path, settings_dict['thumbnail_folder_name'])
        self.waveform_path = os.path.join(self.media_path, settings_dict['waveform_folder_name'])
        self.thumbnail_trash_path = os.path.join(self.trash_path, settings_dict['thumbnail_folder_name'])
        self.waveform_trash_path = os.path.join(self.trash_path, settings_dict['waveform_folder_name'])
        self.thumbnail_extension = settings_dict['thumbnail_extension']
        self.waveform_extension = settings_dict['waveform_extension']
        self.thumbnail_size = settings_dict['thumbnail_size']
    except KeyError as e:
        Logger.error(f'can not read settings {e}')

add_binary_header(binary_data, uuid_string, type_number)

Prepend a 36-byte UUID header to binary_data.

The frontend identifies the media item from this header when it receives the binary WebSocket frame without an out-of-band correlation identifier.

Parameters:

Name Type Description Default
binary_data

Raw bytes of the thumbnail or waveform file.

required
uuid_string

36-character UUID string to embed.

required
type_number

Integer payload type tag (1 = thumbnail, 2 = waveform).

required

Returns:

Type Description

bytes — 36-byte UUID header followed by binary_data.

Source code in src/cuemseditor/CuemsDBMedia.py
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
def add_binary_header(self, binary_data, uuid_string, type_number):
    """Prepend a 36-byte UUID header to *binary_data*.

    The frontend identifies the media item from this header when it
    receives the binary WebSocket frame without an out-of-band correlation
    identifier.

    Args:
        binary_data: Raw bytes of the thumbnail or waveform file.
        uuid_string: 36-character UUID string to embed.
        type_number: Integer payload type tag (1 = thumbnail, 2 = waveform).

    Returns:
        ``bytes`` — 36-byte UUID header followed by *binary_data*.
    """
    # 36 bytes; first 36 positions, char = uuid
    return struct.pack('<36s', str.encode(uuid_string)) + binary_data

check_if_media_existed_in_projects(filename)

Query ProjectMedia rows referencing filename regardless of media FK.

Used by CuemsUpload after a successful upload to detect re-uploads of files that projects already reference by filename.

Parameters:

Name Type Description Default
filename

unix_name to look up in ProjectMedia.media_filename.

required

Returns:

Type Description

Peewee ModelSelect of matching ProjectMedia rows, or

False if none exist.

Source code in src/cuemseditor/CuemsDBMedia.py
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
def check_if_media_existed_in_projects(self, filename):
    """Query ``ProjectMedia`` rows referencing *filename* regardless of media FK.

    Used by ``CuemsUpload`` after a successful upload to detect re-uploads
    of files that projects already reference by filename.

    Args:
        filename: ``unix_name`` to look up in ``ProjectMedia.media_filename``.

    Returns:
        Peewee ``ModelSelect`` of matching ``ProjectMedia`` rows, or
        ``False`` if none exist.
    """
    projectmedia_mproject_refs = ProjectMedia.select(ProjectMedia.project_id, ProjectMedia.media_filename).where(ProjectMedia.media_filename == filename)

    if projectmedia_mproject_refs:
        return projectmedia_mproject_refs
    else:
        return False

create_audio_thubnail(filename, duration)

Generate a waveform overview image for an audio file using audiowaveform.

Produces a PNG-format thumbnail at thumbnail_size pixels using 8-bit sample data. The -e (end time) argument is duration.milliseconds_rounded / 1000 seconds.

Parameters:

Name Type Description Default
filename

Filename of the source audio file inside <media_path>.

required
duration

CTimecode duration of the audio file.

required

Returns:

Type Description

Absolute path to the created thumbnail file, or None if

audiowaveform did not produce an output file.

Source code in src/cuemseditor/CuemsDBMedia.py
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
@logged
def create_audio_thubnail(self, filename, duration):
    """Generate a waveform overview image for an audio file using ``audiowaveform``.

    Produces a PNG-format thumbnail at ``thumbnail_size`` pixels using
    8-bit sample data.  The ``-e`` (end time) argument is
    ``duration.milliseconds_rounded / 1000`` seconds.

    Args:
        filename: Filename of the source audio file inside ``<media_path>``.
        duration: ``CTimecode`` duration of the audio file.

    Returns:
        Absolute path to the created thumbnail file, or ``None`` if
        ``audiowaveform`` did not produce an output file.
    """
    # audiowaveform -i sample.wav -o sample.dat -b 8
    file_path = self.get_file_path(filename)
    thumbnail_file_path = self.get_thumbnail_path(filename)
    # TODO: support 24-bit data
    try:
        result = subprocess.run(['audiowaveform', '-i', file_path, '-o', thumbnail_file_path, '-e', str(duration.milliseconds_rounded / 1000), '-w', str(self.thumbnail_size[0]), '-h', str(self.thumbnail_size[1]), '--no-axis-labels', '--amplitude-scale', '0.9'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
    except subprocess.TimeoutExpired:
        Logger.warning(f'audiowaveform thumbnail timed out for {filename}')
        return None
    if result.returncode != 0:
        Logger.warning(f'audiowaveform thumbnail rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
    if os.path.exists(thumbnail_file_path):
        return thumbnail_file_path
    else:
        Logger.debug(f'thumbnail file not created for {filename}, output: {result.stdout.decode("utf8")}')

create_audio_waveform(filename)

Generate an 8-bit audiowaveform JSON data file for an audio file.

Writes to <waveform_path>/<stem>_<ext>.json.

Parameters:

Name Type Description Default
filename

Filename of the source audio file inside <media_path>.

required

Returns:

Type Description

Absolute path to the created waveform file, or None if

audiowaveform did not produce an output file.

Source code in src/cuemseditor/CuemsDBMedia.py
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
def create_audio_waveform(self, filename):
    """Generate an 8-bit audiowaveform JSON data file for an audio file.

    Writes to ``<waveform_path>/<stem>_<ext>.json``.

    Args:
        filename: Filename of the source audio file inside ``<media_path>``.

    Returns:
        Absolute path to the created waveform file, or ``None`` if
        ``audiowaveform`` did not produce an output file.
    """
    # audiowaveform -i sample.wav -o sample.dat -b 8
    file_path = self.get_file_path(filename)
    waveform_file_path = self.get_waveform_path(filename)
    try:
        result = subprocess.run(['audiowaveform', '-i', file_path, '-o', waveform_file_path, '-b', '8'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
    except subprocess.TimeoutExpired:
        Logger.warning(f'audiowaveform data timed out for {filename}')
        return None
    if result.returncode != 0:
        Logger.warning(f'audiowaveform data rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
    if os.path.exists(waveform_file_path):
        Logger.debug(f'waveform file created for {filename}, output: {result.stdout.decode("utf8")}')
        return waveform_file_path
    else:
        Logger.debug(f'waveform file not created for {filename}, output: {result.stdout.decode("utf8")}')

create_video_index(filename)

Run cuems-videoindexer to pre-build the frame index sidecar (.idx) for a video file.

The .idx file is created in <media_path>/indexes/ so that videocomposer can call loadCachedIndex on first arm instead of building the index at playback time.

Parameters:

Name Type Description Default
filename

Filename of the video file inside <media_path>.

required
Source code in src/cuemseditor/CuemsDBMedia.py
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
def create_video_index(self, filename):
    """Run ``cuems-videoindexer`` to pre-build the frame index sidecar (``.idx``) for a video file.

    The ``.idx`` file is created in ``<media_path>/indexes/`` so that
    ``videocomposer`` can call ``loadCachedIndex`` on first arm instead of
    building the index at playback time.

    Args:
        filename: Filename of the video file inside ``<media_path>``.
    """
    file_path = self.get_file_path(filename)
    try:
        result = subprocess.run(
            ['cuems-videoindexer', file_path],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            timeout=300
        )
        idx_dir = os.path.join(self.media_path, 'indexes')
        idx_path = os.path.join(idx_dir, os.path.basename(file_path) + '.idx')
        if os.path.exists(idx_path):
            Logger.debug(f'video index created for {filename}')
        else:
            Logger.warning(f'video index not created for {filename}: {result.stdout.decode("utf8")}')
    except Exception as e:
        Logger.warning(f'video index failed for {filename}: {e}')

create_video_thumbnail(filename, duration)

Generate a JPEG thumbnail for a video or image file using ffmpeg.

Seeks to 200 ms when duration is provided to avoid all-black frames at the start of some video files. Writes to <thumbnail_path>/<stem>_<ext>.jpg.

Parameters:

Name Type Description Default
filename

Filename of the source file inside <media_path>.

required
duration

CTimecode duration or None (images have no duration; the first frame is used unconditionally).

required

Returns:

Type Description

Absolute path to the created thumbnail file, or None if

ffmpeg did not produce an output file.

Source code in src/cuemseditor/CuemsDBMedia.py
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
@logged
def create_video_thumbnail(self, filename, duration):
    """Generate a JPEG thumbnail for a video or image file using ``ffmpeg``.

    Seeks to 200 ms when *duration* is provided to avoid all-black frames
    at the start of some video files.  Writes to
    ``<thumbnail_path>/<stem>_<ext>.jpg``.

    Args:
        filename: Filename of the source file inside ``<media_path>``.
        duration: ``CTimecode`` duration or ``None`` (images have no
            duration; the first frame is used unconditionally).

    Returns:
        Absolute path to the created thumbnail file, or ``None`` if
        ``ffmpeg`` did not produce an output file.
    """
    # ffmpeg -y -hide_banner -loglevel warning -i input.mov -vf "scale=240:-1" -frames:v 1 -update true out.png
    file_path = self.get_file_path(filename)
    thumbnail_file_path = self.get_thumbnail_path(filename)
    try:
        if duration is None:
            result = subprocess.run(['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-i', file_path, '-vf', f'scale={str(self.thumbnail_size[0])}:-1', '-frames:v', '1', '-update', 'true', thumbnail_file_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
        else:
            time_option = "-ss"
            timecode = f'200ms'
            result = subprocess.run(['ffmpeg', time_option, timecode, '-y', '-hide_banner', '-loglevel', 'warning', '-i', file_path, '-vf', f'scale={str(self.thumbnail_size[0])}:-1', '-vframes', '1', '-update', 'true', thumbnail_file_path], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
    except subprocess.TimeoutExpired:
        Logger.warning(f'ffmpeg thumbnail timed out for {filename}')
        return None
    if result.returncode != 0:
        Logger.warning(f'ffmpeg thumbnail rc={result.returncode} for {filename}: {result.stdout.decode("utf8")}')
    if os.path.exists(thumbnail_file_path):
        Logger.debug(f'thumbnail file created for {filename}, output: {result.stdout.decode("utf8")}')
        return thumbnail_file_path
    else:
        Logger.debug(f'thumbnail file not created for {filename}, output: {result.stdout.decode("utf8")}')

delete(uuid)

Soft-delete a media file by moving it and its sidecars to the trash.

Moves the media file, thumbnail, and (for audio) waveform to the corresponding trash sub-directories via _move_keeping_name, so unix_name stays valid. Sets Media.in_trash = True atomically. Reverses filesystem moves on transaction rollback.

Parameters:

Name Type Description Default
uuid

UUID of the live media file to trash.

required

Raises:

Type Description
NonExistentItemError

If no live media file with uuid exists.

Source code in src/cuemseditor/CuemsDBMedia.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
def delete(self, uuid):
    """Soft-delete a media file by moving it and its sidecars to the trash.

    Moves the media file, thumbnail, and (for audio) waveform to the
    corresponding trash sub-directories via ``_move_keeping_name``, so
    ``unix_name`` stays valid.  Sets ``Media.in_trash = True``
    atomically.  Reverses filesystem moves on transaction rollback.

    Args:
        uuid: UUID of the live media file to trash.

    Raises:
        NonExistentItemError: If no live media file with *uuid* exists.
    """
    try:
        trash_state = False
        media = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

        with self.db.atomic() as transaction:
            moves = []
            try:
                file_thumbnail_path = self.get_thumbnail_path(media.unix_name)
                if os.path.exists(file_thumbnail_path):
                    self._move_keeping_name(file_thumbnail_path, self.thumbnail_trash_path, moves)

                if self.is_audio(media):
                    file_waveform_path = self.get_waveform_path(media.unix_name)
                    if os.path.exists(file_waveform_path):
                        self._move_keeping_name(file_waveform_path, self.waveform_trash_path, moves)

                self._move_keeping_name(self.get_file_path(media.unix_name), self.trash_path, moves)
                media.in_trash = True
                media.save()
                Logger.debug('updating instance in db: {}'.format(media))
            except Exception as e:
                Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                transaction.rollback()
                Logger.debug("moving files back to media folder")
                self._undo_moves(moves)
                raise e

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

delete_from_trash(uuid)

Permanently delete a trashed media file and its sidecars.

Removes the Media DB row (and its ProjectMedia join rows via cascade) and deletes the media file, thumbnail, and waveform from the trash directories.

Parameters:

Name Type Description Default
uuid

UUID of a trashed media file.

required

Raises:

Type Description
NonExistentItemError

If no trashed media file with uuid exists.

Source code in src/cuemseditor/CuemsDBMedia.py
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
def delete_from_trash(self, uuid):
    """Permanently delete a trashed media file and its sidecars.

    Removes the ``Media`` DB row (and its ``ProjectMedia`` join rows via
    cascade) and deletes the media file, thumbnail, and waveform from the
    trash directories.

    Args:
        uuid: UUID of a trashed media file.

    Raises:
        NonExistentItemError: If no trashed media file with *uuid* exists.
    """
    try:
        trash_state = True
        media = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

        with self.db.atomic() as transaction:
            try:
                file_path = self.get_file_path(media.unix_name, trash_state=True)
                file_thumbnail_path = self.get_thumbnail_path(media.unix_name, trash_state=True)

                if os.path.exists(file_thumbnail_path):
                    os.remove(file_thumbnail_path)

                if self.is_audio(media):
                    file_waveform_path = self.get_waveform_path(media.unix_name, trash_state=True)
                    if os.path.exists(file_waveform_path):
                        os.remove(file_waveform_path)

                media.delete_instance(recursive=True)
                os.remove(file_path)
                Logger.debug('modifing instance in table: {}'.format(media))
            except Exception as e:
                Logger.error("error: {} {}; trying to delete file from trash, rolling back database".format(type(e), e))
                transaction.rollback()
                raise e

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

get_duration(filename)

Extract a media file's duration and return a CTimecode.

Thin wrapper around the module-level :func:probe_duration, resolving filename against media_path. Kept as a method so callers with a CuemsDBMedia instance need not know the media directory layout.

Parameters:

Name Type Description Default
filename

Filename of the media file inside <media_path>.

required

Returns:

Type Description

CTimecode instance representing the media duration.

Raises:

Type Description
NotTimeCodeError

If the duration cannot be probed (see :func:probe_duration).

Source code in src/cuemseditor/CuemsDBMedia.py
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
def get_duration(self, filename):
    """Extract a media file's duration and return a ``CTimecode``.

    Thin wrapper around the module-level :func:`probe_duration`, resolving
    *filename* against ``media_path``. Kept as a method so callers with a
    ``CuemsDBMedia`` instance need not know the media directory layout.

    Args:
        filename: Filename of the media file inside ``<media_path>``.

    Returns:
        ``CTimecode`` instance representing the media duration.

    Raises:
        NotTimeCodeError: If the duration cannot be probed (see
            :func:`probe_duration`).
    """
    return probe_duration(os.path.join(self.media_path, filename))

get_file_path(filename, trash_state=False)

Return the absolute path for a media file in the live or trash directory.

Parameters:

Name Type Description Default
filename

Media unix_name.

required
trash_state

True to resolve inside the trash directory. Defaults to False.

False

Returns:

Type Description

Absolute path string.

Source code in src/cuemseditor/CuemsDBMedia.py
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
def get_file_path(self, filename, trash_state=False):
    """Return the absolute path for a media file in the live or trash directory.

    Args:
        filename: Media ``unix_name``.
        trash_state: ``True`` to resolve inside the trash directory.
            Defaults to ``False``.

    Returns:
        Absolute path string.
    """
    if trash_state is False:
        return os.path.join(self.media_path, filename)
    else:
        return os.path.join(self.trash_path, filename)

get_thumbnail_filename(filename)

Derive the thumbnail filename for filename.

Convention: <stem>_<ext_without_dot><thumbnail_extension> e.g. concert.mp4 → concert_mp4.jpg.

Parameters:

Name Type Description Default
filename

Source media filename.

required

Returns:

Type Description

Thumbnail filename string (no directory component).

Source code in src/cuemseditor/CuemsDBMedia.py
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
def get_thumbnail_filename(self, filename):
    """Derive the thumbnail filename for *filename*.

    Convention: ``<stem>_<ext_without_dot><thumbnail_extension>``
    e.g. ``concert.mp4`` → ``concert_mp4.jpg``.

    Args:
        filename: Source media filename.

    Returns:
        Thumbnail filename string (no directory component).
    """
    name_root, file_extension = os.path.splitext(filename)
    thumbnail_file_name = f'{name_root}_{file_extension[1:]}{self.thumbnail_extension}'
    return thumbnail_file_name

get_thumbnail_path(filename, trash_state=False)

Return the absolute path for a thumbnail file.

Parameters:

Name Type Description Default
filename

Source media unix_name.

required
trash_state

True to resolve inside the trash thumbnail directory. Defaults to False.

False

Returns:

Type Description

Absolute path string.

Source code in src/cuemseditor/CuemsDBMedia.py
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
def get_thumbnail_path(self, filename, trash_state=False):
    """Return the absolute path for a thumbnail file.

    Args:
        filename: Source media ``unix_name``.
        trash_state: ``True`` to resolve inside the trash thumbnail
            directory.  Defaults to ``False``.

    Returns:
        Absolute path string.
    """
    thumbnail_file_name = self.get_thumbnail_filename(filename)
    if trash_state is False:
        thumbnail_file_path = os.path.join(self.thumbnail_path, thumbnail_file_name)
    else:
        thumbnail_file_path = os.path.join(self.thumbnail_trash_path, thumbnail_file_name)
    return thumbnail_file_path

get_type(filename)

Detect the MediaType of filename from its extension.

Extension matching is case-insensitive.

Parameters:

Name Type Description Default
filename

Filename string (no directory component needed).

required

Returns:

Type Description

MediaType.MOVIE, MediaType.AUDIO, or MediaType.IMAGE;

None if the extension is not in any known list.

Source code in src/cuemseditor/CuemsDBMedia.py
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
def get_type(self, filename):
    """Detect the ``MediaType`` of *filename* from its extension.

    Extension matching is case-insensitive.

    Args:
        filename: Filename string (no directory component needed).

    Returns:
        ``MediaType.MOVIE``, ``MediaType.AUDIO``, or ``MediaType.IMAGE``;
        ``None`` if the extension is not in any known list.
    """
    movie_list = ('.mov', '.avi', '.mkv', '.mpg', '.mp4', '.webm', '.m4v', '.flv', '.wmv', '.ogv', '.3gp')
    audio_list = ('.aif', '.aiff', '.wav', '.mp3', '.m4a', '.ogg', '.oga', '.flac', '.aac', '.wma', '.opus', '.weba')
    image_list = ('.png', '.jpg', '.jpeg', '.tga', '.webp', '.gif', '.bmp', '.heic', '.heif', '.tiff', '.tif', '.ico')
    name_root, file_extension = os.path.splitext(filename)
    file_extension_lower = file_extension.lower() if file_extension else ''
    _type = None

    if file_extension_lower in movie_list:
        _type = MediaType.MOVIE
    elif file_extension_lower in audio_list:
        _type = MediaType.AUDIO
    elif file_extension_lower in image_list:
        _type = MediaType.IMAGE

    return _type

get_waveform_filename(filename)

Derive the waveform filename for filename.

Convention: <stem>_<ext_without_dot><waveform_extension> e.g. concert.wav → concert_wav.json.

Parameters:

Name Type Description Default
filename

Source media filename.

required

Returns:

Type Description

Waveform filename string (no directory component).

Source code in src/cuemseditor/CuemsDBMedia.py
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
def get_waveform_filename(self, filename):
    """Derive the waveform filename for *filename*.

    Convention: ``<stem>_<ext_without_dot><waveform_extension>``
    e.g. ``concert.wav`` → ``concert_wav.json``.

    Args:
        filename: Source media filename.

    Returns:
        Waveform filename string (no directory component).
    """
    name_root, file_extension = os.path.splitext(filename)
    waveform_file_name = f'{name_root}_{file_extension[1:]}{self.waveform_extension}'
    return waveform_file_name

get_waveform_path(filename, trash_state=False)

Return the absolute path for a waveform file.

Parameters:

Name Type Description Default
filename

Source media unix_name.

required
trash_state

True to resolve inside the trash waveform directory. Defaults to False.

False

Returns:

Type Description

Absolute path string.

Source code in src/cuemseditor/CuemsDBMedia.py
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
def get_waveform_path(self, filename, trash_state=False):
    """Return the absolute path for a waveform file.

    Args:
        filename: Source media ``unix_name``.
        trash_state: ``True`` to resolve inside the trash waveform
            directory.  Defaults to ``False``.

    Returns:
        Absolute path string.
    """
    waveform_file_name = self.get_waveform_filename(filename)
    if trash_state is False:
        waveform_file_path = os.path.join(self.waveform_path, waveform_file_name)
    else:
        waveform_file_path = os.path.join(self.waveform_trash_path, waveform_file_name)
    return waveform_file_path

is_audio(db_record)

Return True if db_record has media_type == 'AUDIO'.

Parameters:

Name Type Description Default
db_record

A Media ORM instance.

required

Returns:

Type Description

bool

Source code in src/cuemseditor/CuemsDBMedia.py
846
847
848
849
850
851
852
853
854
855
856
857
858
def is_audio(self, db_record):
    """Return ``True`` if *db_record* has ``media_type == 'AUDIO'``.

    Args:
        db_record: A ``Media`` ORM instance.

    Returns:
        ``bool``
    """
    if db_record.media_type == MediaType.AUDIO.name:
        return True
    else:
        return False

list()

Return all live (non-trashed) media files with project usage counts.

Returns:

Type Description

List of single-key dicts ``{uuid_str: {name, unix_name, created,

modified, duration, type, in_projects, in_projects_list,

in_trash_projects}}, ordered bycreated`` descending.

duration is the CTimecode string (or None); the frontend

copies it into cue Media.duration on save. in_projects_list

is a list of {uuid, name, in_trash} dicts for each referencing

project.

Source code in src/cuemseditor/CuemsDBMedia.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
def list(self):
    """Return all live (non-trashed) media files with project usage counts.

    Returns:
        List of single-key dicts ``{uuid_str: {name, unix_name, created,
        modified, duration, type, in_projects, in_projects_list,
        in_trash_projects}}``, ordered by ``created`` descending.
        ``duration`` is the ``CTimecode`` string (or ``None``); the frontend
        copies it into cue ``Media.duration`` on save.  ``in_projects_list``
        is a list of ``{uuid, name, in_trash}`` dicts for each referencing
        project.
    """
    media_list = list()

    medias = (Media
              .select(Media,
                      fn.COUNT(Case(Project.in_trash, (('0', 1),), None)).alias('in_project_count'),
                      fn.COUNT(Case(Project.in_trash, (('1', 1),), None)).alias('in_project_trash_count'))
              .join(ProjectMedia, JOIN.LEFT_OUTER)
              .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
              .where((Media.in_trash == False))
              .group_by(Media).order_by(Media.created.desc()))

    medias = prefetch(
        medias,
        ProjectMedia,
        Project)

    for media in medias:
        project_list = []  # Reset list for each media item
        for project in media.projects():
            project_dict = {'uuid': str(project.uuid), 'name': project.name, 'in_trash': project.in_trash}
            project_list.append(project_dict)

        media_dict = {str(media.uuid): {'name': media.name, 'unix_name': media.unix_name, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, "in_projects": media.in_project_count, "in_projects_list": project_list, "in_trash_projects": media.in_project_trash_count}}
        media_list.append(media_dict)

    return media_list

list_trash()

Return all trashed media files with project usage counts.

Returns:

Type Description

Same shape as :meth:list but for media where

in_trash == True. in_projects_list is not included.

Source code in src/cuemseditor/CuemsDBMedia.py
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
def list_trash(self):
    """Return all trashed media files with project usage counts.

    Returns:
        Same shape as :meth:`list` but for media where
        ``in_trash == True``.  ``in_projects_list`` is not included.
    """
    media_list = list()

    medias = (Media
              .select(Media.uuid, Media.name, Media.unix_name, Media.created, Media.modified, Media.duration, Media.media_type,
                      fn.COUNT(Case(Project.in_trash, (('0', 1),), None)).alias('in_project_count'),
                      fn.COUNT(Case(Project.in_trash, (('1', 1),), None)).alias('in_project_trash_count'))
              .join(ProjectMedia, JOIN.LEFT_OUTER)
              .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
              .where(Media.in_trash == True)
              .group_by(Media.uuid).order_by(Media.created.desc()))
    for media in medias:
        media_dict = {str(media.uuid): {'name': media.name, 'unix_name': media.unix_name, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, "in_projects": media.in_project_count, "in_trash_projects": media.in_project_trash_count}}
        media_list.append(media_dict)

    return media_list

load_meta(uuid)

Return full metadata for a media file, including project usage.

Parameters:

Name Type Description Default
uuid

Media UUID string (live or trashed).

required

Returns:

Type Description

Single-key dict ``{uuid: {name, unix_name, description, created,

modified, duration, type, in_trash, in_projects, in_trash_projects}}``.

in_projects is a list of live project UUID strings;

in_trash_projects is a list of trashed project UUID strings.

Raises:

Type Description
NonExistentItemError

If no media record with uuid exists.

Source code in src/cuemseditor/CuemsDBMedia.py
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def load_meta(self, uuid):
    """Return full metadata for a media file, including project usage.

    Args:
        uuid: Media UUID string (live or trashed).

    Returns:
        Single-key dict ``{uuid: {name, unix_name, description, created,
        modified, duration, type, in_trash, in_projects, in_trash_projects}}``.
        ``in_projects`` is a list of live project UUID strings;
        ``in_trash_projects`` is a list of trashed project UUID strings.

    Raises:
        NonExistentItemError: If no media record with *uuid* exists.
    """
    try:
        media = Media.get(Media.uuid == uuid)
        file_meta = dict()
        project_list = list()
        project_trash_list = list()
        media_projects_query = media.projects()
        for project in media_projects_query:
            if project.in_trash == False:
                project_list.append(str(project.uuid))
            else:
                project_trash_list.append(str(project.uuid))

        file_meta[uuid] = {'name': media.name, 'unix_name': media.unix_name, 'description': media.description, 'created': media.created, 'modified': media.modified, 'duration': media.duration, 'type': media.media_type, 'in_trash': media.in_trash, 'in_projects': project_list, 'in_trash_projects': project_trash_list}
        return file_meta

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

load_thumbnail(uuid)

Read and return the thumbnail file as a length-prefixed binary blob.

The returned bytes start with a 36-byte UUID header (see :meth:add_binary_header) so the frontend can correlate the binary WebSocket frame to the originating request.

Parameters:

Name Type Description Default
uuid

Media UUID string.

required

Returns:

Type Description

bytes — 36-byte UUID header + raw thumbnail file contents.

Raises:

Type Description
NonExistentItemError

If the media record or its thumbnail file cannot be found.

Source code in src/cuemseditor/CuemsDBMedia.py
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
def load_thumbnail(self, uuid):
    """Read and return the thumbnail file as a length-prefixed binary blob.

    The returned bytes start with a 36-byte UUID header (see
    :meth:`add_binary_header`) so the frontend can correlate the binary
    WebSocket frame to the originating request.

    Args:
        uuid: Media UUID string.

    Returns:
        ``bytes`` — 36-byte UUID header + raw thumbnail file contents.

    Raises:
        NonExistentItemError: If the media record or its thumbnail file
            cannot be found.
    """
    try:
        media_filename = Media.get(Media.uuid == uuid).unix_name
        thumbnail_file_path = self.get_thumbnail_path(media_filename)
        try:
            with open(thumbnail_file_path, 'rb') as file:
                media_thumbnail_binary_data = file.read()
            return self.add_binary_header(media_thumbnail_binary_data, uuid, 1)

        except Exception as e:
            raise NonExistentItemError("item with uuid: {} error reading thumbnail ; {}, {}".format(uuid, type(e), e))

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

load_waveform(uuid)

Read and return the waveform data file as a length-prefixed binary blob.

The returned bytes start with a 36-byte UUID header (see :meth:add_binary_header) so the frontend can correlate the binary WebSocket frame to the originating request.

Parameters:

Name Type Description Default
uuid

Media UUID string.

required

Returns:

Type Description

bytes — 36-byte UUID header + raw waveform file contents.

Raises:

Type Description
NonExistentItemError

If the media record or its waveform file cannot be found.

Source code in src/cuemseditor/CuemsDBMedia.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
def load_waveform(self, uuid):
    """Read and return the waveform data file as a length-prefixed binary blob.

    The returned bytes start with a 36-byte UUID header (see
    :meth:`add_binary_header`) so the frontend can correlate the binary
    WebSocket frame to the originating request.

    Args:
        uuid: Media UUID string.

    Returns:
        ``bytes`` — 36-byte UUID header + raw waveform file contents.

    Raises:
        NonExistentItemError: If the media record or its waveform file
            cannot be found.
    """
    try:
        media_filename = Media.get(Media.uuid == uuid).unix_name
        waveform_file_path = self.get_waveform_path(media_filename)
        try:
            with open(waveform_file_path, 'rb') as file:
                media_waveform_binary_data = file.read()
            return self.add_binary_header(media_waveform_binary_data, uuid, 2)

        except Exception as e:
            raise NonExistentItemError("item with uuid: {} error reading  waveform ; {}, {}".format(uuid, type(e), e))

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

new(tmp_file_path, filename, md5=None)

Intake a newly uploaded file: move it into the media library and create sidecars.

Steps performed atomically:

  1. CopyMoveVersioned.move from tmp_file_path to <media_path>/<filename> (versioned if filename exists).
  2. Detect MediaType from the file extension.
  3. Extract duration via ffprobe (audio/video).
  4. Generate thumbnail (ffmpeg), waveform (audiowaveform for audio), and video index (cuems-videoindexer for movies).
  5. Insert the Media DB row.

On any failure the transaction is rolled back and any partially created sidecar files are removed.

Parameters:

Name Type Description Default
tmp_file_path

Absolute path to the file in the tmp upload directory.

required
filename

Sanitised target filename (no directory component).

required
md5

the file's MD5 as the upload verified it (None when the caller has none); stored as file_md5 (869fat84r, D18).

None

Returns:

Type Description

The actual destination filename (may differ from filename if

CopyMoveVersioned added a suffix to avoid collision).

Raises:

Type Description
Exception

Re-raises after rollback and cleanup on any failure.

Source code in src/cuemseditor/CuemsDBMedia.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def new(self, tmp_file_path, filename, md5=None):
    """Intake a newly uploaded file: move it into the media library and create sidecars.

    Steps performed atomically:

    1. ``CopyMoveVersioned.move`` from ``tmp_file_path`` to
       ``<media_path>/<filename>`` (versioned if *filename* exists).
    2. Detect ``MediaType`` from the file extension.
    3. Extract duration via ``ffprobe`` (audio/video).
    4. Generate thumbnail (ffmpeg), waveform (audiowaveform for audio), and
       video index (cuems-videoindexer for movies).
    5. Insert the ``Media`` DB row.

    On any failure the transaction is rolled back and any partially created
    sidecar files are removed.

    Args:
        tmp_file_path: Absolute path to the file in the tmp upload directory.
        filename: Sanitised target filename (no directory component).
        md5: the file's MD5 as the upload verified it (``None`` when the
            caller has none); stored as ``file_md5`` (869fat84r, D18).

    Returns:
        The actual destination filename (may differ from *filename* if
        ``CopyMoveVersioned`` added a suffix to avoid collision).

    Raises:
        Exception: Re-raises after rollback and cleanup on any failure.
    """
    with self.db.atomic() as transaction:
        trash_state = False
        try:
            dest_filename = None
            dest_filename = CopyMoveVersioned.move(tmp_file_path, self.media_path, self._free_filename(filename))

            try:
                _type = self.get_type(dest_filename)
            except Exception as e:
                Logger.warning(f'could not get media type; error : {e}')
                _type = None
                raise e

            try:
                if _type in (MediaType.MOVIE, MediaType.AUDIO):
                    media_duration = self.get_duration(dest_filename)
                else:
                    media_duration = None
            except Exception as e:
                Logger.warning(f'could not get media duration; error : {e}')
                media_duration = None

            try:
                if _type is MediaType.MOVIE:
                    Logger.debug(f'creating thumbnail for movie: {dest_filename}')
                    dest_thumbnail_filename = None
                    dest_thumbnail_filename = self.create_video_thumbnail(dest_filename, media_duration)
                    self.create_video_index(dest_filename)
                elif _type is MediaType.AUDIO:
                    Logger.debug(f'creating thumbnail for audio: {dest_filename}')
                    dest_thumbnail_filename = None
                    dest_waveform_filename = None
                    if media_duration is not None:
                        dest_thumbnail_filename = self.create_audio_thubnail(dest_filename, media_duration)
                    else:
                        Logger.warning(f'no duration for {dest_filename}; skipping audio waveform thumbnail')
                    dest_waveform_filename = self.create_audio_waveform(dest_filename)
                elif _type is MediaType.IMAGE:
                    Logger.debug(f'creating thumbnail for image: {dest_filename}')
                    dest_thumbnail_filename = None
                    dest_thumbnail_filename = self.create_video_thumbnail(dest_filename, None)
            except Exception as e:
                Logger.error(f'could not generate {_type} thumbnail or waveform; error : {e}')
                media_thumbnail_binary_data = None

            # Stored like the duration (869fat84r): a movie's pixel size, so
            # the engine does not probe the file when it arms a cue (a
            # failed probe stores nothing; never fatal); and, for every
            # media type, the file's size and the MD5 the upload verified.
            dest_path = self.get_file_path(dest_filename)
            pixel_width = pixel_height = None
            if _type is MediaType.MOVIE:
                width, height = probe_dimensions(dest_path)
                if width and height:
                    pixel_width, pixel_height = width, height
            file_size = media_file_size(dest_path)
            file_md5 = md5.lower() if isinstance(md5, str) and len(md5) == 32 else None

            media_uuid = new_uuid()
            Media.create(uuid=str(media_uuid), name=dest_filename, unix_name=dest_filename, created=new_datetime(), modified=new_datetime(), duration=media_duration, media_type=_type.name, in_trash=False, pixel_width=pixel_width, pixel_height=pixel_height, file_size=file_size, file_md5=file_md5)
            Logger.debug(f'new media created: {media_uuid} {dest_filename}')
            return dest_filename
        except Exception as e:
            Logger.error("error: {} {} trying to move new file, rolling back database insert".format(type(e), e))
            transaction.rollback()
            if dest_filename is None and dest_thumbnail_filename is None:  # if move or copy where not successful we don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                if _type is MediaType.AUDIO:
                    if dest_waveform_filename is None:
                        raise e
                else:
                    raise e
            if os.path.exists(self.get_file_path(dest_filename)):
                os.remove(self.get_file_path(dest_filename))

            if os.path.exists(self.get_thumbnail_path(dest_filename)):
                os.remove(self.get_thumbnail_path(dest_filename))

            if os.path.exists(self.get_waveform_path(dest_filename)):
                os.remove(self.get_waveform_path(dest_filename))

            raise e

restore(uuid)

Restore a trashed media file and its sidecars back to the media library.

Moves files from the trash sub-directories back to the live media directories via _move_keeping_name, so unix_name stays valid. Sets Media.in_trash = False atomically. Reverses filesystem moves on transaction rollback.

A live file already at <media_path>/<unix_name> is not tracked by the library, but cues may play it by name, so the restore is refused rather than moving it aside.

Parameters:

Name Type Description Default
uuid

UUID of a trashed media file to restore.

required

Raises:

Type Description
NonExistentItemError

If no trashed media file with uuid exists.

FileExistsError

If an untracked file already holds the name in the media directory.

Source code in src/cuemseditor/CuemsDBMedia.py
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
def restore(self, uuid):
    """Restore a trashed media file and its sidecars back to the media library.

    Moves files from the trash sub-directories back to the live media
    directories via ``_move_keeping_name``, so ``unix_name`` stays valid.
    Sets ``Media.in_trash = False`` atomically.  Reverses filesystem
    moves on transaction rollback.

    A live file already at ``<media_path>/<unix_name>`` is not tracked by
    the library, but cues may play it by name, so the restore is refused
    rather than moving it aside.

    Args:
        uuid: UUID of a trashed media file to restore.

    Raises:
        NonExistentItemError: If no trashed media file with *uuid* exists.
        FileExistsError: If an untracked file already holds the name in
            the media directory.
    """
    try:
        trash_state = True
        media_trash = Media.get((Media.uuid == uuid) & (Media.in_trash == trash_state))

        if os.path.exists(self.get_file_path(media_trash.unix_name)):
            raise FileExistsError(f'cannot restore {media_trash.unix_name}: a file with that name, not tracked by the media library, '
                                  f'is already in {self.media_path}; move it away first')

        with self.db.atomic() as transaction:
            moves = []
            try:
                file_thumbnail_path = self.get_thumbnail_path(media_trash.unix_name, trash_state=True)
                if os.path.exists(file_thumbnail_path):
                    self._move_keeping_name(file_thumbnail_path, self.thumbnail_path, moves)

                if self.is_audio(media_trash):
                    file_waveform_path = self.get_waveform_path(media_trash.unix_name, trash_state=True)
                    if os.path.exists(file_waveform_path):
                        self._move_keeping_name(file_waveform_path, self.waveform_path, moves)

                self._move_keeping_name(self.get_file_path(media_trash.unix_name, trash_state=True), self.media_path, moves)
                media_trash.in_trash = False
                media_trash.save()
                Logger.debug('updating instance in db: {}'.format(media_trash))
            except Exception as e:
                Logger.error("error: {} {}; trying to move file from trash, rolling back database".format(type(e), e))
                transaction.rollback()
                self._undo_moves(moves)
                raise e
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

save(uuid, data)

Update the name and description fields of a live media record.

Parameters:

Name Type Description Default
uuid

Media UUID string.

required
data

Dict with structure {uuid: {name, description}}.

required

Returns:

Type Description

'updated' on success.

Raises:

Type Description
NonExistentItemError

If the media record does not exist or is in the trash.

Source code in src/cuemseditor/CuemsDBMedia.py
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def save(self, uuid, data):
    """Update the ``name`` and ``description`` fields of a live media record.

    Args:
        uuid: Media UUID string.
        data: Dict with structure ``{uuid: {name, description}}``.

    Returns:
        ``'updated'`` on success.

    Raises:
        NonExistentItemError: If the media record does not exist or is in
            the trash.
    """
    try:
        media = Media.get((Media.uuid == uuid) & (Media.in_trash == False))
        with self.db.atomic() as transaction:
            try:
                media.update(name=StringSanitizer.sanitize_name(data['uuid']['name']), description=StringSanitizer.sanitize_text_size(data['uuid']['description']), modified=new_datetime()).execute()
                return 'updated'
            except Exception as e:
                Logger.error("error: {} {} trying to update  media data, rolling back database update".format(type(e), e))
                transaction.rollback()
                raise e

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

MediaProbe

What one ffprobe call says about a file: its duration and the pixel size of its first video stream, each with a state, so "this file has no picture" is never mistaken for "the probe failed".

Source code in src/cuemseditor/CuemsDBMedia.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
class MediaProbe:
    """What one ffprobe call says about a file: its duration and the pixel
    size of its first video stream, each with a state, so "this file has no
    picture" is never mistaken for "the probe failed"."""

    def __init__(self, duration=None, duration_state=PROBE_FAILED,
                 width=None, height=None, picture_state=PROBE_FAILED):
        self.duration = duration
        self.duration_state = duration_state
        self.width = width
        self.height = height
        self.picture_state = picture_state

    def __repr__(self):
        return (f'MediaProbe(duration={self.duration!s} [{self.duration_state}], '
                f'picture={self.width}x{self.height} [{self.picture_state}])')

MediaType

Bases: Enum

Media file category.

Used as the media_type column value in the Media ORM model (stored as MOVIE, AUDIO, or IMAGE string).

Source code in src/cuemseditor/CuemsDBMedia.py
275
276
277
278
279
280
281
282
283
284
class MediaType(Enum):
    """Media file category.

    Used as the ``media_type`` column value in the ``Media`` ORM model (stored
    as ``MOVIE``, ``AUDIO``, or ``IMAGE`` string).
    """

    MOVIE = auto()
    AUDIO = auto()
    IMAGE = auto()

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

media_file_size(file_path)

Size in bytes, or None when unreadable or empty.

Source code in src/cuemseditor/CuemsDBMedia.py
58
59
60
61
62
63
64
def media_file_size(file_path):
    """Size in bytes, or ``None`` when unreadable or empty."""
    try:
        size = os.stat(file_path).st_size
    except OSError:
        return None
    return size if size > 0 else None

probe_dimensions(file_path)

Return (width, height) px of a file's first video stream, or (None, None).

The same ffprobe query the engine ran when it armed a video cue, so the stored values are the values the engine used to measure (869fat84r). Never raises: a failure logs a WARNING and returns (None, None).

Source code in src/cuemseditor/CuemsDBMedia.py
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def probe_dimensions(file_path):
    """Return ``(width, height)`` px of a file's first video stream, or
    ``(None, None)``.

    The same ffprobe query the engine ran when it armed a video cue, so the
    stored values are the values the engine used to measure (869fat84r).
    Never raises: a failure logs a WARNING and returns ``(None, None)``.
    """
    cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
           '-show_entries', 'stream=width,height', '-of', 'csv=p=0', file_path]
    try:
        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                timeout=DIMENSIONS_PROBE_TIMEOUT)
        if result.returncode == 0:
            parts = result.stdout.decode('utf8', errors='replace').strip().split(',')
            if len(parts) == 2:
                width, height = int(parts[0]), int(parts[1])
                if width > 0 and height > 0:
                    return (width, height)
        Logger.warning(f'could not read the pixel size of {file_path} '
                       f'(ffprobe rc={result.returncode})')
    except (subprocess.TimeoutExpired, OSError, ValueError) as e:
        Logger.warning(f'could not read the pixel size of {file_path}: {e}')
    return (None, None)

probe_duration(file_path)

Probe a media file's duration with ffprobe and return a CTimecode.

Runs ffprobe -show_entries format=duration (plain float seconds, NOT -sexagesimal) and constructs CTimecode(start_seconds=...) which canonicalises correctly at every framerate including the whole-second carry boundary. This replaces the previous sexagesimal-string reformatting hack, which silently corrupted any duration whose millisecond fraction ended in a zero (stored short by up to ~0.9 s) and mangled the carry case into a minutes-long overshoot.

Parameters:

Name Type Description Default
file_path

Absolute path to the media file.

required

Returns:

Type Description

CTimecode at the default ms framerate (str() → HH:MM:SS.mmm).

Raises:

Type Description
NotTimeCodeError

on any failure — ffprobe timeout, non-zero exit, empty / N/A / non-numeric / non-finite / negative output.

Source code in src/cuemseditor/CuemsDBMedia.py
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
def probe_duration(file_path):
    """Probe a media file's duration with ffprobe and return a ``CTimecode``.

    Runs ``ffprobe -show_entries format=duration`` (plain float seconds, NOT
    ``-sexagesimal``) and constructs ``CTimecode(start_seconds=...)`` which
    canonicalises correctly at every framerate including the whole-second carry
    boundary. This replaces the previous sexagesimal-string reformatting hack,
    which silently corrupted any duration whose millisecond fraction ended in a
    zero (stored short by up to ~0.9 s) and mangled the carry case into a
    minutes-long overshoot.

    Args:
        file_path: Absolute path to the media file.

    Returns:
        ``CTimecode`` at the default ms framerate (``str()`` → ``HH:MM:SS.mmm``).

    Raises:
        NotTimeCodeError: on any failure — ffprobe timeout, non-zero exit,
            empty / ``N/A`` / non-numeric / non-finite / negative output.
    """
    cmd = ['ffprobe', '-v', 'error',
           '-show_entries', 'format=duration',
           '-of', 'default=noprint_wrappers=1:nokey=1', file_path]
    try:
        result = subprocess.run(cmd, stdout=subprocess.PIPE,
                                stderr=subprocess.PIPE, timeout=FFPROBE_TIMEOUT)
    except subprocess.TimeoutExpired as e:
        raise NotTimeCodeError(
            f'ffprobe timed out after {FFPROBE_TIMEOUT}s for {file_path}') from e
    if result.returncode != 0:
        stderr = result.stderr.decode('utf8', errors='replace').strip()
        raise NotTimeCodeError(
            f'ffprobe failed (rc={result.returncode}) for {file_path}: {stderr}')
    out = result.stdout.decode('utf8').strip()
    if not out or out == 'N/A':
        raise NotTimeCodeError(f'ffprobe returned no duration ({out!r}) for {file_path}')
    try:
        seconds = float(out)
    except ValueError as e:
        raise NotTimeCodeError(f'ffprobe output not a number: {out!r}') from e
    # float() accepts 'nan'/'inf' silently; those blow up inside CTimecode
    # (ValueError / OverflowError) rather than raising our contract exception.
    if not math.isfinite(seconds):
        raise NotTimeCodeError(f'ffprobe returned non-finite duration {out!r} for {file_path}')
    if seconds < 0:
        raise NotTimeCodeError(f'ffprobe returned negative duration {seconds} for {file_path}')
    return CTimecode(start_seconds=seconds)

probe_media(file_path, timeout=DIMENSIONS_PROBE_TIMEOUT)

Read a file's duration and pixel size with ONE ffprobe call.

The duration is the same format=duration field :func:probe_duration reads, made into the same CTimecode, so an unchanged file gives the same stored string. Never raises.

Source code in src/cuemseditor/CuemsDBMedia.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def probe_media(file_path, timeout=DIMENSIONS_PROBE_TIMEOUT):
    """Read a file's duration and pixel size with ONE ffprobe call.

    The duration is the same ``format=duration`` field :func:`probe_duration`
    reads, made into the same ``CTimecode``, so an unchanged file gives the
    same stored string. Never raises.
    """
    cmd = ['ffprobe', '-v', 'error', '-select_streams', 'v:0',
           '-show_entries', 'stream=width,height:format=duration',
           '-of', 'json', file_path]
    try:
        result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                timeout=timeout)
    except (subprocess.TimeoutExpired, OSError) as e:
        Logger.warning(f'could not probe {file_path}: {e}')
        return MediaProbe()
    if result.returncode != 0:
        Logger.warning(f'could not probe {file_path} (ffprobe rc={result.returncode}): '
                       f'{result.stderr.decode("utf8", errors="replace").strip()}')
        return MediaProbe()
    try:
        data = json.loads(result.stdout.decode('utf8', errors='replace') or '{}')
    except ValueError:
        return MediaProbe()
    probe = MediaProbe()
    # Duration.
    raw = (data.get('format') or {}).get('duration')
    if raw in (None, '', 'N/A'):
        probe.duration_state = PROBE_NO_STREAM
    else:
        try:
            seconds = float(raw)
            if math.isfinite(seconds) and seconds >= 0:
                probe.duration = CTimecode(start_seconds=seconds)
                probe.duration_state = PROBE_OK
        except (TypeError, ValueError, OverflowError):
            pass
    # Picture.
    streams = data.get('streams') or []
    if not streams:
        probe.picture_state = PROBE_NO_STREAM
    else:
        width, height = streams[0].get('width'), streams[0].get('height')
        if isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0:
            probe.width, probe.height, probe.picture_state = width, height, PROBE_OK
    return probe

run_video_indexer(file_path, timeout=VIDEO_INDEXER_TIMEOUT) async

Run cuems-videoindexer on file_path as an asyncio subprocess (no executor thread is held while it works).

Success is the exit code AND an index the videocomposer will accept, not "an .idx file exists": a stale one exists too. Never raises.

Source code in src/cuemseditor/CuemsDBMedia.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
async def run_video_indexer(file_path, timeout=VIDEO_INDEXER_TIMEOUT):
    """Run ``cuems-videoindexer`` on *file_path* as an asyncio subprocess (no
    executor thread is held while it works).

    Success is the exit code AND an index the videocomposer will accept, not
    "an .idx file exists": a stale one exists too. Never raises.
    """
    exe = shutil.which(VIDEO_INDEXER)
    if not exe:
        Logger.warning(f'{VIDEO_INDEXER} is not installed: cannot rebuild the index of '
                       f'{file_path}')
        return False
    try:
        proc = await asyncio.create_subprocess_exec(
            exe, file_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
        try:
            out, _ = await asyncio.wait_for(proc.communicate(), timeout)
        except asyncio.TimeoutError:
            proc.kill()
            await proc.wait()
            Logger.warning(f'{VIDEO_INDEXER} timed out after {timeout}s for {file_path}')
            return False
    except Exception as e:
        Logger.warning(f'{VIDEO_INDEXER} could not run for {file_path}: {type(e).__name__}: {e}')
        return False
    if proc.returncode == 0 and video_index_state(file_path) == INDEX_VALID:
        return True
    tail = (out or b'').decode('utf8', errors='replace').strip()[-300:]
    Logger.warning(f'{VIDEO_INDEXER} did not produce a valid index for {file_path} '
                   f'(rc={proc.returncode}): {tail}')
    return False

video_index_path(file_path)

/lib/media/a.mp4 -> /lib/media/indexes/a.mp4.idx (the videocomposer's getIndexPath).

Source code in src/cuemseditor/CuemsDBMedia.py
214
215
216
217
218
def video_index_path(file_path):
    """``/lib/media/a.mp4`` -> ``/lib/media/indexes/a.mp4.idx`` (the
    videocomposer's ``getIndexPath``)."""
    return os.path.join(os.path.dirname(file_path), 'indexes',
                        os.path.basename(file_path) + '.idx')

video_index_state(file_path)

Is the video's .idx the one the videocomposer would accept?

It compares what the videocomposer compares: magic, version, the video's size and mtime (whole seconds), and a positive frame count. Fails closed: anything it cannot read counts as missing.

Source code in src/cuemseditor/CuemsDBMedia.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def video_index_state(file_path):
    """Is the video's ``.idx`` the one the videocomposer would accept?

    It compares what the videocomposer compares: magic, version, the video's
    size and mtime (whole seconds), and a positive frame count. Fails closed:
    anything it cannot read counts as missing.
    """
    try:
        st = os.stat(file_path)
        with open(video_index_path(file_path), 'rb') as f:
            head = f.read(_IDX_HEAD.size)
        magic, version, size, mtime, frames = _IDX_HEAD.unpack(head)
    except (OSError, struct.error):
        return INDEX_MISSING
    if magic != _IDX_MAGIC or version != _IDX_VERSION or frames <= 0:
        return INDEX_MISSING
    if size != st.st_size or mtime != st.st_mtime_ns // 1_000_000_000:
        return INDEX_STALE
    return INDEX_VALID

CuemsBaseModel

Bases: Model

Peewee base model that binds all CueMS ORM classes to the shared database.

All concrete models inherit from this class so they all operate on the same SqliteDatabase instance that CuemsDBManager initialises at startup via database.init(path).

Source code in src/cuemseditor/CuemsDBModel.py
23
24
25
26
27
28
29
30
31
32
class CuemsBaseModel(Model):
    """Peewee base model that binds all CueMS ORM classes to the shared database.

    All concrete models inherit from this class so they all operate on the
    same ``SqliteDatabase`` instance that ``CuemsDBManager`` initialises at
    startup via ``database.init(path)``.
    """

    class Meta:
        database = database

Media

Bases: CuemsBaseModel

ORM model for a media file record.

Each row represents one file in library_path/media/. The in_trash flag soft-deletes the file to library_path/trash/media/, preserving unique-name constraints on name and unix_name.

Attributes:

Name Type Description
uuid

Primary key; used as the media identifier in cue XML.

name

Human-readable filename (unique, sanitized).

unix_name

Filesystem filename (unique, sanitized).

description

Optional free-text description.

created

Upload timestamp.

modified

Last metadata-update timestamp.

duration

CTimecode string (e.g. "00:03:12.450"); None for images.

media_type

"MOVIE", "AUDIO", or "IMAGE" (from MediaType.name).

in_trash

True when the file has been soft-deleted.

pixel_width / pixel_height

original size in pixels of a movie's first video stream, measured at upload like the duration, so the engine does not probe the file when it arms a cue (869fat84r); None when unknown.

file_size

the file's size in bytes; the engine compares it with its own copy to notice a replaced file. Every media type.

file_md5

the file's MD5 (lowercase hex), from the upload, which already carries it; identifies the file for integrity checks. Every media type (D18).

Source code in src/cuemseditor/CuemsDBModel.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
class Media(CuemsBaseModel):
    """ORM model for a media file record.

    Each row represents one file in ``library_path/media/``.  The
    ``in_trash`` flag soft-deletes the file to ``library_path/trash/media/``,
    preserving unique-name constraints on ``name`` and ``unix_name``.

    Attributes:
        uuid: Primary key; used as the media identifier in cue XML.
        name: Human-readable filename (unique, sanitized).
        unix_name: Filesystem filename (unique, sanitized).
        description: Optional free-text description.
        created: Upload timestamp.
        modified: Last metadata-update timestamp.
        duration: ``CTimecode`` string (e.g. ``"00:03:12.450"``); ``None``
            for images.
        media_type: ``"MOVIE"``, ``"AUDIO"``, or ``"IMAGE"`` (from
            ``MediaType.name``).
        in_trash: ``True`` when the file has been soft-deleted.
        pixel_width / pixel_height: original size in pixels of a movie's
            first video stream, measured at upload like the duration, so the
            engine does not probe the file when it arms a cue (869fat84r);
            ``None`` when unknown.
        file_size: the file's size in bytes; the engine compares it with its
            own copy to notice a replaced file. Every media type.
        file_md5: the file's MD5 (lowercase hex), from the upload, which
            already carries it; identifies the file for integrity checks.
            Every media type (D18).
    """

    uuid = UUIDField(index=True, unique=True, primary_key=True)
    name = CharField(unique=True)
    unix_name = CharField(unique=True)
    description = TextField(null=True)  #TODO: define maxsize
    created = DateTimeField(default=new_datetime())
    modified = DateTimeField(default=new_datetime())
    duration = CharField(null=True)
    media_type = CharField()
    in_trash = BooleanField(default=False)
    # Added 2026-10 (869fat84r); existing databases get them from
    # ensure_media_columns() at start-up.
    pixel_width = IntegerField(null=True)
    pixel_height = IntegerField(null=True)
    file_size = IntegerField(null=True)
    file_md5 = CharField(null=True)

    @staticmethod
    def all_fields():
        """Return the full column list for use in explicit SELECT clauses.

        Returns:
            List of Peewee field descriptors covering every column on
            ``Media``.
        """
        return [Media.uuid, Media.name, Media.unix_name, Media.description, Media.created, Media.modified, Media.duration, Media.media_type, Media.in_trash, Media.pixel_width, Media.pixel_height, Media.file_size, Media.file_md5]

    def projects(self):
        """Query all ``Project`` records that reference this media file.

        Returns:
            Peewee ``ModelSelect`` ordered by ``Project.created``, with a
            ``count`` alias for the number of ``ProjectMedia`` join rows.
        """
        return (Project
                .select(*Project.all_fields(), fn.COUNT(ProjectMedia.id).alias('count'))
                .join(ProjectMedia, on=ProjectMedia.project)
                .where(ProjectMedia.media == self)
                .order_by(Project.created)
                .group_by(Project.uuid))

    def orphan(self):
        """Query ``Media`` records that are not referenced by any project.

        Returns:
            Peewee ``ModelSelect`` of orphaned media rows ordered by
            ``Media.created``.
        """
        return (Media
                .select()
                .join(ProjectMedia, JOIN.LEFT_OUTER)
                .where(ProjectMedia.media == None)
                .order_by(Media.created))

all_fields() staticmethod

Return the full column list for use in explicit SELECT clauses.

Returns:

Type Description

List of Peewee field descriptors covering every column on

Media.

Source code in src/cuemseditor/CuemsDBModel.py
134
135
136
137
138
139
140
141
142
@staticmethod
def all_fields():
    """Return the full column list for use in explicit SELECT clauses.

    Returns:
        List of Peewee field descriptors covering every column on
        ``Media``.
    """
    return [Media.uuid, Media.name, Media.unix_name, Media.description, Media.created, Media.modified, Media.duration, Media.media_type, Media.in_trash, Media.pixel_width, Media.pixel_height, Media.file_size, Media.file_md5]

orphan()

Query Media records that are not referenced by any project.

Returns:

Type Description

Peewee ModelSelect of orphaned media rows ordered by

Media.created.

Source code in src/cuemseditor/CuemsDBModel.py
158
159
160
161
162
163
164
165
166
167
168
169
def orphan(self):
    """Query ``Media`` records that are not referenced by any project.

    Returns:
        Peewee ``ModelSelect`` of orphaned media rows ordered by
        ``Media.created``.
    """
    return (Media
            .select()
            .join(ProjectMedia, JOIN.LEFT_OUTER)
            .where(ProjectMedia.media == None)
            .order_by(Media.created))

projects()

Query all Project records that reference this media file.

Returns:

Type Description

Peewee ModelSelect ordered by Project.created, with a

count alias for the number of ProjectMedia join rows.

Source code in src/cuemseditor/CuemsDBModel.py
144
145
146
147
148
149
150
151
152
153
154
155
156
def projects(self):
    """Query all ``Project`` records that reference this media file.

    Returns:
        Peewee ``ModelSelect`` ordered by ``Project.created``, with a
        ``count`` alias for the number of ``ProjectMedia`` join rows.
    """
    return (Project
            .select(*Project.all_fields(), fn.COUNT(ProjectMedia.id).alias('count'))
            .join(ProjectMedia, on=ProjectMedia.project)
            .where(ProjectMedia.media == self)
            .order_by(Project.created)
            .group_by(Project.uuid))

Project

Bases: CuemsBaseModel

ORM model for a CueMS project record.

Each row corresponds to one project directory under library_path/projects/<unix_name>/. The in_trash flag moves the project to the trash rather than hard-deleting it, preserving the DB record and unique-name constraints.

Attributes:

Name Type Description
uuid

Primary key; also the CuemsScript.id in the project XML.

name

Human-readable display name (unique, sanitized).

unix_name

Filesystem-safe directory name (unique, sanitized).

description

Free-text description; may be None.

created

Creation timestamp.

modified

Last-save timestamp.

in_trash

True when the project has been soft-deleted.

Source code in src/cuemseditor/CuemsDBModel.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class Project(CuemsBaseModel):
    """ORM model for a CueMS project record.

    Each row corresponds to one project directory under
    ``library_path/projects/<unix_name>/``. The ``in_trash`` flag moves the
    project to the trash rather than hard-deleting it, preserving the DB
    record and unique-name constraints.

    Attributes:
        uuid: Primary key; also the ``CuemsScript.id`` in the project XML.
        name: Human-readable display name (unique, sanitized).
        unix_name: Filesystem-safe directory name (unique, sanitized).
        description: Free-text description; may be ``None``.
        created: Creation timestamp.
        modified: Last-save timestamp.
        in_trash: ``True`` when the project has been soft-deleted.
    """

    uuid = UUIDField(index=True, unique=True, primary_key=True)
    name = CharField(unique=True)
    unix_name = CharField(unique=True)
    description = TextField(null=True)  #TODO: define maxsize
    created = DateTimeField(default=new_datetime())
    modified = DateTimeField(default=new_datetime())
    in_trash = BooleanField(default=False)

    @staticmethod
    def all_fields():
        """Return the full column list for use in explicit SELECT clauses.

        Returns:
            List of Peewee field descriptors covering every column on
            ``Project``.
        """
        return [Project.uuid, Project.name, Project.unix_name, Project.description, Project.created, Project.modified, Project.in_trash]

    def medias(self):
        """Query all ``Media`` records linked to this project.

        Joins through ``ProjectMedia`` and returns each media file with a
        ``count`` alias (number of ``ProjectMedia`` rows for the pair).

        Returns:
            Peewee ``ModelSelect`` ordered by ``Media.created``.
        """
        return (Media
                .select(*Media.all_fields(), fn.COUNT(ProjectMedia.id).alias('count'))
                .join(ProjectMedia, on=ProjectMedia.media)
                .where(ProjectMedia.project == self)
                .order_by(Media.created)
                .group_by(Media.uuid))

all_fields() staticmethod

Return the full column list for use in explicit SELECT clauses.

Returns:

Type Description

List of Peewee field descriptors covering every column on

Project.

Source code in src/cuemseditor/CuemsDBModel.py
61
62
63
64
65
66
67
68
69
@staticmethod
def all_fields():
    """Return the full column list for use in explicit SELECT clauses.

    Returns:
        List of Peewee field descriptors covering every column on
        ``Project``.
    """
    return [Project.uuid, Project.name, Project.unix_name, Project.description, Project.created, Project.modified, Project.in_trash]

medias()

Query all Media records linked to this project.

Joins through ProjectMedia and returns each media file with a count alias (number of ProjectMedia rows for the pair).

Returns:

Type Description

Peewee ModelSelect ordered by Media.created.

Source code in src/cuemseditor/CuemsDBModel.py
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def medias(self):
    """Query all ``Media`` records linked to this project.

    Joins through ``ProjectMedia`` and returns each media file with a
    ``count`` alias (number of ``ProjectMedia`` rows for the pair).

    Returns:
        Peewee ``ModelSelect`` ordered by ``Media.created``.
    """
    return (Media
            .select(*Media.all_fields(), fn.COUNT(ProjectMedia.id).alias('count'))
            .join(ProjectMedia, on=ProjectMedia.media)
            .where(ProjectMedia.project == self)
            .order_by(Media.created)
            .group_by(Media.uuid))

ProjectMedia

Bases: CuemsBaseModel

Many-to-many join table linking projects to their media files.

media_filename is a denormalized copy of Media.unix_name kept alongside the foreign key so that queries can match on filename without an extra join to Media.

Attributes:

Name Type Description
id

Auto-increment surrogate primary key.

project

FK to Project.

media

FK to Media; nullable to allow orphan detection.

media_filename

Denormalized Media.unix_name for fast lookups.

Source code in src/cuemseditor/CuemsDBModel.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
class ProjectMedia(CuemsBaseModel):
    """Many-to-many join table linking projects to their media files.

    ``media_filename`` is a denormalized copy of ``Media.unix_name`` kept
    alongside the foreign key so that queries can match on filename without
    an extra join to ``Media``.

    Attributes:
        id: Auto-increment surrogate primary key.
        project: FK to ``Project``.
        media: FK to ``Media``; nullable to allow orphan detection.
        media_filename: Denormalized ``Media.unix_name`` for fast lookups.
    """

    id = PrimaryKeyField()
    project = ForeignKeyField(Project, backref='project_medias')
    media = ForeignKeyField(Media, backref='media_projects', null=True)
    media_filename = CharField(null=True)  # This is a denormalized field to speed up queries

    def missing_refs(self):
        """Query ``ProjectMedia`` rows whose ``Media`` or ``Project`` FK is dangling.

        Returns:
            Peewee ``ModelSelect`` of rows where the referenced ``Media`` or
            ``Project`` record no longer exists.
        """
        return (ProjectMedia
                .select()
                .join(Media, JOIN.LEFT_OUTER, on=(Media.uuid == ProjectMedia.media))
                .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
                .where(
                    (Media.uuid == None) |
                    (Project.uuid == None))
                .order_by(Media.created))

missing_refs()

Query ProjectMedia rows whose Media or Project FK is dangling.

Returns:

Type Description

Peewee ModelSelect of rows where the referenced Media or

Project record no longer exists.

Source code in src/cuemseditor/CuemsDBModel.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
def missing_refs(self):
    """Query ``ProjectMedia`` rows whose ``Media`` or ``Project`` FK is dangling.

    Returns:
        Peewee ``ModelSelect`` of rows where the referenced ``Media`` or
        ``Project`` record no longer exists.
    """
    return (ProjectMedia
            .select()
            .join(Media, JOIN.LEFT_OUTER, on=(Media.uuid == ProjectMedia.media))
            .join(Project, JOIN.LEFT_OUTER, on=(Project.uuid == ProjectMedia.project))
            .where(
                (Media.uuid == None) |
                (Project.uuid == None))
            .order_by(Media.created))

ensure_media_columns(db)

Add the missing media columns to an existing database.

create_tables(safe=True) creates missing tables but never adds a column to an existing one, and the editor has no migration framework, so this runs at every start (CuemsDBManager) and in the repair tool, which opens the database itself. Additive and idempotent: an older editor still reads the migrated database (peewee selects named columns).

Returns:

Type Description

The names of the columns added (empty when nothing was missing).

Raises:

Type Description
Any database error

the caller must not carry on with a model the

Source code in src/cuemseditor/CuemsDBModel.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def ensure_media_columns(db):
    """Add the missing ``media`` columns to an existing database.

    ``create_tables(safe=True)`` creates missing tables but never adds a
    column to an existing one, and the editor has no migration framework, so
    this runs at every start (``CuemsDBManager``) and in the repair tool,
    which opens the database itself. Additive and idempotent: an older
    editor still reads the migrated database (peewee selects named columns).

    Returns:
        The names of the columns added (empty when nothing was missing).

    Raises:
        Any database error: the caller must not carry on with a model the
        table does not match (every media query would fail).
    """
    added = []
    for name in missing_media_columns(db):
        db.execute_sql(
            f'ALTER TABLE "{Media._meta.table_name}" ADD COLUMN "{name}" '
            f'{MEDIA_ADDED_COLUMNS[name]}')
        Logger.info(f'database migration: added column media.{name}')
        added.append(name)
    return added

missing_media_columns(db)

Added media columns that this database does not have yet.

Source code in src/cuemseditor/CuemsDBModel.py
217
218
219
220
def missing_media_columns(db):
    """Added ``media`` columns that this database does not have yet."""
    existing = {column.name for column in db.get_columns(Media._meta.table_name)}
    return [name for name in MEDIA_ADDED_COLUMNS if name not in existing]

CuemsDBProject

Bases: StringSanitizer

Project CRUD, XML script I/O, and filesystem directory management.

Each CueMS project lives at <projects_path>/<unix_name>/cue_script.xml. All mutating operations are wrapped in Peewee atomic transactions with rollback; filesystem changes are reversed on failure where possible.

User-supplied name strings are sanitised via the inherited StringSanitizer methods before being used as directory names or stored in the database.

Example

db_project = CuemsDBProject(settings_dict, database) uuid = db_project.new({"CuemsScript": {"name": "My Show", ...}}, "my-show") project_data = db_project.load(uuid) db_project.update(uuid, project_data) db_project.delete(uuid) # soft-delete → trash db_project.restore(uuid) # restore from trash db_project.delete_from_trash(uuid) # permanent delete

Source code in src/cuemseditor/CuemsDBProject.py
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
class CuemsDBProject(StringSanitizer):
    """Project CRUD, XML script I/O, and filesystem directory management.

    Each CueMS project lives at
    ``<projects_path>/<unix_name>/cue_script.xml``.  All mutating operations
    are wrapped in Peewee atomic transactions with rollback; filesystem
    changes are reversed on failure where possible.

    User-supplied name strings are sanitised via the inherited
    ``StringSanitizer`` methods before being used as directory names or
    stored in the database.

    Example:
        >>> db_project = CuemsDBProject(settings_dict, database)
        >>> uuid = db_project.new({"CuemsScript": {"name": "My Show", ...}}, "my-show")
        >>> project_data = db_project.load(uuid)
        >>> db_project.update(uuid, project_data)
        >>> db_project.delete(uuid)          # soft-delete → trash
        >>> db_project.restore(uuid)         # restore from trash
        >>> db_project.delete_from_trash(uuid)  # permanent delete
    """

    def __init__(self, settings_dict, db_connection):
        """Initialise paths from *settings_dict*.

        Args:
            settings_dict: Mapping consumed from ``settings.xml``.  Required
                keys: ``tmp_path``, ``library_path``, ``script_file_name``,
                ``script_schema_name``, ``project_folder_name``,
                ``trash_folder_name``, ``media_folder_name``.
            db_connection: The shared Peewee ``SqliteDatabase`` instance owned
                by ``CuemsDBManager``.

        Raises:
            KeyError: If any required key is missing from *settings_dict*.
        """
        self.db = db_connection
        self.settings_dict = settings_dict
        try:
            self.tmp_path = self.settings_dict['tmp_path']
            self.library_path = settings_dict['library_path']
            self.script_file_name = settings_dict['script_file_name']
            self.script_schema_name = settings_dict['script_schema_name']
            self.projects_path = os.path.join(self.library_path, settings_dict['project_folder_name'])
            self.trash_path = os.path.join(self.library_path, settings_dict['trash_folder_name'], settings_dict['project_folder_name'])
            self.media_path = os.path.join(self.library_path, settings_dict['media_folder_name'])
        except KeyError as e:
            Logger.error(f'can not read settings {e}')
            raise e

    def get_project_unix_name(self, uuid):
        """Return the filesystem directory name for a live (non-trashed) project.

        Args:
            uuid: Project UUID string.

        Returns:
            ``unix_name`` value from the ``Project`` DB record.

        Raises:
            NonExistentItemError: If no live project with *uuid* exists.
        """
        try:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
            return project.unix_name
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def load(self, uuid, include_trash=False):
        """Load a project's ``CuemsScript`` dict from its XML file on disk.

        Args:
            uuid: Project UUID string.
            include_trash: When ``True``, also searches trashed projects.
                Defaults to ``False``.

        Returns:
            ``dict`` produced by ``XmlReaderWriter.read()`` — the parsed
            ``CuemsScript`` structure.

        Raises:
            NonExistentItemError: If no project with *uuid* exists (or the
                project is in the trash and *include_trash* is ``False``).
        """
        try:
            if not include_trash:
                project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
            else:
                project = Project.get(Project.uuid == uuid)
            return self.load_xml(project.unix_name)
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def list(self):
        """Return a list of all live (non-trashed) projects, newest first.

        Returns:
            List of single-key dicts ``{uuid_str: {name, unix_name,
            description, created, modified}}``, ordered by ``created``
            descending.

        Example:
            >>> projects = db_project.list()
            >>> for entry in projects:
            ...     uuid, meta = next(iter(entry.items()))
            ...     print(uuid, meta['name'])
        """
        project_list = list()
        projects = Project.select().where(Project.in_trash == False).order_by(Project.created.desc())
        for project in projects:
            project_dict = {str(project.uuid): {'name': project.name, 'unix_name': project.unix_name, 'description': project.description, 'created': project.created, 'modified': project.modified}}
            project_list.append(project_dict)

        return project_list

    def list_trash(self):
        """Return a list of all trashed projects, newest first.

        Returns:
            Same shape as :meth:`list` but for projects where
            ``in_trash == True``.
        """
        project_trash_list = list()
        projects_trash = Project.select().where(Project.in_trash == True).order_by(Project.created.desc())
        for project in projects_trash:
            project_dict = {str(project.uuid): {'name': project.name, 'unix_name': project.unix_name, 'description': project.description, 'created': project.created, 'modified': project.modified}}
            project_trash_list.append(project_dict)

        return project_trash_list

    def update(self, uuid, data):
        """Save an edited project: update DB metadata and rewrite the XML file.

        Runs inside a Peewee atomic transaction.  The XML is validated
        against ``script.xsd`` by ``XmlReaderWriter.write_from_object`` —
        an invalid cue tree raises before any persistent change is made.

        A pre-save pass via :meth:`_fix_media_durations` corrects zero
        durations sent by the frontend.

        Args:
            uuid: Project UUID string; must match ``data['CuemsScript']['id']``.
            data: ``CuemsScript`` dict as received from the frontend over
                WebSocket.

        Raises:
            NonExistentItemError: If the project does not exist or is in the
                trash.
            Exception: Re-raises any error after rolling back the transaction.
        """
        try:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

        try:
            del data['CuemsScript']['unix_name']
        except KeyError:
            pass

        # Reject media cues without a usable media block FIRST: the duration
        # fixer below writes Media rows, and nothing may be written for a save
        # that is going to be refused.
        validate_media_cues_in_contents(
            (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or [],
            media_exists=self._media_exists,
        )
        # SAFETY NET: older frontends send Media.duration as '00:00:00.000';
        # overwrite from the DB (source of truth) before saving.
        # As of the media-duration fix, the frontend copies the real duration
        # from the file_list payload (CuemsDBMedia.list() now carries a
        # 'duration' key) — remove this net only once ALL deployed frontends
        # AND editors are >= those versions.
        self._fix_media_durations(data)

        self._clean_dangling_targets(data)

        # Reject zero/unparseable FadeCue durations BEFORE parsing: the parser
        # bypasses FadeCue.set_duration, and a saved zero later becomes a
        # silent no-op at reveal (gradient-motiond drops dur <= 0).
        validate_fade_durations_in_contents(
            (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or []
        )

        with self.db.atomic() as transaction:
            try:
                project.name = StringSanitizer.sanitize_name(data['CuemsScript']['name'])
                now = new_datetime()
                data['CuemsScript']['modified'] = now
                project.modified = now
                project.description = StringSanitizer.sanitize_text_size(data['CuemsScript']['description'])
                project.save()
                project_object = CuemsParser(data).parse()
                self.update_media_relations(project, project_object)
                self.save_xml(project.unix_name, project_object)
            except Exception as e:
                Logger.error("error: {} {} trying to update  project, rolling back database update".format(type(e), e))
                transaction.rollback()
                raise e

    def _media_exists(self, unix_name):
        """Is a media file of that unix_name known to the library (trash included)?"""
        return Media.select(Media.uuid).where(Media.unix_name == unix_name).exists()

    # SAFETY NET (see update()): overwrite frontend-sent Media durations from
    # the DB. Delegates to the module-level fix_media_durations_in_contents so
    # the repair script and this path share one walk.
    def _fix_media_durations(self, data):
        """Overwrite each cue's ``Media.duration``, and each VideoCue's stored
        pixel size and file size, from the database.

        The DB is the source of truth for media duration. This corrects the
        legacy frontend behaviour of sending ``00:00:00.000``. Orphaned media
        references (no ``Media`` row) are logged, not modified. The frontend
        never sends the stored size, so it is always added here (869fat84r).
        """
        try:
            cuelist = data.get('CuemsScript', {}).get('CueList', {})
            contents = cuelist.get('contents', [])
            stats = fix_media_durations_in_contents(contents, *self._save_resolvers())
            if stats.orphans:
                Logger.warning(
                    f"media duration fix: {len(stats.orphans)} cue(s) reference "
                    f"media not in DB (duration left as-is): {stats.orphans}")
        except Exception as e:
            Logger.warning(f"Could not fix media durations: {e}")

    def _save_resolvers(self):
        """The save path's resolvers, ``(duration, stored values)``, for one
        save (869fat84r).

        Both read the ``Media`` row. A row with no stored size yet (written
        before rc15), or whose file no longer matches it, is first measured,
        as a load measures it (D20, plan §7.9.4, §8.2.3): one probe for its
        duration and pixel size, the size written last as the "checked" mark;
        so the save writes the file's real values.
        Verification stops at :data:`REFRESH_DEADLINE_S`; the rows not reached
        are verified at a later save or load. A movie or image row with a size
        but no pixel size is probed for it once (D13). The MD5 is never
        computed here: hashing a large file would make the save wait; it comes
        from the upload or the repair tool (D18). A failed probe stores
        nothing. Memoised per save: a file used by several cues is read once.
        """
        deadline = time.monotonic() + REFRESH_DEADLINE_S
        rows = {}
        values_cache = {}

        def row_of(file_name):
            if file_name not in rows:
                try:
                    media = Media.get(Media.unix_name == file_name)
                except DoesNotExist:
                    raise KeyError(file_name)
                media = self._verify_row_for_save(media, deadline)
                rows[file_name] = media
            return rows[file_name]

        def duration(file_name):
            return row_of(file_name).duration

        def stored_values(file_name):
            if file_name in values_cache:
                return values_cache[file_name]
            media = row_of(file_name)
            values = dimensions_of_row(media) or {}
            if ('pixel_width' not in values and 'file_size' in values
                    and media.media_type in ('MOVIE', 'IMAGE')):
                width, height = probe_dimensions(self._media_file_path(media))
                if width and height:
                    Media.update(pixel_width=width, pixel_height=height).where(
                        Media.uuid == media.uuid).execute()
                    values.update(pixel_width=width, pixel_height=height)
                    Logger.info(f'stored the pixel size of {file_name}: {width}x{height}')
            values_cache[file_name] = values or None
            return values_cache[file_name]

        return duration, stored_values

    def _verify_row_for_save(self, media, deadline):
        """Measure a row's file before a save writes its values, when the row
        has no stored size yet (legacy) or its file no longer matches it (a
        file replaced by hand): so a save writes the file's real values
        (plan §8.2.3). Not after the save's deadline, not for a missing or
        empty file, not for a file that failed before and has not changed.
        Returns the row as it is now."""
        if time.monotonic() >= deadline:
            return media
        check = self._check_media_file(media)
        if check is None or check.state not in ('legacy', 'changed'):
            return media
        try:
            self._verify_media_row(media, check.path, legacy=(check.state == 'legacy'))
        except Exception as e:
            Logger.error(f'could not verify {media.unix_name}: {type(e).__name__}: {e}')
            return media
        return Media.get_or_none(Media.uuid == media.uuid) or media

    def _media_file_path(self, media):
        """Absolute path of a media file, honouring its trash state."""
        if media.in_trash:
            return os.path.join(self.library_path, self.settings_dict['trash_folder_name'],
                                self.settings_dict['media_folder_name'], media.unix_name)
        return os.path.join(self.media_path, media.unix_name)

    CUE_TYPES = ['AudioCue', 'VideoCue', 'DmxCue', 'ActionCue', 'FadeCue', 'CueList']

    def _clean_dangling_targets(self, data):
        """Clear target and action_target references that point to non-existing cues.

        When a cue is deleted in the frontend, any ActionCue (or regular cue)
        still referencing it by UUID will have a dangling reference. This method
        collects all cue UUIDs and nullifies any reference that cannot be resolved.
        """
        try:
            cuelist = data.get('CuemsScript', {}).get('CueList', {})
            contents = cuelist.get('contents', [])
            all_ids = set()
            self._collect_cue_ids(contents, all_ids)
            self._nullify_dangling_refs(contents, all_ids)
        except Exception as e:
            Logger.warning(f"Could not clean dangling targets: {e}")

    def _collect_cue_ids(self, contents, ids):
        """Recursively collect all cue UUIDs from the project contents."""
        if not contents:
            return
        for item in contents:
            for cue_type in self.CUE_TYPES:
                if cue_type in item:
                    cue_data = item[cue_type]
                    cue_id = cue_data.get('id')
                    if cue_id:
                        ids.add(cue_id)
                    if cue_type == 'CueList':
                        self._collect_cue_ids(cue_data.get('contents', []), ids)

    def _nullify_dangling_refs(self, contents, valid_ids):
        """Recursively clear target/action_target refs that point to non-existing cues."""
        if not contents:
            return
        for item in contents:
            for cue_type in self.CUE_TYPES:
                if cue_type in item:
                    cue_data = item[cue_type]
                    if cue_type == 'CueList':
                        self._nullify_dangling_refs(cue_data.get('contents', []), valid_ids)
                        continue
                    target = cue_data.get('target')
                    if target and target not in valid_ids:
                        Logger.warning(f"{cue_type} {cue_data.get('id')} has dangling target {target}, clearing")
                        cue_data['target'] = None
                    if cue_type in ('ActionCue', 'FadeCue'):
                        action_target = cue_data.get('action_target')
                        if action_target and action_target not in valid_ids:
                            Logger.warning(f"{cue_type} {cue_data.get('id')} has dangling action_target {action_target}, clearing")
                            cue_data['action_target'] = None

    def new(self, data, unix_name):
        """Create a new project: allocate a UUID, write the XML, insert the DB row.

        The project directory ``<projects_path>/<unix_name>/`` and its
        ``cue_script.xml`` are created inside an atomic transaction.  The
        directory is removed on rollback.

        Args:
            data: ``CuemsScript`` dict from the frontend; ``id``, ``created``,
                and ``modified`` are assigned here and written back into
                *data* before parsing.
            unix_name: Raw directory name candidate supplied by the frontend;
                sanitised via ``StringSanitizer.sanitize_dir_permit_increment``
                before use.

        Returns:
            New project UUID string.

        Raises:
            IntegrityError: If ``name`` or ``unix_name`` already exists.
            KeyError: If ``data['CuemsScript']`` is missing required fields.
            Exception: Re-raises after rollback and directory cleanup.
        """
        try:
            unix_name = StringSanitizer.sanitize_dir_permit_increment(unix_name)
        except Exception as e:
            raise e

        try:
            project_uuid = str(new_uuid())
            data['CuemsScript']['id'] = project_uuid
            now = new_datetime()
            data['CuemsScript']['created'] = now
            data['CuemsScript']['modified'] = now
        except KeyError as e:
            Logger.error("error: Missing {} ;trying to make new  project, rolling back database insert".format(e))
            raise e
        except Exception as e:
            Logger.error("error: {} {} ;trying to read  project data".format(type(e), e))
            raise e

        # Same gates as update(): media cues first (nothing written for a
        # refused save), then the duration fill and the fade gate.
        validate_media_cues_in_contents(
            (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or [],
            media_exists=self._media_exists,
        )
        self._fix_media_durations(data)
        validate_fade_durations_in_contents(
            (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or []
        )

        with self.db.atomic() as transaction:
            try:
                project = Project.create(uuid=project_uuid, unix_name=unix_name, name=StringSanitizer.sanitize_name(data['CuemsScript']['name']), description=StringSanitizer.sanitize_text_size(data['CuemsScript']['description']), created=now, modified=now)
                os.mkdir(os.path.join(self.projects_path, unix_name))
                Logger.debug('data is now: {}'.format(data))
                project_object = CuemsParser(data).parse()
                Logger.debug(f'project_object is now: {type(project_object)},{project_object}')
                self.add_media_relations(project, project_object)
                self.save_xml(unix_name, project_object)
                return project_uuid
            except IntegrityError as e:
                transaction.rollback()
                Logger.error("error: {} {} ;name or unix_name already exists, rolling back database insert".format(type(e), e))
                raise e
            except Exception as e:
                transaction.rollback()
                Logger.error("error: {} {} ;trying to make new  project, rolling back database insert".format(type(e), e))

                if os.path.exists(os.path.join(self.projects_path, unix_name)):
                    shutil.rmtree(os.path.join(self.projects_path, unix_name))

                raise e

    def _is_name_available(self, unix_name, display_name):
        """Check that both unix_name and display_name are free in the DB
        (including trashed records, since unique constraints span all rows).
        """
        return not Project.select().where(
            (Project.unix_name == unix_name) | (Project.name == display_name)
        ).exists()

    def duplicate(self, uuid):
        """Duplicate a project: copy the directory and create a new DB record.

        The copy is named ``<original_name> - Copy``; a numeric suffix
        ``(N)`` is appended if the name is taken by another live or trashed
        record.  The duplicated ``cue_script.xml`` has its ``CuemsScript.id``
        and ``name`` updated to match the new DB record.

        Args:
            uuid: UUID of the project to duplicate; must be a live project.

        Returns:
            New project UUID string.

        Raises:
            NonExistentItemError: If the source project does not exist.
            Exception: Re-raises after rollback and copy cleanup.
        """
        try:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
            with self.db.atomic() as transaction:
                try:
                    new_unix_name = None
                    project_path = os.path.join(self.projects_path, project.unix_name)
                    base_unix = project.unix_name
                    base_display = project.name + ' - Copy'

                    # Find a name pair unique on both filesystem AND DB
                    # (trashed projects keep their DB records and unique constraints)
                    candidate_unix = base_unix
                    candidate_display = base_display
                    i = 0
                    while (os.path.exists(os.path.join(self.projects_path, candidate_unix))
                           or not self._is_name_available(candidate_unix, candidate_display)):
                        i += 1
                        candidate_unix = f"{base_unix}-{i:03d}"
                        candidate_display = f"{base_display} ({i})"

                    shutil.copytree(project_path, os.path.join(self.projects_path, candidate_unix),
                                    ignore=shutil.ignore_patterns(*_NOT_COPIED))
                    new_unix_name = candidate_unix

                    project.unix_name = new_unix_name
                    new_project_uuid = str(new_uuid())
                    project.uuid = new_project_uuid
                    project.name = candidate_display
                    project.modified = new_datetime()
                    project.save(force_insert=True)

                    dup_project = Project.get(Project.uuid == new_project_uuid)
                    data = self.load_xml(dup_project.unix_name)
                    data['CuemsScript']['id'] = new_project_uuid
                    data['CuemsScript']['name'] = project.name
                    data['CuemsScript']['modified'] = project.modified
                    # Fill durations and stored sizes from the DB, as a save
                    # does, so a legacy project's copy carries them (869fat84r).
                    self._fix_media_durations(data)
                    # NO fade-duration validation here on purpose: the source is
                    # load_xml of an existing project — legacy scripts must stay
                    # duplicable; the engine's reveal guard covers them.
                    project_object = CuemsParser(data).parse()
                    self.add_media_relations(dup_project, project_object)
                    self.save_xml(new_unix_name, project_object)
                    return new_project_uuid
                except Exception as e:
                    Logger.error("error: {} {}; trying to duplicate  project, rolling back database update".format(type(e), e))
                    transaction.rollback()
                    if new_unix_name is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                        raise e
                    if os.path.exists(os.path.join(self.projects_path, new_unix_name)):
                        shutil.rmtree(os.path.join(self.projects_path, new_unix_name))
                    raise e

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def export(self, uuid):
        tmp_project_path = None
        output_filename = None
        try:
            try:
                project = Project.get(Project.uuid==uuid)
            except DoesNotExist:
                raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

            unix_name = project.unix_name
            project_path = os.path.join(self.projects_path, unix_name, self.script_file_name)
            project_medias = ProjectMedia.select().where(ProjectMedia.project == project)
            tmp_project_path = os.path.join(self.tmp_path, unix_name)
            if not os.path.exists(tmp_project_path):
                os.makedirs(tmp_project_path)
            Logger.debug('exporting project {} to {}'.format(unix_name, tmp_project_path))
            shutil.copy(project_path, tmp_project_path)

            project_medias = prefetch(project_medias, Media)
            if project_medias:
                Logger.debug('project {} has media relations, exporting them'.format(unix_name))
                tmp_media_path = os.path.join(tmp_project_path, 'media')
                os.makedirs(tmp_media_path)
                for media in project_medias:
                    if media.media is None:
                        # A file deleted for good, still named by the project
                        # until it is re-uploaded (869fat84r D20).
                        Logger.warning(f'project {unix_name} uses {media.media_filename}, which '
                                       'is no longer in the library: not exported')
                        continue
                    media_path = os.path.join(self.media_path, media.media.unix_name)
                    try:
                        shutil.copy(media_path, tmp_media_path)
                        Logger.debug('copying media {} to {}'.format(media.media.unix_name, tmp_media_path))
                    except Exception as e:
                        Logger.error("error: {} {}; copying media to project export dir".format(type(e), e))
                        raise e
            else:
                Logger.debug('project {} has no media relations, skipping media export'.format(unix_name))

            shutil.make_archive(tmp_project_path, 'zip', self.tmp_path, unix_name)
            output_filename = unix_name + '.zip'
            server_export_path = os.path.join(self.settings_dict['html_root_path'], self.settings_dict['export_folder_name'])
            try:
                dest_filename = CopyMoveVersioned.move(os.path.join(self.tmp_path, output_filename), server_export_path, output_filename)
                return os.path.join(self.settings_dict['export_folder_name'], dest_filename)
            except Exception as e:
                Logger.error("error: {} {}; moving exported project to exports folder".format(type(e), e))
                raise e
        except Exception as e:
            Logger.error("error: {} {}; exporting project".format(type(e), e))
            raise e
        finally:
            if tmp_project_path is not None and os.path.exists(tmp_project_path):
                shutil.rmtree(tmp_project_path)
                Logger.debug('cleaning tmp project export folder: {}'.format(tmp_project_path))
            if output_filename is not None and os.path.exists(os.path.join(self.tmp_path, output_filename)):
                os.remove(os.path.join(self.tmp_path, output_filename))
                Logger.debug('cleaning tmp project export file: {}'.format(os.path.join(self.tmp_path, output_filename)))

    def delete(self, uuid):
        """Soft-delete a project by moving it to the trash directory.

        The project directory is moved via ``CopyMoveVersioned.move`` to
        ``<trash_path>/``.  The DB row's ``in_trash`` flag is set to ``True``
        atomically.  On any failure the directory is moved back and the
        transaction is rolled back.

        Args:
            uuid: UUID of the live project to trash.

        Raises:
            NonExistentItemError: If no live project with *uuid* exists.
            Exception: Re-raises after rollback and filesystem cleanup.
        """
        try:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
            with self.db.atomic() as transaction:
                try:
                    dest_filename = None
                    file_path = os.path.join(self.projects_path, project.unix_name)
                    dest_filename = CopyMoveVersioned.move(file_path, self.trash_path, project.unix_name)
                    project.in_trash = True
                    project.save()
                    Logger.debug('updating instance in db: {}'.format(project))
                except Exception as e:
                    Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    if dest_filename is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                        raise e
                    if os.path.exists(os.path.join(self.trash_path, dest_filename)):
                        shutil.move(os.path.join(self.trash_path, dest_filename), os.path.join(self.projects_path, project.unix_name))
                    raise e

        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def restore(self, uuid):
        """Restore a trashed project back to the active projects directory.

        Moves the directory from ``<trash_path>/`` to ``<projects_path>/``
        via ``CopyMoveVersioned.move``, which handles name collisions by
        appending a numeric suffix.  The DB record's ``unix_name`` is updated
        to the actual destination name and ``in_trash`` is set to ``False``.

        Args:
            uuid: UUID of the trashed project to restore.

        Raises:
            NonExistentItemError: If no trashed project with *uuid* exists.
            Exception: Re-raises after rollback and filesystem cleanup.
        """
        try:
            project_trash = Project.get((Project.uuid == uuid) & (Project.in_trash == True))

            with self.db.atomic() as transaction:
                try:
                    dest_filename = None
                    project_path = os.path.join(self.trash_path, project_trash.unix_name)
                    dest_filename = CopyMoveVersioned.move(project_path, self.projects_path, project_trash.unix_name)
                    project_trash.unix_name = dest_filename
                    project_trash.in_trash = False
                    project_trash.save()
                    Logger.debug('updating instance in db: {}'.format(project_trash))
                except Exception as e:
                    Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    if dest_filename is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                        raise e
                    if os.path.exists(os.path.join(self.projects_path, dest_filename)):
                        shutil.move(os.path.join(self.projects_path, dest_filename), os.path.join(self.trash_path, project_path.unix_name))
                    raise e
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    def delete_from_trash(self, uuid):
        """Permanently delete a trashed project: remove the DB record and directory.

        Calls ``project.delete_instance(recursive=True)`` to cascade-delete
        ``ProjectMedia`` join rows, then removes the project directory tree
        with ``shutil.rmtree``.

        Args:
            uuid: UUID of a project that is currently in the trash.

        Raises:
            NonExistentItemError: If no trashed project with *uuid* exists.
            Exception: Re-raises after rolling back the transaction (directory
                may already be gone at that point).
        """
        try:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == True))

            with self.db.atomic() as transaction:
                try:
                    project_path = os.path.join(self.trash_path, project.unix_name)
                    project.delete_instance(recursive=True)
                    shutil.rmtree(project_path)  # non empty dir, must use rmtree
                    Logger.debug('deleting project from trash: {}'.format(project))
                except Exception as e:
                    Logger.error("error: {} {}; trying to delete project to trash, rolling back database".format(type(e), e))
                    transaction.rollback()
                    raise e
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    # --- A media file that changed after its values were stored (D20) -------

    def _script_path(self, unix_name):
        return os.path.join(self.projects_path, unix_name, self.script_file_name)

    def plan_media_refresh(self, project_uuid):
        """Step 1 of the load-time check: read and compare, write nothing.

        Reads the project file without the schema, finds each file it uses,
        compares the file with its row (size; and the ``.idx`` header of a
        video the sync carries), and lists the values present in the project
        that no longer match their rows (:func:`stale_by_file`).

        Raises:
            NonExistentItemError: no live project with *project_uuid*.
        """
        try:
            project = Project.get((Project.uuid == project_uuid) & (Project.in_trash == False))
        except DoesNotExist:
            raise NonExistentItemError("item with uuid: {} does not exist".format(project_uuid))
        plan = RefreshPlan(project_uuid, project.unix_name, project.name)
        blocks = _light_media_blocks(self._script_path(project.unix_name))
        rows = _media_rows(blocks)
        plan.stale = stale_by_file(blocks, rows)
        for row in rows.values():
            if row is not None:     # an orphan is skipped; the node's check covers it
                check = self._check_media_file(row)
                if check is not None:
                    plan.checks.append(check)
        return plan

    def _check_media_file(self, row):
        path = self._media_file_path(row)
        try:
            st = os.stat(path)
        except OSError:
            return None             # missing on the controller: the node's check covers it
        if st.st_size <= 0:
            return None
        key = (path, st.st_size, st.st_mtime_ns)
        check = MediaFileCheck(row.unix_name, path, row.media_type, st.st_size, st.st_mtime_ns)
        if row.media_type == 'MOVIE' and not row.in_trash and is_indexed_video(row.unix_name):
            check.index = video_index_state(path)
        stored = _positive_int(row.file_size)
        if key in _FAILED_MARKS:
            check.state = 'skipped'
        elif stored is None:
            check.state = 'legacy'
        elif stored != st.st_size:
            check.state = 'changed'
        elif check.index == INDEX_STALE and key not in _VERIFIED_MARKS:
            check.state = 'changed'     # same size, but not the file the index was made for
        check.needs_index = (check.index is not None and check.index != INDEX_VALID
                             and check.state != 'skipped' and key not in _INDEX_FAILED_MARKS)
        return check

    def verify_media_files(self, plan, deadline, clock=time.monotonic):
        """Step 2: re-probe each changed or legacy file and correct its row.

        Stops at *deadline* (a *clock* value); the files not reached go first
        at the next load. One file that fails is logged and skipped.

        Returns:
            ``dict`` of file-name lists: ``verified``, ``changed``,
            ``legacy``, ``failed``, ``in_copy``, ``not_reached``.
        """
        report = {k: [] for k in ('verified', 'changed', 'legacy', 'failed',
                                  'in_copy', 'not_reached')}
        first = _NOT_REACHED.get(plan.project_uuid, [])
        todo = sorted(plan.to_verify, key=lambda c: c.file_name not in first)
        for check in todo:
            if clock() >= deadline:
                report['not_reached'].append(check.file_name)
                continue
            row = Media.get_or_none(Media.unix_name == check.file_name)
            if row is None:
                continue
            try:
                self._verify_media_row(row, check.path, legacy=(check.state == 'legacy'),
                                       report=report)
            except Exception as e:
                report['failed'].append(check.file_name)
                Logger.error(f'could not verify {check.file_name}: {type(e).__name__}: {e}')
        if report['not_reached']:
            _NOT_REACHED[plan.project_uuid] = report['not_reached']
            Logger.warning(f'project {plan.unix_name}: {len(report["not_reached"])} media '
                           f'file(s) not checked before the {REFRESH_DEADLINE_S} s limit, '
                           f'left for the next load: {report["not_reached"]}')
        else:
            _NOT_REACHED.pop(plan.project_uuid, None)
        return report

    def _verify_media_row(self, row, path, legacy, report=None):
        """Probe *path* once and make *row* match it. Returns ``True`` when the
        row was written.

        stat, probe, stat again: a file still being copied is left alone. The
        values are written in one statement with ``file_size`` (the "checked"
        mark) in it, and only when the duration was read: a failed duration
        probe leaves the row untouched, so the next load tries again (after
        the file changes or the editor restarts). The pixel size is only an
        arm-time saving: when it cannot be read the engine probes it, so it
        never holds the row back. A changed file's MD5 is cleared (the repair
        tool refills it); hashing here would cost seconds.
        """
        started = time.monotonic()
        name = row.unix_name
        report = report if report is not None else {}
        try:
            st1 = os.stat(path)
            result = probe_media(path)
            st2 = os.stat(path)
        except OSError as e:
            Logger.warning(f'could not check {name}: {e}')
            return False
        if (st1.st_size, st1.st_mtime_ns) != (st2.st_size, st2.st_mtime_ns):
            report.setdefault('in_copy', []).append(name)
            Logger.warning(f'{name} changed while it was being checked (still being '
                           'copied?); left for the next load')
            return False
        key = (path, st1.st_size, st1.st_mtime_ns)
        update = {}
        old_duration = row.duration
        if row.media_type in ('MOVIE', 'AUDIO'):
            if result.duration_state != PROBE_OK:
                _FAILED_MARKS.add(key)
                report.setdefault('failed', []).append(name)
                Logger.error(f'could not read the duration of {name} '
                             f'({result.duration_state}): its stored values are kept '
                             f'(duration {old_duration}); it is tried again when the file '
                             'changes or the editor restarts')
                return False
            if str(result.duration) != (old_duration or ''):
                update['duration'] = str(result.duration)
        if row.media_type in ('MOVIE', 'IMAGE'):
            if result.picture_state == PROBE_OK:
                if (row.pixel_width, row.pixel_height) != (result.width, result.height):
                    update.update(pixel_width=result.width, pixel_height=result.height)
            else:
                if result.picture_state == PROBE_FAILED:
                    Logger.warning(f'could not read the pixel size of {name}: the engine '
                                   'probes it when it arms the cue')
                if not legacy and (row.pixel_width or row.pixel_height):
                    update.update(pixel_width=None, pixel_height=None)
        if not legacy and row.file_md5:
            update['file_md5'] = None
        update['file_size'] = st1.st_size
        Media.update(**update).where(Media.uuid == row.uuid).execute()
        _VERIFIED_MARKS.add(key)
        report.setdefault('verified', []).append(name)
        report.setdefault('legacy' if legacy else 'changed', []).append(name)
        ms = int((time.monotonic() - started) * 1000)
        new_duration = update.get('duration', old_duration)
        if not legacy:
            Logger.warning(f'media file {name} changed after its values were stored: size '
                           f'{row.file_size} -> {st1.st_size}, duration {old_duration} -> '
                           f'{new_duration}; values corrected ({ms} ms)')
        elif 'duration' in update:
            Logger.warning(f'media file {name}: stored duration {old_duration} corrected to '
                           f'{new_duration} (editors before 2026-07-06 stored some durations '
                           f'up to 0.9 s short) ({ms} ms)')
        else:
            Logger.info(f'media file {name}: stored values verified ({ms} ms)')
        return True

    def media_report(self, project_uuid, context, reason=None):
        """The value of a ``media_check_report`` frame (plan §8.3), read-only.

        ``files``: the stale values the project holds, by file, capped at
        :data:`REPORT_FILES_MAX` (``total_files`` counts them all).
        ``unverified``: files whose size or index no longer matches their row
        and that have not been measured yet. ``complete`` is false when part
        of the check did not run (*reason*, or a file whose probe failed
        before): the UI must not take that report as an all-clear.
        """
        plan = self.plan_media_refresh(project_uuid)
        if reason is None and any(c.state == 'skipped' for c in plan.checks):
            reason = 'probe_failed'
        files, total = cap_report_files(plan.stale)
        return {
            'project_uuid': str(project_uuid),
            'project_name': plan.project_name,
            'context': context,
            'complete': reason is None,
            'reason': reason,
            'files': files,
            'unverified': sorted(c.file_name for c in plan.checks if c.state == 'changed'),
            'total_files': total,
        }

    def add_media_relations(self, project, project_object):
        """Create ``ProjectMedia`` join rows for all media referenced by *project_object*.

        Args:
            project: ``Project`` ORM instance.
            project_object: ``CuemsScript`` object whose ``get_media_filenames()``
                returns the set of ``unix_name`` strings to link.
        """
        media_filenames_list = project_object.get_media_filenames()
        for media_name in media_filenames_list:
            media = Media.get(Media.unix_name == media_name)
            ProjectMedia.create(project=project, media=media, media_filename=media_name)

    def update_media_relations(self, project, project_object):
        """Diff and sync ``ProjectMedia`` join rows after a project save.

        Computes the symmetric difference between the old and new media sets,
        then removes stale ``ProjectMedia`` rows and creates new ones.

        Args:
            project: ``Project`` ORM instance being saved.
            project_object: ``CuemsScript`` object reflecting the new cue
                tree after the save.
        """
        Logger.debug('updating media relations for project: {}'.format(project.unix_name))
        old_media_query = project.medias()
        old_media_dict = dict()
        Logger.debug('query done')
        for media in old_media_query:
            old_media_dict[media.unix_name] = str(media.uuid)
        old_media_list = list(old_media_dict.keys())
        Logger.debug('old media list: {}'.format(old_media_list))
        media_list = project_object.get_media_filenames()
        Logger.debug('media list: {}'.format(media_list))

        remove_set = set(old_media_list).difference(media_list)
        add_set = set(media_list).difference(old_media_list)

        Logger.debug('media remove list: {}'.format(remove_set))
        Logger.debug('media add list: {}'.format(add_set))

        if remove_set:
            for media_unix_name in remove_set:
                ProjectMedia.delete().where((ProjectMedia.project == project) & (ProjectMedia.media == old_media_dict[media_unix_name])).execute()

        if add_set:
            for media_unix_name in add_set:
                media = Media.select(Media.uuid).where(Media.unix_name == media_unix_name).get()
                ProjectMedia.create(project=project, media=media, media_filename=media_unix_name)

    def update_projects_existed_media(self, project_uuid, media_filename):
        """Re-link a re-uploaded media file to the projects that referenced it by filename.

        Called after ``CuemsUpload`` detects that *media_filename* was already
        referenced by one or more projects.  Reads the XML, finds matching
        cues, and reconciles the stored media UUID with what is now in the DB.

        Args:
            project_uuid: UUID of the project to update.
            media_filename: ``unix_name`` of the re-uploaded media file.
        """
        # A trashed project lives in the trash, not where load() reads; it is
        # relinked when it is restored and saved (869fat84r D20, Q2).
        try:
            project = Project.get(Project.uuid == project_uuid)
        except DoesNotExist:
            Logger.warning(f'no project {project_uuid} to relink {media_filename} in')
            return
        if project.in_trash:
            Logger.info(f'project {project.unix_name} is in the trash: not relinking {media_filename}')
            return
        # Relink only (D21): the re-uploaded row has fresh values; the project
        # still holds the old file's, reported at its next load or open and
        # written by its next save.
        self._relink_existed_media(project, media_filename)

    def _relink_existed_media(self, project, media_filename):
        project_uuid = str(project.uuid)
        project_object = CuemsParser(self.load(project_uuid)).parse()
        media_dict = project_object.get_media()
        matching_media_dict = dict()
        for cue_uuid, media_object in media_dict.items():
            for media_uiid, project_media_filename in media_object.items():
                if media_filename == project_media_filename:
                    matching_media_dict[cue_uuid] = media_object

        if matching_media_dict:
            Logger.debug('found cues with media filename: {} in project: {}'.format(media_filename, project_uuid))
            first_media_object = next(iter(matching_media_dict.values()))
            old_media_uuid = next(iter(first_media_object.keys()))
            # TODO: manage if  all media with same filename have the same uuid  SHOULD BE TRUE
            for cue_uuid, media in matching_media_dict.items():
                for media_uuid, media_filename in media.items():
                    if media_uuid != old_media_uuid:
                        Logger.warning('found different media uuid for same media filename: {} in project {},  cue {}, using first found: {}'.format(media_filename, project_uuid, cue_uuid, old_media_uuid))
            try:
                self.update_existed_media_uuid(media_filename, old_media_uuid)
            except IntegrityError:
                Logger.warning('error updating media uuid for media filename: {} from project: {}. Media uuid inconsistency detected'.format(media_filename, project_uuid))
            self.deletele_mising_media_references(media_filename)
            self.update_media_relations(project, project_object)
        else:
            Logger.warning('no cues found for media filename: {}'.format(media_filename))

    def update_existed_media_uuid(self, media_filename, old_media_uuid):
        """Overwrite the UUID of a ``Media`` row to match the value recorded in project XML.

        Used when a re-uploaded file produces a new UUID in the DB that differs
        from the UUID stored inside existing project XML files.

        Args:
            media_filename: ``unix_name`` identifying the ``Media`` row.
            old_media_uuid: The UUID string to assign (taken from the XML).

        Raises:
            NonExistentItemError: If no ``Media`` row has *media_filename*.
        """
        Logger.debug('updating media uuid for media filename: {} with old uuid: {}'.format(media_filename, old_media_uuid))
        try:
            media = Media.get(Media.unix_name == media_filename)
            with self.db.atomic() as transaction:
                try:
                    Media.update(uuid=old_media_uuid).where(Media.unix_name == media_filename).execute()
                except Exception as e:
                    Logger.error("error: {} {}; trying to update media uuid, rolling back database update".format(type(e), e))
                    transaction.rollback()
                    raise e
        except DoesNotExist:
            raise NonExistentItemError("item with unix_name: {} does not exist".format(media_filename))

    def deletele_mising_media_references(self, media_filename):
        """Delete ``ProjectMedia`` rows whose ``media_id`` FK is NULL for *media_filename*.

        Cleans up dangling join rows left when a ``Media`` record was replaced
        by a re-upload.

        Args:
            media_filename: ``unix_name`` to filter on.
        """
        Logger.debug('deleting missing media references for media filename: {}'.format(media_filename))
        # Parenthesised: Python's ``&`` binds tighter than ``==``. (It was
        # ``and``, which deleted every dangling row of every file.)
        missing_media_project_refs = ProjectMedia.delete().where(
            (ProjectMedia.media_filename == media_filename) & (ProjectMedia.media.is_null())
        ).execute()

    def save_xml(self, unix_name, project_object):
        """Write *project_object* to ``<projects_path>/<unix_name>/cue_script.xml``.

        Validates against ``script.xsd`` before writing; raises if the
        ``CuemsScript`` object fails schema validation.

        The file is replaced atomically (869fat84r D20): written to a
        temporary file in the same directory, given the old file's mode (the
        nodes' rsync reads it as ``nobody``), then renamed over it, so a power
        cut never leaves a truncated project. A directory the editor cannot
        create files in falls back to writing in place, as before.

        Args:
            unix_name: Project directory name.
            project_object: ``CuemsScript`` instance to serialize.
        """
        final = self._script_path(unix_name)
        directory = os.path.dirname(final)
        tmp = os.path.join(directory, f'.{self.script_file_name}.{os.getpid()}.{os.urandom(4).hex()}.tmp')
        try:
            open(tmp, 'xb').close()
        except OSError as e:
            Logger.warning(f'cannot create a temporary file in {directory} ({e}); writing '
                           f'{final} in place')
            XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=final).write_from_object(project_object)
            return
        try:
            XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=tmp).write_from_object(project_object)
            if os.path.exists(final):
                shutil.copymode(final, tmp)
            os.replace(tmp, final)
        except BaseException:
            with contextlib.suppress(OSError):
                os.remove(tmp)
            raise

    def load_xml(self, unix_name):
        """Read and parse ``<projects_path>/<unix_name>/cue_script.xml``.

        Args:
            unix_name: Project directory name.

        Returns:
            ``CuemsScript`` dict produced by ``XmlReaderWriter.read()``.
        """
        reader = XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=(os.path.join(self.projects_path, unix_name, self.script_file_name)))
        return reader.read()

__init__(settings_dict, db_connection)

Initialise paths from settings_dict.

Parameters:

Name Type Description Default
settings_dict

Mapping consumed from settings.xml. Required keys: tmp_path, library_path, script_file_name, script_schema_name, project_folder_name, trash_folder_name, media_folder_name.

required
db_connection

The shared Peewee SqliteDatabase instance owned by CuemsDBManager.

required

Raises:

Type Description
KeyError

If any required key is missing from settings_dict.

Source code in src/cuemseditor/CuemsDBProject.py
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def __init__(self, settings_dict, db_connection):
    """Initialise paths from *settings_dict*.

    Args:
        settings_dict: Mapping consumed from ``settings.xml``.  Required
            keys: ``tmp_path``, ``library_path``, ``script_file_name``,
            ``script_schema_name``, ``project_folder_name``,
            ``trash_folder_name``, ``media_folder_name``.
        db_connection: The shared Peewee ``SqliteDatabase`` instance owned
            by ``CuemsDBManager``.

    Raises:
        KeyError: If any required key is missing from *settings_dict*.
    """
    self.db = db_connection
    self.settings_dict = settings_dict
    try:
        self.tmp_path = self.settings_dict['tmp_path']
        self.library_path = settings_dict['library_path']
        self.script_file_name = settings_dict['script_file_name']
        self.script_schema_name = settings_dict['script_schema_name']
        self.projects_path = os.path.join(self.library_path, settings_dict['project_folder_name'])
        self.trash_path = os.path.join(self.library_path, settings_dict['trash_folder_name'], settings_dict['project_folder_name'])
        self.media_path = os.path.join(self.library_path, settings_dict['media_folder_name'])
    except KeyError as e:
        Logger.error(f'can not read settings {e}')
        raise e

add_media_relations(project, project_object)

Create ProjectMedia join rows for all media referenced by project_object.

Parameters:

Name Type Description Default
project

Project ORM instance.

required
project_object

CuemsScript object whose get_media_filenames() returns the set of unix_name strings to link.

required
Source code in src/cuemseditor/CuemsDBProject.py
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
def add_media_relations(self, project, project_object):
    """Create ``ProjectMedia`` join rows for all media referenced by *project_object*.

    Args:
        project: ``Project`` ORM instance.
        project_object: ``CuemsScript`` object whose ``get_media_filenames()``
            returns the set of ``unix_name`` strings to link.
    """
    media_filenames_list = project_object.get_media_filenames()
    for media_name in media_filenames_list:
        media = Media.get(Media.unix_name == media_name)
        ProjectMedia.create(project=project, media=media, media_filename=media_name)

delete(uuid)

Soft-delete a project by moving it to the trash directory.

The project directory is moved via CopyMoveVersioned.move to <trash_path>/. The DB row's in_trash flag is set to True atomically. On any failure the directory is moved back and the transaction is rolled back.

Parameters:

Name Type Description Default
uuid

UUID of the live project to trash.

required

Raises:

Type Description
NonExistentItemError

If no live project with uuid exists.

Exception

Re-raises after rollback and filesystem cleanup.

Source code in src/cuemseditor/CuemsDBProject.py
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
def delete(self, uuid):
    """Soft-delete a project by moving it to the trash directory.

    The project directory is moved via ``CopyMoveVersioned.move`` to
    ``<trash_path>/``.  The DB row's ``in_trash`` flag is set to ``True``
    atomically.  On any failure the directory is moved back and the
    transaction is rolled back.

    Args:
        uuid: UUID of the live project to trash.

    Raises:
        NonExistentItemError: If no live project with *uuid* exists.
        Exception: Re-raises after rollback and filesystem cleanup.
    """
    try:
        project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
        with self.db.atomic() as transaction:
            try:
                dest_filename = None
                file_path = os.path.join(self.projects_path, project.unix_name)
                dest_filename = CopyMoveVersioned.move(file_path, self.trash_path, project.unix_name)
                project.in_trash = True
                project.save()
                Logger.debug('updating instance in db: {}'.format(project))
            except Exception as e:
                Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                transaction.rollback()
                if dest_filename is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                    raise e
                if os.path.exists(os.path.join(self.trash_path, dest_filename)):
                    shutil.move(os.path.join(self.trash_path, dest_filename), os.path.join(self.projects_path, project.unix_name))
                raise e

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

delete_from_trash(uuid)

Permanently delete a trashed project: remove the DB record and directory.

Calls project.delete_instance(recursive=True) to cascade-delete ProjectMedia join rows, then removes the project directory tree with shutil.rmtree.

Parameters:

Name Type Description Default
uuid

UUID of a project that is currently in the trash.

required

Raises:

Type Description
NonExistentItemError

If no trashed project with uuid exists.

Exception

Re-raises after rolling back the transaction (directory may already be gone at that point).

Source code in src/cuemseditor/CuemsDBProject.py
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
def delete_from_trash(self, uuid):
    """Permanently delete a trashed project: remove the DB record and directory.

    Calls ``project.delete_instance(recursive=True)`` to cascade-delete
    ``ProjectMedia`` join rows, then removes the project directory tree
    with ``shutil.rmtree``.

    Args:
        uuid: UUID of a project that is currently in the trash.

    Raises:
        NonExistentItemError: If no trashed project with *uuid* exists.
        Exception: Re-raises after rolling back the transaction (directory
            may already be gone at that point).
    """
    try:
        project = Project.get((Project.uuid == uuid) & (Project.in_trash == True))

        with self.db.atomic() as transaction:
            try:
                project_path = os.path.join(self.trash_path, project.unix_name)
                project.delete_instance(recursive=True)
                shutil.rmtree(project_path)  # non empty dir, must use rmtree
                Logger.debug('deleting project from trash: {}'.format(project))
            except Exception as e:
                Logger.error("error: {} {}; trying to delete project to trash, rolling back database".format(type(e), e))
                transaction.rollback()
                raise e
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

deletele_mising_media_references(media_filename)

Delete ProjectMedia rows whose media_id FK is NULL for media_filename.

Cleans up dangling join rows left when a Media record was replaced by a re-upload.

Parameters:

Name Type Description Default
media_filename

unix_name to filter on.

required
Source code in src/cuemseditor/CuemsDBProject.py
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
def deletele_mising_media_references(self, media_filename):
    """Delete ``ProjectMedia`` rows whose ``media_id`` FK is NULL for *media_filename*.

    Cleans up dangling join rows left when a ``Media`` record was replaced
    by a re-upload.

    Args:
        media_filename: ``unix_name`` to filter on.
    """
    Logger.debug('deleting missing media references for media filename: {}'.format(media_filename))
    # Parenthesised: Python's ``&`` binds tighter than ``==``. (It was
    # ``and``, which deleted every dangling row of every file.)
    missing_media_project_refs = ProjectMedia.delete().where(
        (ProjectMedia.media_filename == media_filename) & (ProjectMedia.media.is_null())
    ).execute()

duplicate(uuid)

Duplicate a project: copy the directory and create a new DB record.

The copy is named <original_name> - Copy; a numeric suffix (N) is appended if the name is taken by another live or trashed record. The duplicated cue_script.xml has its CuemsScript.id and name updated to match the new DB record.

Parameters:

Name Type Description Default
uuid

UUID of the project to duplicate; must be a live project.

required

Returns:

Type Description

New project UUID string.

Raises:

Type Description
NonExistentItemError

If the source project does not exist.

Exception

Re-raises after rollback and copy cleanup.

Source code in src/cuemseditor/CuemsDBProject.py
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
def duplicate(self, uuid):
    """Duplicate a project: copy the directory and create a new DB record.

    The copy is named ``<original_name> - Copy``; a numeric suffix
    ``(N)`` is appended if the name is taken by another live or trashed
    record.  The duplicated ``cue_script.xml`` has its ``CuemsScript.id``
    and ``name`` updated to match the new DB record.

    Args:
        uuid: UUID of the project to duplicate; must be a live project.

    Returns:
        New project UUID string.

    Raises:
        NonExistentItemError: If the source project does not exist.
        Exception: Re-raises after rollback and copy cleanup.
    """
    try:
        project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
        with self.db.atomic() as transaction:
            try:
                new_unix_name = None
                project_path = os.path.join(self.projects_path, project.unix_name)
                base_unix = project.unix_name
                base_display = project.name + ' - Copy'

                # Find a name pair unique on both filesystem AND DB
                # (trashed projects keep their DB records and unique constraints)
                candidate_unix = base_unix
                candidate_display = base_display
                i = 0
                while (os.path.exists(os.path.join(self.projects_path, candidate_unix))
                       or not self._is_name_available(candidate_unix, candidate_display)):
                    i += 1
                    candidate_unix = f"{base_unix}-{i:03d}"
                    candidate_display = f"{base_display} ({i})"

                shutil.copytree(project_path, os.path.join(self.projects_path, candidate_unix),
                                ignore=shutil.ignore_patterns(*_NOT_COPIED))
                new_unix_name = candidate_unix

                project.unix_name = new_unix_name
                new_project_uuid = str(new_uuid())
                project.uuid = new_project_uuid
                project.name = candidate_display
                project.modified = new_datetime()
                project.save(force_insert=True)

                dup_project = Project.get(Project.uuid == new_project_uuid)
                data = self.load_xml(dup_project.unix_name)
                data['CuemsScript']['id'] = new_project_uuid
                data['CuemsScript']['name'] = project.name
                data['CuemsScript']['modified'] = project.modified
                # Fill durations and stored sizes from the DB, as a save
                # does, so a legacy project's copy carries them (869fat84r).
                self._fix_media_durations(data)
                # NO fade-duration validation here on purpose: the source is
                # load_xml of an existing project — legacy scripts must stay
                # duplicable; the engine's reveal guard covers them.
                project_object = CuemsParser(data).parse()
                self.add_media_relations(dup_project, project_object)
                self.save_xml(new_unix_name, project_object)
                return new_project_uuid
            except Exception as e:
                Logger.error("error: {} {}; trying to duplicate  project, rolling back database update".format(type(e), e))
                transaction.rollback()
                if new_unix_name is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                    raise e
                if os.path.exists(os.path.join(self.projects_path, new_unix_name)):
                    shutil.rmtree(os.path.join(self.projects_path, new_unix_name))
                raise e

    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

get_project_unix_name(uuid)

Return the filesystem directory name for a live (non-trashed) project.

Parameters:

Name Type Description Default
uuid

Project UUID string.

required

Returns:

Type Description

unix_name value from the Project DB record.

Raises:

Type Description
NonExistentItemError

If no live project with uuid exists.

Source code in src/cuemseditor/CuemsDBProject.py
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
def get_project_unix_name(self, uuid):
    """Return the filesystem directory name for a live (non-trashed) project.

    Args:
        uuid: Project UUID string.

    Returns:
        ``unix_name`` value from the ``Project`` DB record.

    Raises:
        NonExistentItemError: If no live project with *uuid* exists.
    """
    try:
        project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
        return project.unix_name
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

list()

Return a list of all live (non-trashed) projects, newest first.

Returns:

Type Description

List of single-key dicts ``{uuid_str: {name, unix_name,

description, created, modified}}, ordered bycreated``

descending.

Example

projects = db_project.list() for entry in projects: ... uuid, meta = next(iter(entry.items())) ... print(uuid, meta['name'])

Source code in src/cuemseditor/CuemsDBProject.py
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
def list(self):
    """Return a list of all live (non-trashed) projects, newest first.

    Returns:
        List of single-key dicts ``{uuid_str: {name, unix_name,
        description, created, modified}}``, ordered by ``created``
        descending.

    Example:
        >>> projects = db_project.list()
        >>> for entry in projects:
        ...     uuid, meta = next(iter(entry.items()))
        ...     print(uuid, meta['name'])
    """
    project_list = list()
    projects = Project.select().where(Project.in_trash == False).order_by(Project.created.desc())
    for project in projects:
        project_dict = {str(project.uuid): {'name': project.name, 'unix_name': project.unix_name, 'description': project.description, 'created': project.created, 'modified': project.modified}}
        project_list.append(project_dict)

    return project_list

list_trash()

Return a list of all trashed projects, newest first.

Returns:

Type Description

Same shape as :meth:list but for projects where

in_trash == True.

Source code in src/cuemseditor/CuemsDBProject.py
687
688
689
690
691
692
693
694
695
696
697
698
699
700
def list_trash(self):
    """Return a list of all trashed projects, newest first.

    Returns:
        Same shape as :meth:`list` but for projects where
        ``in_trash == True``.
    """
    project_trash_list = list()
    projects_trash = Project.select().where(Project.in_trash == True).order_by(Project.created.desc())
    for project in projects_trash:
        project_dict = {str(project.uuid): {'name': project.name, 'unix_name': project.unix_name, 'description': project.description, 'created': project.created, 'modified': project.modified}}
        project_trash_list.append(project_dict)

    return project_trash_list

load(uuid, include_trash=False)

Load a project's CuemsScript dict from its XML file on disk.

Parameters:

Name Type Description Default
uuid

Project UUID string.

required
include_trash

When True, also searches trashed projects. Defaults to False.

False

Returns:

Type Description

dict produced by XmlReaderWriter.read() — the parsed

CuemsScript structure.

Raises:

Type Description
NonExistentItemError

If no project with uuid exists (or the project is in the trash and include_trash is False).

Source code in src/cuemseditor/CuemsDBProject.py
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
def load(self, uuid, include_trash=False):
    """Load a project's ``CuemsScript`` dict from its XML file on disk.

    Args:
        uuid: Project UUID string.
        include_trash: When ``True``, also searches trashed projects.
            Defaults to ``False``.

    Returns:
        ``dict`` produced by ``XmlReaderWriter.read()`` — the parsed
        ``CuemsScript`` structure.

    Raises:
        NonExistentItemError: If no project with *uuid* exists (or the
            project is in the trash and *include_trash* is ``False``).
    """
    try:
        if not include_trash:
            project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
        else:
            project = Project.get(Project.uuid == uuid)
        return self.load_xml(project.unix_name)
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

load_xml(unix_name)

Read and parse <projects_path>/<unix_name>/cue_script.xml.

Parameters:

Name Type Description Default
unix_name

Project directory name.

required

Returns:

Type Description

CuemsScript dict produced by XmlReaderWriter.read().

Source code in src/cuemseditor/CuemsDBProject.py
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
def load_xml(self, unix_name):
    """Read and parse ``<projects_path>/<unix_name>/cue_script.xml``.

    Args:
        unix_name: Project directory name.

    Returns:
        ``CuemsScript`` dict produced by ``XmlReaderWriter.read()``.
    """
    reader = XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=(os.path.join(self.projects_path, unix_name, self.script_file_name)))
    return reader.read()

media_report(project_uuid, context, reason=None)

The value of a media_check_report frame (plan §8.3), read-only.

files: the stale values the project holds, by file, capped at :data:REPORT_FILES_MAX (total_files counts them all). unverified: files whose size or index no longer matches their row and that have not been measured yet. complete is false when part of the check did not run (reason, or a file whose probe failed before): the UI must not take that report as an all-clear.

Source code in src/cuemseditor/CuemsDBProject.py
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
def media_report(self, project_uuid, context, reason=None):
    """The value of a ``media_check_report`` frame (plan §8.3), read-only.

    ``files``: the stale values the project holds, by file, capped at
    :data:`REPORT_FILES_MAX` (``total_files`` counts them all).
    ``unverified``: files whose size or index no longer matches their row
    and that have not been measured yet. ``complete`` is false when part
    of the check did not run (*reason*, or a file whose probe failed
    before): the UI must not take that report as an all-clear.
    """
    plan = self.plan_media_refresh(project_uuid)
    if reason is None and any(c.state == 'skipped' for c in plan.checks):
        reason = 'probe_failed'
    files, total = cap_report_files(plan.stale)
    return {
        'project_uuid': str(project_uuid),
        'project_name': plan.project_name,
        'context': context,
        'complete': reason is None,
        'reason': reason,
        'files': files,
        'unverified': sorted(c.file_name for c in plan.checks if c.state == 'changed'),
        'total_files': total,
    }

new(data, unix_name)

Create a new project: allocate a UUID, write the XML, insert the DB row.

The project directory <projects_path>/<unix_name>/ and its cue_script.xml are created inside an atomic transaction. The directory is removed on rollback.

Parameters:

Name Type Description Default
data

CuemsScript dict from the frontend; id, created, and modified are assigned here and written back into data before parsing.

required
unix_name

Raw directory name candidate supplied by the frontend; sanitised via StringSanitizer.sanitize_dir_permit_increment before use.

required

Returns:

Type Description

New project UUID string.

Raises:

Type Description
IntegrityError

If name or unix_name already exists.

KeyError

If data['CuemsScript'] is missing required fields.

Exception

Re-raises after rollback and directory cleanup.

Source code in src/cuemseditor/CuemsDBProject.py
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
def new(self, data, unix_name):
    """Create a new project: allocate a UUID, write the XML, insert the DB row.

    The project directory ``<projects_path>/<unix_name>/`` and its
    ``cue_script.xml`` are created inside an atomic transaction.  The
    directory is removed on rollback.

    Args:
        data: ``CuemsScript`` dict from the frontend; ``id``, ``created``,
            and ``modified`` are assigned here and written back into
            *data* before parsing.
        unix_name: Raw directory name candidate supplied by the frontend;
            sanitised via ``StringSanitizer.sanitize_dir_permit_increment``
            before use.

    Returns:
        New project UUID string.

    Raises:
        IntegrityError: If ``name`` or ``unix_name`` already exists.
        KeyError: If ``data['CuemsScript']`` is missing required fields.
        Exception: Re-raises after rollback and directory cleanup.
    """
    try:
        unix_name = StringSanitizer.sanitize_dir_permit_increment(unix_name)
    except Exception as e:
        raise e

    try:
        project_uuid = str(new_uuid())
        data['CuemsScript']['id'] = project_uuid
        now = new_datetime()
        data['CuemsScript']['created'] = now
        data['CuemsScript']['modified'] = now
    except KeyError as e:
        Logger.error("error: Missing {} ;trying to make new  project, rolling back database insert".format(e))
        raise e
    except Exception as e:
        Logger.error("error: {} {} ;trying to read  project data".format(type(e), e))
        raise e

    # Same gates as update(): media cues first (nothing written for a
    # refused save), then the duration fill and the fade gate.
    validate_media_cues_in_contents(
        (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or [],
        media_exists=self._media_exists,
    )
    self._fix_media_durations(data)
    validate_fade_durations_in_contents(
        (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or []
    )

    with self.db.atomic() as transaction:
        try:
            project = Project.create(uuid=project_uuid, unix_name=unix_name, name=StringSanitizer.sanitize_name(data['CuemsScript']['name']), description=StringSanitizer.sanitize_text_size(data['CuemsScript']['description']), created=now, modified=now)
            os.mkdir(os.path.join(self.projects_path, unix_name))
            Logger.debug('data is now: {}'.format(data))
            project_object = CuemsParser(data).parse()
            Logger.debug(f'project_object is now: {type(project_object)},{project_object}')
            self.add_media_relations(project, project_object)
            self.save_xml(unix_name, project_object)
            return project_uuid
        except IntegrityError as e:
            transaction.rollback()
            Logger.error("error: {} {} ;name or unix_name already exists, rolling back database insert".format(type(e), e))
            raise e
        except Exception as e:
            transaction.rollback()
            Logger.error("error: {} {} ;trying to make new  project, rolling back database insert".format(type(e), e))

            if os.path.exists(os.path.join(self.projects_path, unix_name)):
                shutil.rmtree(os.path.join(self.projects_path, unix_name))

            raise e

plan_media_refresh(project_uuid)

Step 1 of the load-time check: read and compare, write nothing.

Reads the project file without the schema, finds each file it uses, compares the file with its row (size; and the .idx header of a video the sync carries), and lists the values present in the project that no longer match their rows (:func:stale_by_file).

Raises:

Type Description
NonExistentItemError

no live project with project_uuid.

Source code in src/cuemseditor/CuemsDBProject.py
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
def plan_media_refresh(self, project_uuid):
    """Step 1 of the load-time check: read and compare, write nothing.

    Reads the project file without the schema, finds each file it uses,
    compares the file with its row (size; and the ``.idx`` header of a
    video the sync carries), and lists the values present in the project
    that no longer match their rows (:func:`stale_by_file`).

    Raises:
        NonExistentItemError: no live project with *project_uuid*.
    """
    try:
        project = Project.get((Project.uuid == project_uuid) & (Project.in_trash == False))
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(project_uuid))
    plan = RefreshPlan(project_uuid, project.unix_name, project.name)
    blocks = _light_media_blocks(self._script_path(project.unix_name))
    rows = _media_rows(blocks)
    plan.stale = stale_by_file(blocks, rows)
    for row in rows.values():
        if row is not None:     # an orphan is skipped; the node's check covers it
            check = self._check_media_file(row)
            if check is not None:
                plan.checks.append(check)
    return plan

restore(uuid)

Restore a trashed project back to the active projects directory.

Moves the directory from <trash_path>/ to <projects_path>/ via CopyMoveVersioned.move, which handles name collisions by appending a numeric suffix. The DB record's unix_name is updated to the actual destination name and in_trash is set to False.

Parameters:

Name Type Description Default
uuid

UUID of the trashed project to restore.

required

Raises:

Type Description
NonExistentItemError

If no trashed project with uuid exists.

Exception

Re-raises after rollback and filesystem cleanup.

Source code in src/cuemseditor/CuemsDBProject.py
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
def restore(self, uuid):
    """Restore a trashed project back to the active projects directory.

    Moves the directory from ``<trash_path>/`` to ``<projects_path>/``
    via ``CopyMoveVersioned.move``, which handles name collisions by
    appending a numeric suffix.  The DB record's ``unix_name`` is updated
    to the actual destination name and ``in_trash`` is set to ``False``.

    Args:
        uuid: UUID of the trashed project to restore.

    Raises:
        NonExistentItemError: If no trashed project with *uuid* exists.
        Exception: Re-raises after rollback and filesystem cleanup.
    """
    try:
        project_trash = Project.get((Project.uuid == uuid) & (Project.in_trash == True))

        with self.db.atomic() as transaction:
            try:
                dest_filename = None
                project_path = os.path.join(self.trash_path, project_trash.unix_name)
                dest_filename = CopyMoveVersioned.move(project_path, self.projects_path, project_trash.unix_name)
                project_trash.unix_name = dest_filename
                project_trash.in_trash = False
                project_trash.save()
                Logger.debug('updating instance in db: {}'.format(project_trash))
            except Exception as e:
                Logger.error("error: {} {}; trying to move file to trash, rolling back database".format(type(e), e))
                transaction.rollback()
                if dest_filename is None:  # if move or copy where not successful with don't need to clean and can end here forwarding the exception, else continue cleaning and then forward the exception
                    raise e
                if os.path.exists(os.path.join(self.projects_path, dest_filename)):
                    shutil.move(os.path.join(self.projects_path, dest_filename), os.path.join(self.trash_path, project_path.unix_name))
                raise e
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

save_xml(unix_name, project_object)

Write project_object to <projects_path>/<unix_name>/cue_script.xml.

Validates against script.xsd before writing; raises if the CuemsScript object fails schema validation.

The file is replaced atomically (869fat84r D20): written to a temporary file in the same directory, given the old file's mode (the nodes' rsync reads it as nobody), then renamed over it, so a power cut never leaves a truncated project. A directory the editor cannot create files in falls back to writing in place, as before.

Parameters:

Name Type Description Default
unix_name

Project directory name.

required
project_object

CuemsScript instance to serialize.

required
Source code in src/cuemseditor/CuemsDBProject.py
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
def save_xml(self, unix_name, project_object):
    """Write *project_object* to ``<projects_path>/<unix_name>/cue_script.xml``.

    Validates against ``script.xsd`` before writing; raises if the
    ``CuemsScript`` object fails schema validation.

    The file is replaced atomically (869fat84r D20): written to a
    temporary file in the same directory, given the old file's mode (the
    nodes' rsync reads it as ``nobody``), then renamed over it, so a power
    cut never leaves a truncated project. A directory the editor cannot
    create files in falls back to writing in place, as before.

    Args:
        unix_name: Project directory name.
        project_object: ``CuemsScript`` instance to serialize.
    """
    final = self._script_path(unix_name)
    directory = os.path.dirname(final)
    tmp = os.path.join(directory, f'.{self.script_file_name}.{os.getpid()}.{os.urandom(4).hex()}.tmp')
    try:
        open(tmp, 'xb').close()
    except OSError as e:
        Logger.warning(f'cannot create a temporary file in {directory} ({e}); writing '
                       f'{final} in place')
        XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=final).write_from_object(project_object)
        return
    try:
        XmlReaderWriter(schema_name=self.script_schema_name, xmlfile=tmp).write_from_object(project_object)
        if os.path.exists(final):
            shutil.copymode(final, tmp)
        os.replace(tmp, final)
    except BaseException:
        with contextlib.suppress(OSError):
            os.remove(tmp)
        raise

update(uuid, data)

Save an edited project: update DB metadata and rewrite the XML file.

Runs inside a Peewee atomic transaction. The XML is validated against script.xsd by XmlReaderWriter.write_from_object — an invalid cue tree raises before any persistent change is made.

A pre-save pass via :meth:_fix_media_durations corrects zero durations sent by the frontend.

Parameters:

Name Type Description Default
uuid

Project UUID string; must match data['CuemsScript']['id'].

required
data

CuemsScript dict as received from the frontend over WebSocket.

required

Raises:

Type Description
NonExistentItemError

If the project does not exist or is in the trash.

Exception

Re-raises any error after rolling back the transaction.

Source code in src/cuemseditor/CuemsDBProject.py
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
def update(self, uuid, data):
    """Save an edited project: update DB metadata and rewrite the XML file.

    Runs inside a Peewee atomic transaction.  The XML is validated
    against ``script.xsd`` by ``XmlReaderWriter.write_from_object`` —
    an invalid cue tree raises before any persistent change is made.

    A pre-save pass via :meth:`_fix_media_durations` corrects zero
    durations sent by the frontend.

    Args:
        uuid: Project UUID string; must match ``data['CuemsScript']['id']``.
        data: ``CuemsScript`` dict as received from the frontend over
            WebSocket.

    Raises:
        NonExistentItemError: If the project does not exist or is in the
            trash.
        Exception: Re-raises any error after rolling back the transaction.
    """
    try:
        project = Project.get((Project.uuid == uuid) & (Project.in_trash == False))
    except DoesNotExist:
        raise NonExistentItemError("item with uuid: {} does not exist".format(uuid))

    try:
        del data['CuemsScript']['unix_name']
    except KeyError:
        pass

    # Reject media cues without a usable media block FIRST: the duration
    # fixer below writes Media rows, and nothing may be written for a save
    # that is going to be refused.
    validate_media_cues_in_contents(
        (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or [],
        media_exists=self._media_exists,
    )
    # SAFETY NET: older frontends send Media.duration as '00:00:00.000';
    # overwrite from the DB (source of truth) before saving.
    # As of the media-duration fix, the frontend copies the real duration
    # from the file_list payload (CuemsDBMedia.list() now carries a
    # 'duration' key) — remove this net only once ALL deployed frontends
    # AND editors are >= those versions.
    self._fix_media_durations(data)

    self._clean_dangling_targets(data)

    # Reject zero/unparseable FadeCue durations BEFORE parsing: the parser
    # bypasses FadeCue.set_duration, and a saved zero later becomes a
    # silent no-op at reveal (gradient-motiond drops dur <= 0).
    validate_fade_durations_in_contents(
        (data.get('CuemsScript', {}).get('CueList') or {}).get('contents') or []
    )

    with self.db.atomic() as transaction:
        try:
            project.name = StringSanitizer.sanitize_name(data['CuemsScript']['name'])
            now = new_datetime()
            data['CuemsScript']['modified'] = now
            project.modified = now
            project.description = StringSanitizer.sanitize_text_size(data['CuemsScript']['description'])
            project.save()
            project_object = CuemsParser(data).parse()
            self.update_media_relations(project, project_object)
            self.save_xml(project.unix_name, project_object)
        except Exception as e:
            Logger.error("error: {} {} trying to update  project, rolling back database update".format(type(e), e))
            transaction.rollback()
            raise e

update_existed_media_uuid(media_filename, old_media_uuid)

Overwrite the UUID of a Media row to match the value recorded in project XML.

Used when a re-uploaded file produces a new UUID in the DB that differs from the UUID stored inside existing project XML files.

Parameters:

Name Type Description Default
media_filename

unix_name identifying the Media row.

required
old_media_uuid

The UUID string to assign (taken from the XML).

required

Raises:

Type Description
NonExistentItemError

If no Media row has media_filename.

Source code in src/cuemseditor/CuemsDBProject.py
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
def update_existed_media_uuid(self, media_filename, old_media_uuid):
    """Overwrite the UUID of a ``Media`` row to match the value recorded in project XML.

    Used when a re-uploaded file produces a new UUID in the DB that differs
    from the UUID stored inside existing project XML files.

    Args:
        media_filename: ``unix_name`` identifying the ``Media`` row.
        old_media_uuid: The UUID string to assign (taken from the XML).

    Raises:
        NonExistentItemError: If no ``Media`` row has *media_filename*.
    """
    Logger.debug('updating media uuid for media filename: {} with old uuid: {}'.format(media_filename, old_media_uuid))
    try:
        media = Media.get(Media.unix_name == media_filename)
        with self.db.atomic() as transaction:
            try:
                Media.update(uuid=old_media_uuid).where(Media.unix_name == media_filename).execute()
            except Exception as e:
                Logger.error("error: {} {}; trying to update media uuid, rolling back database update".format(type(e), e))
                transaction.rollback()
                raise e
    except DoesNotExist:
        raise NonExistentItemError("item with unix_name: {} does not exist".format(media_filename))

update_media_relations(project, project_object)

Diff and sync ProjectMedia join rows after a project save.

Computes the symmetric difference between the old and new media sets, then removes stale ProjectMedia rows and creates new ones.

Parameters:

Name Type Description Default
project

Project ORM instance being saved.

required
project_object

CuemsScript object reflecting the new cue tree after the save.

required
Source code in src/cuemseditor/CuemsDBProject.py
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
def update_media_relations(self, project, project_object):
    """Diff and sync ``ProjectMedia`` join rows after a project save.

    Computes the symmetric difference between the old and new media sets,
    then removes stale ``ProjectMedia`` rows and creates new ones.

    Args:
        project: ``Project`` ORM instance being saved.
        project_object: ``CuemsScript`` object reflecting the new cue
            tree after the save.
    """
    Logger.debug('updating media relations for project: {}'.format(project.unix_name))
    old_media_query = project.medias()
    old_media_dict = dict()
    Logger.debug('query done')
    for media in old_media_query:
        old_media_dict[media.unix_name] = str(media.uuid)
    old_media_list = list(old_media_dict.keys())
    Logger.debug('old media list: {}'.format(old_media_list))
    media_list = project_object.get_media_filenames()
    Logger.debug('media list: {}'.format(media_list))

    remove_set = set(old_media_list).difference(media_list)
    add_set = set(media_list).difference(old_media_list)

    Logger.debug('media remove list: {}'.format(remove_set))
    Logger.debug('media add list: {}'.format(add_set))

    if remove_set:
        for media_unix_name in remove_set:
            ProjectMedia.delete().where((ProjectMedia.project == project) & (ProjectMedia.media == old_media_dict[media_unix_name])).execute()

    if add_set:
        for media_unix_name in add_set:
            media = Media.select(Media.uuid).where(Media.unix_name == media_unix_name).get()
            ProjectMedia.create(project=project, media=media, media_filename=media_unix_name)

update_projects_existed_media(project_uuid, media_filename)

Re-link a re-uploaded media file to the projects that referenced it by filename.

Called after CuemsUpload detects that media_filename was already referenced by one or more projects. Reads the XML, finds matching cues, and reconciles the stored media UUID with what is now in the DB.

Parameters:

Name Type Description Default
project_uuid

UUID of the project to update.

required
media_filename

unix_name of the re-uploaded media file.

required
Source code in src/cuemseditor/CuemsDBProject.py
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
def update_projects_existed_media(self, project_uuid, media_filename):
    """Re-link a re-uploaded media file to the projects that referenced it by filename.

    Called after ``CuemsUpload`` detects that *media_filename* was already
    referenced by one or more projects.  Reads the XML, finds matching
    cues, and reconciles the stored media UUID with what is now in the DB.

    Args:
        project_uuid: UUID of the project to update.
        media_filename: ``unix_name`` of the re-uploaded media file.
    """
    # A trashed project lives in the trash, not where load() reads; it is
    # relinked when it is restored and saved (869fat84r D20, Q2).
    try:
        project = Project.get(Project.uuid == project_uuid)
    except DoesNotExist:
        Logger.warning(f'no project {project_uuid} to relink {media_filename} in')
        return
    if project.in_trash:
        Logger.info(f'project {project.unix_name} is in the trash: not relinking {media_filename}')
        return
    # Relink only (D21): the re-uploaded row has fresh values; the project
    # still holds the old file's, reported at its next load or open and
    # written by its next save.
    self._relink_existed_media(project, media_filename)

verify_media_files(plan, deadline, clock=time.monotonic)

Step 2: re-probe each changed or legacy file and correct its row.

Stops at deadline (a clock value); the files not reached go first at the next load. One file that fails is logged and skipped.

Returns:

Type Description

dict of file-name lists: verified, changed,

legacy, failed, in_copy, not_reached.

Source code in src/cuemseditor/CuemsDBProject.py
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
def verify_media_files(self, plan, deadline, clock=time.monotonic):
    """Step 2: re-probe each changed or legacy file and correct its row.

    Stops at *deadline* (a *clock* value); the files not reached go first
    at the next load. One file that fails is logged and skipped.

    Returns:
        ``dict`` of file-name lists: ``verified``, ``changed``,
        ``legacy``, ``failed``, ``in_copy``, ``not_reached``.
    """
    report = {k: [] for k in ('verified', 'changed', 'legacy', 'failed',
                              'in_copy', 'not_reached')}
    first = _NOT_REACHED.get(plan.project_uuid, [])
    todo = sorted(plan.to_verify, key=lambda c: c.file_name not in first)
    for check in todo:
        if clock() >= deadline:
            report['not_reached'].append(check.file_name)
            continue
        row = Media.get_or_none(Media.unix_name == check.file_name)
        if row is None:
            continue
        try:
            self._verify_media_row(row, check.path, legacy=(check.state == 'legacy'),
                                   report=report)
        except Exception as e:
            report['failed'].append(check.file_name)
            Logger.error(f'could not verify {check.file_name}: {type(e).__name__}: {e}')
    if report['not_reached']:
        _NOT_REACHED[plan.project_uuid] = report['not_reached']
        Logger.warning(f'project {plan.unix_name}: {len(report["not_reached"])} media '
                       f'file(s) not checked before the {REFRESH_DEADLINE_S} s limit, '
                       f'left for the next load: {report["not_reached"]}')
    else:
        _NOT_REACHED.pop(plan.project_uuid, None)
    return report

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

DurationFixStats

Result of a :func:fix_media_durations_in_contents walk.

Attributes:

Name Type Description
media_refs

Number of cue Media blocks visited.

replacements

Number of duration values actually changed.

dimension_changes

Number of Media blocks whose stored size (pixel_width / pixel_height / file_size) changed: filled, replaced, or removed.

orphans

List of file_name values referencing a media file with no Media row (duration left untouched — a leftover 00:00:00.000 there matches the valid timecode shape, so it is invisible to a pattern scan; report it explicitly instead).

errors

file_name values whose Media block could not be processed (logged; the walk went on with the next cue).

Source code in src/cuemseditor/CuemsDBProject.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class DurationFixStats:
    """Result of a :func:`fix_media_durations_in_contents` walk.

    Attributes:
        media_refs: Number of cue ``Media`` blocks visited.
        replacements: Number of ``duration`` values actually changed.
        dimension_changes: Number of ``Media`` blocks whose stored size
            (``pixel_width`` / ``pixel_height`` / ``file_size``) changed:
            filled, replaced, or removed.
        orphans: List of ``file_name`` values referencing a media file with no
            ``Media`` row (duration left untouched — a leftover
            ``00:00:00.000`` there matches the valid timecode shape, so it is
            invisible to a pattern scan; report it explicitly instead).
        errors: ``file_name`` values whose ``Media`` block could not be
            processed (logged; the walk went on with the next cue).
    """

    def __init__(self):
        self.media_refs = 0
        self.replacements = 0
        self.dimension_changes = 0
        self.orphans = []
        self.errors = []

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

MediaFileCheck

Step 1's verdict on one file a project uses.

state: unchanged, changed (its size differs from the row, or its video index was made for another file), legacy (the row has no stored size yet), or skipped (its duration could not be read before and the file has not changed since).

Source code in src/cuemseditor/CuemsDBProject.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
class MediaFileCheck:
    """Step 1's verdict on one file a project uses.

    ``state``: ``unchanged``, ``changed`` (its size differs from the row, or
    its video index was made for another file), ``legacy`` (the row has no
    stored size yet), or ``skipped`` (its duration could not be read before
    and the file has not changed since).
    """

    def __init__(self, file_name, path, media_type, size, mtime_ns):
        self.file_name = file_name
        self.path = path
        self.media_type = media_type
        self.size = size
        self.mtime_ns = mtime_ns
        self.state = 'unchanged'
        self.index = None            # None: not a video the sync carries
        self.needs_index = False

    def __repr__(self):
        return f'MediaFileCheck({self.file_name!r}, {self.state}, index={self.index})'

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

RefreshPlan

What a load must do before the engine is asked, and what the project holds that is stale (step 1, read-only).

Source code in src/cuemseditor/CuemsDBProject.py
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
class RefreshPlan:
    """What a load must do before the engine is asked, and what the project
    holds that is stale (step 1, read-only)."""

    def __init__(self, project_uuid, unix_name, project_name=None):
        self.project_uuid = str(project_uuid)
        self.unix_name = unix_name
        self.project_name = project_name
        self.checks = []
        self.stale = []              # stale_by_file()

    @property
    def has_stale_values(self):
        return bool(self.stale)

    @property
    def to_verify(self):
        return [c for c in self.checks if c.state in ('changed', 'legacy')]

    @property
    def to_index(self):
        return [c for c in self.checks if c.needs_index]

    @property
    def has_work(self):
        """Files to measure or to index. Stale values alone are reported, not
        work: they cost no engine query and no probe."""
        return bool(self.to_verify or self.to_index)

has_work property

Files to measure or to index. Stale values alone are reported, not work: they cost no engine query and no probe.

cap_report_files(entries, cap=None)

At most cap (:data:REPORT_FILES_MAX) entries, and their full count.

Source code in src/cuemseditor/CuemsDBProject.py
513
514
515
516
def cap_report_files(entries, cap=None):
    """At most *cap* (:data:`REPORT_FILES_MAX`) entries, and their full count."""
    cap = REPORT_FILES_MAX if cap is None else cap
    return entries[:cap], len(entries)

db_dimensions_resolver(file_name)

Resolve a media unix_name to its stored size (see :func:dimensions_of_row), or None when unknown. Read-only: no probe.

Raises KeyError when no Media row exists for file_name.

Source code in src/cuemseditor/CuemsDBProject.py
114
115
116
117
118
119
120
121
122
123
124
def db_dimensions_resolver(file_name):
    """Resolve a media ``unix_name`` to its stored size (see
    :func:`dimensions_of_row`), or ``None`` when unknown. Read-only: no probe.

    Raises ``KeyError`` when no ``Media`` row exists for *file_name*.
    """
    try:
        media = Media.get(Media.unix_name == file_name)
    except DoesNotExist:
        raise KeyError(file_name)
    return dimensions_of_row(media)

db_duration_resolver(file_name)

Resolve a media unix_name to its stored duration string (or None).

Raises KeyError when no Media row exists for file_name, so the walk can distinguish an orphaned reference from a present-but-NULL duration.

Source code in src/cuemseditor/CuemsDBProject.py
101
102
103
104
105
106
107
108
109
110
111
def db_duration_resolver(file_name):
    """Resolve a media ``unix_name`` to its stored duration string (or ``None``).

    Raises ``KeyError`` when no ``Media`` row exists for *file_name*, so the
    walk can distinguish an orphaned reference from a present-but-NULL duration.
    """
    try:
        media = Media.get(Media.unix_name == file_name)
    except DoesNotExist:
        raise KeyError(file_name)
    return media.duration

dimensions_of_row(media)

What a Media row knows about its file: pixel_width and pixel_height (both or neither), file_size, file_md5; only the valid ones. None when it knows nothing.

Source code in src/cuemseditor/CuemsDBProject.py
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def dimensions_of_row(media):
    """What a ``Media`` row knows about its file: ``pixel_width`` and
    ``pixel_height`` (both or neither), ``file_size``, ``file_md5``; only the
    valid ones. ``None`` when it knows nothing."""
    values = {}
    width, height = _positive_int(media.pixel_width), _positive_int(media.pixel_height)
    if width and height:
        values.update(pixel_width=width, pixel_height=height)
    size = _positive_int(media.file_size)
    if size:
        values['file_size'] = size
    md5 = _md5(getattr(media, 'file_md5', None))
    if md5:
        values['file_md5'] = md5
    return values or None

fix_media_durations_in_contents(contents, resolver, dimensions_resolver=None)

Overwrite each cue Media's duration, and VideoCues' stored size, from the resolvers, in place.

Shared by the WebSocket save path (:meth:CuemsDBProject._fix_media_durations) and the standalone repair script, so both trust the same DB source of truth and walk cue trees identically. Each caller passes its own resolvers: only the save path's may probe and write the DB.

For every Media block, in this order:

  1. a pixel_width / pixel_height / file_size that is not a positive integer, or a file_md5 that is not 32 hex digits, is removed, whatever the cue: the parser assigns these keys raw, and the schema would reject the whole save; an AudioCue loses the pixel size (it belongs to VideoCues);
  2. the duration, from resolver (an orphan stops here, untouched);
  3. the stored values, from dimensions_resolver when given: the pixel size, size and MD5 on a VideoCue, size and MD5 on an AudioCue (D18). They replace the client's; None (unknown) removes them.

One block that fails is logged and skipped; the walk goes on.

Parameters:

Name Type Description Default
contents

CueList['contents'] list (recursively walked).

required
resolver

callable file_name -> duration_str_or_None; must raise KeyError for a media file absent from the DB.

required
dimensions_resolver

optional callable file_name -> dict | None (see :func:db_dimensions_resolver); KeyError for an orphan.

None

Returns:

Type Description

class:DurationFixStats.

Source code in src/cuemseditor/CuemsDBProject.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def fix_media_durations_in_contents(contents, resolver, dimensions_resolver=None):
    """Overwrite each cue ``Media``'s duration, and VideoCues' stored size,
    from the resolvers, in place.

    Shared by the WebSocket save path (:meth:`CuemsDBProject._fix_media_durations`)
    and the standalone repair script, so both trust the same DB source of
    truth and walk cue trees identically. Each caller passes its own
    resolvers: only the save path's may probe and write the DB.

    For every ``Media`` block, in this order:

    1. a ``pixel_width`` / ``pixel_height`` / ``file_size`` that is not a
       positive integer, or a ``file_md5`` that is not 32 hex digits, is
       removed, whatever the cue: the parser assigns these keys raw, and the
       schema would reject the whole save; an AudioCue loses the pixel size
       (it belongs to VideoCues);
    2. the duration, from *resolver* (an orphan stops here, untouched);
    3. the stored values, from *dimensions_resolver* when given: the pixel
       size, size and MD5 on a VideoCue, size and MD5 on an AudioCue (D18).
       They replace the client's; ``None`` (unknown) removes them.

    One block that fails is logged and skipped; the walk goes on.

    Args:
        contents: ``CueList['contents']`` list (recursively walked).
        resolver: callable ``file_name -> duration_str_or_None``; must raise
            ``KeyError`` for a media file absent from the DB.
        dimensions_resolver: optional callable ``file_name -> dict | None``
            (see :func:`db_dimensions_resolver`); ``KeyError`` for an orphan.

    Returns:
        :class:`DurationFixStats`.
    """
    stats = DurationFixStats()
    _walk_media_durations(contents, resolver, dimensions_resolver, stats)
    return stats

mark_index_failed(path)

Do not try to rebuild path's index again until the file changes.

Source code in src/cuemseditor/CuemsDBProject.py
428
429
430
431
432
def mark_index_failed(path):
    """Do not try to rebuild *path*'s index again until the file changes."""
    key = _file_key(path)
    if key:
        _INDEX_FAILED_MARKS.add(key)

reset_refresh_marks()

Forget every in-memory mark (tests).

Source code in src/cuemseditor/CuemsDBProject.py
414
415
416
417
def reset_refresh_marks():
    """Forget every in-memory mark (tests)."""
    for marks in (_FAILED_MARKS, _VERIFIED_MARKS, _INDEX_FAILED_MARKS, _NOT_REACHED):
        marks.clear()

stale_by_file(blocks, rows)

The stale values of a project, one entry per file: [{file_name, cues, changes}], cues counting the cues whose block holds stale values for that file.

Source code in src/cuemseditor/CuemsDBProject.py
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def stale_by_file(blocks, rows):
    """The stale values of a project, one entry per file:
    ``[{file_name, cues, changes}]``, ``cues`` counting the cues whose block
    holds stale values for that file."""
    files = {}
    for cue_type, values in blocks:
        name = values.get('file_name') or ''
        changes = stale_changes(cue_type, values, rows.get(name))
        if not changes:
            continue
        entry = files.setdefault(name, {'file_name': name, 'cues': 0, 'changes': []})
        entry['cues'] += 1
        seen = {c['field'] for c in entry['changes']}
        entry['changes'].extend(c for c in changes if c['field'] not in seen)
    return [files[k] for k in sorted(files)]

stale_changes(cue_type, values, row)

The values PRESENT in this Media block that the fill would change.

The same decisions as :func:_fix_one_media, on normalised values, so a value read as a string from the file is compared as the int the fill writes. A value that is only missing (a project written before rc15) is not stale: the next save adds it. Returns [{field, stored, current}], field one of duration, pixel_size ("WxH"), file_size, file_md5; current is None when the fill would remove it.

Source code in src/cuemseditor/CuemsDBProject.py
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def stale_changes(cue_type, values, row):
    """The values PRESENT in this ``Media`` block that the fill would change.

    The same decisions as :func:`_fix_one_media`, on normalised values, so a
    value read as a string from the file is compared as the int the fill
    writes. A value that is only missing (a project written before rc15) is
    not stale: the next save adds it. Returns ``[{field, stored, current}]``,
    ``field`` one of ``duration``, ``pixel_size`` (``"WxH"``), ``file_size``,
    ``file_md5``; ``current`` is ``None`` when the fill would remove it.
    """
    allowed = _allowed_keys(cue_type)
    present = {k: values[k] for k in STORED_KEYS if k in values}
    known = bool(values.get('file_name')) and row is not None   # an orphan: the fill stops
    expected = ({k: v for k, v in (dimensions_of_row(row) or {}).items() if k in allowed}
                if known else {})
    wrong = {k for k, v in present.items()
             if k not in allowed or _valid(k, v) is None or (known and _valid(k, v) != expected.get(k))}
    changes = []
    if known and row.duration and 'duration' in values and values['duration'] != str(row.duration):
        changes.append({'field': 'duration', 'stored': values['duration'], 'current': str(row.duration)})
    if wrong & set(PIXEL_KEYS):
        stored = f"{present.get('pixel_width', '?')}x{present.get('pixel_height', '?')}"
        current = (f"{expected['pixel_width']}x{expected['pixel_height']}"
                   if 'pixel_width' in expected else None)
        changes.append({'field': 'pixel_size', 'stored': stored, 'current': current})
    for key in ('file_size', 'file_md5'):
        if key in wrong:
            current = expected.get(key)
            changes.append({'field': key, 'stored': present[key],
                            'current': None if current is None else str(current)})
    return changes

validate_fade_durations_in_contents(contents)

Reject any FadeCue whose duration is missing, unparseable, or <= 0.

Save-time gate: CuemsParser/GenericParser assigns via dict.__setitem__, bypassing FadeCue.set_duration's own positive-and-non-zero rule, so a zero coming from a client would reach the XML and later become a silent no-op at reveal (gradient-motiond drops dur <= 0 over fire-and-forget UDP). Collects ALL offenders and raises a single ValueError; the WS layer forwards the text to the client.

Source code in src/cuemseditor/CuemsDBProject.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def validate_fade_durations_in_contents(contents):
    """Reject any FadeCue whose duration is missing, unparseable, or <= 0.

    Save-time gate: ``CuemsParser``/``GenericParser`` assigns via
    ``dict.__setitem__``, bypassing ``FadeCue.set_duration``'s own
    positive-and-non-zero rule, so a zero coming from a client would reach
    the XML and later become a silent no-op at reveal (gradient-motiond
    drops ``dur <= 0`` over fire-and-forget UDP).  Collects ALL offenders and
    raises a single ``ValueError``; the WS layer forwards the text to the
    client.
    """
    offenders = []
    _walk_fade_durations(contents, offenders)
    if offenders:
        detail = '; '.join(
            f"'{name or 'unnamed'}' (id {cue_id or 'unknown'}: {reason})"
            for name, cue_id, reason in offenders
        )
        raise ValueError(
            f"FadeCue duration must be greater than zero: {detail}"
        )

validate_media_cues_in_contents(contents, media_exists=None)

Reject any AudioCue/VideoCue without a usable media block.

Save-time gate, run BEFORE anything is written. A client that cannot resolve a cue's file drops the whole Media block (the frontend did so for files in the media trash); the shapes that then arrive are: no Media key, None, {}, or a block with an empty file_name or no id. Without this gate such a save died inside the atomic block with a bare KeyError 'file_name' from CueList.get_media (cuems-utils < rc16) — and, had it not died, would have written an empty <Media/> and lost the cue's media for good.

media_exists(file_name) -> bool lets the DB check that the file is known (trashed is fine: it can be restored; deleted cannot). Collects ALL offenders and raises a single ValueError whose text names them; the WS layer forwards it to the client as is.

Source code in src/cuemseditor/CuemsDBProject.py
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def validate_media_cues_in_contents(contents, media_exists=None):
    """Reject any AudioCue/VideoCue without a usable media block.

    Save-time gate, run BEFORE anything is written. A client that cannot
    resolve a cue's file drops the whole ``Media`` block (the frontend did
    so for files in the media trash); the shapes that then arrive are: no
    ``Media`` key, ``None``, ``{}``, or a block with an empty ``file_name``
    or no ``id``. Without this gate such a save died inside the atomic
    block with a bare ``KeyError 'file_name'`` from ``CueList.get_media``
    (cuems-utils < rc16) — and, had it not died, would have written an
    empty ``<Media/>`` and lost the cue's media for good.

    ``media_exists(file_name) -> bool`` lets the DB check that the file is
    known (trashed is fine: it can be restored; deleted cannot). Collects
    ALL offenders and raises a single ``ValueError`` whose text names them;
    the WS layer forwards it to the client as is.
    """
    offenders = []
    _walk_media_cues(contents, offenders, media_exists)
    if offenders:
        detail = '; '.join(
            f"'{name or 'unnamed'}' (id {cue_id or 'unknown'}: {reason})"
            for name, cue_id, reason in offenders
        )
        raise ValueError(f"Media cue without a usable media file: {detail}")

CuemsLibraryMaintenance

Ensures the library directory tree exists before any DB or file operation.

Creates the following structure under library_path on first run::

<library_path>/
├── media/
│   ├── thumbnails/
│   └── waveforms/
├── projects/
└── trash/
    ├── media/
    │   ├── thumbnails/
    │   └── waveforms/
    └── projects/

Call once at service startup, before CuemsDBManager is constructed.

Example

CuemsLibraryMaintenance("/var/lib/cuems/library")

Source code in src/cuemseditor/CuemsLibraryMaintenance.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class CuemsLibraryMaintenance():
    """Ensures the library directory tree exists before any DB or file operation.

    Creates the following structure under ``library_path`` on first run::

        <library_path>/
        ├── media/
        │   ├── thumbnails/
        │   └── waveforms/
        ├── projects/
        └── trash/
            ├── media/
            │   ├── thumbnails/
            │   └── waveforms/
            └── projects/

    Call once at service startup, before ``CuemsDBManager`` is constructed.

    Example:
        >>> CuemsLibraryMaintenance("/var/lib/cuems/library")
    """

    def __init__(self, library_path):
        """Bootstrap the library directory tree.

        Args:
            library_path: Absolute path to the root library directory.
                          Created if it does not exist.
        """
        self.library_path = library_path
        library_directories = ['media', 'media/thumbnails', 'media/waveforms', 'projects']
        trahsh_directories = [os.path.join('trash', directory) for directory in library_directories]
        library_directories.extend(trahsh_directories)
        library_directory_paths = [os.path.join(self.library_path, directory) for directory in library_directories]
        library_directory_paths.append(os.path.normpath(self.library_path))  # Ensure the root library path is also included
        self.ensure_directories(library_directory_paths)

    def ensure_directories(self, directories):
        """Create any missing directories from *directories*.

        Args:
            directories: Iterable of absolute directory paths to verify/create.
        """
        for directory_path in directories:
            if not os.path.exists(directory_path):
                self.create_directory(directory_path)

    @logged
    def create_directory(self, directory_path):
        """Create *directory_path* and all intermediate parents.

        Args:
            directory_path: Absolute path to create.
        """
        os.makedirs(directory_path)

__init__(library_path)

Bootstrap the library directory tree.

Parameters:

Name Type Description Default
library_path

Absolute path to the root library directory. Created if it does not exist.

required
Source code in src/cuemseditor/CuemsLibraryMaintenance.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def __init__(self, library_path):
    """Bootstrap the library directory tree.

    Args:
        library_path: Absolute path to the root library directory.
                      Created if it does not exist.
    """
    self.library_path = library_path
    library_directories = ['media', 'media/thumbnails', 'media/waveforms', 'projects']
    trahsh_directories = [os.path.join('trash', directory) for directory in library_directories]
    library_directories.extend(trahsh_directories)
    library_directory_paths = [os.path.join(self.library_path, directory) for directory in library_directories]
    library_directory_paths.append(os.path.normpath(self.library_path))  # Ensure the root library path is also included
    self.ensure_directories(library_directory_paths)

create_directory(directory_path)

Create directory_path and all intermediate parents.

Parameters:

Name Type Description Default
directory_path

Absolute path to create.

required
Source code in src/cuemseditor/CuemsLibraryMaintenance.py
52
53
54
55
56
57
58
59
@logged
def create_directory(self, directory_path):
    """Create *directory_path* and all intermediate parents.

    Args:
        directory_path: Absolute path to create.
    """
    os.makedirs(directory_path)

ensure_directories(directories)

Create any missing directories from directories.

Parameters:

Name Type Description Default
directories

Iterable of absolute directory paths to verify/create.

required
Source code in src/cuemseditor/CuemsLibraryMaintenance.py
42
43
44
45
46
47
48
49
50
def ensure_directories(self, directories):
    """Create any missing directories from *directories*.

    Args:
        directories: Iterable of absolute directory paths to verify/create.
    """
    for directory_path in directories:
        if not os.path.exists(directory_path):
            self.create_directory(directory_path)

CuemsDBManager

Database facade that owns the SQLite connection, schema creation, and the project / media sub-managers.

Initialise exactly once per process after CuemsLibraryMaintenance has created the library directory tree. The shared database singleton is initialised and connected here; CuemsDBProject and CuemsDBMedia receive the same connection object.

Attributes:

Name Type Description
project

CuemsDBProject instance for project CRUD operations.

media

CuemsDBMedia instance for media CRUD operations.

Example

settings = { ... "database_name": "cuems.db", ... "library_path": "/var/lib/cuems/library", ... "project_folder_name": "projects", ... "media_folder_name": "media", ... "trash_folder_name": "trash", ... "tmp_path": "/tmp/cuems", ... "script_file_name": "cue_script.xml", ... "script_schema_name": "script.xsd", ... "thumbnail_folder_name": "thumbnails", ... "waveform_folder_name": "waveforms", ... "thumbnail_extension": ".jpg", ... "waveform_extension": ".json", ... "thumbnail_size": [240, 120], ... } db = CuemsDBManager(settings) projects = db.project.list() media_files = db.media.list()

Source code in src/cuemseditor/CuemsProjectManager.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
class CuemsDBManager():
    """Database facade that owns the SQLite connection, schema creation, and
    the ``project`` / ``media`` sub-managers.

    Initialise exactly once per process after ``CuemsLibraryMaintenance``
    has created the library directory tree.  The shared ``database``
    singleton is initialised and connected here; ``CuemsDBProject`` and
    ``CuemsDBMedia`` receive the same connection object.

    Attributes:
        project: ``CuemsDBProject`` instance for project CRUD operations.
        media: ``CuemsDBMedia`` instance for media CRUD operations.

    Example:
        >>> settings = {
        ...     "database_name": "cuems.db",
        ...     "library_path": "/var/lib/cuems/library",
        ...     "project_folder_name": "projects",
        ...     "media_folder_name": "media",
        ...     "trash_folder_name": "trash",
        ...     "tmp_path": "/tmp/cuems",
        ...     "script_file_name": "cue_script.xml",
        ...     "script_schema_name": "script.xsd",
        ...     "thumbnail_folder_name": "thumbnails",
        ...     "waveform_folder_name": "waveforms",
        ...     "thumbnail_extension": ".jpg",
        ...     "waveform_extension": ".json",
        ...     "thumbnail_size": [240, 120],
        ... }
        >>> db = CuemsDBManager(settings)
        >>> projects = db.project.list()
        >>> media_files = db.media.list()
    """

    def __init__(self, settings_dict):
        """Initialise the database connection and create missing tables.

        Calls ``database.init(path)`` to bind the shared Peewee
        ``SqliteDatabase`` to the file at
        ``settings_dict['library_path'] / settings_dict['database_name']``,
        then calls ``database.create_tables(..., safe=True)`` so first-run
        tables are created without dropping existing data.

        Args:
            settings_dict: Mapping consumed from ``settings.xml``; must
                contain at minimum ``database_name`` and ``library_path``.

        Raises:
            KeyError: If ``database_name`` or ``library_path`` is absent from
                *settings_dict*.
        """
        self.settings_dict = settings_dict

        try:
            self.db_name = settings_dict['database_name']
            self.library_path = settings_dict['library_path']
        except KeyError as e:
            Logger.error(f'can not read settings {e}')
            raise

        self.db_path = os.path.join(self.library_path, self.db_name)
        self.models = [Project, Media, ProjectMedia]
        database.init(self.db_path)
        database.connect()
        Logger.debug(f'database connected {database}, {self.db_name}')
        for model in self.models:
            if database.table_exists(model._meta.table):  # pylint: disable=maybe-no-member
                continue
            else:
                Logger.warning(f'table "{model._meta.table_name}" does not exist, creating')  # pylint: disable=maybe-no-member
        # safe=True uses IF NOT EXIST on table create
        database.create_tables(self.models, safe=True)
        # create_tables never adds a column to an existing table (869fat84r).
        try:
            ensure_media_columns(database)
        except Exception as e:
            Logger.error(f'database migration failed for {self.db_path}: {e}; '
                         'the editor cannot run on this database')
            raise
        self.project = CuemsDBProject(self.settings_dict, database)
        self.media = CuemsDBMedia(self.settings_dict, database)

__init__(settings_dict)

Initialise the database connection and create missing tables.

Calls database.init(path) to bind the shared Peewee SqliteDatabase to the file at settings_dict['library_path'] / settings_dict['database_name'], then calls database.create_tables(..., safe=True) so first-run tables are created without dropping existing data.

Parameters:

Name Type Description Default
settings_dict

Mapping consumed from settings.xml; must contain at minimum database_name and library_path.

required

Raises:

Type Description
KeyError

If database_name or library_path is absent from settings_dict.

Source code in src/cuemseditor/CuemsProjectManager.py
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def __init__(self, settings_dict):
    """Initialise the database connection and create missing tables.

    Calls ``database.init(path)`` to bind the shared Peewee
    ``SqliteDatabase`` to the file at
    ``settings_dict['library_path'] / settings_dict['database_name']``,
    then calls ``database.create_tables(..., safe=True)`` so first-run
    tables are created without dropping existing data.

    Args:
        settings_dict: Mapping consumed from ``settings.xml``; must
            contain at minimum ``database_name`` and ``library_path``.

    Raises:
        KeyError: If ``database_name`` or ``library_path`` is absent from
            *settings_dict*.
    """
    self.settings_dict = settings_dict

    try:
        self.db_name = settings_dict['database_name']
        self.library_path = settings_dict['library_path']
    except KeyError as e:
        Logger.error(f'can not read settings {e}')
        raise

    self.db_path = os.path.join(self.library_path, self.db_name)
    self.models = [Project, Media, ProjectMedia]
    database.init(self.db_path)
    database.connect()
    Logger.debug(f'database connected {database}, {self.db_name}')
    for model in self.models:
        if database.table_exists(model._meta.table):  # pylint: disable=maybe-no-member
            continue
        else:
            Logger.warning(f'table "{model._meta.table_name}" does not exist, creating')  # pylint: disable=maybe-no-member
    # safe=True uses IF NOT EXIST on table create
    database.create_tables(self.models, safe=True)
    # create_tables never adds a column to an existing table (869fat84r).
    try:
        ensure_media_columns(database)
    except Exception as e:
        Logger.error(f'database migration failed for {self.db_path}: {e}; '
                     'the editor cannot run on this database')
        raise
    self.project = CuemsDBProject(self.settings_dict, database)
    self.media = CuemsDBMedia(self.settings_dict, database)

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

CuemsUpload

Bases: StringSanitizer

Async binary upload handler for the /upload WebSocket endpoint.

Implements a two-phase upload protocol over a dedicated WebSocket connection (separate from the main project-manager connection):

  1. Init phase — client sends a JSON frame: {"action": "upload", "value": {"name": "...", "size": <bytes>}}. Server responds {"ready": true} and transitions to the data phase.

  2. Data phase — client sends raw binary frames; server acknowledges each with {"ready": true}. When all bytes are received the client sends {"action": "finished", "value": "<md5-hex>"}; the server verifies integrity, moves the file into the media library via CuemsDBMedia.new, and sends {"close": true}.

The tmp file is automatically deleted if the upload fails or the object is garbage-collected.

Example

Inside CuemsWsServer.upload_session:

upload = CuemsUpload(server, websocket) await upload.message_handler()

Source code in src/cuemseditor/CuemsUpload.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
class CuemsUpload(StringSanitizer):
    """Async binary upload handler for the ``/upload`` WebSocket endpoint.

    Implements a two-phase upload protocol over a dedicated WebSocket
    connection (separate from the main project-manager connection):

    1. **Init phase** — client sends a JSON frame:
       ``{"action": "upload", "value": {"name": "...", "size": <bytes>}}``.
       Server responds ``{"ready": true}`` and transitions to the data phase.

    2. **Data phase** — client sends raw binary frames; server acknowledges
       each with ``{"ready": true}``.  When all bytes are received the client
       sends ``{"action": "finished", "value": "<md5-hex>"}``; the server
       verifies integrity, moves the file into the media library via
       ``CuemsDBMedia.new``, and sends ``{"close": true}``.

    The tmp file is automatically deleted if the upload fails or the object
    is garbage-collected.

    Example:
        >>> # Inside CuemsWsServer.upload_session:
        >>> upload = CuemsUpload(server, websocket)
        >>> await upload.message_handler()
    """

    uploading = False
    filename = None
    tmp_filename = None
    bytes_received = 0
    filesize = 0
    file_handle = None

    def __init__(self, server, websocket):
        """Bind the upload session to *server* and *websocket*.

        Args:
            server: The ``CuemsWsServer`` instance; provides ``settings_dict``,
                ``event_loop``, ``executor``, and ``db``.
            websocket: The WebSocket connection for the ``/upload`` path.
        """
        self.server = server
        self.websocket = websocket
        self.tmp_path = self.server.settings_dict['tmp_path']
        self.media_path = self.server.db.media.media_path

    async def message_handler(self):
        """Main receive loop for the upload WebSocket connection.

        Dispatches string frames to :meth:`process_upload_message` and binary
        frames to :meth:`process_upload_packet`.  Exits cleanly when the
        WebSocket is closed.
        """
        while True:
            try:
                message = await self.websocket.recv()
                if isinstance(message, str):
                    await self.process_upload_message(message)
                elif isinstance(message, bytes):
                    await self.process_upload_packet(message)
            except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError):
                Logger.debug('upload connection closed, exiting loop')
                break

    async def message_sender(self, message):
        """Send a JSON string frame to the client, ignoring closed-connection errors.

        Args:
            message: JSON-serialised string to send.
        """
        try:
            await self.websocket.send(message)
        except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
            Logger.debug(e)

    async def process_upload_message(self, message):
        """Dispatch a JSON control frame from the client.

        Handles the ``"upload"`` action by delegating to :meth:`set_upload`.
        Unknown actions are silently ignored.

        Args:
            message: Raw JSON string received from the WebSocket.

        Returns:
            ``False`` if the frame has no ``action`` key; otherwise ``None``.
        """
        data = json.loads(message)
        if 'action' not in data:
            return False
        if data['action'] == 'upload':
            await self.set_upload(file_info=data["value"])

    async def set_upload(self, file_info):
        """Initialise upload state and signal readiness to the client.

        Sanitises the filename, creates a unique tmp filename
        ``<name>.tmp<random>``, verifies the tmp path is accessible, and
        sends ``{"ready": true}`` to the client.

        Args:
            file_info: Dict from the client init frame with at minimum
                ``name`` (str) and ``size`` (int in bytes).

        Returns:
            ``False`` on error (error message already sent to client).
        """
        if not os.path.exists(self.media_path):
            Logger.error("upload folder doenst exists")
            await self.message_sender(json.dumps({'error': 'upload folder doenst exist', 'fatal': True}))
            return False

        self.filename = StringSanitizer.sanitize_file_name(file_info['name'])
        self.tmp_filename = self.filename + '.tmp' + str(randint(100000, 999999))
        Logger.debug('tmp upload path: {}'.format(self.tmp_file_path()))

        if not os.path.exists(self.tmp_file_path()):
            self.filesize = file_info['size']
            self.uploading = 'Ready'
            await self.message_sender(json.dumps({"ready": True}))
        else:
            await self.message_sender(json.dumps({'error': 'file already exists', 'fatal': True}))
            Logger.error("file already exists")

    async def process_upload_packet(self, bin_data):
        """Receive and assemble binary data frames into the tmp file.

        Opens the tmp file in write-binary mode and loops, writing each
        incoming binary frame and ACKing it with ``{"ready": true}``.
        When the client sends the ``"finished"`` JSON control frame the loop
        exits and :meth:`upload_done` is called.

        Args:
            bin_data: First binary frame received (written before the loop).

        Returns:
            ``False`` if no ``action`` key is present in the finish frame.
        """
        if self.uploading == 'Ready':
            async with aiofiles.open(self.tmp_file_path(), mode='wb', loop=self.server.event_loop, executor=self.server.executor) as stream:
                await stream.write(bin_data)
                self.bytes_received += len(bin_data)
                await self.message_sender(json.dumps({"ready": True}))

                while True:
                    message = await self.websocket.recv()
                    if isinstance(message, bytes):
                        await stream.write(message)
                        self.bytes_received += len(message)
                        await self.message_sender(json.dumps({"ready": True}))
                    else:
                        data = json.loads(message)
                        if 'action' not in data:
                            return False
                        if data['action'] == 'finished':
                            await stream.flush()
                            await stream.close()
                            await self.upload_done(data["value"])
                        break

    async def upload_done(self, received_md5):
        """Verify upload integrity and move the file into the media library.

        Steps:

        1. :meth:`check_file_integrity` — verifies the MD5 digest of the tmp
           file against *received_md5*.
        2. ``CuemsDBMedia.new`` — moves the tmp file to the media library and
           creates the DB record and sidecars.
        3. :meth:`check_if_media_existed` — re-links projects that already
           referenced the uploaded filename.
        4. Sends ``{"close": true}`` to the client.

        Args:
            received_md5: Hex MD5 digest string sent by the client.
        """
        try:
            await self.server.event_loop.run_in_executor(self.server.executor, self.check_file_integrity, self.tmp_file_path(), received_md5)

            # The MD5 the client sent, just verified above, is stored with the
            # media (file_md5, 869fat84r D18): no second hash of the file.
            dest_filename = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.new, self.tmp_file_path(), self.filename, received_md5)
            self.tmp_filename = None
            Logger.debug('upload completed')
            await self.server.event_loop.run_in_executor(self.server.executor, self.check_if_media_existed, dest_filename)
            await self.message_sender(json.dumps({"close": True}))
            await self.server.notify_others_list_changes(None, "file_list")
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.message_sender(json.dumps({'error': 'error saving file', 'fatal': True}))

    # --- Synchronous helpers (run via executor) ---

    def check_if_media_existed(self, filename):
        """Re-link projects that already referenced *filename* before this upload.

        Queries ``ProjectMedia`` for rows whose ``media_filename`` matches
        *filename*, then calls
        ``CuemsDBProject.update_projects_existed_media`` for each referencing
        project so their XML UUIDs stay consistent.

        Args:
            filename: Destination filename returned by ``CuemsDBMedia.new``.
        """
        Logger.debug(f"checking if file {filename} already existed in projects")
        projectmedia_list = self.server.db.media.check_if_media_existed_in_projects(filename)
        if projectmedia_list:
            for projectmedia in projectmedia_list:
                Logger.info("file {} already existed in project: {}".format(filename, projectmedia.project_id))
                # One project that cannot be relinked must not stop the others
                # nor fail the upload: the file is already in the library
                # (869fat84r D20, Q2).
                try:
                    self.server.db.project.update_projects_existed_media(projectmedia.project_id, filename)
                except Exception as e:
                    Logger.error(f'could not relink {filename} in project {projectmedia.project_id}: '
                                 f'{type(e).__name__}: {e}')
        else:
            Logger.debug("file did not exist in any project, no action needed")

    def check_file_integrity(self, path, original_md5):
        """Verify the MD5 digest of *path* against *original_md5*.

        Reads the file in 64 KiB chunks to avoid loading large uploads into
        memory all at once.

        Args:
            path: Absolute path to the tmp file.
            original_md5: Hex MD5 string provided by the client.

        Returns:
            ``True`` if the digests match.

        Raises:
            FileIntegrityError: If the computed digest differs from
                *original_md5*.

        Example:
            >>> upload.check_file_integrity("/tmp/cuems/video.tmp123456", "d41d8cd98f00b204e9800998ecf8427e")
            True
        """
        hash_md5 = md5()
        with open(path, "rb") as file_to_check:
            for chunk in iter(lambda: file_to_check.read(65536), b""):
                hash_md5.update(chunk)

        returned_md5 = hash_md5.hexdigest()
        if original_md5 != returned_md5:
            raise FileIntegrityError('MD5 mistmatch')

        return True

    def tmp_file_path(self):
        """Return the absolute path to the current tmp upload file.

        Returns:
            Absolute path string, or ``None`` if no upload is in progress.
        """
        if not self.tmp_filename is None:
            return os.path.join(self.tmp_path, self.tmp_filename)

    def __del__(self):
        """Clean up the tmp file if the upload session is destroyed mid-transfer."""
        try:
            if self.tmp_file_path():
                os.remove(self.tmp_file_path())  # TODO: change to pathlib?
                Logger.debug('cleaning tmp upload file on object destruction: ({})'.format(self.tmp_file_path()))
        except FileNotFoundError:
            pass

__del__()

Clean up the tmp file if the upload session is destroyed mid-transfer.

Source code in src/cuemseditor/CuemsUpload.py
278
279
280
281
282
283
284
285
def __del__(self):
    """Clean up the tmp file if the upload session is destroyed mid-transfer."""
    try:
        if self.tmp_file_path():
            os.remove(self.tmp_file_path())  # TODO: change to pathlib?
            Logger.debug('cleaning tmp upload file on object destruction: ({})'.format(self.tmp_file_path()))
    except FileNotFoundError:
        pass

__init__(server, websocket)

Bind the upload session to server and websocket.

Parameters:

Name Type Description Default
server

The CuemsWsServer instance; provides settings_dict, event_loop, executor, and db.

required
websocket

The WebSocket connection for the /upload path.

required
Source code in src/cuemseditor/CuemsUpload.py
50
51
52
53
54
55
56
57
58
59
60
61
def __init__(self, server, websocket):
    """Bind the upload session to *server* and *websocket*.

    Args:
        server: The ``CuemsWsServer`` instance; provides ``settings_dict``,
            ``event_loop``, ``executor``, and ``db``.
        websocket: The WebSocket connection for the ``/upload`` path.
    """
    self.server = server
    self.websocket = websocket
    self.tmp_path = self.server.settings_dict['tmp_path']
    self.media_path = self.server.db.media.media_path

check_file_integrity(path, original_md5)

Verify the MD5 digest of path against original_md5.

Reads the file in 64 KiB chunks to avoid loading large uploads into memory all at once.

Parameters:

Name Type Description Default
path

Absolute path to the tmp file.

required
original_md5

Hex MD5 string provided by the client.

required

Returns:

Type Description

True if the digests match.

Raises:

Type Description
FileIntegrityError

If the computed digest differs from original_md5.

Example

upload.check_file_integrity("/tmp/cuems/video.tmp123456", "d41d8cd98f00b204e9800998ecf8427e") True

Source code in src/cuemseditor/CuemsUpload.py
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def check_file_integrity(self, path, original_md5):
    """Verify the MD5 digest of *path* against *original_md5*.

    Reads the file in 64 KiB chunks to avoid loading large uploads into
    memory all at once.

    Args:
        path: Absolute path to the tmp file.
        original_md5: Hex MD5 string provided by the client.

    Returns:
        ``True`` if the digests match.

    Raises:
        FileIntegrityError: If the computed digest differs from
            *original_md5*.

    Example:
        >>> upload.check_file_integrity("/tmp/cuems/video.tmp123456", "d41d8cd98f00b204e9800998ecf8427e")
        True
    """
    hash_md5 = md5()
    with open(path, "rb") as file_to_check:
        for chunk in iter(lambda: file_to_check.read(65536), b""):
            hash_md5.update(chunk)

    returned_md5 = hash_md5.hexdigest()
    if original_md5 != returned_md5:
        raise FileIntegrityError('MD5 mistmatch')

    return True

check_if_media_existed(filename)

Re-link projects that already referenced filename before this upload.

Queries ProjectMedia for rows whose media_filename matches filename, then calls CuemsDBProject.update_projects_existed_media for each referencing project so their XML UUIDs stay consistent.

Parameters:

Name Type Description Default
filename

Destination filename returned by CuemsDBMedia.new.

required
Source code in src/cuemseditor/CuemsUpload.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
def check_if_media_existed(self, filename):
    """Re-link projects that already referenced *filename* before this upload.

    Queries ``ProjectMedia`` for rows whose ``media_filename`` matches
    *filename*, then calls
    ``CuemsDBProject.update_projects_existed_media`` for each referencing
    project so their XML UUIDs stay consistent.

    Args:
        filename: Destination filename returned by ``CuemsDBMedia.new``.
    """
    Logger.debug(f"checking if file {filename} already existed in projects")
    projectmedia_list = self.server.db.media.check_if_media_existed_in_projects(filename)
    if projectmedia_list:
        for projectmedia in projectmedia_list:
            Logger.info("file {} already existed in project: {}".format(filename, projectmedia.project_id))
            # One project that cannot be relinked must not stop the others
            # nor fail the upload: the file is already in the library
            # (869fat84r D20, Q2).
            try:
                self.server.db.project.update_projects_existed_media(projectmedia.project_id, filename)
            except Exception as e:
                Logger.error(f'could not relink {filename} in project {projectmedia.project_id}: '
                             f'{type(e).__name__}: {e}')
    else:
        Logger.debug("file did not exist in any project, no action needed")

message_handler() async

Main receive loop for the upload WebSocket connection.

Dispatches string frames to :meth:process_upload_message and binary frames to :meth:process_upload_packet. Exits cleanly when the WebSocket is closed.

Source code in src/cuemseditor/CuemsUpload.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
async def message_handler(self):
    """Main receive loop for the upload WebSocket connection.

    Dispatches string frames to :meth:`process_upload_message` and binary
    frames to :meth:`process_upload_packet`.  Exits cleanly when the
    WebSocket is closed.
    """
    while True:
        try:
            message = await self.websocket.recv()
            if isinstance(message, str):
                await self.process_upload_message(message)
            elif isinstance(message, bytes):
                await self.process_upload_packet(message)
        except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError):
            Logger.debug('upload connection closed, exiting loop')
            break

message_sender(message) async

Send a JSON string frame to the client, ignoring closed-connection errors.

Parameters:

Name Type Description Default
message

JSON-serialised string to send.

required
Source code in src/cuemseditor/CuemsUpload.py
81
82
83
84
85
86
87
88
89
90
async def message_sender(self, message):
    """Send a JSON string frame to the client, ignoring closed-connection errors.

    Args:
        message: JSON-serialised string to send.
    """
    try:
        await self.websocket.send(message)
    except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
        Logger.debug(e)

process_upload_message(message) async

Dispatch a JSON control frame from the client.

Handles the "upload" action by delegating to :meth:set_upload. Unknown actions are silently ignored.

Parameters:

Name Type Description Default
message

Raw JSON string received from the WebSocket.

required

Returns:

Type Description

False if the frame has no action key; otherwise None.

Source code in src/cuemseditor/CuemsUpload.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
async def process_upload_message(self, message):
    """Dispatch a JSON control frame from the client.

    Handles the ``"upload"`` action by delegating to :meth:`set_upload`.
    Unknown actions are silently ignored.

    Args:
        message: Raw JSON string received from the WebSocket.

    Returns:
        ``False`` if the frame has no ``action`` key; otherwise ``None``.
    """
    data = json.loads(message)
    if 'action' not in data:
        return False
    if data['action'] == 'upload':
        await self.set_upload(file_info=data["value"])

process_upload_packet(bin_data) async

Receive and assemble binary data frames into the tmp file.

Opens the tmp file in write-binary mode and loops, writing each incoming binary frame and ACKing it with {"ready": true}. When the client sends the "finished" JSON control frame the loop exits and :meth:upload_done is called.

Parameters:

Name Type Description Default
bin_data

First binary frame received (written before the loop).

required

Returns:

Type Description

False if no action key is present in the finish frame.

Source code in src/cuemseditor/CuemsUpload.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
async def process_upload_packet(self, bin_data):
    """Receive and assemble binary data frames into the tmp file.

    Opens the tmp file in write-binary mode and loops, writing each
    incoming binary frame and ACKing it with ``{"ready": true}``.
    When the client sends the ``"finished"`` JSON control frame the loop
    exits and :meth:`upload_done` is called.

    Args:
        bin_data: First binary frame received (written before the loop).

    Returns:
        ``False`` if no ``action`` key is present in the finish frame.
    """
    if self.uploading == 'Ready':
        async with aiofiles.open(self.tmp_file_path(), mode='wb', loop=self.server.event_loop, executor=self.server.executor) as stream:
            await stream.write(bin_data)
            self.bytes_received += len(bin_data)
            await self.message_sender(json.dumps({"ready": True}))

            while True:
                message = await self.websocket.recv()
                if isinstance(message, bytes):
                    await stream.write(message)
                    self.bytes_received += len(message)
                    await self.message_sender(json.dumps({"ready": True}))
                else:
                    data = json.loads(message)
                    if 'action' not in data:
                        return False
                    if data['action'] == 'finished':
                        await stream.flush()
                        await stream.close()
                        await self.upload_done(data["value"])
                    break

set_upload(file_info) async

Initialise upload state and signal readiness to the client.

Sanitises the filename, creates a unique tmp filename <name>.tmp<random>, verifies the tmp path is accessible, and sends {"ready": true} to the client.

Parameters:

Name Type Description Default
file_info

Dict from the client init frame with at minimum name (str) and size (int in bytes).

required

Returns:

Type Description

False on error (error message already sent to client).

Source code in src/cuemseditor/CuemsUpload.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
async def set_upload(self, file_info):
    """Initialise upload state and signal readiness to the client.

    Sanitises the filename, creates a unique tmp filename
    ``<name>.tmp<random>``, verifies the tmp path is accessible, and
    sends ``{"ready": true}`` to the client.

    Args:
        file_info: Dict from the client init frame with at minimum
            ``name`` (str) and ``size`` (int in bytes).

    Returns:
        ``False`` on error (error message already sent to client).
    """
    if not os.path.exists(self.media_path):
        Logger.error("upload folder doenst exists")
        await self.message_sender(json.dumps({'error': 'upload folder doenst exist', 'fatal': True}))
        return False

    self.filename = StringSanitizer.sanitize_file_name(file_info['name'])
    self.tmp_filename = self.filename + '.tmp' + str(randint(100000, 999999))
    Logger.debug('tmp upload path: {}'.format(self.tmp_file_path()))

    if not os.path.exists(self.tmp_file_path()):
        self.filesize = file_info['size']
        self.uploading = 'Ready'
        await self.message_sender(json.dumps({"ready": True}))
    else:
        await self.message_sender(json.dumps({'error': 'file already exists', 'fatal': True}))
        Logger.error("file already exists")

tmp_file_path()

Return the absolute path to the current tmp upload file.

Returns:

Type Description

Absolute path string, or None if no upload is in progress.

Source code in src/cuemseditor/CuemsUpload.py
269
270
271
272
273
274
275
276
def tmp_file_path(self):
    """Return the absolute path to the current tmp upload file.

    Returns:
        Absolute path string, or ``None`` if no upload is in progress.
    """
    if not self.tmp_filename is None:
        return os.path.join(self.tmp_path, self.tmp_filename)

upload_done(received_md5) async

Verify upload integrity and move the file into the media library.

Steps:

  1. :meth:check_file_integrity — verifies the MD5 digest of the tmp file against received_md5.
  2. CuemsDBMedia.new — moves the tmp file to the media library and creates the DB record and sidecars.
  3. :meth:check_if_media_existed — re-links projects that already referenced the uploaded filename.
  4. Sends {"close": true} to the client.

Parameters:

Name Type Description Default
received_md5

Hex MD5 digest string sent by the client.

required
Source code in src/cuemseditor/CuemsUpload.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
async def upload_done(self, received_md5):
    """Verify upload integrity and move the file into the media library.

    Steps:

    1. :meth:`check_file_integrity` — verifies the MD5 digest of the tmp
       file against *received_md5*.
    2. ``CuemsDBMedia.new`` — moves the tmp file to the media library and
       creates the DB record and sidecars.
    3. :meth:`check_if_media_existed` — re-links projects that already
       referenced the uploaded filename.
    4. Sends ``{"close": true}`` to the client.

    Args:
        received_md5: Hex MD5 digest string sent by the client.
    """
    try:
        await self.server.event_loop.run_in_executor(self.server.executor, self.check_file_integrity, self.tmp_file_path(), received_md5)

        # The MD5 the client sent, just verified above, is stored with the
        # media (file_md5, 869fat84r D18): no second hash of the file.
        dest_filename = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.new, self.tmp_file_path(), self.filename, received_md5)
        self.tmp_filename = None
        Logger.debug('upload completed')
        await self.server.event_loop.run_in_executor(self.server.executor, self.check_if_media_existed, dest_filename)
        await self.message_sender(json.dumps({"close": True}))
        await self.server.notify_others_list_changes(None, "file_list")
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.message_sender(json.dumps({'error': 'error saving file', 'fatal': True}))

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

CuemsWsServer

Top-level asyncio WebSocket server for the CueMS editor backend.

Accepts connections on two paths:

  • / or /?session=<uuid> — main project-manager session handled by :class:CuemsWsUser.
  • /upload — binary file upload session handled by :class:CuemsUpload.

Owns the NNG Communicator (engine IPC), the CuemsDBManager, the active users dict, the sessions state, and a thread-pool executor for blocking DB and filesystem operations.

Example

server = CuemsWsServer(settings_dict, mappings_dict) server.start(port=9092)

The settings_dict must contain at minimum:

.. code-block:: python

{
    "editor_ipc": "ipc:///tmp/cuems-editor.sock",
    "tmp_path": "/tmp/cuems",
    "session_uuid": "<uuid>",
    "library_path": "/var/lib/cuems/library",
    # ... plus all keys required by CuemsDBManager
}
Source code in src/cuemseditor/CuemsWsServer.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
class CuemsWsServer():
    """Top-level asyncio WebSocket server for the CueMS editor backend.

    Accepts connections on two paths:

    * ``/`` or ``/?session=<uuid>`` — main project-manager session handled by
      :class:`CuemsWsUser`.
    * ``/upload`` — binary file upload session handled by
      :class:`CuemsUpload`.

    Owns the NNG ``Communicator`` (engine IPC), the ``CuemsDBManager``, the
    active ``users`` dict, the ``sessions`` state, and a thread-pool
    ``executor`` for blocking DB and filesystem operations.

    Example:
        >>> server = CuemsWsServer(settings_dict, mappings_dict)
        >>> server.start(port=9092)

    The ``settings_dict`` must contain at minimum:

    .. code-block:: python

        {
            "editor_ipc": "ipc:///tmp/cuems-editor.sock",
            "tmp_path": "/tmp/cuems",
            "session_uuid": "<uuid>",
            "library_path": "/var/lib/cuems/library",
            # ... plus all keys required by CuemsDBManager
        }
    """

    def __init__(self, settings_dict, mappings_dict):
        """Initialise the server: bind the NNG communicator and validate the tmp path.

        Does **not** start listening; call :meth:`start` to enter the event
        loop.

        Args:
            settings_dict: Settings dict from ``settings.xml``; required keys
                include ``editor_ipc``, ``tmp_path``, ``session_uuid``, and
                ``library_path``.
            mappings_dict: Output mapping dict from ``project_mappings.xml``;
                passed verbatim to the frontend as the initial mappings
                message and updated when nodes change adoption status.

        Raises:
            KeyError: If a required key is absent from *settings_dict*.
            FileNotFoundError: If ``tmp_path`` does not exist or is not
                read/write/executable.
        """
        self.engine_communicator = Communicator(address=settings_dict['editor_ipc'])
        #self.engine_queue = Comunicator(address="ipc:///tmp/test2.sock")
        self.engine_messages = list()
        self.users = dict()
        self.sessions = dict()
        self.settings_dict = settings_dict
        self.mappings_dict = mappings_dict
        self.initital_template = create_script()
        try:
            self.tmp_path = self.settings_dict['tmp_path']
            self.session_uuid = self.settings_dict['session_uuid']
            self.library_path = self.settings_dict['library_path']
        except KeyError as e:
            Logger.error(f'can not read settings {e}')
            raise e
        Logger.debug(f'library path set to : {self.library_path}')

        if (not os.path.exists(self.tmp_path)) or (not os.access(self.tmp_path, os.X_OK & os.R_OK & os.W_OK)):
            Logger.error("error: upload folder is not usable")
            raise FileNotFoundError('Can not access upload folder')

        self.network_map_watcher_task = None
        self.reload_network_map_nodes()

    def start(self, port=None):
        """Enter the asyncio event loop and start listening for connections.

        Blocks until :meth:`stop` is called (via SIGINT or SIGTERM).

        Args:
            port: TCP port to listen on.  May also be pre-set via
                ``self.port``; raises ``ValueError`` if neither is provided.

        Raises:
            ValueError: If *port* is ``None`` and ``self.port`` has not been
                set.
        """
        # Use provided port, or fall back to self.port if set
        if port is None:
            if not hasattr(self, 'port') or self.port is None:
                raise ValueError("port argument is required or must be set on server instance")
            port = self.port
        self.port = port
        self.host = 'localhost'
        newfeature = asyncio.get_event_loop().run_until_complete(self.run_async_server())

    async def run_async_server(self):
        """Initialise the DB, executor, and WebSocket server, then serve forever.

        Called by :meth:`start`.  Sets up:

        * ``self.db`` — ``CuemsDBManager`` instance.
        * ``self.executor`` — ``ThreadPoolExecutor`` for blocking work.
        * ``self.project_server`` — the ``websockets`` server object.
        * SIGINT / SIGTERM handlers that call :meth:`stop`.
        """
        self.db = CuemsDBManager(self.settings_dict)
        self.event_loop = asyncio.get_event_loop()

        self.executor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix='ws_ProjectManager_ThreadPoolExecutor', max_workers=5)  # TODO: adjust max workers
        #self.event_loop.set_exception_handler(self.exception_handler) ### TODO:UNCOMENT FOR PRODUCTION
        self.project_server = await serve(self.connection_handler, self.host, self.port)
        self.network_map_watcher_task = asyncio.create_task(self.watch_network_map())
        for sig in (signal.SIGINT, signal.SIGTERM):
            self.event_loop.add_signal_handler(sig, self.stop)
        Logger.info('server listening on {}, port {}'.format(self.host, self.port))
        await self.project_server.serve_forever()

    def stop(self):
        """Initiate a graceful shutdown from a signal handler.

        Schedules ``project_server.close()`` and :meth:`stop_async` on the
        event loop from any thread context.
        """
        #self.event_loop.call_soon_threadsafe(self.queue_task.cancel)
        self.event_loop.call_soon_threadsafe(self.project_server.close)
        Logger.info('ws server closing')
        asyncio.run_coroutine_threadsafe(self.stop_async(), self.event_loop)

    async def stop_async(self):
        """Wait for the WebSocket server to finish closing, then stop the event loop."""
        watcher = getattr(self, 'network_map_watcher_task', None)
        if watcher is not None:
            watcher.cancel()
        await self.project_server.wait_closed()
        Logger.info('ws server closed')
        self.event_loop.call_soon(self.event_loop.stop)
        Logger.info('event loop stoped')

    async def connection_handler(self, websocket):
        """Route an incoming WebSocket connection to the correct session type.

        ``/`` and ``/?session=<uuid>`` go to :meth:`project_manager_session`;
        ``/upload`` goes to :meth:`upload_session`; other paths are logged and
        dropped.

        Args:
            websocket: Incoming ``websockets`` connection object.
        """
        Logger.info("new connection: {}, path: {}".format(websocket.remote_address, websocket.request.path))
        path = websocket.request.path
        if (path == '/' or path[0:9] == '/?session'):                   # project manager
            await self.project_manager_session(websocket, path)
        elif path == '/upload':                                           # file upload
            await self.upload_session(websocket)
        else:
            Logger.info("unknown path: {}".format(path))

    async def project_manager_session(self, websocket, path):
        """Manage the full lifecycle of a project-manager WebSocket session.

        Creates a :class:`CuemsWsUser`, registers it, sends the initial
        template and mappings messages, then runs the consumer/producer/
        processor task set.  Cleans up via :meth:`unregister` on exit.

        Args:
            websocket: The accepted WebSocket connection.
            path: The request path (used to extract an optional session UUID).
        """
        user_session = CuemsWsUser(self, websocket)
        await self.register(user_session, path)
        await user_session.outgoing.put(self.initial_json_template())
        await user_session.outgoing.put(self.initial_setting_message())
        try:
            consumer_task = asyncio.create_task(user_session.consumer_handler())
            producer_task = asyncio.create_task(user_session.producer_handler())
            # start 3 message processing task so a load or any other time consuming action still leaves with 2 tasks running  and interface feels responsive. TODO:discuss this
            processor_tasks = [asyncio.create_task(user_session.consumer()) for _ in range(3)]

            done_tasks, pending_tasks = await asyncio.wait([consumer_task, producer_task, *processor_tasks], return_when=asyncio.FIRST_COMPLETED)
            for task in pending_tasks:
                task.cancel()

        except Exception as e:
            Logger.debug(f"{e}, {type(e)}")

        finally:
            await self.unregister(user_session)

    async def upload_session(self, websocket):
        """Handle a binary file upload over the ``/upload`` WebSocket path.

        Creates a :class:`CuemsUpload` and runs its :meth:`~CuemsUpload.message_handler`
        until the connection closes.

        Args:
            websocket: The accepted WebSocket connection on the ``/upload`` path.
        """
        user_upload_session = CuemsUpload(self, websocket)
        Logger.info("new upload session: {}".format(user_upload_session))

        await user_upload_session.message_handler()
        Logger.info("upload session ended: {}".format(user_upload_session))

    async def register(self, user_session, path):
        """Register a new user session and restore any prior session state.

        Adds *user_session* to ``self.users``, broadcasts the updated user
        count, assigns a ``session_id`` via :meth:`check_session`, and calls
        :meth:`load_session`.

        Args:
            user_session: The :class:`CuemsWsUser` to register.
            path: Request path string (may carry a ``?session=<uuid>`` param).
        """
        Logger.info("user registered: {}".format(id(user_session.websocket)))
        self.users[user_session] = None
        await self.notify_users("users")
        user_session.session_id = await self.check_session(user_session, path)
        await self.load_session(user_session)

    async def check_session(self, user_session, path):
        """Parse or generate a session UUID from the request path.

        If the path carries a ``?session=<uuid>`` parameter and the UUID is
        already in ``self.sessions``, it is reused.  Otherwise a new UUID is
        generated.  The session entry is updated with the current WebSocket id
        and the UUID is sent to the client via :meth:`notify_session`.

        Args:
            user_session: The :class:`CuemsWsUser` being registered.
            path: Request path string.

        Returns:
            Session UUID string.
        """
        session_uuid_patern = r"/\?session=(?P<uuid>[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})?"

        matches = re.search(session_uuid_patern, path)
        if matches:
            if (matches.groupdict()['uuid'] != None):
                uuid = matches.groupdict()['uuid']
                if uuid not in self.sessions:
                    Logger.debug(f"uuid not found {uuid}, creating new session")
                    uuid = str(new_uuid())
                else:
                    Logger.debug(f"session_id found, reusing {uuid}")
            else:
                uuid = str(new_uuid())
        else:
            uuid = str(new_uuid())
        try:
            self.sessions[uuid]['ws'] = id(user_session.websocket)
        except KeyError:
            self.sessions[uuid] = {'ws': id(user_session.websocket)}

        await self.notify_session(user_session, uuid)

        return uuid

    async def load_session(self, user_session):
        """Restore session state for a reconnecting user.

        Currently a no-op; the commented-out body would re-send the previously
        loaded project.  Reserved for future reconnection support.

        Args:
            user_session: The :class:`CuemsWsUser` whose prior state to
                restore.
        """
        pass
        # try:
        #     await user_session.send_project(self.sessions[user_session.session_id]['loaded_project'], 'project_load')
        # except KeyError:
        #     pass

    async def notify_session(self, user_session, uuid):
        """Send the assigned session UUID to the client.

        Args:
            user_session: The :class:`CuemsWsUser` to notify.
            uuid: Session UUID string to send.
        """
        message = json.dumps({"type": "session_id", "value": uuid})
        await user_session.outgoing.put(message)

    async def unregister(self, user_task):
        """Remove a user session and broadcast the updated user count.

        Args:
            user_task: The :class:`CuemsWsUser` to remove.
        """
        Logger.info("user unregistered: {}".format(id(user_task.websocket)))
        self.users.pop(user_task, None)
        await self.notify_users("users")

    async def notify_others_list_changes(self, calling_user, list_type):
        """Notify all other connected users that a list has changed.

        Used after project/media create, delete, or restore operations so all
        open browser tabs refresh their lists.

        Args:
            calling_user: The user who triggered the change; excluded from
                the notification.
            list_type: String list name to include in the message value (e.g.
                ``"project_list"``, ``"file_list"``).
        """
        if self.users:  # notify others, not the user trigering the action, and only if the have same project loaded
            message = json.dumps({"type": "list_update", "value": list_type})
            for user, project in self.users.items():
                if user is not calling_user:
                    await user.outgoing.put(message)
                    Logger.debug('notifing {} {}'.format(user, list_type))

    async def send_to_project_sessions(self, project_uuid, message, skip=None):
        """Send *message* (an encoded frame), as given, to every session whose
        last loaded, saved or created project is *project_uuid*, except
        *skip* (869fat84r D20: ``media_check_report``).

        ``users`` holds the last project a session touched, not strictly the
        one open now: a session that moved on may receive a report for its
        previous project, which the UI keys by project.
        """
        for user, project in list(self.users.items()):
            if user is not skip and str(project) == str(project_uuid):
                await user.outgoing.put(message)

    async def notify_others_same_project(self, calling_user, msg_type, project_uuid=None):
        """Notify users who have the same project loaded that it was modified.

        If *project_uuid* is provided, only users with that exact project
        loaded are notified.  If ``None``, uses the calling user's loaded
        project.

        Args:
            calling_user: The user who triggered the change; excluded from
                the notification.
            msg_type: Message type string (not currently used in the payload).
            project_uuid: UUID string of the modified project, or ``None`` to
                infer from *calling_user*'s session.
        """
        if self.users:  # notify others, not the user trigering the action, and only if the have same project loaded
            message = json.dumps({"type": "project_update", "value": project_uuid})
            for user, project in self.users.items():
                if user is not calling_user:
                    if project_uuid is not None:
                        if str(project) != str(project_uuid):
                            continue
                    else:
                        if str(project) != str(self.users[calling_user]):
                            continue

                    Logger.debug('same project loaded')
                    await user.outgoing.put(message)
                    Logger.debug('notifing {}'.format(user))

    async def notify_users(self, type):
        """Broadcast a message to all connected users.

        Args:
            type: Message type string; only ``"users"`` is currently used
                (sends the connected-user count).
        """
        if self.users:  # asyncio.wait doesn't accept an empty dict
            message = self.users_event(type)
            for user in self.users:
                await user.outgoing.put(message)

    # warning, these non async functions should be not blocking or user @sync_to_async to get their own thread

    def merge_node_data(self, existing_nodes, new_nodes):
        """Merge existing node data (with outputs) with new node data (with updated status).

        Matches nodes by UUID and preserves outputs configuration while updating
        basic fields (``online``, ``adopted``, ``ip``, ``name``, ``node_type``,
        ``mac``).

        Args:
            existing_nodes: List of existing node dicts with outputs configuration.
            new_nodes: List of new node dicts from ``network_map.xml`` with
                updated status.

        Returns:
            List of merged node dicts; nodes absent from *existing_nodes* are
            included as-is from *new_nodes*.
        """
        # Create a lookup dict for existing nodes by UUID
        existing_by_uuid = {}
        for node_item in existing_nodes:
            if 'node' in node_item:
                uuid = node_item['node'].get('uuid')
                if uuid:
                    existing_by_uuid[uuid] = node_item

        # Merge new nodes with existing data
        merged_nodes = []
        for new_node_item in new_nodes:
            if 'node' not in new_node_item:
                continue

            new_node = new_node_item['node']
            uuid = new_node.get('uuid')

            if uuid and uuid in existing_by_uuid:
                # Node exists - merge data
                existing_node = existing_by_uuid[uuid]['node'].copy()

                # Update basic fields from network_map (online, adopted, ip, name, etc.).
                # role_id/alias/hostname are the optional identity fields
                # introduced by feat/node-identity in cuems-common: see
                # docs/node-identity-contract.md in cuems-common. They drive
                # the frontend's human-readable node label and cuems-logs'
                # -n filter resolution. Propagate them like the rest of the
                # mutable state so the UI sees changes after nodeconf updates
                # the XML (e.g. after an adoption or apply-identity).
                basic_fields = ['online', 'adopted', 'ip', 'name', 'node_type', 'mac',
                                'role_id', 'alias', 'hostname']
                for field in basic_fields:
                    if field in new_node:
                        existing_node[field] = new_node[field]

                # Keep outputs (audio, video, dmx) from existing node
                merged_nodes.append({'node': existing_node})
            else:
                # New node not in existing data - add as-is
                merged_nodes.append(new_node_item)

        return merged_nodes

    def reload_network_map_nodes(self):
        """Reload ``network_map.xml`` and update ``mappings_dict`` with current node status.

        Retries up to 3 times with exponential back-off in case the file is
        being written concurrently.  Merges the reloaded node data with the
        existing ``mappings_dict`` via :meth:`merge_node_data` to preserve
        output configurations while refreshing online/adopted status.

        Returns:
            ``True`` on success, ``False`` if the file does not exist or all
            retries fail.
        """
        max_retries = 3
        initial_delay = 0.1
        delay_after_write = 0.05

        for attempt in range(max_retries):
            try:
                cf_manager = ConfigManager(load_all=False)
                network_map_file = cf_manager.conf_path('network_map.xml')

                if not os.path.isfile(network_map_file):
                    if attempt == 0:
                        Logger.warning(f'network_map.xml not found at {network_map_file}')
                    return False

                time.sleep(delay_after_write)

                cf_manager.load_network_map()
                # network_map is now a dict with 'node_list' key
                network_map_dict = cf_manager.network_map
                nodes, new_nodes = NetworkMap.get_nodes_by_adoption(network_map_dict)

                # Merge with existing data to preserve outputs configuration
                # Combine both lists to handle nodes that change adoption status
                existing_nodes = self.mappings_dict.get('nodes') or []
                existing_new_nodes = self.mappings_dict.get('new_nodes') or []
                all_existing = existing_nodes + existing_new_nodes

                merged_nodes = self.merge_node_data(all_existing, nodes)
                merged_new_nodes = self.merge_node_data(all_existing, new_nodes)

                self.mappings_dict['nodes'] = merged_nodes
                self.mappings_dict['new_nodes'] = merged_new_nodes
                self.mappings_dict['nodeconf_available'] = self.nodeconf_available()
                Logger.debug(f'Network map reloaded successfully: {len(merged_nodes)} adopted nodes, {len(merged_new_nodes)} new nodes')
                return True

            except Exception as e:
                if attempt < max_retries - 1:
                    delay = initial_delay * (2 ** attempt)
                    Logger.warning(f'Error loading network_map (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {delay}s...')
                    time.sleep(delay)
                else:
                    Logger.error(f'Error loading network_map after {max_retries} attempts: {e}')
                    return False

        return False

    def initial_json_template(self):
        """Build the initial ``CuemsScript`` template message sent to new clients.

        Returns:
            JSON string ``{"type": "initial_template", "value": {"CuemsScript": ...}}``.
        """
        return json.dumps({"type": "initial_template", "value": {"CuemsScript": self.initital_template}})

    NODECONF_IPC = '/tmp/nodeconf.ipc'

    def nodeconf_available(self):
        """Is cuems-nodeconf reachable right now?

        Adoption goes engine -> /tmp/nodeconf.ipc, and nodeconf ships disabled
        on most of the fleet; there every adopt/un-adopt click can only end in
        an error, so the UI wants to grey the controls instead.

        Deliberately sampled per message rather than cached at map-reload time:
        nodeconf skips the write when the map's content has not changed, and it
        writes nothing at all once it is stopped — so a flag refreshed only on
        map changes would happily report `true` for as long as the operator
        left the panel open after `systemctl stop cuems-nodeconf`.
        """
        return os.path.exists(self.NODECONF_IPC)

    def initial_setting_message(self):
        """Build the initial mappings message sent to new clients.

        Returns:
            JSON string ``{"type": "initial_mappings", "value": <mappings_dict>}``.
        """
        self.mappings_dict['nodeconf_available'] = self.nodeconf_available()
        return json.dumps({"type": "initial_mappings", "value": self.mappings_dict})

    NETWORK_MAP_POLL_S = 3.0

    async def watch_network_map(self):
        """Broadcast the node list whenever cuems-nodeconf rewrites the map.

        cuems-nodeconf is resident: it re-merges avahi discovery and rewrites
        network_map.xml on every (debounced) avahi event or every 30 s. We used
        to read that file only on connect and right after our own successful
        nodelist_modify, so a node powered on AFTER the operator opened the
        settings panel never appeared in `new_nodes` — the adoption feature
        looked broken exactly when it was needed.

        One mtime poll per host (not per client) at NETWORK_MAP_POLL_S; on a
        change every connected UI gets the refreshed list.
        """
        try:
            cf_manager = ConfigManager(load_all=False)
            map_file = cf_manager.conf_path('network_map.xml')
        except Exception as e:
            Logger.warning(f'network_map watcher disabled, cannot resolve path: {e}')
            return

        last_mtime = None
        try:
            last_mtime = os.stat(map_file).st_mtime
        except OSError:
            pass
        last_nodeconf = self.nodeconf_available()

        Logger.info(f'watching {map_file} for node list changes')
        while True:
            try:
                await asyncio.sleep(self.NETWORK_MAP_POLL_S)

                # nodeconf stopping or starting changes nothing on disk, so the
                # mtime check below would never notice it — and the UI would go
                # on offering adopt buttons that can only fail.
                nodeconf_now = self.nodeconf_available()
                if nodeconf_now != last_nodeconf:
                    Logger.info(
                        f'cuems-nodeconf availability changed: {nodeconf_now}'
                    )
                    last_nodeconf = nodeconf_now
                    await self.notify_all_node_list_update()

                try:
                    mtime = os.stat(map_file).st_mtime
                except OSError:
                    # nodeconf may be mid-replace, or the file may not exist on
                    # a host where nodeconf never ran. Neither is an error.
                    continue
                if last_mtime is not None and mtime != last_mtime:
                    Logger.debug('network_map.xml changed on disk, broadcasting')
                    await self.notify_all_node_list_update()
                last_mtime = mtime
            except asyncio.CancelledError:
                Logger.info('network_map watcher stopped')
                raise
            except Exception as e:
                Logger.warning(f'network_map watcher error: {e}')

    async def notify_all_node_list_update(self):
        """Reload the network map and broadcast the updated node list to all clients.

        Runs :meth:`reload_network_map_nodes` in the thread-pool executor to
        avoid blocking the event loop during file I/O.  Broadcasts
        :meth:`initial_setting_message` to every connected user on success.
        """
        reload_success = await self.event_loop.run_in_executor(
            self.executor,
            self.reload_network_map_nodes
        )
        if reload_success:
            if self.users:
                message = self.initial_setting_message()
                for user in self.users:
                    await user.outgoing.put(message)
                Logger.debug(f'Broadcasted updated node list to {len(self.users)} connected client(s)')
            else:
                Logger.debug('Node list updated but no clients connected')
        else:
            Logger.warning('Failed to reload network map, not broadcasting update')

    def users_event(self, type, uuid=None):
        """Build a users-count or item-modified event message.

        Args:
            type: ``"users"`` to send the connected-user count; any other
                value builds an item-modified message (currently unused).
            uuid: Item UUID for non-``"users"`` message types.

        Returns:
            JSON string.
        """
        if type == "users":
            return json.dumps({"type": type, "value": len(self.users)})
        else:
            return json.dumps({"type": type, "uuid": uuid, "value": "modified in server"})  # TODO: not used

    def exception_handler(self, loop, context):
        """Log uncaught asyncio exceptions at DEBUG level.

        Registered as the event loop's exception handler in production mode
        (currently commented out to keep stack traces visible during
        development).

        Args:
            loop: The asyncio event loop.
            context: Exception context dict provided by asyncio.
        """
        Logger.debug("Caught the following exception: (ignore if on closing)")
        Logger.debug(context['message'])

__init__(settings_dict, mappings_dict)

Initialise the server: bind the NNG communicator and validate the tmp path.

Does not start listening; call :meth:start to enter the event loop.

Parameters:

Name Type Description Default
settings_dict

Settings dict from settings.xml; required keys include editor_ipc, tmp_path, session_uuid, and library_path.

required
mappings_dict

Output mapping dict from project_mappings.xml; passed verbatim to the frontend as the initial mappings message and updated when nodes change adoption status.

required

Raises:

Type Description
KeyError

If a required key is absent from settings_dict.

FileNotFoundError

If tmp_path does not exist or is not read/write/executable.

Source code in src/cuemseditor/CuemsWsServer.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def __init__(self, settings_dict, mappings_dict):
    """Initialise the server: bind the NNG communicator and validate the tmp path.

    Does **not** start listening; call :meth:`start` to enter the event
    loop.

    Args:
        settings_dict: Settings dict from ``settings.xml``; required keys
            include ``editor_ipc``, ``tmp_path``, ``session_uuid``, and
            ``library_path``.
        mappings_dict: Output mapping dict from ``project_mappings.xml``;
            passed verbatim to the frontend as the initial mappings
            message and updated when nodes change adoption status.

    Raises:
        KeyError: If a required key is absent from *settings_dict*.
        FileNotFoundError: If ``tmp_path`` does not exist or is not
            read/write/executable.
    """
    self.engine_communicator = Communicator(address=settings_dict['editor_ipc'])
    #self.engine_queue = Comunicator(address="ipc:///tmp/test2.sock")
    self.engine_messages = list()
    self.users = dict()
    self.sessions = dict()
    self.settings_dict = settings_dict
    self.mappings_dict = mappings_dict
    self.initital_template = create_script()
    try:
        self.tmp_path = self.settings_dict['tmp_path']
        self.session_uuid = self.settings_dict['session_uuid']
        self.library_path = self.settings_dict['library_path']
    except KeyError as e:
        Logger.error(f'can not read settings {e}')
        raise e
    Logger.debug(f'library path set to : {self.library_path}')

    if (not os.path.exists(self.tmp_path)) or (not os.access(self.tmp_path, os.X_OK & os.R_OK & os.W_OK)):
        Logger.error("error: upload folder is not usable")
        raise FileNotFoundError('Can not access upload folder')

    self.network_map_watcher_task = None
    self.reload_network_map_nodes()

check_session(user_session, path) async

Parse or generate a session UUID from the request path.

If the path carries a ?session=<uuid> parameter and the UUID is already in self.sessions, it is reused. Otherwise a new UUID is generated. The session entry is updated with the current WebSocket id and the UUID is sent to the client via :meth:notify_session.

Parameters:

Name Type Description Default
user_session

The :class:CuemsWsUser being registered.

required
path

Request path string.

required

Returns:

Type Description

Session UUID string.

Source code in src/cuemseditor/CuemsWsServer.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
async def check_session(self, user_session, path):
    """Parse or generate a session UUID from the request path.

    If the path carries a ``?session=<uuid>`` parameter and the UUID is
    already in ``self.sessions``, it is reused.  Otherwise a new UUID is
    generated.  The session entry is updated with the current WebSocket id
    and the UUID is sent to the client via :meth:`notify_session`.

    Args:
        user_session: The :class:`CuemsWsUser` being registered.
        path: Request path string.

    Returns:
        Session UUID string.
    """
    session_uuid_patern = r"/\?session=(?P<uuid>[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})?"

    matches = re.search(session_uuid_patern, path)
    if matches:
        if (matches.groupdict()['uuid'] != None):
            uuid = matches.groupdict()['uuid']
            if uuid not in self.sessions:
                Logger.debug(f"uuid not found {uuid}, creating new session")
                uuid = str(new_uuid())
            else:
                Logger.debug(f"session_id found, reusing {uuid}")
        else:
            uuid = str(new_uuid())
    else:
        uuid = str(new_uuid())
    try:
        self.sessions[uuid]['ws'] = id(user_session.websocket)
    except KeyError:
        self.sessions[uuid] = {'ws': id(user_session.websocket)}

    await self.notify_session(user_session, uuid)

    return uuid

connection_handler(websocket) async

Route an incoming WebSocket connection to the correct session type.

/ and /?session=<uuid> go to :meth:project_manager_session; /upload goes to :meth:upload_session; other paths are logged and dropped.

Parameters:

Name Type Description Default
websocket

Incoming websockets connection object.

required
Source code in src/cuemseditor/CuemsWsServer.py
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
async def connection_handler(self, websocket):
    """Route an incoming WebSocket connection to the correct session type.

    ``/`` and ``/?session=<uuid>`` go to :meth:`project_manager_session`;
    ``/upload`` goes to :meth:`upload_session`; other paths are logged and
    dropped.

    Args:
        websocket: Incoming ``websockets`` connection object.
    """
    Logger.info("new connection: {}, path: {}".format(websocket.remote_address, websocket.request.path))
    path = websocket.request.path
    if (path == '/' or path[0:9] == '/?session'):                   # project manager
        await self.project_manager_session(websocket, path)
    elif path == '/upload':                                           # file upload
        await self.upload_session(websocket)
    else:
        Logger.info("unknown path: {}".format(path))

exception_handler(loop, context)

Log uncaught asyncio exceptions at DEBUG level.

Registered as the event loop's exception handler in production mode (currently commented out to keep stack traces visible during development).

Parameters:

Name Type Description Default
loop

The asyncio event loop.

required
context

Exception context dict provided by asyncio.

required
Source code in src/cuemseditor/CuemsWsServer.py
652
653
654
655
656
657
658
659
660
661
662
663
664
def exception_handler(self, loop, context):
    """Log uncaught asyncio exceptions at DEBUG level.

    Registered as the event loop's exception handler in production mode
    (currently commented out to keep stack traces visible during
    development).

    Args:
        loop: The asyncio event loop.
        context: Exception context dict provided by asyncio.
    """
    Logger.debug("Caught the following exception: (ignore if on closing)")
    Logger.debug(context['message'])

initial_json_template()

Build the initial CuemsScript template message sent to new clients.

Returns:

Type Description

JSON string {"type": "initial_template", "value": {"CuemsScript": ...}}.

Source code in src/cuemseditor/CuemsWsServer.py
519
520
521
522
523
524
525
def initial_json_template(self):
    """Build the initial ``CuemsScript`` template message sent to new clients.

    Returns:
        JSON string ``{"type": "initial_template", "value": {"CuemsScript": ...}}``.
    """
    return json.dumps({"type": "initial_template", "value": {"CuemsScript": self.initital_template}})

initial_setting_message()

Build the initial mappings message sent to new clients.

Returns:

Type Description

JSON string {"type": "initial_mappings", "value": <mappings_dict>}.

Source code in src/cuemseditor/CuemsWsServer.py
544
545
546
547
548
549
550
551
def initial_setting_message(self):
    """Build the initial mappings message sent to new clients.

    Returns:
        JSON string ``{"type": "initial_mappings", "value": <mappings_dict>}``.
    """
    self.mappings_dict['nodeconf_available'] = self.nodeconf_available()
    return json.dumps({"type": "initial_mappings", "value": self.mappings_dict})

load_session(user_session) async

Restore session state for a reconnecting user.

Currently a no-op; the commented-out body would re-send the previously loaded project. Reserved for future reconnection support.

Parameters:

Name Type Description Default
user_session

The :class:CuemsWsUser whose prior state to restore.

required
Source code in src/cuemseditor/CuemsWsServer.py
290
291
292
293
294
295
296
297
298
299
300
async def load_session(self, user_session):
    """Restore session state for a reconnecting user.

    Currently a no-op; the commented-out body would re-send the previously
    loaded project.  Reserved for future reconnection support.

    Args:
        user_session: The :class:`CuemsWsUser` whose prior state to
            restore.
    """
    pass

merge_node_data(existing_nodes, new_nodes)

Merge existing node data (with outputs) with new node data (with updated status).

Matches nodes by UUID and preserves outputs configuration while updating basic fields (online, adopted, ip, name, node_type, mac).

Parameters:

Name Type Description Default
existing_nodes

List of existing node dicts with outputs configuration.

required
new_nodes

List of new node dicts from network_map.xml with updated status.

required

Returns:

Type Description

List of merged node dicts; nodes absent from existing_nodes are

included as-is from new_nodes.

Source code in src/cuemseditor/CuemsWsServer.py
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
def merge_node_data(self, existing_nodes, new_nodes):
    """Merge existing node data (with outputs) with new node data (with updated status).

    Matches nodes by UUID and preserves outputs configuration while updating
    basic fields (``online``, ``adopted``, ``ip``, ``name``, ``node_type``,
    ``mac``).

    Args:
        existing_nodes: List of existing node dicts with outputs configuration.
        new_nodes: List of new node dicts from ``network_map.xml`` with
            updated status.

    Returns:
        List of merged node dicts; nodes absent from *existing_nodes* are
        included as-is from *new_nodes*.
    """
    # Create a lookup dict for existing nodes by UUID
    existing_by_uuid = {}
    for node_item in existing_nodes:
        if 'node' in node_item:
            uuid = node_item['node'].get('uuid')
            if uuid:
                existing_by_uuid[uuid] = node_item

    # Merge new nodes with existing data
    merged_nodes = []
    for new_node_item in new_nodes:
        if 'node' not in new_node_item:
            continue

        new_node = new_node_item['node']
        uuid = new_node.get('uuid')

        if uuid and uuid in existing_by_uuid:
            # Node exists - merge data
            existing_node = existing_by_uuid[uuid]['node'].copy()

            # Update basic fields from network_map (online, adopted, ip, name, etc.).
            # role_id/alias/hostname are the optional identity fields
            # introduced by feat/node-identity in cuems-common: see
            # docs/node-identity-contract.md in cuems-common. They drive
            # the frontend's human-readable node label and cuems-logs'
            # -n filter resolution. Propagate them like the rest of the
            # mutable state so the UI sees changes after nodeconf updates
            # the XML (e.g. after an adoption or apply-identity).
            basic_fields = ['online', 'adopted', 'ip', 'name', 'node_type', 'mac',
                            'role_id', 'alias', 'hostname']
            for field in basic_fields:
                if field in new_node:
                    existing_node[field] = new_node[field]

            # Keep outputs (audio, video, dmx) from existing node
            merged_nodes.append({'node': existing_node})
        else:
            # New node not in existing data - add as-is
            merged_nodes.append(new_node_item)

    return merged_nodes

nodeconf_available()

Is cuems-nodeconf reachable right now?

Adoption goes engine -> /tmp/nodeconf.ipc, and nodeconf ships disabled on most of the fleet; there every adopt/un-adopt click can only end in an error, so the UI wants to grey the controls instead.

Deliberately sampled per message rather than cached at map-reload time: nodeconf skips the write when the map's content has not changed, and it writes nothing at all once it is stopped — so a flag refreshed only on map changes would happily report true for as long as the operator left the panel open after systemctl stop cuems-nodeconf.

Source code in src/cuemseditor/CuemsWsServer.py
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def nodeconf_available(self):
    """Is cuems-nodeconf reachable right now?

    Adoption goes engine -> /tmp/nodeconf.ipc, and nodeconf ships disabled
    on most of the fleet; there every adopt/un-adopt click can only end in
    an error, so the UI wants to grey the controls instead.

    Deliberately sampled per message rather than cached at map-reload time:
    nodeconf skips the write when the map's content has not changed, and it
    writes nothing at all once it is stopped — so a flag refreshed only on
    map changes would happily report `true` for as long as the operator
    left the panel open after `systemctl stop cuems-nodeconf`.
    """
    return os.path.exists(self.NODECONF_IPC)

notify_all_node_list_update() async

Reload the network map and broadcast the updated node list to all clients.

Runs :meth:reload_network_map_nodes in the thread-pool executor to avoid blocking the event loop during file I/O. Broadcasts :meth:initial_setting_message to every connected user on success.

Source code in src/cuemseditor/CuemsWsServer.py
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
async def notify_all_node_list_update(self):
    """Reload the network map and broadcast the updated node list to all clients.

    Runs :meth:`reload_network_map_nodes` in the thread-pool executor to
    avoid blocking the event loop during file I/O.  Broadcasts
    :meth:`initial_setting_message` to every connected user on success.
    """
    reload_success = await self.event_loop.run_in_executor(
        self.executor,
        self.reload_network_map_nodes
    )
    if reload_success:
        if self.users:
            message = self.initial_setting_message()
            for user in self.users:
                await user.outgoing.put(message)
            Logger.debug(f'Broadcasted updated node list to {len(self.users)} connected client(s)')
        else:
            Logger.debug('Node list updated but no clients connected')
    else:
        Logger.warning('Failed to reload network map, not broadcasting update')

notify_others_list_changes(calling_user, list_type) async

Notify all other connected users that a list has changed.

Used after project/media create, delete, or restore operations so all open browser tabs refresh their lists.

Parameters:

Name Type Description Default
calling_user

The user who triggered the change; excluded from the notification.

required
list_type

String list name to include in the message value (e.g. "project_list", "file_list").

required
Source code in src/cuemseditor/CuemsWsServer.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
async def notify_others_list_changes(self, calling_user, list_type):
    """Notify all other connected users that a list has changed.

    Used after project/media create, delete, or restore operations so all
    open browser tabs refresh their lists.

    Args:
        calling_user: The user who triggered the change; excluded from
            the notification.
        list_type: String list name to include in the message value (e.g.
            ``"project_list"``, ``"file_list"``).
    """
    if self.users:  # notify others, not the user trigering the action, and only if the have same project loaded
        message = json.dumps({"type": "list_update", "value": list_type})
        for user, project in self.users.items():
            if user is not calling_user:
                await user.outgoing.put(message)
                Logger.debug('notifing {} {}'.format(user, list_type))

notify_others_same_project(calling_user, msg_type, project_uuid=None) async

Notify users who have the same project loaded that it was modified.

If project_uuid is provided, only users with that exact project loaded are notified. If None, uses the calling user's loaded project.

Parameters:

Name Type Description Default
calling_user

The user who triggered the change; excluded from the notification.

required
msg_type

Message type string (not currently used in the payload).

required
project_uuid

UUID string of the modified project, or None to infer from calling_user's session.

None
Source code in src/cuemseditor/CuemsWsServer.py
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
async def notify_others_same_project(self, calling_user, msg_type, project_uuid=None):
    """Notify users who have the same project loaded that it was modified.

    If *project_uuid* is provided, only users with that exact project
    loaded are notified.  If ``None``, uses the calling user's loaded
    project.

    Args:
        calling_user: The user who triggered the change; excluded from
            the notification.
        msg_type: Message type string (not currently used in the payload).
        project_uuid: UUID string of the modified project, or ``None`` to
            infer from *calling_user*'s session.
    """
    if self.users:  # notify others, not the user trigering the action, and only if the have same project loaded
        message = json.dumps({"type": "project_update", "value": project_uuid})
        for user, project in self.users.items():
            if user is not calling_user:
                if project_uuid is not None:
                    if str(project) != str(project_uuid):
                        continue
                else:
                    if str(project) != str(self.users[calling_user]):
                        continue

                Logger.debug('same project loaded')
                await user.outgoing.put(message)
                Logger.debug('notifing {}'.format(user))

notify_session(user_session, uuid) async

Send the assigned session UUID to the client.

Parameters:

Name Type Description Default
user_session

The :class:CuemsWsUser to notify.

required
uuid

Session UUID string to send.

required
Source code in src/cuemseditor/CuemsWsServer.py
306
307
308
309
310
311
312
313
314
async def notify_session(self, user_session, uuid):
    """Send the assigned session UUID to the client.

    Args:
        user_session: The :class:`CuemsWsUser` to notify.
        uuid: Session UUID string to send.
    """
    message = json.dumps({"type": "session_id", "value": uuid})
    await user_session.outgoing.put(message)

notify_users(type) async

Broadcast a message to all connected users.

Parameters:

Name Type Description Default
type

Message type string; only "users" is currently used (sends the connected-user count).

required
Source code in src/cuemseditor/CuemsWsServer.py
387
388
389
390
391
392
393
394
395
396
397
async def notify_users(self, type):
    """Broadcast a message to all connected users.

    Args:
        type: Message type string; only ``"users"`` is currently used
            (sends the connected-user count).
    """
    if self.users:  # asyncio.wait doesn't accept an empty dict
        message = self.users_event(type)
        for user in self.users:
            await user.outgoing.put(message)

project_manager_session(websocket, path) async

Manage the full lifecycle of a project-manager WebSocket session.

Creates a :class:CuemsWsUser, registers it, sends the initial template and mappings messages, then runs the consumer/producer/ processor task set. Cleans up via :meth:unregister on exit.

Parameters:

Name Type Description Default
websocket

The accepted WebSocket connection.

required
path

The request path (used to extract an optional session UUID).

required
Source code in src/cuemseditor/CuemsWsServer.py
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
async def project_manager_session(self, websocket, path):
    """Manage the full lifecycle of a project-manager WebSocket session.

    Creates a :class:`CuemsWsUser`, registers it, sends the initial
    template and mappings messages, then runs the consumer/producer/
    processor task set.  Cleans up via :meth:`unregister` on exit.

    Args:
        websocket: The accepted WebSocket connection.
        path: The request path (used to extract an optional session UUID).
    """
    user_session = CuemsWsUser(self, websocket)
    await self.register(user_session, path)
    await user_session.outgoing.put(self.initial_json_template())
    await user_session.outgoing.put(self.initial_setting_message())
    try:
        consumer_task = asyncio.create_task(user_session.consumer_handler())
        producer_task = asyncio.create_task(user_session.producer_handler())
        # start 3 message processing task so a load or any other time consuming action still leaves with 2 tasks running  and interface feels responsive. TODO:discuss this
        processor_tasks = [asyncio.create_task(user_session.consumer()) for _ in range(3)]

        done_tasks, pending_tasks = await asyncio.wait([consumer_task, producer_task, *processor_tasks], return_when=asyncio.FIRST_COMPLETED)
        for task in pending_tasks:
            task.cancel()

    except Exception as e:
        Logger.debug(f"{e}, {type(e)}")

    finally:
        await self.unregister(user_session)

register(user_session, path) async

Register a new user session and restore any prior session state.

Adds user_session to self.users, broadcasts the updated user count, assigns a session_id via :meth:check_session, and calls :meth:load_session.

Parameters:

Name Type Description Default
user_session

The :class:CuemsWsUser to register.

required
path

Request path string (may carry a ?session=<uuid> param).

required
Source code in src/cuemseditor/CuemsWsServer.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
async def register(self, user_session, path):
    """Register a new user session and restore any prior session state.

    Adds *user_session* to ``self.users``, broadcasts the updated user
    count, assigns a ``session_id`` via :meth:`check_session`, and calls
    :meth:`load_session`.

    Args:
        user_session: The :class:`CuemsWsUser` to register.
        path: Request path string (may carry a ``?session=<uuid>`` param).
    """
    Logger.info("user registered: {}".format(id(user_session.websocket)))
    self.users[user_session] = None
    await self.notify_users("users")
    user_session.session_id = await self.check_session(user_session, path)
    await self.load_session(user_session)

reload_network_map_nodes()

Reload network_map.xml and update mappings_dict with current node status.

Retries up to 3 times with exponential back-off in case the file is being written concurrently. Merges the reloaded node data with the existing mappings_dict via :meth:merge_node_data to preserve output configurations while refreshing online/adopted status.

Returns:

Type Description

True on success, False if the file does not exist or all

retries fail.

Source code in src/cuemseditor/CuemsWsServer.py
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
def reload_network_map_nodes(self):
    """Reload ``network_map.xml`` and update ``mappings_dict`` with current node status.

    Retries up to 3 times with exponential back-off in case the file is
    being written concurrently.  Merges the reloaded node data with the
    existing ``mappings_dict`` via :meth:`merge_node_data` to preserve
    output configurations while refreshing online/adopted status.

    Returns:
        ``True`` on success, ``False`` if the file does not exist or all
        retries fail.
    """
    max_retries = 3
    initial_delay = 0.1
    delay_after_write = 0.05

    for attempt in range(max_retries):
        try:
            cf_manager = ConfigManager(load_all=False)
            network_map_file = cf_manager.conf_path('network_map.xml')

            if not os.path.isfile(network_map_file):
                if attempt == 0:
                    Logger.warning(f'network_map.xml not found at {network_map_file}')
                return False

            time.sleep(delay_after_write)

            cf_manager.load_network_map()
            # network_map is now a dict with 'node_list' key
            network_map_dict = cf_manager.network_map
            nodes, new_nodes = NetworkMap.get_nodes_by_adoption(network_map_dict)

            # Merge with existing data to preserve outputs configuration
            # Combine both lists to handle nodes that change adoption status
            existing_nodes = self.mappings_dict.get('nodes') or []
            existing_new_nodes = self.mappings_dict.get('new_nodes') or []
            all_existing = existing_nodes + existing_new_nodes

            merged_nodes = self.merge_node_data(all_existing, nodes)
            merged_new_nodes = self.merge_node_data(all_existing, new_nodes)

            self.mappings_dict['nodes'] = merged_nodes
            self.mappings_dict['new_nodes'] = merged_new_nodes
            self.mappings_dict['nodeconf_available'] = self.nodeconf_available()
            Logger.debug(f'Network map reloaded successfully: {len(merged_nodes)} adopted nodes, {len(merged_new_nodes)} new nodes')
            return True

        except Exception as e:
            if attempt < max_retries - 1:
                delay = initial_delay * (2 ** attempt)
                Logger.warning(f'Error loading network_map (attempt {attempt + 1}/{max_retries}): {e}. Retrying in {delay}s...')
                time.sleep(delay)
            else:
                Logger.error(f'Error loading network_map after {max_retries} attempts: {e}')
                return False

    return False

run_async_server() async

Initialise the DB, executor, and WebSocket server, then serve forever.

Called by :meth:start. Sets up:

  • self.db — CuemsDBManager instance.
  • self.executor — ThreadPoolExecutor for blocking work.
  • self.project_server — the websockets server object.
  • SIGINT / SIGTERM handlers that call :meth:stop.
Source code in src/cuemseditor/CuemsWsServer.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
async def run_async_server(self):
    """Initialise the DB, executor, and WebSocket server, then serve forever.

    Called by :meth:`start`.  Sets up:

    * ``self.db`` — ``CuemsDBManager`` instance.
    * ``self.executor`` — ``ThreadPoolExecutor`` for blocking work.
    * ``self.project_server`` — the ``websockets`` server object.
    * SIGINT / SIGTERM handlers that call :meth:`stop`.
    """
    self.db = CuemsDBManager(self.settings_dict)
    self.event_loop = asyncio.get_event_loop()

    self.executor = concurrent.futures.ThreadPoolExecutor(thread_name_prefix='ws_ProjectManager_ThreadPoolExecutor', max_workers=5)  # TODO: adjust max workers
    #self.event_loop.set_exception_handler(self.exception_handler) ### TODO:UNCOMENT FOR PRODUCTION
    self.project_server = await serve(self.connection_handler, self.host, self.port)
    self.network_map_watcher_task = asyncio.create_task(self.watch_network_map())
    for sig in (signal.SIGINT, signal.SIGTERM):
        self.event_loop.add_signal_handler(sig, self.stop)
    Logger.info('server listening on {}, port {}'.format(self.host, self.port))
    await self.project_server.serve_forever()

send_to_project_sessions(project_uuid, message, skip=None) async

Send message (an encoded frame), as given, to every session whose last loaded, saved or created project is project_uuid, except skip (869fat84r D20: media_check_report).

users holds the last project a session touched, not strictly the one open now: a session that moved on may receive a report for its previous project, which the UI keys by project.

Source code in src/cuemseditor/CuemsWsServer.py
345
346
347
348
349
350
351
352
353
354
355
356
async def send_to_project_sessions(self, project_uuid, message, skip=None):
    """Send *message* (an encoded frame), as given, to every session whose
    last loaded, saved or created project is *project_uuid*, except
    *skip* (869fat84r D20: ``media_check_report``).

    ``users`` holds the last project a session touched, not strictly the
    one open now: a session that moved on may receive a report for its
    previous project, which the UI keys by project.
    """
    for user, project in list(self.users.items()):
        if user is not skip and str(project) == str(project_uuid):
            await user.outgoing.put(message)

start(port=None)

Enter the asyncio event loop and start listening for connections.

Blocks until :meth:stop is called (via SIGINT or SIGTERM).

Parameters:

Name Type Description Default
port

TCP port to listen on. May also be pre-set via self.port; raises ValueError if neither is provided.

None

Raises:

Type Description
ValueError

If port is None and self.port has not been set.

Source code in src/cuemseditor/CuemsWsServer.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def start(self, port=None):
    """Enter the asyncio event loop and start listening for connections.

    Blocks until :meth:`stop` is called (via SIGINT or SIGTERM).

    Args:
        port: TCP port to listen on.  May also be pre-set via
            ``self.port``; raises ``ValueError`` if neither is provided.

    Raises:
        ValueError: If *port* is ``None`` and ``self.port`` has not been
            set.
    """
    # Use provided port, or fall back to self.port if set
    if port is None:
        if not hasattr(self, 'port') or self.port is None:
            raise ValueError("port argument is required or must be set on server instance")
        port = self.port
    self.port = port
    self.host = 'localhost'
    newfeature = asyncio.get_event_loop().run_until_complete(self.run_async_server())

stop()

Initiate a graceful shutdown from a signal handler.

Schedules project_server.close() and :meth:stop_async on the event loop from any thread context.

Source code in src/cuemseditor/CuemsWsServer.py
148
149
150
151
152
153
154
155
156
157
def stop(self):
    """Initiate a graceful shutdown from a signal handler.

    Schedules ``project_server.close()`` and :meth:`stop_async` on the
    event loop from any thread context.
    """
    #self.event_loop.call_soon_threadsafe(self.queue_task.cancel)
    self.event_loop.call_soon_threadsafe(self.project_server.close)
    Logger.info('ws server closing')
    asyncio.run_coroutine_threadsafe(self.stop_async(), self.event_loop)

stop_async() async

Wait for the WebSocket server to finish closing, then stop the event loop.

Source code in src/cuemseditor/CuemsWsServer.py
159
160
161
162
163
164
165
166
167
async def stop_async(self):
    """Wait for the WebSocket server to finish closing, then stop the event loop."""
    watcher = getattr(self, 'network_map_watcher_task', None)
    if watcher is not None:
        watcher.cancel()
    await self.project_server.wait_closed()
    Logger.info('ws server closed')
    self.event_loop.call_soon(self.event_loop.stop)
    Logger.info('event loop stoped')

unregister(user_task) async

Remove a user session and broadcast the updated user count.

Parameters:

Name Type Description Default
user_task

The :class:CuemsWsUser to remove.

required
Source code in src/cuemseditor/CuemsWsServer.py
316
317
318
319
320
321
322
323
324
async def unregister(self, user_task):
    """Remove a user session and broadcast the updated user count.

    Args:
        user_task: The :class:`CuemsWsUser` to remove.
    """
    Logger.info("user unregistered: {}".format(id(user_task.websocket)))
    self.users.pop(user_task, None)
    await self.notify_users("users")

upload_session(websocket) async

Handle a binary file upload over the /upload WebSocket path.

Creates a :class:CuemsUpload and runs its :meth:~CuemsUpload.message_handler until the connection closes.

Parameters:

Name Type Description Default
websocket

The accepted WebSocket connection on the /upload path.

required
Source code in src/cuemseditor/CuemsWsServer.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
async def upload_session(self, websocket):
    """Handle a binary file upload over the ``/upload`` WebSocket path.

    Creates a :class:`CuemsUpload` and runs its :meth:`~CuemsUpload.message_handler`
    until the connection closes.

    Args:
        websocket: The accepted WebSocket connection on the ``/upload`` path.
    """
    user_upload_session = CuemsUpload(self, websocket)
    Logger.info("new upload session: {}".format(user_upload_session))

    await user_upload_session.message_handler()
    Logger.info("upload session ended: {}".format(user_upload_session))

users_event(type, uuid=None)

Build a users-count or item-modified event message.

Parameters:

Name Type Description Default
type

"users" to send the connected-user count; any other value builds an item-modified message (currently unused).

required
uuid

Item UUID for non-"users" message types.

None

Returns:

Type Description

JSON string.

Source code in src/cuemseditor/CuemsWsServer.py
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
def users_event(self, type, uuid=None):
    """Build a users-count or item-modified event message.

    Args:
        type: ``"users"`` to send the connected-user count; any other
            value builds an item-modified message (currently unused).
        uuid: Item UUID for non-``"users"`` message types.

    Returns:
        JSON string.
    """
    if type == "users":
        return json.dumps({"type": type, "value": len(self.users)})
    else:
        return json.dumps({"type": type, "uuid": uuid, "value": "modified in server"})  # TODO: not used

watch_network_map() async

Broadcast the node list whenever cuems-nodeconf rewrites the map.

cuems-nodeconf is resident: it re-merges avahi discovery and rewrites network_map.xml on every (debounced) avahi event or every 30 s. We used to read that file only on connect and right after our own successful nodelist_modify, so a node powered on AFTER the operator opened the settings panel never appeared in new_nodes — the adoption feature looked broken exactly when it was needed.

One mtime poll per host (not per client) at NETWORK_MAP_POLL_S; on a change every connected UI gets the refreshed list.

Source code in src/cuemseditor/CuemsWsServer.py
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
async def watch_network_map(self):
    """Broadcast the node list whenever cuems-nodeconf rewrites the map.

    cuems-nodeconf is resident: it re-merges avahi discovery and rewrites
    network_map.xml on every (debounced) avahi event or every 30 s. We used
    to read that file only on connect and right after our own successful
    nodelist_modify, so a node powered on AFTER the operator opened the
    settings panel never appeared in `new_nodes` — the adoption feature
    looked broken exactly when it was needed.

    One mtime poll per host (not per client) at NETWORK_MAP_POLL_S; on a
    change every connected UI gets the refreshed list.
    """
    try:
        cf_manager = ConfigManager(load_all=False)
        map_file = cf_manager.conf_path('network_map.xml')
    except Exception as e:
        Logger.warning(f'network_map watcher disabled, cannot resolve path: {e}')
        return

    last_mtime = None
    try:
        last_mtime = os.stat(map_file).st_mtime
    except OSError:
        pass
    last_nodeconf = self.nodeconf_available()

    Logger.info(f'watching {map_file} for node list changes')
    while True:
        try:
            await asyncio.sleep(self.NETWORK_MAP_POLL_S)

            # nodeconf stopping or starting changes nothing on disk, so the
            # mtime check below would never notice it — and the UI would go
            # on offering adopt buttons that can only fail.
            nodeconf_now = self.nodeconf_available()
            if nodeconf_now != last_nodeconf:
                Logger.info(
                    f'cuems-nodeconf availability changed: {nodeconf_now}'
                )
                last_nodeconf = nodeconf_now
                await self.notify_all_node_list_update()

            try:
                mtime = os.stat(map_file).st_mtime
            except OSError:
                # nodeconf may be mid-replace, or the file may not exist on
                # a host where nodeconf never ran. Neither is an error.
                continue
            if last_mtime is not None and mtime != last_mtime:
                Logger.debug('network_map.xml changed on disk, broadcasting')
                await self.notify_all_node_list_update()
            last_mtime = mtime
        except asyncio.CancelledError:
            Logger.info('network_map watcher stopped')
            raise
        except Exception as e:
            Logger.warning(f'network_map watcher error: {e}')

CuemsWsServerError

Bases: Exception

Base exception for all cuems-editor errors.

All other editor exceptions inherit from this class, so callers can catch CuemsWsServerError to handle any editor-originated failure.

Source code in src/cuemseditor/CuemsErrors.py
1
2
3
4
5
6
class CuemsWsServerError(Exception):
    """Base exception for all cuems-editor errors.

    All other editor exceptions inherit from this class, so callers can
    catch ``CuemsWsServerError`` to handle any editor-originated failure.
    """

EngineError

Bases: CuemsWsServerError

Raised when cuems-engine returns a non-OK NNG IPC response.

Also raised on timeout or connection failure. The str() of the exception carries the engine's error value or the transport diagnostic.

Example

raise EngineError("Timeout: engine did not respond in 25s for project_load")

Source code in src/cuemseditor/CuemsErrors.py
33
34
35
36
37
38
39
40
41
class EngineError(CuemsWsServerError):
    """Raised when ``cuems-engine`` returns a non-OK NNG IPC response.

    Also raised on timeout or connection failure.  The ``str()`` of the
    exception carries the engine's error value or the transport diagnostic.

    Example:
        >>> raise EngineError("Timeout: engine did not respond in 25s for project_load")
    """

FileIntegrityError

Bases: CuemsWsServerError

Raised when an uploaded file's MD5 digest does not match the declared value.

Example

raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")

Source code in src/cuemseditor/CuemsErrors.py
 9
10
11
12
13
14
class FileIntegrityError(CuemsWsServerError):
    """Raised when an uploaded file's MD5 digest does not match the declared value.

    Example:
        >>> raise FileIntegrityError("MD5 mismatch: expected abc123, got def456")
    """

NonExistentItemError

Bases: CuemsWsServerError

Raised when a database lookup by UUID or unix_name returns no result.

Example

raise NonExistentItemError("item with uuid: 1234-... does not exist")

Source code in src/cuemseditor/CuemsErrors.py
17
18
19
20
21
22
class NonExistentItemError(CuemsWsServerError):
    """Raised when a database lookup by UUID or unix_name returns no result.

    Example:
        >>> raise NonExistentItemError("item with uuid: 1234-... does not exist")
    """

NotTimeCodeError

Bases: CuemsWsServerError

Raised when a string cannot be parsed as a valid CueMS timecode.

Thrown by CuemsDBMedia.get_duration when ffprobe output does not match the HH:MM:SS.mmm pattern expected by CTimecode.

Source code in src/cuemseditor/CuemsErrors.py
25
26
27
28
29
30
class NotTimeCodeError(CuemsWsServerError):
    """Raised when a string cannot be parsed as a valid CueMS timecode.

    Thrown by ``CuemsDBMedia.get_duration`` when ``ffprobe`` output does not
    match the ``HH:MM:SS.mmm`` pattern expected by ``CTimecode``.
    """

CuemsWsUser

Per-connection session handler for the project-manager WebSocket endpoint.

Owns two asyncio queues — incoming (fed by :meth:consumer_handler) and outgoing (drained by :meth:producer_handler). Three concurrent :meth:consumer coroutines dispatch commands from the incoming queue so that slow operations (e.g. engine IPC) do not block the interface.

All project and media operations are dispatched to the thread-pool executor via server.event_loop.run_in_executor so they do not block the event loop.

Attributes:

Name Type Description
session_id

UUID string assigned by :meth:CuemsWsServer.check_session after registration; None until then.

incoming

asyncio.Queue of raw JSON strings from the WebSocket.

outgoing

asyncio.Queue of serialised JSON strings to send.

Example

The server creates one instance per connection::

user = CuemsWsUser(server, websocket)
await server.register(user, path)
consumer_task = asyncio.create_task(user.consumer_handler())
producer_task = asyncio.create_task(user.producer_handler())
processor_tasks = [asyncio.create_task(user.consumer()) for _ in range(3)]
Source code in src/cuemseditor/CuemsWsUser.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
class CuemsWsUser():
    """Per-connection session handler for the project-manager WebSocket endpoint.

    Owns two asyncio queues — ``incoming`` (fed by :meth:`consumer_handler`)
    and ``outgoing`` (drained by :meth:`producer_handler`).  Three concurrent
    :meth:`consumer` coroutines dispatch commands from the incoming queue so
    that slow operations (e.g. engine IPC) do not block the interface.

    All project and media operations are dispatched to the thread-pool
    executor via ``server.event_loop.run_in_executor`` so they do not block
    the event loop.

    Attributes:
        session_id: UUID string assigned by :meth:`CuemsWsServer.check_session`
            after registration; ``None`` until then.
        incoming: ``asyncio.Queue`` of raw JSON strings from the WebSocket.
        outgoing: ``asyncio.Queue`` of serialised JSON strings to send.

    Example:
        The server creates one instance per connection::

            user = CuemsWsUser(server, websocket)
            await server.register(user, path)
            consumer_task = asyncio.create_task(user.consumer_handler())
            producer_task = asyncio.create_task(user.producer_handler())
            processor_tasks = [asyncio.create_task(user.consumer()) for _ in range(3)]
    """

    def __init__(self, server, websocket):
        """Bind the session to *server* and *websocket*.

        Args:
            server: The parent ``CuemsWsServer`` instance.
            websocket: The WebSocket connection for this session.
        """
        self.server = server
        asyncio.set_event_loop(server.event_loop)
        self.incoming = asyncio.Queue()
        self.outgoing = asyncio.Queue()
        self.websocket = websocket
        self.session_id = None
        server.users[self] = None

    async def consumer_handler(self):
        """Read frames from the WebSocket and enqueue them on ``incoming``.

        Runs until the WebSocket closes.  Connection-closed exceptions are
        caught and logged at DEBUG level.
        """
        try:
            async for message in self.websocket:
                await self.incoming.put(message)
        except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
            Logger.debug(e)

    async def producer_handler(self):
        """Dequeue messages from ``outgoing`` and send them over the WebSocket.

        Runs until the WebSocket closes.  Connection-closed exceptions break
        the loop cleanly.
        """
        while True:
            message = await self.outgoing.get()
            try:
                await self.websocket.send(message)
            except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
                Logger.debug(e)
                break

    async def consumer(self):
        """Dequeue and dispatch one command at a time from ``incoming``.

        Parses the JSON frame, looks up the ``action`` key in the dispatch
        map, and awaits the corresponding handler coroutine.  Errors at any
        stage are caught and reported to the client via
        :meth:`notify_error_to_user`.

        The server launches three concurrent instances of this coroutine so
        that one slow handler (e.g. engine IPC) does not starve the other two.
        """
        while True:
            message = await self.incoming.get()
            try:
                data = json.loads(message)
            except Exception as e:
                Logger.error("error: {} {}".format(type(e), e))
                await self.notify_error_to_user('error decoding json')
                continue
            try:
                action = data.get("action")
                value = data.get("value")
                if not action:
                    Logger.error(f"unsupported event: {data}")
                    await self.notify_error_to_user(f"unsupported event: {data}")
                    return

                action_map = {
                    "project_load": lambda: self.send_project(value, action),
                    "project_ready": lambda: self.project_ready(value, action),
                    "project_deploy": lambda: self.project_deploy(value, action),
                    "project_new": lambda: self.received_new_project(value, action, data.get("unix_name")),
                    "project_save": lambda: self.received_project(value, action),
                    "project_delete": lambda: self.request_delete_project(value, action),
                    "project_restore": lambda: self.request_restore_project(value, action),
                    "project_trash_delete": lambda: self.request_delete_project_trash(value, action),
                    "project_list": lambda: self.list_project(action),
                    "project_duplicate": lambda: self.request_duplicate_project(value, action),
                    "project_export": lambda: self.request_export_project(value, action),
                    "file_list": lambda: self.list_file(action),
                    "project_trash_list": lambda: self.list_project_trash(action),
                    "file_trash_list": lambda: self.list_file_trash(action),
                    "file_save": lambda: self.received_file_data(value, action),
                    "file_load_meta": lambda: self.request_file_load_meta(value, action),
                    "file_load_thumbnail": lambda: self.request_file_load_thumbnail(value, action),
                    "file_load_waveform": lambda: self.request_file_load_waveform(value, action),
                    "file_delete": lambda: self.request_delete_file(value, action),
                    "file_restore": lambda: self.request_restore_file(value, action),
                    "file_trash_delete": lambda: self.request_delete_file_trash(value, action),
                    "hw_discovery": lambda: self.hw_discovery(action),
                    "nodeconf": lambda: self.nodeconf(action),
                    "nodelist_modify": lambda: self.nodelist_modify(value, action, data.get("modify_action")),
                    "nodelist_get": lambda: self.nodelist_get(action),
                    "node_status": lambda: self.node_status(action),
                    "project_status": lambda: self.project_status(action),
                    "project_unload": lambda: self.project_unload(action),
                }

                if action in action_map:
                    await action_map[action]()
                else:
                    Logger.error(f"unsupported action: {data}")
                    await self.notify_error_to_user(f"unsupported action: {data}")
            except KeyError as e:
                Logger.error("error missing key:  {}".format(e))
                await self.notify_error_to_user('error missing key {} in request'.format(e))
            except Exception as e:
                Logger.error("error: {} {}".format(type(e), e))
                await self.notify_error_to_user('error processing request')

    async def comunicate_with_engine(self, action, action_uuid, engine_command, query_mode=False):
        """Send a command to ``cuems-engine`` via NNG and validate the response.

        Waits up to ``TIMEOUT`` seconds for a response.  Validates that the
        response carries the expected ``action_uuid``, ``type``, and (unless
        *query_mode* is ``True``) a ``value`` of ``"OK"``.

        Args:
            action: The action string used for type-checking the engine reply.
            action_uuid: UUID string embedded in the command; must be echoed
                back by the engine.
            engine_command: Dict to send as the NNG request payload.
            query_mode: When ``True``, accepts any ``value`` from the engine
                (not just ``"OK"``).  Used for ``project_status`` which returns
                a state dict.  Defaults to ``False``.

        Returns:
            ``response['value']`` from the engine reply.

        Raises:
            EngineError: On timeout, connection failure, UUID mismatch,
                error response, or unexpected response type.

        Example:
            >>> action_uuid = str(new_uuid())
            >>> result = await self.comunicate_with_engine(
            ...     "project_load",
            ...     action_uuid,
            ...     {"action": "project_load", "action_uuid": action_uuid, "value": "my-show"},
            ... )
        """
        try:
            async with asyncio.timeout(TIMEOUT):
                response = await self.server.engine_communicator.send_request(engine_command)
        except TimeoutError:
            raise EngineError(f'Timeout: Engine did not respond in {TIMEOUT}s for {action} (uuid: {action_uuid})')
        except Exception as e:
            raise EngineError(f'Cannot connect to engine: {e} ({type(e).__name__})')

        if not response:
            raise EngineError(f'Engine did not respond with valid response for {action}')

        Logger.debug(f'Got response from engine: {response}')

        if 'action_uuid' not in response:
            raise EngineError(f'Engine response missing action_uuid: {response}')

        if action_uuid not in response['action_uuid']:
            raise EngineError(f'Action UUID mismatch. Expected: {action_uuid}, Got: {response.get("action_uuid")}')

        if 'type' not in response:
            raise EngineError(f'Engine response missing type field: {response}')

        if response['type'] == 'error':
            error_value = response.get('value', 'Unknown error')
            raise EngineError(f'Engine reports error: {error_value}')

        if response['type'] != action:
            raise EngineError(f'Response type mismatch. Expected: {action}, Got: {response["type"]}')

        if not query_mode and response.get('value') != 'OK':
            raise EngineError(f'Engine reports error: {response.get("value", "Unknown error")}')

        Logger.debug(f'Engine response for {action} is OK')
        return response['value']

    async def notify_user(self, msg=None, uuid=None, action=None, new_uuid=None):
        """Enqueue a success notification to the client.

        Three call forms:

        * ``notify_user(msg=...)`` — state message: ``{"type": "state", "value": msg}``.
        * ``notify_user(uuid=..., action=...)`` — action confirmation:
          ``{"type": action, "value": uuid}``.
        * ``notify_user(uuid=..., action=..., new_uuid=...)`` — action with
          rename: ``{"type": action, "value": {"uuid": uuid, "new_uuid": new_uuid}}``.

        Args:
            msg: Plain state message string.
            uuid: Subject item UUID string.
            action: Action name echoed back to the client.
            new_uuid: New UUID string (used for ``project_duplicate``).
        """
        if (uuid is None) and (action is None) and (msg is not None):
            await self.outgoing.put(json.dumps({"type": "state", "value": msg}))
        elif (msg is None and new_uuid is None):
            await self.outgoing.put(json.dumps({"type": action, "value": uuid}))
        elif (msg is None and new_uuid is not None):
            await self.outgoing.put(json.dumps({"type": action, "value": {"uuid": uuid, "new_uuid": new_uuid}}))

    async def notify_error_to_user(self, msg=None, uuid=None, action=None):
        """Enqueue an error notification to the client.

        Three call forms depending on which combination of arguments is
        provided:

        * ``notify_error_to_user(msg=...)`` — bare error:
          ``{"type": "error", "value": msg}``.
        * ``notify_error_to_user(msg=..., action=...)`` — action error:
          ``{"type": "error", "action": action, "value": msg}``.
        * ``notify_error_to_user(msg=..., action=..., uuid=...)`` — item error:
          ``{"type": "error", "uuid": uuid, "action": action, "value": msg}``.

        Args:
            msg: Human-readable error description.
            uuid: UUID of the subject item, if applicable.
            action: Action name that triggered the error, if applicable.
        """
        if (msg is not None) and (uuid is None) and (action is None):
            await self.outgoing.put(json.dumps({"type": "error", "value": msg}))
        elif (action is not None) and (msg is not None) and (uuid is None):
            await self.outgoing.put(json.dumps({"type": "error", "action": action, "value": msg}))
        elif (action is not None) and (msg is not None) and (uuid is not None):
            await self.outgoing.put(json.dumps({"type": "error", "uuid": uuid, "action": action, "value": msg}))

    async def project_ready(self, project_uuid, action):
        """Tell the engine to arm the project identified by *project_uuid*.

        Resolves the ``unix_name`` from the DB, checks the project's media
        files first (869fat84r D20; never blocks the load), builds the
        ``project_ready`` engine command, and awaits the response. After the
        reply, the media check's ``media_check_report`` goes to this session
        and to every other session on the project.

        Args:
            project_uuid: UUID string of the project to arm.
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting ready project {project_uuid}")
        try:
            unix_name = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.get_project_unix_name, project_uuid)
            refresh = await self._refresh_media(project_uuid)
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid, "value": unix_name}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)
            Logger.debug(f"project {project_uuid} ready: {result}")

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": project_uuid}))
            # After the reply, so a client waiting for it never reads this first.
            await self._send_media_report(refresh.get('media_report') if refresh else None)

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def _refresh_media(self, project_uuid):
        """Check *project_uuid*'s media before it is loaded (see
        :mod:`cuemseditor.CuemsMediaRefresh`). Returns its report, or ``None``.
        Never raises: the load goes on with the stored values whatever
        happens here."""
        try:
            return await refresh_media_before_load(self.server.db.project, project_uuid,
                                                   executor=self.server.executor,
                                                   engine_running=self._engine_running)
        except Exception as e:
            Logger.error(f'media check before loading {project_uuid} failed: '
                         f'{type(e).__name__}: {e}; loading with the stored values')
            return None

    async def _send_media_report(self, value):
        """Send a ``media_check_report`` (plan §8.3) to this session and, for
        the ``ready`` and ``save`` contexts, to every other session on the
        project. Nothing when *value* is ``None``. Never raises."""
        if not value:
            return
        try:
            frame = json.dumps({"type": "media_check_report", "value": value})
            await self.outgoing.put(frame)
            if value.get('context') in ('ready', 'save'):
                await self.server.send_to_project_sessions(value['project_uuid'], frame, skip=self)
        except Exception as e:
            Logger.error(f'could not send the media report: {type(e).__name__}: {e}')

    def _in_background(self, coroutine):
        """Run *coroutine* after the current reply, keeping a reference so the
        task is not collected; its errors are logged, never raised."""
        task = asyncio.ensure_future(coroutine)
        pending = getattr(self, '_background_tasks', None)
        if pending is None:
            pending = self._background_tasks = set()
        pending.add(task)
        task.add_done_callback(pending.discard)
        return task

    async def _report_on_open(self, project_uuid):
        """The read-only media check of a project being opened, then its report."""
        value = await check_media_on_open(self.server.db.project, project_uuid,
                                          executor=self.server.executor)
        await self._send_media_report(value)

    async def _report_after_save(self, project_uuid):
        """The read-only media check after a save; its report clears (or keeps)
        the warning of every session on the project."""
        try:
            value = await self.server.event_loop.run_in_executor(
                self.server.executor, self.server.db.project.media_report, project_uuid, 'save')
        except Exception as e:
            Logger.error(f'project {project_uuid}: the media check after the save failed '
                         f'({type(e).__name__}: {e})')
            return
        await self._send_media_report(value)

    async def _engine_running(self):
        """``True`` while a show is running, ``False`` when not, ``None`` when
        the engine does not answer (its ``project_status``)."""
        action_uuid = str(new_uuid())
        command = {"action": "project_status", "action_uuid": action_uuid}
        try:
            result = await self.comunicate_with_engine('project_status', action_uuid, command,
                                                       query_mode=True)
        except Exception as e:
            Logger.info(f'project_status before the media check: {e}')
            return None
        status = result.get('status') if isinstance(result, dict) else None
        return {'running': True, 'none': False}.get(status)

    async def hw_discovery(self, action):
        """Ask the engine to run a hardware discovery scan.

        Args:
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} dicovery")
        try:
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def nodeconf(self, action):
        """Ask the engine to run node configuration.

        Args:
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} dicovery")
        try:
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def project_status(self, action):
        """Query the engine for the current project load state without side effects.

        Uses ``query_mode=True`` so the engine's dict response (not just
        ``"OK"``) is accepted and forwarded to the client.

        Args:
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
        try:
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command, query_mode=True)

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), action=action)

    async def project_unload(self, action):
        """Tell the engine to unload the current project and clear session state.

        On success, sets ``server.users[self] = None`` and clears
        ``sessions[session_id]['loaded_project']``.

        Args:
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
        try:
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)

            self.server.users[self] = None
            if self.session_id and self.session_id in self.server.sessions:
                self.server.sessions[self.session_id]['loaded_project'] = None

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": "OK"}))

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), action=action)

    async def nodelist_modify(self, node_uuid, action, modify_action):
        """Add or remove a node from the engine's active node list.

        After a successful engine response, triggers
        ``server.notify_all_node_list_update`` to broadcast the refreshed
        node list to all connected clients.

        Args:
            node_uuid: UUID string of the node to add or remove.
            action: Action name from the WebSocket frame (echoed in the reply).
            modify_action: ``"ADD"`` or ``"REMOVE"``.

        Raises:
            ValueError: If *modify_action* is not ``"ADD"`` or ``"REMOVE"``.
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} for node {node_uuid} with action {modify_action}")
        try:
            if modify_action not in ["ADD", "REMOVE"]:
                raise ValueError(f"Invalid modify_action: {modify_action}. Must be 'ADD' or 'REMOVE'")

            # Without this a None/empty value travels the whole chain and comes
            # back from nodeconf as the baffling "Node None not found".
            if not node_uuid or not isinstance(node_uuid, str):
                raise ValueError(f"nodelist_modify needs a node uuid, got {node_uuid!r}")

            action_uuid = str(new_uuid())
            engine_command = {
                "action": functionNameAsString(),
                "action_uuid": action_uuid,
                "value": node_uuid,
                "modify_action": modify_action
            }

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)
            Logger.debug(f"nodelist_modify for node {node_uuid}: {result}")

            await self.outgoing.put(json.dumps({"type": "nodelist_modify", "value": "OK"}))

            await asyncio.sleep(0.1)

            try:
                await self.server.notify_all_node_list_update()
            except Exception as e:
                Logger.warning(f'Failed to reload and broadcast node list update after adoption: {e}')

        except EngineError as e:
            Logger.error(f"Engine error in nodelist_modify: {e}")
            await self.notify_error_to_user(str(e), action=action)
        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), action=action)

    async def nodelist_get(self, action):
        """Re-read network_map.xml and send this client the current node list.

        Same payload the client already gets on connect
        (``initial_mappings``: ``nodes`` = adopted, ``new_nodes`` = discovered
        but not adopted), so no new client-side handling is needed — it is just
        a way to refresh on demand instead of reconnecting.

        Args:
            action: Action name from the WebSocket frame.
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
        try:
            reload_ok = await self.server.event_loop.run_in_executor(
                self.server.executor,
                self.server.reload_network_map_nodes
            )
            if not reload_ok:
                raise ValueError("could not read network_map.xml")

            await self.outgoing.put(self.server.initial_setting_message())

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), action=action)

    async def node_status(self, action):
        """Ask the engine which nodes are answering right now.

        Relays the engine's ``cluster_status``:
        ``{"alive": [...], "adopted": [...], "controller": uuid, "age_s": n}``.

        This is NOT the same thing as the ``online`` field in each node of
        ``initial_mappings``: that one is cuems-nodeconf's discovery view
        (refreshed within ~30 s), while ``alive`` here is the engine's
        sub-second ping/pong — the only signal the GO gate trusts. A UI must
        show them as two separate indicators; merging them tells the operator
        a node is dead when it was merely not seen by the last discovery pass,
        or alive when it vanished 20 s ago.

        ``age_s`` is how stale the probe behind this answer is. A failed or
        timed-out call means *unknown*, never *dead*: while a project load is
        in flight this request queues behind it (the engine serializes editor
        commands) and can time out while every node is perfectly healthy.

        The reply also carries the last load's diagnosis — ``missing`` (project
        nodes not in the cluster at all) and ``unreachable`` (adopted, used by
        the project, no pong). Their cues will not play, and GO is deliberately
        *not* blocked. Nothing is done to them here on purpose: this method
        relays the engine's dict verbatim, so new keys reach the browser with
        no editor change. Do not add a hop.

        Args:
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
        try:
            action_uuid = str(new_uuid())
            engine_command = {
                "action": "cluster_status",
                "action_uuid": action_uuid,
            }

            result = await self.comunicate_with_engine(
                "cluster_status", action_uuid, engine_command, query_mode=True
            )

            await self.outgoing.put(json.dumps({"type": "node_status", "value": result}))

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), action=action)

    async def project_deploy(self, project_uuid, action):
        """Deploy a project to the engine for playback.

        Resolves ``unix_name`` from the DB and sends a ``project_deploy``
        command to the engine.

        Args:
            project_uuid: UUID string of the project to deploy.
            action: Action name from the WebSocket frame (echoed in the reply).
        """
        Logger.info(f"user {id(self.websocket)} requesting deploy project {project_uuid}")
        try:
            unix_name = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.get_project_unix_name, project_uuid)
            action_uuid = str(new_uuid())
            engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid, "value": unix_name}

            result = await self.comunicate_with_engine(action, action_uuid, engine_command)

            await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": project_uuid}))

        except Exception as e:
            Logger.error(f"error: {type(e)} {e}")
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def list_project(self, action):
        """Send the list of live projects to the client.

        Args:
            action: Action name echoed back in the response frame.
        """
        Logger.info("user {} loading project list".format(id(self.websocket)))
        try:
            project_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.list)
            await self.outgoing.put(json.dumps({"type": action, "value": project_list}))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def send_project(self, project_uuid, action):
        """Load a project's XML from disk and send it to the client.

        Also records *project_uuid* as the session's loaded project in
        ``server.users`` and ``server.sessions``.

        Args:
            project_uuid: UUID string of the project to load.
            action: Action name from the WebSocket frame.
        """
        try:
            Logger.info("user {} loading project {}".format(id(self.websocket), project_uuid))
            project = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.load, project_uuid)
            msg = json.dumps({"type": "project", "value": project})
            await self.outgoing.put(msg)
            self.server.users[self] = project_uuid
            self.server.sessions[self.session_id]['loaded_project'] = project_uuid
            # After the project frame, never before it (869fat84r D20).
            self._in_background(self._report_on_open(project_uuid))
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    @staticmethod
    def _error_text(e):
        """What the client shows for a failed save.

        A ``ValueError`` is a save-time gate speaking to the operator (fade
        duration, media cue without a file): forward its text as is. Anything
        else keeps the ``<class '…'>`` prefix the UI has always shown.
        """
        if isinstance(e, ValueError):
            return str(e)
        return str(type(e)) + str(e)

    async def received_new_project(self, data, action, unix_name):
        """Create a new project and notify the client and other users.

        Args:
            data: ``CuemsScript`` dict from the frontend.
            action: Action name echoed back in the confirmation.
            unix_name: Candidate directory name from the frontend.
        """
        try:
            project_uuid = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.new, data, unix_name)

            Logger.info("user {} new project {}".format(id(self.websocket), project_uuid))

            self.server.users[self] = project_uuid
            await self.notify_user(uuid=project_uuid, action=action)
            await self.server.notify_others_list_changes(self, "project_list")
            await self.server.notify_others_same_project(self, "project_modified", project_uuid)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(self._error_text(e), action="project_new")

    async def received_project(self, data, action):
        """Save an edited project and notify the client and other users.

        Args:
            data: ``CuemsScript`` dict containing ``id`` and updated fields.
            action: Action name echoed back in the confirmation.
        """
        try:
            project_uuid = data['CuemsScript']['id']
            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.update, project_uuid, data)
            Logger.info("user {} saving project {}".format(id(self.websocket), project_uuid))

            self.server.users[self] = project_uuid
            await self.notify_user(uuid=project_uuid, action=action)
            await self.server.notify_others_list_changes(self, "project_list")
            await self.server.notify_others_same_project(self, "project_modified", project_uuid)
            self._in_background(self._report_after_save(project_uuid))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(self._error_text(e), uuid=project_uuid, action=action)

    async def list_project_trash(self, action):
        """Send the list of trashed projects to the client.

        Args:
            action: Action name echoed back in the response frame.
        """
        Logger.info("user {} loading project trash list".format(id(self.websocket)))
        try:
            project_trash_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.list_trash)
            await self.outgoing.put(json.dumps({"type": action, "value": project_trash_list}))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def request_delete_project(self, project_uuid, action):
        """Soft-delete a project (move to trash) and notify affected clients.

        Args:
            project_uuid: UUID string of the project to trash.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.info("user {} deleting project: {}".format(id(self.websocket), project_uuid))

            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.delete, project_uuid)

            await self.notify_user(uuid=project_uuid, action=action)
            await self.server.notify_others_same_project(self, "project_update", project_uuid=project_uuid)
            await self.server.notify_others_list_changes(self, "project_list")
            await self.server.notify_others_list_changes(self, "project_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def request_duplicate_project(self, project_uuid, action):
        """Duplicate a project and notify the client and other users.

        The reply includes both the original and new UUID so the frontend can
        navigate to the copy.

        Args:
            project_uuid: UUID string of the project to duplicate.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.info("user {} duplicating project: {}".format(id(self.websocket), project_uuid))
            new_project_uuid = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.duplicate, project_uuid)
            await self.notify_user(uuid=project_uuid, action=action, new_uuid=new_project_uuid)
            await self.server.notify_others_list_changes(self, "project_list")
            await self.server.notify_others_list_changes(self, "file_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def request_export_project(self, project_uuid, action):
        try:
            Logger.info("user {} exporting project: {}".format(id(self.websocket), project_uuid))
            exported_file_url = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.export, project_uuid)
            await self.outgoing.put(json.dumps({"type": action, "value": exported_file_url}))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def request_restore_project(self, project_uuid, action):
        """Restore a trashed project and notify the client and other users.

        Args:
            project_uuid: UUID string of the project to restore.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.info("user {} restoring project: {}".format(id(self.websocket), project_uuid))
            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.restore, project_uuid)
            await self.notify_user(uuid=project_uuid, action=action)
            await self.server.notify_others_list_changes(self, "project_list")
            await self.server.notify_others_list_changes(self, "project_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def request_delete_project_trash(self, project_uuid, action):
        """Permanently delete a trashed project and notify the client.

        Args:
            project_uuid: UUID string of the trashed project to delete.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.info("user {} deleting project from trash: {}".format(id(self.websocket), project_uuid))

            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.delete_from_trash, project_uuid)

            await self.notify_user(uuid=project_uuid, action=action)
            await self.server.notify_others_list_changes(self, "project_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

    async def list_file(self, action):
        """Send the list of live media files to the client.

        Args:
            action: Action name echoed back in the response frame.
        """
        Logger.info("user {} loading file list".format(id(self.websocket)))
        try:
            file_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.list)
            await self.outgoing.put(json.dumps({"type": action, "value": file_list}))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def received_file_data(self, data, action):
        """Update metadata (name, description) for a media file.

        Args:
            data: Dict with structure ``{uuid: {name, description}}``.
            action: Action name echoed back in the confirmation.
        """
        try:
            file_uuid = data['uuid']

            Logger.info("user {} update file data {}".format(id(self.websocket), file_uuid))

            return_message = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.save, file_uuid, data)
            await self.notify_user(uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def request_file_load_meta(self, file_uuid, action):
        """Send full metadata for a media file to the client.

        Args:
            file_uuid: UUID string of the media file.
            action: Action name echoed back in the response frame.
        """
        try:
            Logger.info("user {} loading file meta data {}".format(id(self.websocket), file_uuid))

            file_meta_data = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_meta, file_uuid)
            await self.outgoing.put(json.dumps({"type": action, "value": file_meta_data}))
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def request_file_load_thumbnail(self, file_uuid, action):
        """Send the thumbnail binary frame for a media file to the client.

        The response is a raw binary WebSocket frame with a 36-byte UUID
        header prepended (not a JSON frame).

        Args:
            file_uuid: UUID string of the media file.
            action: Action name (used only in error replies).
        """
        try:
            Logger.info("user {} loading file thumbnail {}".format(id(self.websocket), file_uuid))

            file_thumbnail = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_thumbnail, file_uuid)
            await self.outgoing.put(file_thumbnail)  # TODO: add uuid encoded in the binary message
        except NonExistentItemError as e:
            Logger.warning(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def request_file_load_waveform(self, file_uuid, action):
        """Send the waveform binary frame for a media file to the client.

        The response is a raw binary WebSocket frame with a 36-byte UUID
        header prepended (not a JSON frame).

        Args:
            file_uuid: UUID string of the media file.
            action: Action name (used only in error replies).
        """
        try:
            Logger.info("user {} loading file waveform {}".format(id(self.websocket), file_uuid))

            file_waveform = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_waveform, file_uuid)
            await self.outgoing.put(file_waveform)  # TODO: add uuid encoded in the binary message
        except NonExistentItemError as e:
            Logger.warning(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def list_file_trash(self, action):
        """Send the list of trashed media files to the client.

        Args:
            action: Action name echoed back in the response frame.
        """
        Logger.info("user {} loading file trash list".format(id(self.websocket)))
        try:
            file_trash_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.list_trash)
            await self.outgoing.put(json.dumps({"type": action, "value": file_trash_list}))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), action=action)

    async def request_delete_file(self, file_uuid, action):
        """Soft-delete a media file (move to trash) and notify affected clients.

        Args:
            file_uuid: UUID string of the media file to trash.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.debug("user {} deleting file: {}".format(id(self.websocket), file_uuid))
            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.delete, file_uuid)
            await self.notify_user(uuid=file_uuid, action=action)
            await self.server.notify_others_list_changes(self, "file_list")
            await self.server.notify_others_list_changes(self, "file_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def request_restore_file(self, file_uuid, action):
        """Restore a trashed media file and notify affected clients.

        Args:
            file_uuid: UUID string of the trashed media file to restore.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.debug("user {} restoring file: {}".format(id(self.websocket), file_uuid))
            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.restore, file_uuid)
            await self.notify_user(uuid=file_uuid, action=action)
            await self.server.notify_others_list_changes(self, "file_list")
            await self.server.notify_others_list_changes(self, "file_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

    async def request_delete_file_trash(self, file_uuid, action):
        """Permanently delete a trashed media file and notify the client.

        Args:
            file_uuid: UUID string of the trashed media file to delete permanently.
            action: Action name echoed back in the confirmation.
        """
        try:
            Logger.info("user {} deleting file from trash: {}".format(id(self.websocket), file_uuid))
            await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.delete_from_trash, file_uuid)
            await self.notify_user(uuid=file_uuid, action=action)
            await self.server.notify_others_list_changes(self, "file_trash_list")
        except NonExistentItemError as e:
            Logger.info(e)
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

__init__(server, websocket)

Bind the session to server and websocket.

Parameters:

Name Type Description Default
server

The parent CuemsWsServer instance.

required
websocket

The WebSocket connection for this session.

required
Source code in src/cuemseditor/CuemsWsUser.py
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def __init__(self, server, websocket):
    """Bind the session to *server* and *websocket*.

    Args:
        server: The parent ``CuemsWsServer`` instance.
        websocket: The WebSocket connection for this session.
    """
    self.server = server
    asyncio.set_event_loop(server.event_loop)
    self.incoming = asyncio.Queue()
    self.outgoing = asyncio.Queue()
    self.websocket = websocket
    self.session_id = None
    server.users[self] = None

comunicate_with_engine(action, action_uuid, engine_command, query_mode=False) async

Send a command to cuems-engine via NNG and validate the response.

Waits up to TIMEOUT seconds for a response. Validates that the response carries the expected action_uuid, type, and (unless query_mode is True) a value of "OK".

Parameters:

Name Type Description Default
action

The action string used for type-checking the engine reply.

required
action_uuid

UUID string embedded in the command; must be echoed back by the engine.

required
engine_command

Dict to send as the NNG request payload.

required
query_mode

When True, accepts any value from the engine (not just "OK"). Used for project_status which returns a state dict. Defaults to False.

False

Returns:

Type Description

response['value'] from the engine reply.

Raises:

Type Description
EngineError

On timeout, connection failure, UUID mismatch, error response, or unexpected response type.

Example

action_uuid = str(new_uuid()) result = await self.comunicate_with_engine( ... "project_load", ... action_uuid, ... {"action": "project_load", "action_uuid": action_uuid, "value": "my-show"}, ... )

Source code in src/cuemseditor/CuemsWsUser.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
async def comunicate_with_engine(self, action, action_uuid, engine_command, query_mode=False):
    """Send a command to ``cuems-engine`` via NNG and validate the response.

    Waits up to ``TIMEOUT`` seconds for a response.  Validates that the
    response carries the expected ``action_uuid``, ``type``, and (unless
    *query_mode* is ``True``) a ``value`` of ``"OK"``.

    Args:
        action: The action string used for type-checking the engine reply.
        action_uuid: UUID string embedded in the command; must be echoed
            back by the engine.
        engine_command: Dict to send as the NNG request payload.
        query_mode: When ``True``, accepts any ``value`` from the engine
            (not just ``"OK"``).  Used for ``project_status`` which returns
            a state dict.  Defaults to ``False``.

    Returns:
        ``response['value']`` from the engine reply.

    Raises:
        EngineError: On timeout, connection failure, UUID mismatch,
            error response, or unexpected response type.

    Example:
        >>> action_uuid = str(new_uuid())
        >>> result = await self.comunicate_with_engine(
        ...     "project_load",
        ...     action_uuid,
        ...     {"action": "project_load", "action_uuid": action_uuid, "value": "my-show"},
        ... )
    """
    try:
        async with asyncio.timeout(TIMEOUT):
            response = await self.server.engine_communicator.send_request(engine_command)
    except TimeoutError:
        raise EngineError(f'Timeout: Engine did not respond in {TIMEOUT}s for {action} (uuid: {action_uuid})')
    except Exception as e:
        raise EngineError(f'Cannot connect to engine: {e} ({type(e).__name__})')

    if not response:
        raise EngineError(f'Engine did not respond with valid response for {action}')

    Logger.debug(f'Got response from engine: {response}')

    if 'action_uuid' not in response:
        raise EngineError(f'Engine response missing action_uuid: {response}')

    if action_uuid not in response['action_uuid']:
        raise EngineError(f'Action UUID mismatch. Expected: {action_uuid}, Got: {response.get("action_uuid")}')

    if 'type' not in response:
        raise EngineError(f'Engine response missing type field: {response}')

    if response['type'] == 'error':
        error_value = response.get('value', 'Unknown error')
        raise EngineError(f'Engine reports error: {error_value}')

    if response['type'] != action:
        raise EngineError(f'Response type mismatch. Expected: {action}, Got: {response["type"]}')

    if not query_mode and response.get('value') != 'OK':
        raise EngineError(f'Engine reports error: {response.get("value", "Unknown error")}')

    Logger.debug(f'Engine response for {action} is OK')
    return response['value']

consumer() async

Dequeue and dispatch one command at a time from incoming.

Parses the JSON frame, looks up the action key in the dispatch map, and awaits the corresponding handler coroutine. Errors at any stage are caught and reported to the client via :meth:notify_error_to_user.

The server launches three concurrent instances of this coroutine so that one slow handler (e.g. engine IPC) does not starve the other two.

Source code in src/cuemseditor/CuemsWsUser.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
async def consumer(self):
    """Dequeue and dispatch one command at a time from ``incoming``.

    Parses the JSON frame, looks up the ``action`` key in the dispatch
    map, and awaits the corresponding handler coroutine.  Errors at any
    stage are caught and reported to the client via
    :meth:`notify_error_to_user`.

    The server launches three concurrent instances of this coroutine so
    that one slow handler (e.g. engine IPC) does not starve the other two.
    """
    while True:
        message = await self.incoming.get()
        try:
            data = json.loads(message)
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user('error decoding json')
            continue
        try:
            action = data.get("action")
            value = data.get("value")
            if not action:
                Logger.error(f"unsupported event: {data}")
                await self.notify_error_to_user(f"unsupported event: {data}")
                return

            action_map = {
                "project_load": lambda: self.send_project(value, action),
                "project_ready": lambda: self.project_ready(value, action),
                "project_deploy": lambda: self.project_deploy(value, action),
                "project_new": lambda: self.received_new_project(value, action, data.get("unix_name")),
                "project_save": lambda: self.received_project(value, action),
                "project_delete": lambda: self.request_delete_project(value, action),
                "project_restore": lambda: self.request_restore_project(value, action),
                "project_trash_delete": lambda: self.request_delete_project_trash(value, action),
                "project_list": lambda: self.list_project(action),
                "project_duplicate": lambda: self.request_duplicate_project(value, action),
                "project_export": lambda: self.request_export_project(value, action),
                "file_list": lambda: self.list_file(action),
                "project_trash_list": lambda: self.list_project_trash(action),
                "file_trash_list": lambda: self.list_file_trash(action),
                "file_save": lambda: self.received_file_data(value, action),
                "file_load_meta": lambda: self.request_file_load_meta(value, action),
                "file_load_thumbnail": lambda: self.request_file_load_thumbnail(value, action),
                "file_load_waveform": lambda: self.request_file_load_waveform(value, action),
                "file_delete": lambda: self.request_delete_file(value, action),
                "file_restore": lambda: self.request_restore_file(value, action),
                "file_trash_delete": lambda: self.request_delete_file_trash(value, action),
                "hw_discovery": lambda: self.hw_discovery(action),
                "nodeconf": lambda: self.nodeconf(action),
                "nodelist_modify": lambda: self.nodelist_modify(value, action, data.get("modify_action")),
                "nodelist_get": lambda: self.nodelist_get(action),
                "node_status": lambda: self.node_status(action),
                "project_status": lambda: self.project_status(action),
                "project_unload": lambda: self.project_unload(action),
            }

            if action in action_map:
                await action_map[action]()
            else:
                Logger.error(f"unsupported action: {data}")
                await self.notify_error_to_user(f"unsupported action: {data}")
        except KeyError as e:
            Logger.error("error missing key:  {}".format(e))
            await self.notify_error_to_user('error missing key {} in request'.format(e))
        except Exception as e:
            Logger.error("error: {} {}".format(type(e), e))
            await self.notify_error_to_user('error processing request')

consumer_handler() async

Read frames from the WebSocket and enqueue them on incoming.

Runs until the WebSocket closes. Connection-closed exceptions are caught and logged at DEBUG level.

Source code in src/cuemseditor/CuemsWsUser.py
64
65
66
67
68
69
70
71
72
73
74
async def consumer_handler(self):
    """Read frames from the WebSocket and enqueue them on ``incoming``.

    Runs until the WebSocket closes.  Connection-closed exceptions are
    caught and logged at DEBUG level.
    """
    try:
        async for message in self.websocket:
            await self.incoming.put(message)
    except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
        Logger.debug(e)

hw_discovery(action) async

Ask the engine to run a hardware discovery scan.

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
async def hw_discovery(self, action):
    """Ask the engine to run a hardware discovery scan.

    Args:
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} dicovery")
    try:
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

list_file(action) async

Send the list of live media files to the client.

Parameters:

Name Type Description Default
action

Action name echoed back in the response frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
816
817
818
819
820
821
822
823
824
825
826
827
828
async def list_file(self, action):
    """Send the list of live media files to the client.

    Args:
        action: Action name echoed back in the response frame.
    """
    Logger.info("user {} loading file list".format(id(self.websocket)))
    try:
        file_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.list)
        await self.outgoing.put(json.dumps({"type": action, "value": file_list}))
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

list_file_trash(action) async

Send the list of trashed media files to the client.

Parameters:

Name Type Description Default
action

Action name echoed back in the response frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
911
912
913
914
915
916
917
918
919
920
921
922
923
async def list_file_trash(self, action):
    """Send the list of trashed media files to the client.

    Args:
        action: Action name echoed back in the response frame.
    """
    Logger.info("user {} loading file trash list".format(id(self.websocket)))
    try:
        file_trash_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.list_trash)
        await self.outgoing.put(json.dumps({"type": action, "value": file_trash_list}))
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

list_project(action) async

Send the list of live projects to the client.

Parameters:

Name Type Description Default
action

Action name echoed back in the response frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
612
613
614
615
616
617
618
619
620
621
622
623
624
async def list_project(self, action):
    """Send the list of live projects to the client.

    Args:
        action: Action name echoed back in the response frame.
    """
    Logger.info("user {} loading project list".format(id(self.websocket)))
    try:
        project_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.list)
        await self.outgoing.put(json.dumps({"type": action, "value": project_list}))
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

list_project_trash(action) async

Send the list of trashed projects to the client.

Parameters:

Name Type Description Default
action

Action name echoed back in the response frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
706
707
708
709
710
711
712
713
714
715
716
717
718
async def list_project_trash(self, action):
    """Send the list of trashed projects to the client.

    Args:
        action: Action name echoed back in the response frame.
    """
    Logger.info("user {} loading project trash list".format(id(self.websocket)))
    try:
        project_trash_list = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.list_trash)
        await self.outgoing.put(json.dumps({"type": action, "value": project_trash_list}))
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

node_status(action) async

Ask the engine which nodes are answering right now.

Relays the engine's cluster_status: {"alive": [...], "adopted": [...], "controller": uuid, "age_s": n}.

This is NOT the same thing as the online field in each node of initial_mappings: that one is cuems-nodeconf's discovery view (refreshed within ~30 s), while alive here is the engine's sub-second ping/pong — the only signal the GO gate trusts. A UI must show them as two separate indicators; merging them tells the operator a node is dead when it was merely not seen by the last discovery pass, or alive when it vanished 20 s ago.

age_s is how stale the probe behind this answer is. A failed or timed-out call means unknown, never dead: while a project load is in flight this request queues behind it (the engine serializes editor commands) and can time out while every node is perfectly healthy.

The reply also carries the last load's diagnosis — missing (project nodes not in the cluster at all) and unreachable (adopted, used by the project, no pong). Their cues will not play, and GO is deliberately not blocked. Nothing is done to them here on purpose: this method relays the engine's dict verbatim, so new keys reach the browser with no editor change. Do not add a hop.

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
async def node_status(self, action):
    """Ask the engine which nodes are answering right now.

    Relays the engine's ``cluster_status``:
    ``{"alive": [...], "adopted": [...], "controller": uuid, "age_s": n}``.

    This is NOT the same thing as the ``online`` field in each node of
    ``initial_mappings``: that one is cuems-nodeconf's discovery view
    (refreshed within ~30 s), while ``alive`` here is the engine's
    sub-second ping/pong — the only signal the GO gate trusts. A UI must
    show them as two separate indicators; merging them tells the operator
    a node is dead when it was merely not seen by the last discovery pass,
    or alive when it vanished 20 s ago.

    ``age_s`` is how stale the probe behind this answer is. A failed or
    timed-out call means *unknown*, never *dead*: while a project load is
    in flight this request queues behind it (the engine serializes editor
    commands) and can time out while every node is perfectly healthy.

    The reply also carries the last load's diagnosis — ``missing`` (project
    nodes not in the cluster at all) and ``unreachable`` (adopted, used by
    the project, no pong). Their cues will not play, and GO is deliberately
    *not* blocked. Nothing is done to them here on purpose: this method
    relays the engine's dict verbatim, so new keys reach the browser with
    no editor change. Do not add a hop.

    Args:
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
    try:
        action_uuid = str(new_uuid())
        engine_command = {
            "action": "cluster_status",
            "action_uuid": action_uuid,
        }

        result = await self.comunicate_with_engine(
            "cluster_status", action_uuid, engine_command, query_mode=True
        )

        await self.outgoing.put(json.dumps({"type": "node_status", "value": result}))

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), action=action)

nodeconf(action) async

Ask the engine to run node configuration.

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
async def nodeconf(self, action):
    """Ask the engine to run node configuration.

    Args:
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} dicovery")
    try:
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), action=action)

nodelist_get(action) async

Re-read network_map.xml and send this client the current node list.

Same payload the client already gets on connect (initial_mappings: nodes = adopted, new_nodes = discovered but not adopted), so no new client-side handling is needed — it is just a way to refresh on demand instead of reconnecting.

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
async def nodelist_get(self, action):
    """Re-read network_map.xml and send this client the current node list.

    Same payload the client already gets on connect
    (``initial_mappings``: ``nodes`` = adopted, ``new_nodes`` = discovered
    but not adopted), so no new client-side handling is needed — it is just
    a way to refresh on demand instead of reconnecting.

    Args:
        action: Action name from the WebSocket frame.
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
    try:
        reload_ok = await self.server.event_loop.run_in_executor(
            self.server.executor,
            self.server.reload_network_map_nodes
        )
        if not reload_ok:
            raise ValueError("could not read network_map.xml")

        await self.outgoing.put(self.server.initial_setting_message())

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), action=action)

nodelist_modify(node_uuid, action, modify_action) async

Add or remove a node from the engine's active node list.

After a successful engine response, triggers server.notify_all_node_list_update to broadcast the refreshed node list to all connected clients.

Parameters:

Name Type Description Default
node_uuid

UUID string of the node to add or remove.

required
action

Action name from the WebSocket frame (echoed in the reply).

required
modify_action

"ADD" or "REMOVE".

required

Raises:

Type Description
ValueError

If modify_action is not "ADD" or "REMOVE".

Source code in src/cuemseditor/CuemsWsUser.py
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
async def nodelist_modify(self, node_uuid, action, modify_action):
    """Add or remove a node from the engine's active node list.

    After a successful engine response, triggers
    ``server.notify_all_node_list_update`` to broadcast the refreshed
    node list to all connected clients.

    Args:
        node_uuid: UUID string of the node to add or remove.
        action: Action name from the WebSocket frame (echoed in the reply).
        modify_action: ``"ADD"`` or ``"REMOVE"``.

    Raises:
        ValueError: If *modify_action* is not ``"ADD"`` or ``"REMOVE"``.
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()} for node {node_uuid} with action {modify_action}")
    try:
        if modify_action not in ["ADD", "REMOVE"]:
            raise ValueError(f"Invalid modify_action: {modify_action}. Must be 'ADD' or 'REMOVE'")

        # Without this a None/empty value travels the whole chain and comes
        # back from nodeconf as the baffling "Node None not found".
        if not node_uuid or not isinstance(node_uuid, str):
            raise ValueError(f"nodelist_modify needs a node uuid, got {node_uuid!r}")

        action_uuid = str(new_uuid())
        engine_command = {
            "action": functionNameAsString(),
            "action_uuid": action_uuid,
            "value": node_uuid,
            "modify_action": modify_action
        }

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)
        Logger.debug(f"nodelist_modify for node {node_uuid}: {result}")

        await self.outgoing.put(json.dumps({"type": "nodelist_modify", "value": "OK"}))

        await asyncio.sleep(0.1)

        try:
            await self.server.notify_all_node_list_update()
        except Exception as e:
            Logger.warning(f'Failed to reload and broadcast node list update after adoption: {e}')

    except EngineError as e:
        Logger.error(f"Engine error in nodelist_modify: {e}")
        await self.notify_error_to_user(str(e), action=action)
    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), action=action)

notify_error_to_user(msg=None, uuid=None, action=None) async

Enqueue an error notification to the client.

Three call forms depending on which combination of arguments is provided:

  • notify_error_to_user(msg=...) — bare error: {"type": "error", "value": msg}.
  • notify_error_to_user(msg=..., action=...) — action error: {"type": "error", "action": action, "value": msg}.
  • notify_error_to_user(msg=..., action=..., uuid=...) — item error: {"type": "error", "uuid": uuid, "action": action, "value": msg}.

Parameters:

Name Type Description Default
msg

Human-readable error description.

None
uuid

UUID of the subject item, if applicable.

None
action

Action name that triggered the error, if applicable.

None
Source code in src/cuemseditor/CuemsWsUser.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
async def notify_error_to_user(self, msg=None, uuid=None, action=None):
    """Enqueue an error notification to the client.

    Three call forms depending on which combination of arguments is
    provided:

    * ``notify_error_to_user(msg=...)`` — bare error:
      ``{"type": "error", "value": msg}``.
    * ``notify_error_to_user(msg=..., action=...)`` — action error:
      ``{"type": "error", "action": action, "value": msg}``.
    * ``notify_error_to_user(msg=..., action=..., uuid=...)`` — item error:
      ``{"type": "error", "uuid": uuid, "action": action, "value": msg}``.

    Args:
        msg: Human-readable error description.
        uuid: UUID of the subject item, if applicable.
        action: Action name that triggered the error, if applicable.
    """
    if (msg is not None) and (uuid is None) and (action is None):
        await self.outgoing.put(json.dumps({"type": "error", "value": msg}))
    elif (action is not None) and (msg is not None) and (uuid is None):
        await self.outgoing.put(json.dumps({"type": "error", "action": action, "value": msg}))
    elif (action is not None) and (msg is not None) and (uuid is not None):
        await self.outgoing.put(json.dumps({"type": "error", "uuid": uuid, "action": action, "value": msg}))

notify_user(msg=None, uuid=None, action=None, new_uuid=None) async

Enqueue a success notification to the client.

Three call forms:

  • notify_user(msg=...) — state message: {"type": "state", "value": msg}.
  • notify_user(uuid=..., action=...) — action confirmation: {"type": action, "value": uuid}.
  • notify_user(uuid=..., action=..., new_uuid=...) — action with rename: {"type": action, "value": {"uuid": uuid, "new_uuid": new_uuid}}.

Parameters:

Name Type Description Default
msg

Plain state message string.

None
uuid

Subject item UUID string.

None
action

Action name echoed back to the client.

None
new_uuid

New UUID string (used for project_duplicate).

None
Source code in src/cuemseditor/CuemsWsUser.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
async def notify_user(self, msg=None, uuid=None, action=None, new_uuid=None):
    """Enqueue a success notification to the client.

    Three call forms:

    * ``notify_user(msg=...)`` — state message: ``{"type": "state", "value": msg}``.
    * ``notify_user(uuid=..., action=...)`` — action confirmation:
      ``{"type": action, "value": uuid}``.
    * ``notify_user(uuid=..., action=..., new_uuid=...)`` — action with
      rename: ``{"type": action, "value": {"uuid": uuid, "new_uuid": new_uuid}}``.

    Args:
        msg: Plain state message string.
        uuid: Subject item UUID string.
        action: Action name echoed back to the client.
        new_uuid: New UUID string (used for ``project_duplicate``).
    """
    if (uuid is None) and (action is None) and (msg is not None):
        await self.outgoing.put(json.dumps({"type": "state", "value": msg}))
    elif (msg is None and new_uuid is None):
        await self.outgoing.put(json.dumps({"type": action, "value": uuid}))
    elif (msg is None and new_uuid is not None):
        await self.outgoing.put(json.dumps({"type": action, "value": {"uuid": uuid, "new_uuid": new_uuid}}))

producer_handler() async

Dequeue messages from outgoing and send them over the WebSocket.

Runs until the WebSocket closes. Connection-closed exceptions break the loop cleanly.

Source code in src/cuemseditor/CuemsWsUser.py
76
77
78
79
80
81
82
83
84
85
86
87
88
async def producer_handler(self):
    """Dequeue messages from ``outgoing`` and send them over the WebSocket.

    Runs until the WebSocket closes.  Connection-closed exceptions break
    the loop cleanly.
    """
    while True:
        message = await self.outgoing.get()
        try:
            await self.websocket.send(message)
        except (ws.exceptions.ConnectionClosed, ws.exceptions.ConnectionClosedOK, ws.exceptions.ConnectionClosedError) as e:
            Logger.debug(e)
            break

project_deploy(project_uuid, action) async

Deploy a project to the engine for playback.

Resolves unix_name from the DB and sends a project_deploy command to the engine.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to deploy.

required
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
async def project_deploy(self, project_uuid, action):
    """Deploy a project to the engine for playback.

    Resolves ``unix_name`` from the DB and sends a ``project_deploy``
    command to the engine.

    Args:
        project_uuid: UUID string of the project to deploy.
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting deploy project {project_uuid}")
    try:
        unix_name = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.get_project_unix_name, project_uuid)
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid, "value": unix_name}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": project_uuid}))

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

project_ready(project_uuid, action) async

Tell the engine to arm the project identified by project_uuid.

Resolves the unix_name from the DB, checks the project's media files first (869fat84r D20; never blocks the load), builds the project_ready engine command, and awaits the response. After the reply, the media check's media_check_report goes to this session and to every other session on the project.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to arm.

required
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
async def project_ready(self, project_uuid, action):
    """Tell the engine to arm the project identified by *project_uuid*.

    Resolves the ``unix_name`` from the DB, checks the project's media
    files first (869fat84r D20; never blocks the load), builds the
    ``project_ready`` engine command, and awaits the response. After the
    reply, the media check's ``media_check_report`` goes to this session
    and to every other session on the project.

    Args:
        project_uuid: UUID string of the project to arm.
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting ready project {project_uuid}")
    try:
        unix_name = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.get_project_unix_name, project_uuid)
        refresh = await self._refresh_media(project_uuid)
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid, "value": unix_name}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)
        Logger.debug(f"project {project_uuid} ready: {result}")

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": project_uuid}))
        # After the reply, so a client waiting for it never reads this first.
        await self._send_media_report(refresh.get('media_report') if refresh else None)

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

project_status(action) async

Query the engine for the current project load state without side effects.

Uses query_mode=True so the engine's dict response (not just "OK") is accepted and forwarded to the client.

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
async def project_status(self, action):
    """Query the engine for the current project load state without side effects.

    Uses ``query_mode=True`` so the engine's dict response (not just
    ``"OK"``) is accepted and forwarded to the client.

    Args:
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
    try:
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command, query_mode=True)

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": result}))

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), action=action)

project_unload(action) async

Tell the engine to unload the current project and clear session state.

On success, sets server.users[self] = None and clears sessions[session_id]['loaded_project'].

Parameters:

Name Type Description Default
action

Action name from the WebSocket frame (echoed in the reply).

required
Source code in src/cuemseditor/CuemsWsUser.py
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
async def project_unload(self, action):
    """Tell the engine to unload the current project and clear session state.

    On success, sets ``server.users[self] = None`` and clears
    ``sessions[session_id]['loaded_project']``.

    Args:
        action: Action name from the WebSocket frame (echoed in the reply).
    """
    Logger.info(f"user {id(self.websocket)} requesting {functionNameAsString()}")
    try:
        action_uuid = str(new_uuid())
        engine_command = {"action": functionNameAsString(), "action_uuid": action_uuid}

        result = await self.comunicate_with_engine(action, action_uuid, engine_command)

        self.server.users[self] = None
        if self.session_id and self.session_id in self.server.sessions:
            self.server.sessions[self.session_id]['loaded_project'] = None

        await self.outgoing.put(json.dumps({"type": functionNameAsString(), "value": "OK"}))

    except Exception as e:
        Logger.error(f"error: {type(e)} {e}")
        await self.notify_error_to_user(str(e), action=action)

received_file_data(data, action) async

Update metadata (name, description) for a media file.

Parameters:

Name Type Description Default
data

Dict with structure {uuid: {name, description}}.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
async def received_file_data(self, data, action):
    """Update metadata (name, description) for a media file.

    Args:
        data: Dict with structure ``{uuid: {name, description}}``.
        action: Action name echoed back in the confirmation.
    """
    try:
        file_uuid = data['uuid']

        Logger.info("user {} update file data {}".format(id(self.websocket), file_uuid))

        return_message = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.save, file_uuid, data)
        await self.notify_user(uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

received_new_project(data, action, unix_name) async

Create a new project and notify the client and other users.

Parameters:

Name Type Description Default
data

CuemsScript dict from the frontend.

required
action

Action name echoed back in the confirmation.

required
unix_name

Candidate directory name from the frontend.

required
Source code in src/cuemseditor/CuemsWsUser.py
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
async def received_new_project(self, data, action, unix_name):
    """Create a new project and notify the client and other users.

    Args:
        data: ``CuemsScript`` dict from the frontend.
        action: Action name echoed back in the confirmation.
        unix_name: Candidate directory name from the frontend.
    """
    try:
        project_uuid = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.new, data, unix_name)

        Logger.info("user {} new project {}".format(id(self.websocket), project_uuid))

        self.server.users[self] = project_uuid
        await self.notify_user(uuid=project_uuid, action=action)
        await self.server.notify_others_list_changes(self, "project_list")
        await self.server.notify_others_same_project(self, "project_modified", project_uuid)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(self._error_text(e), action="project_new")

received_project(data, action) async

Save an edited project and notify the client and other users.

Parameters:

Name Type Description Default
data

CuemsScript dict containing id and updated fields.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
async def received_project(self, data, action):
    """Save an edited project and notify the client and other users.

    Args:
        data: ``CuemsScript`` dict containing ``id`` and updated fields.
        action: Action name echoed back in the confirmation.
    """
    try:
        project_uuid = data['CuemsScript']['id']
        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.update, project_uuid, data)
        Logger.info("user {} saving project {}".format(id(self.websocket), project_uuid))

        self.server.users[self] = project_uuid
        await self.notify_user(uuid=project_uuid, action=action)
        await self.server.notify_others_list_changes(self, "project_list")
        await self.server.notify_others_same_project(self, "project_modified", project_uuid)
        self._in_background(self._report_after_save(project_uuid))
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(self._error_text(e), uuid=project_uuid, action=action)

request_delete_file(file_uuid, action) async

Soft-delete a media file (move to trash) and notify affected clients.

Parameters:

Name Type Description Default
file_uuid

UUID string of the media file to trash.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
async def request_delete_file(self, file_uuid, action):
    """Soft-delete a media file (move to trash) and notify affected clients.

    Args:
        file_uuid: UUID string of the media file to trash.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.debug("user {} deleting file: {}".format(id(self.websocket), file_uuid))
        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.delete, file_uuid)
        await self.notify_user(uuid=file_uuid, action=action)
        await self.server.notify_others_list_changes(self, "file_list")
        await self.server.notify_others_list_changes(self, "file_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_delete_file_trash(file_uuid, action) async

Permanently delete a trashed media file and notify the client.

Parameters:

Name Type Description Default
file_uuid

UUID string of the trashed media file to delete permanently.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
async def request_delete_file_trash(self, file_uuid, action):
    """Permanently delete a trashed media file and notify the client.

    Args:
        file_uuid: UUID string of the trashed media file to delete permanently.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.info("user {} deleting file from trash: {}".format(id(self.websocket), file_uuid))
        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.delete_from_trash, file_uuid)
        await self.notify_user(uuid=file_uuid, action=action)
        await self.server.notify_others_list_changes(self, "file_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_delete_project(project_uuid, action) async

Soft-delete a project (move to trash) and notify affected clients.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to trash.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
async def request_delete_project(self, project_uuid, action):
    """Soft-delete a project (move to trash) and notify affected clients.

    Args:
        project_uuid: UUID string of the project to trash.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.info("user {} deleting project: {}".format(id(self.websocket), project_uuid))

        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.delete, project_uuid)

        await self.notify_user(uuid=project_uuid, action=action)
        await self.server.notify_others_same_project(self, "project_update", project_uuid=project_uuid)
        await self.server.notify_others_list_changes(self, "project_list")
        await self.server.notify_others_list_changes(self, "project_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

request_delete_project_trash(project_uuid, action) async

Permanently delete a trashed project and notify the client.

Parameters:

Name Type Description Default
project_uuid

UUID string of the trashed project to delete.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
async def request_delete_project_trash(self, project_uuid, action):
    """Permanently delete a trashed project and notify the client.

    Args:
        project_uuid: UUID string of the trashed project to delete.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.info("user {} deleting project from trash: {}".format(id(self.websocket), project_uuid))

        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.delete_from_trash, project_uuid)

        await self.notify_user(uuid=project_uuid, action=action)
        await self.server.notify_others_list_changes(self, "project_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

request_duplicate_project(project_uuid, action) async

Duplicate a project and notify the client and other users.

The reply includes both the original and new UUID so the frontend can navigate to the copy.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to duplicate.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
async def request_duplicate_project(self, project_uuid, action):
    """Duplicate a project and notify the client and other users.

    The reply includes both the original and new UUID so the frontend can
    navigate to the copy.

    Args:
        project_uuid: UUID string of the project to duplicate.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.info("user {} duplicating project: {}".format(id(self.websocket), project_uuid))
        new_project_uuid = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.duplicate, project_uuid)
        await self.notify_user(uuid=project_uuid, action=action, new_uuid=new_project_uuid)
        await self.server.notify_others_list_changes(self, "project_list")
        await self.server.notify_others_list_changes(self, "file_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

request_file_load_meta(file_uuid, action) async

Send full metadata for a media file to the client.

Parameters:

Name Type Description Default
file_uuid

UUID string of the media file.

required
action

Action name echoed back in the response frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
async def request_file_load_meta(self, file_uuid, action):
    """Send full metadata for a media file to the client.

    Args:
        file_uuid: UUID string of the media file.
        action: Action name echoed back in the response frame.
    """
    try:
        Logger.info("user {} loading file meta data {}".format(id(self.websocket), file_uuid))

        file_meta_data = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_meta, file_uuid)
        await self.outgoing.put(json.dumps({"type": action, "value": file_meta_data}))
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_file_load_thumbnail(file_uuid, action) async

Send the thumbnail binary frame for a media file to the client.

The response is a raw binary WebSocket frame with a 36-byte UUID header prepended (not a JSON frame).

Parameters:

Name Type Description Default
file_uuid

UUID string of the media file.

required
action

Action name (used only in error replies).

required
Source code in src/cuemseditor/CuemsWsUser.py
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
async def request_file_load_thumbnail(self, file_uuid, action):
    """Send the thumbnail binary frame for a media file to the client.

    The response is a raw binary WebSocket frame with a 36-byte UUID
    header prepended (not a JSON frame).

    Args:
        file_uuid: UUID string of the media file.
        action: Action name (used only in error replies).
    """
    try:
        Logger.info("user {} loading file thumbnail {}".format(id(self.websocket), file_uuid))

        file_thumbnail = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_thumbnail, file_uuid)
        await self.outgoing.put(file_thumbnail)  # TODO: add uuid encoded in the binary message
    except NonExistentItemError as e:
        Logger.warning(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_file_load_waveform(file_uuid, action) async

Send the waveform binary frame for a media file to the client.

The response is a raw binary WebSocket frame with a 36-byte UUID header prepended (not a JSON frame).

Parameters:

Name Type Description Default
file_uuid

UUID string of the media file.

required
action

Action name (used only in error replies).

required
Source code in src/cuemseditor/CuemsWsUser.py
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
async def request_file_load_waveform(self, file_uuid, action):
    """Send the waveform binary frame for a media file to the client.

    The response is a raw binary WebSocket frame with a 36-byte UUID
    header prepended (not a JSON frame).

    Args:
        file_uuid: UUID string of the media file.
        action: Action name (used only in error replies).
    """
    try:
        Logger.info("user {} loading file waveform {}".format(id(self.websocket), file_uuid))

        file_waveform = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.load_waveform, file_uuid)
        await self.outgoing.put(file_waveform)  # TODO: add uuid encoded in the binary message
    except NonExistentItemError as e:
        Logger.warning(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_restore_file(file_uuid, action) async

Restore a trashed media file and notify affected clients.

Parameters:

Name Type Description Default
file_uuid

UUID string of the trashed media file to restore.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
async def request_restore_file(self, file_uuid, action):
    """Restore a trashed media file and notify affected clients.

    Args:
        file_uuid: UUID string of the trashed media file to restore.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.debug("user {} restoring file: {}".format(id(self.websocket), file_uuid))
        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.media.restore, file_uuid)
        await self.notify_user(uuid=file_uuid, action=action)
        await self.server.notify_others_list_changes(self, "file_list")
        await self.server.notify_others_list_changes(self, "file_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=file_uuid, action=action)

request_restore_project(project_uuid, action) async

Restore a trashed project and notify the client and other users.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to restore.

required
action

Action name echoed back in the confirmation.

required
Source code in src/cuemseditor/CuemsWsUser.py
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
async def request_restore_project(self, project_uuid, action):
    """Restore a trashed project and notify the client and other users.

    Args:
        project_uuid: UUID string of the project to restore.
        action: Action name echoed back in the confirmation.
    """
    try:
        Logger.info("user {} restoring project: {}".format(id(self.websocket), project_uuid))
        await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.restore, project_uuid)
        await self.notify_user(uuid=project_uuid, action=action)
        await self.server.notify_others_list_changes(self, "project_list")
        await self.server.notify_others_list_changes(self, "project_trash_list")
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)

send_project(project_uuid, action) async

Load a project's XML from disk and send it to the client.

Also records project_uuid as the session's loaded project in server.users and server.sessions.

Parameters:

Name Type Description Default
project_uuid

UUID string of the project to load.

required
action

Action name from the WebSocket frame.

required
Source code in src/cuemseditor/CuemsWsUser.py
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
async def send_project(self, project_uuid, action):
    """Load a project's XML from disk and send it to the client.

    Also records *project_uuid* as the session's loaded project in
    ``server.users`` and ``server.sessions``.

    Args:
        project_uuid: UUID string of the project to load.
        action: Action name from the WebSocket frame.
    """
    try:
        Logger.info("user {} loading project {}".format(id(self.websocket), project_uuid))
        project = await self.server.event_loop.run_in_executor(self.server.executor, self.server.db.project.load, project_uuid)
        msg = json.dumps({"type": "project", "value": project})
        await self.outgoing.put(msg)
        self.server.users[self] = project_uuid
        self.server.sessions[self.session_id]['loaded_project'] = project_uuid
        # After the project frame, never before it (869fat84r D20).
        self._in_background(self._report_on_open(project_uuid))
    except NonExistentItemError as e:
        Logger.info(e)
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)
    except Exception as e:
        Logger.error("error: {} {}".format(type(e), e))
        await self.notify_error_to_user(str(e), uuid=project_uuid, action=action)