├── .gitignore ├── .python-version ├── LICENSE ├── TODO ├── docs ├── README.md ├── screenshot-full.png └── screenshot-single.png ├── midiclock.py ├── monitor-curses.py ├── monitor-qt.py ├── monitor-simple.py ├── prodj ├── __init__.py ├── core │ ├── __init__.py │ ├── clientlist.py │ ├── prodj.py │ └── vcdj.py ├── curses │ ├── __init__.py │ └── loghandler.py ├── data │ ├── __init__.py │ ├── dataprovider.py │ ├── datastore.py │ ├── dbclient.py │ ├── exceptions.py │ └── pdbprovider.py ├── gui │ ├── __init__.py │ ├── gui.py │ ├── gui_browser.py │ ├── preview_waveform_qt.py │ ├── waveform_blue_map.py │ ├── waveform_gl.py │ └── waveform_qt.py ├── midi │ ├── __init__.py │ ├── midiclock_alsaseq.py │ └── midiclock_rtmidi.py ├── network │ ├── __init__.py │ ├── ip.py │ ├── nfsclient.py │ ├── nfsdownload.py │ ├── packets.py │ ├── packets_dump.py │ ├── packets_nfs.py │ └── rpcreceiver.py └── pdblib │ ├── __init__.py │ ├── __init__.py.save │ ├── album.py │ ├── artist.py │ ├── artwork.py │ ├── color.py │ ├── genre.py │ ├── key.py │ ├── label.py │ ├── page.py │ ├── pagetype.py │ ├── pdbdatabase.py │ ├── pdbfile.py │ ├── piostring.py │ ├── playlist.py │ ├── playlist_map.py │ ├── track.py │ ├── usbanlz.py │ └── usbanlzdatabase.py ├── requirements.txt ├── test_runner.py └── tests ├── blobs ├── pdb_artists_common.bin └── pdb_artists_strange_string.bin ├── test_dbclient.py ├── test_nfsclient.py ├── test_packets.py └── test_pdb.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | stuff 3 | .idea/ 4 | databases/ 5 | tracks.log 6 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - fallback to db parser if database is unavailable 2 | - on air display when nexus mixer is connected 3 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Python ProDJ Link 2 | 3 | ![single player screenshot](screenshot-single.png) 4 | 5 | This is a set of python scripts to participate in a Pioneer ProDJ Link system. 6 | It is particularly useful to monitor whats happening on the players, but can also help by syncing other devices using midi clock. 7 | The code to detect your own mac and ip addresses is os dependant, but thanks to the netifaces it should also work on platforms other than linux (testing required). 8 | 9 | The Qt GUI is useful to perform light shows and react to events in the music. 10 | 11 | ## Getting Started 12 | 13 | These instructions describe necessary work to be done before being able to run the project. 14 | 15 | ### Prerequisites 16 | 17 | python-prodj-link is written in Python 3. It requires 18 | [Construct](https://pypi.python.org/pypi/construct) **(Version 2.9 or later)**, 19 | [PyQt5](https://pypi.python.org/pypi/PyQt5), 20 | [PyOpenGL](https://pypi.org/project/PyOpenGL/) and 21 | [netifaces](https://pypi.org/project/netifaces). 22 | 23 | Use your distributions package management to install these, e.g. on Arch Linux: 24 | 25 | ``` 26 | pacman -S python-construct python-pyqt5 python-netifaces python-opengl 27 | ``` 28 | 29 | Alternatively, you can use pip to install the required dependencies, preferrably in a virtualenv: 30 | ``` 31 | python3 -m virtualenv venv 32 | venv/bin/pip install -r requirements.txt 33 | ``` 34 | 35 | **Note:** Construct v2.9 changed a lot of its internal APIs. 36 | If you still need to use version 2.8, you can find an unmaintained version in the branch [construct-compat](../../../tree/construct-compat). 37 | 38 | ### Testing 39 | ``` 40 | python3 test_runner.py 41 | ``` 42 | 43 | ### Network configuration 44 | 45 | You need to be on the same Ethernet network as the players are discovered using broadcasts. 46 | The players will aquire IPs using DHCP if a server is available, otherwise they fall back to IPv4 autoconfiguration. 47 | If there is no DHCP server on your network, make sure you assign a IP address inside 169.254.0.0/16 to yourself, for example using NetworkManager or avahi-autoipd. 48 | 49 | You can test your setup using wireshark or tcpdump to see if you receive keepalive broadcast on port 50000. 50 | 51 | ## Usage 52 | 53 | ### Qt GUI 54 | 55 | The most useful part is the Qt GUI. 56 | It displays some information about the tracks on every player, including metadata, artwork, current BPM, waveform and preview waveform. 57 | Waveforms are rendered using OpenGL through Qt, thus you need an OpenGL 2.0 compatible graphics driver. 58 | It is also possible to browse media and load these tracks into players remotely. 59 | Additionally, you can download tracks from remote players, either directly when loaded or from the media browser. 60 | 61 | ./monitor-qt.py 62 | 63 | or when using virtualenv: 64 | 65 | venv/bin/python3 monitor-qt.py 66 | 67 | ![two players screenshot with browser](screenshot-full.png) 68 | 69 | ### Midi Clock 70 | 71 | The midiclock script opens a midi sound card and outputs midi clock packets matching the current master bpm. 72 | Additionally, for each beat a note on event (between 60 and 63) is emitted. 73 | This is useful to synchronize beat machines or effect units. 74 | 75 | To create midi clocks with exact timing, this additionally requires the [alsaseq](https://pypi.python.org/pypi/alsaseq) package. 76 | Depending on your distribution you may need to gain privileges to access the sequencer _/dev/snd/seq_. 77 | On Arch Linux, membership in the _audio_ group is required. 78 | 79 | By default, the first midi seqencer is used. 80 | You can list available ports with argument _-l_. 81 | 82 | ./midiclock.py 83 | 84 | or when using virtualenv: 85 | 86 | venv/bin/python3 midiclock.py 87 | 88 | ## Bugs & Contributing 89 | 90 | This is still early beta software! 91 | It can freeze your players, although that has not happened to me with the recent versions yet. 92 | Be careful when using it in an live environment! 93 | 94 | If you experience any errors or have additional features, feel free to open an issue or pull request. 95 | I have already **successfully tested** the script against the following players/mixers: 96 | 97 | * Pioneer CDJ 2000 98 | * Pioneer CDJ 2000 Nexus 99 | * Pioneer CDJ 2000 NXS2 100 | * Pioneer XDJ 1000 101 | * Pioneer DJM 900 Nexus 102 | * Pioneer DJM 900 NXS2 103 | 104 | It may occur that I cannot parse some network packets that I have not seen yet, especially on players other than my XDJ-1000s or if Rekordbox is involved. 105 | Please include debug output when reporting bugs and a network dump (i.e. wireshark) if possible. 106 | 107 | ## Acknowledgments 108 | 109 | * A lot of information from [dysentery](https://github.com/brunchboy/dysentery) 110 | * And some info from Austin Wright's [libpdjl](https://bitbucket.org/awwright/libpdjl) 111 | 112 | ## License 113 | 114 | Licensed under the Apache License, Version 2.0 (the "License"). 115 | You may obtain a copy of the License inside the "LICENSE" file or at 116 | 117 | http://www.apache.org/licenses/LICENSE-2.0 118 | 119 | Unless required by applicable law or agreed to in writing, software 120 | distributed under the License is distributed on an "AS IS" BASIS, 121 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 122 | See the License for the specific language governing permissions and 123 | limitations under the License. 124 | -------------------------------------------------------------------------------- /docs/screenshot-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/docs/screenshot-full.png -------------------------------------------------------------------------------- /docs/screenshot-single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/docs/screenshot-single.png -------------------------------------------------------------------------------- /midiclock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import sys 5 | import argparse 6 | 7 | from prodj.core.prodj import ProDj 8 | 9 | parser = argparse.ArgumentParser(description='Python ProDJ Link Midi Clock') 10 | notes_group = parser.add_mutually_exclusive_group() 11 | notes_group.add_argument('-n', '--notes', action='store_true', help='Send four different note on events depending on the beat') 12 | notes_group.add_argument('-s', '--single-note', action='store_true', help='Send the same note on event on every beat') 13 | parser.add_argument('-l', '--list-ports', action='store_true', help='List available midi ports') 14 | parser.add_argument('-d', '--device', help='MIDI device to use (default: first available device)') 15 | parser.add_argument('-p', '--port', help='MIDI port to use (default: 0)', type=int, default=0) 16 | parser.add_argument('-q', '--quiet', action='store_const', dest='loglevel', const=logging.WARNING, help='Display warning messages only', default=logging.INFO) 17 | parser.add_argument('-D', '--debug', action='store_const', dest='loglevel', const=logging.DEBUG, help='Display verbose debugging information') 18 | parser.add_argument('--note-base', type=int, default=60, help='Note value for first beat') 19 | parser.add_argument('--rtmidi', action='store_true', help='Use deprecated rtmidi backend with timing issues') 20 | args = parser.parse_args() 21 | 22 | logging.basicConfig(level=args.loglevel, format='%(levelname)s: %(message)s') 23 | 24 | if args.rtmidi: 25 | from prodj.midi.midiclock_rtmidi import MidiClock 26 | else: 27 | from prodj.midi.midiclock_alsaseq import MidiClock 28 | 29 | c = MidiClock() 30 | 31 | if args.list_ports: 32 | for id, name, ports in c.iter_alsa_seq_clients(): 33 | logging.info("MIDI device %d: %s, ports: %s", 34 | id, name, ', '.join([str(x) for x in ports])) 35 | sys.exit(0) 36 | 37 | c.open(args.device, args.port) 38 | 39 | p = ProDj() 40 | p.cl.log_played_tracks = False 41 | p.cl.auto_request_beatgrid = False 42 | 43 | bpm = 128 # default bpm until reported from player 44 | beat = 0 45 | c.setBpm(bpm) 46 | 47 | def update_master(player_number): 48 | global bpm, beat, p 49 | client = p.cl.getClient(player_number) 50 | if client is None or not 'master' in client.state: 51 | return 52 | if (args.notes or args.single_notes) and beat != client.beat: 53 | note = args.base_note 54 | if args.notes: 55 | note += client.beat 56 | c.send_note(note) 57 | newbpm = client.bpm*client.actual_pitch 58 | if bpm != newbpm: 59 | c.setBpm(newbpm) 60 | bpm = newbpm 61 | 62 | p.set_client_change_callback(update_master) 63 | 64 | try: 65 | p.start() 66 | p.vcdj_enable() 67 | c.start() 68 | p.join() 69 | except KeyboardInterrupt: 70 | logging.info("Shutting down...") 71 | c.stop() 72 | p.stop() 73 | -------------------------------------------------------------------------------- /monitor-curses.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import curses 4 | import logging 5 | 6 | from prodj.core.prodj import ProDj 7 | from prodj.curses.loghandler import CursesHandler 8 | 9 | #default_loglevel=logging.DEBUG 10 | default_loglevel=logging.INFO 11 | 12 | # init curses 13 | win = curses.initscr() 14 | win.clear() 15 | client_win = win.subwin(16, curses.COLS-1, 0, 0) 16 | log_win = win.subwin(18, 0) 17 | log_win.scrollok(True) 18 | win.hline(17,0,"-",curses.COLS) 19 | win.refresh() 20 | 21 | # init logging 22 | ch = CursesHandler(log_win) 23 | ch.setFormatter(logging.Formatter(fmt='%(levelname)s: %(message)s')) 24 | logging.basicConfig(level=default_loglevel, handlers=[ch]) 25 | 26 | p = ProDj() 27 | p.set_client_keepalive_callback(lambda n: update_clients(client_win)) 28 | p.set_client_change_callback(lambda n: update_clients(client_win)) 29 | 30 | def update_clients(client_win): 31 | try: 32 | client_win.clear() 33 | client_win.addstr(0, 0, "Detected Pioneer devices:\n") 34 | if len(p.cl.clients) == 0: 35 | client_win.addstr(" No devices detected\n") 36 | else: 37 | for c in p.cl.clients: 38 | client_win.addstr("Player {}: {} {} BPM Pitch {:.2f}% Beat {}/{} NextCue {}\n".format( 39 | c.player_number, c.model if c.fw=="" else "{}({})".format(c.model,c.fw), 40 | c.bpm, (c.pitch-1)*100, c.beat, c.beat_count, c.cue_distance)) 41 | if c.status_packet_received: 42 | client_win.addstr(" {} ({}) Track {} from Player {},{} Actual Pitch {:.2f}%\n".format( 43 | c.play_state, ",".join(c.state), c.track_number, c.loaded_player_number, 44 | c.loaded_slot, (c.actual_pitch-1)*100)) 45 | client_win.refresh() 46 | except Exception as e: 47 | logging.critical(str(e)) 48 | 49 | update_clients(client_win) 50 | 51 | try: 52 | p.start() 53 | p.vcdj_enable() 54 | p.join() 55 | except KeyboardInterrupt: 56 | logging.info("Shutting down...") 57 | p.stop() 58 | #except: 59 | # curses.endwin() 60 | # raise 61 | finally: 62 | curses.endwin() 63 | -------------------------------------------------------------------------------- /monitor-qt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | from PyQt5.QtWidgets import QApplication 5 | from PyQt5.QtGui import QPalette 6 | from PyQt5.QtCore import Qt 7 | import signal 8 | import argparse 9 | 10 | from prodj.core.prodj import ProDj 11 | from prodj.gui.gui import Gui 12 | 13 | def arg_size(value): 14 | number = int(value) 15 | if number < 1000 or number > 60000: 16 | raise argparse.ArgumentTypeError("%s is not between 1000 and 60000".format(value)) 17 | return number 18 | 19 | def arg_layout(value): 20 | if value not in ["xy", "yx", "xx", "yy", "row", "column"]: 21 | raise argparse.ArgumentTypeError("%s is not a value from the list xy, yx, xx, yy, row or column".format(value)) 22 | return value 23 | 24 | parser = argparse.ArgumentParser(description='Python ProDJ Link') 25 | provider_group = parser.add_mutually_exclusive_group() 26 | provider_group.add_argument('--disable-pdb', dest='enable_pdb', action='store_false', help='Disable PDB provider') 27 | provider_group.add_argument('--disable-dbc', dest='enable_dbc', action='store_false', help='Disable DBClient provider') 28 | parser.add_argument('--color-preview', action='store_true', help='Show NXS2 colored preview waveforms') 29 | parser.add_argument('--color-waveform', action='store_true', help='Show NXS2 colored big waveforms') 30 | parser.add_argument('-c', '--color', action='store_true', help='Shortcut for --color-preview and --color-waveform') 31 | parser.add_argument('-q', '--quiet', action='store_const', dest='loglevel', const=logging.WARNING, help='Only display warning messages', default=logging.INFO) 32 | parser.add_argument('-d', '--debug', action='store_const', dest='loglevel', const=logging.DEBUG, help='Display verbose debugging information') 33 | parser.add_argument('--dump-packets', action='store_const', dest='loglevel', const=0, help='Dump packet fields for debugging', default=logging.INFO) 34 | parser.add_argument('--chunk-size', dest='chunk_size', help='Chunk size of NFS downloads (high values may be faster but fail on some networks)', type=arg_size, default=None) 35 | parser.add_argument('-f', '--fullscreen', action='store_true', help='Start with fullscreen window') 36 | parser.add_argument('-l', '--layout', dest='layout', help='Display layout, values are xy (default), yx, xx, yy, row or column', type=arg_layout, default="xy") 37 | 38 | args = parser.parse_args() 39 | 40 | logging.basicConfig(level=args.loglevel, format='%(levelname)-7s %(module)s: %(message)s') 41 | 42 | prodj = ProDj() 43 | prodj.data.pdb_enabled = args.enable_pdb 44 | prodj.data.dbc_enabled = args.enable_dbc 45 | if args.chunk_size is not None: 46 | prodj.nfs.setDownloadChunkSize(args.chunk_size) 47 | app = QApplication([]) 48 | gui = Gui(prodj, show_color_waveform=args.color_waveform or args.color, show_color_preview=args.color_preview or args.color, arg_layout=args.layout) 49 | if args.fullscreen: 50 | gui.setWindowState(Qt.WindowFullScreen | Qt.WindowMaximized | Qt.WindowActive) 51 | 52 | pal = app.palette() 53 | pal.setColor(QPalette.Window, Qt.black) 54 | pal.setColor(QPalette.Base, Qt.black) 55 | pal.setColor(QPalette.Button, Qt.black) 56 | pal.setColor(QPalette.WindowText, Qt.white) 57 | pal.setColor(QPalette.Text, Qt.white) 58 | pal.setColor(QPalette.ButtonText, Qt.white) 59 | pal.setColor(QPalette.Disabled, QPalette.ButtonText, Qt.gray) 60 | app.setPalette(pal) 61 | 62 | signal.signal(signal.SIGINT, lambda s,f: app.quit()) 63 | 64 | prodj.set_client_keepalive_callback(gui.keepalive_callback) 65 | prodj.set_client_change_callback(gui.client_change_callback) 66 | prodj.set_media_change_callback(gui.media_callback) 67 | prodj.start() 68 | prodj.vcdj_set_player_number(5) 69 | prodj.vcdj_enable() 70 | 71 | app.exec() 72 | logging.info("Shutting down...") 73 | prodj.stop() 74 | -------------------------------------------------------------------------------- /monitor-simple.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import time 5 | 6 | from prodj.core.prodj import ProDj 7 | 8 | default_loglevel=0 9 | default_loglevel=logging.DEBUG 10 | #default_loglevel=logging.INFO 11 | #default_loglevel=logging.WARNING 12 | 13 | logging.basicConfig(level=default_loglevel, format='%(levelname)s: %(message)s') 14 | 15 | p = ProDj() 16 | 17 | def print_clients(player_number): 18 | return 19 | for c in p.cl.clients: 20 | if c.player_number == player_number: 21 | logging.info("Player {}: {} {} BPM Pitch {:.2f}% ({:.2f}%) Beat {} Beatcnt {} pos {:.6f}".format( 22 | c.player_number, c.model, c.bpm, (c.pitch-1)*100, (c.actual_pitch-1)*100, c.beat, c.beat_count, 23 | c.position if c.position is not None else 0)) 24 | 25 | def print_metadata(player_number, md): 26 | logging.info("Player {} playing {} - {} ({}) {}:{} {} BPM".format(player_number, 27 | md["artist"], md["title"], md["album"], md["duration"]//60, md["duration"]%60, md["bpm"])) 28 | 29 | def print_menu(request, player_number, slot, reply): 30 | logging.info("Root Menu:") 31 | for entry in reply: 32 | logging.info(" {}".format(entry)) 33 | 34 | def print_list(request, player_number, slot, query_ids, reply): 35 | logging.info("List entries:") 36 | for track in reply: 37 | s = "" 38 | for label, content in track.items(): 39 | s += "{}: \"{}\" ".format(label, content) 40 | logging.info(" {}".format(s)) 41 | 42 | p.set_client_keepalive_callback(print_clients) 43 | p.set_client_change_callback(print_clients) 44 | 45 | try: 46 | p.start() 47 | p.cl.auto_request_beatgrid = False # we do not need beatgrids, but usually this doesnt hurt 48 | p.vcdj_set_player_number(5) 49 | p.vcdj_enable() 50 | time.sleep(5) 51 | p.data.get_root_menu(2, "usb", print_menu) 52 | p.data.get_titles(2, "usb", "album", print_list) 53 | #p.data.get_titles_by_album(2, "usb", 16, "bpm", print_list) 54 | #p.data.get_playlists(2, "usb", 0, print_list) 55 | #p.data.get_playlist(2, "usb", 0, 12, "default", print_list) 56 | #p.data.get_artists(2, "usb", "default", print_list) 57 | #p.vcdj.command_load_track(1, 2, "usb", 650) 58 | #p.vcdj.query_link_info(2, "usb") 59 | p.data.get_track_info(2, "usb", 0x7bc6, print_list) 60 | p.join() 61 | except KeyboardInterrupt: 62 | logging.info("Shutting down...") 63 | p.stop() 64 | -------------------------------------------------------------------------------- /prodj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/__init__.py -------------------------------------------------------------------------------- /prodj/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/core/__init__.py -------------------------------------------------------------------------------- /prodj/core/clientlist.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from datetime import datetime 4 | 5 | from prodj.network.packets_dump import pretty_flags 6 | 7 | class ClientList: 8 | def __init__(self, prodj): 9 | self.clients = [] 10 | self.client_keepalive_callback = None 11 | self.client_change_callback = None 12 | self.media_change_callback = None 13 | self.log_played_tracks = True 14 | self.auto_request_beatgrid = True # to enable position detection 15 | self.auto_track_download = False 16 | self.prodj = prodj 17 | 18 | def __len__(self): 19 | return len(self.clients) 20 | 21 | def getClient(self, player_number): 22 | return next((p for p in self.clients if p.player_number == player_number), None) 23 | 24 | def clientsByLoadedTrack(self, loaded_player_number, loaded_slot, track_id): 25 | for p in self.clients: 26 | if (p.loaded_player_number == loaded_player_number and 27 | p.loaded_slot == loaded_slot and 28 | p.track_id == track_id): 29 | yield p 30 | 31 | def clientsByLoadedTrackArtwork(self, loaded_player_number, loaded_slot, artwork_id): 32 | for p in self.clients: 33 | if (p.loaded_player_number == loaded_player_number and 34 | p.loaded_slot == loaded_slot and 35 | p.metadata is not None and 36 | p.metadata["artwork_id"] == artwork_id): 37 | yield p 38 | 39 | def storeMetadataByLoadedTrack(self, loaded_player_number, loaded_slot, track_id, metadata): 40 | for p in self.clients: 41 | if (p.loaded_player_number == loaded_player_number and 42 | p.loaded_slot == loaded_slot and 43 | p.track_id == track_id): 44 | p.metadata = metadata 45 | 46 | def mediaChanged(self, player_number, slot): 47 | logging.debug("Media %s in player %d changed", slot, player_number) 48 | self.prodj.data.cleanup_stores_from_changed_media(player_number, slot) 49 | if self.media_change_callback is not None: 50 | self.media_change_callback(self, player_number, slot) 51 | 52 | def updatePositionByBeat(self, player_number, new_beat_count, new_play_state): 53 | c = self.getClient(player_number) 54 | identifier = (c.loaded_player_number, c.loaded_slot, c.track_id) 55 | if identifier in self.prodj.data.beatgrid_store: 56 | if new_beat_count > 0: 57 | if (c.play_state == "cued" and new_play_state == "cueing") or (c.play_state == "playing" and new_play_state == "paused") or (c.play_state == "paused" and new_play_state == "playing"): 58 | return # ignore absolute position when switching from cued to cueing 59 | if new_play_state != "cued": # when releasing cue scratch, the beat count is still +1 60 | new_beat_count -= 1 61 | beatgrid = self.prodj.data.beatgrid_store[identifier] 62 | if beatgrid is not None and len(beatgrid) > new_beat_count: 63 | c.position = beatgrid[new_beat_count]["time"] / 1000 64 | else: 65 | c.position = 0 66 | else: 67 | c.position = None 68 | c.position_timestamp = time.time() 69 | 70 | def logPlayedTrackCallback(self, request, source_player_number, slot, item_id, reply): 71 | if request != "metadata" or reply is None or len(reply) == 0: 72 | return 73 | with open("tracks.log", "a") as f: 74 | f.write("{}: {} - {} ({})\n".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), reply["artist"], reply["title"], reply["album"])) 75 | 76 | # adds client if it is not known yet, in any case it resets the ttl 77 | def eatKeepalive(self, keepalive_packet): 78 | if keepalive_packet.type in ["type_ip", "type_status"] and keepalive_packet.content.flags.is_nxs_gw: 79 | logging.debug(f"Dropping NXS-GW packet from {keepalive_packet.content.ip_addr} player {pretty_flags(keepalive_packet.content.flags)}") 80 | return 81 | c = next((x for x in self.clients if x.ip_addr == keepalive_packet.content.ip_addr), None) 82 | if c is None: 83 | conflicting_client = next((x for x in self.clients if x.player_number == keepalive_packet.content.player_number), None) 84 | if conflicting_client is not None: 85 | logging.warning("New Player %d (%s), but already used by %s, ignoring keepalive", 86 | keepalive_packet.content.player_number, keepalive_packet.content.ip_addr, conflicting_client.ip_addr) 87 | return 88 | c = Client() 89 | c.model = keepalive_packet.model 90 | c.ip_addr = keepalive_packet.content.ip_addr 91 | c.mac_addr = keepalive_packet.content.mac_addr 92 | c.player_number = keepalive_packet.content.player_number 93 | self.clients += [c] 94 | logging.info("New Player %d: %s, %s, %s", c.player_number, c.model, c.ip_addr, c.mac_addr) 95 | if self.client_keepalive_callback: 96 | self.client_keepalive_callback(c.player_number) 97 | # type_change packets don't contain the new player number, thus wait for the next regular packet to change number 98 | elif keepalive_packet.type != "type_change": 99 | n = keepalive_packet.content.player_number 100 | if c.player_number != n: 101 | logging.info("Player {} changed player number from {} to {}".format(c.ip_addr, c.player_number, n)) 102 | old_player_number = c.player_number 103 | c.player_number = n 104 | for pn in [old_player_number, c.player_number]: 105 | if self.client_keepalive_callback: 106 | self.client_keepalive_callback(pn) 107 | if self.client_change_callback: 108 | self.client_change_callback(pn) 109 | c.updateTtl() 110 | 111 | # updates pitch/bpm/beat information for player if we do not receive status packets (e.g. no vcdj enabled) 112 | def eatBeat(self, beat_packet): 113 | c = self.getClient(beat_packet.player_number) 114 | if c is None: # packet from unknown client 115 | return 116 | c.updateTtl() 117 | client_changed = False; 118 | if beat_packet.type == "type_mixer": 119 | for x in range(1,5): 120 | player = self.getClient(x) 121 | if player is not None: 122 | on_air = beat_packet.content.ch_on_air[x-1] == 1 123 | if player.on_air != on_air: 124 | player.on_air = on_air 125 | client_changed = True 126 | elif beat_packet.type == "type_beat" and (not c.status_packet_received or c.model == "CDJ-2000"): 127 | new_actual_pitch = beat_packet.content.pitch 128 | if c.actual_pitch != new_actual_pitch: 129 | c.actual_pitch = new_actual_pitch 130 | client_changed = True 131 | new_bpm = beat_packet.content.bpm 132 | if c.bpm != new_bpm: 133 | c.bpm = new_bpm 134 | client_changed = True 135 | new_beat = beat_packet.content.beat 136 | if c.beat != new_beat: 137 | c.beat = new_beat 138 | client_changed = True 139 | elif beat_packet.type == "type_absolute_position": 140 | if not c.supports_absolute_position_packets: 141 | c.supports_absolute_position_packets = True 142 | new_actual_pitch = beat_packet.content.pitch / 100 143 | if c.actual_pitch != new_actual_pitch: 144 | c.actual_pitch = new_actual_pitch 145 | client_changed = True 146 | 147 | new_position = beat_packet.content.playhead / 1000 148 | if c.position != new_position: 149 | c.position = new_position 150 | client_changed = True 151 | if self.client_change_callback and client_changed: 152 | self.client_change_callback(c.player_number) 153 | 154 | # update all known player information 155 | def eatStatus(self, status_packet): 156 | if status_packet.type not in ["cdj", "djm", "link_reply"]: 157 | logging.info("Received %s status packet from player %d, ignoring", status_packet.type, status_packet.player_number) 158 | return 159 | c = self.getClient(status_packet.player_number) 160 | if c is None: # packet from unknown client 161 | return 162 | client_changed = False 163 | c.status_packet_received = True 164 | 165 | if status_packet.type == "link_reply": 166 | link_info = { key: status_packet.content[key] for key in ["name", "track_count", "playlist_count", "bytes_total", "bytes_free", "date"] } 167 | if status_packet.content.slot == "usb": 168 | c.usb_info = link_info 169 | elif status_packet.content.slot == "sd": 170 | c.sd_info = link_info 171 | else: 172 | logging.warning("Received link info for %s not implemented", status_packet.content.slot) 173 | logging.info("Player %d Link Info: %s \"%s\", %d tracks, %d playlists, %d/%dMB free", 174 | c.player_number, status_packet.content.slot, link_info["name"], link_info["track_count"], link_info["playlist_count"], 175 | link_info["bytes_free"]//1024//1024, link_info["bytes_total"]//1024//1024) 176 | self.mediaChanged(c.player_number, status_packet.content.slot) 177 | return 178 | c.type = status_packet.type # cdj or djm 179 | 180 | new_bpm = status_packet.content.bpm if status_packet.content.bpm != 655.35 else "-" 181 | if c.bpm != new_bpm: 182 | c.bpm = new_bpm 183 | client_changed = True 184 | 185 | new_pitch = status_packet.content.physical_pitch 186 | if c.pitch != new_pitch: 187 | c.pitch = new_pitch 188 | client_changed = True 189 | 190 | new_beat = status_packet.content.beat if status_packet.content.beat != 0xffffffff else 0 191 | if c.beat != new_beat and new_beat != 0: 192 | c.beat = new_beat 193 | client_changed = True 194 | 195 | new_state = [x for x in ["on_air","sync","master","play"] if status_packet.content.state[x]==True] 196 | if c.state != new_state: 197 | c.state = new_state 198 | client_changed = True 199 | 200 | if c.type == "cdj": 201 | new_beat_count = status_packet.content.beat_count if status_packet.content.beat_count != 0xffffffff else 0 202 | new_play_state = status_packet.content.play_state 203 | if not c.supports_absolute_position_packets: 204 | if new_beat_count != c.beat_count or new_play_state != c.play_state: 205 | self.updatePositionByBeat(c.player_number, new_beat_count, new_play_state) # position tracking, set new absolute grid value 206 | else: # otherwise, increment by pitch 207 | c.updatePositionByPitch() 208 | 209 | if "key" in status_packet.content: 210 | new_key = status_packet.content.key 211 | if c.key != new_key: 212 | c.key = new_key 213 | client_changed = True 214 | 215 | if "key_shift" in status_packet.content: 216 | new_key_shift = status_packet.content.key_shift 217 | if c.key_shift != new_key_shift: 218 | c.key_shift = new_key_shift 219 | client_changed = True 220 | 221 | if "loopStart" in status_packet.content: 222 | new_loop_start = status_packet.content.loopStart / 1_000_000 223 | if c.loop_start != new_loop_start: 224 | c.loop_start = new_loop_start 225 | client_changed = True 226 | 227 | if "loopEnd" in status_packet.content: 228 | new_loop_end = status_packet.content.loopEnd / 1_000_000 229 | if c.loop_end != new_loop_end: 230 | c.loop_end = new_loop_end 231 | client_changed = True 232 | 233 | if "wholeLoopLength" in status_packet.content: 234 | new_whole_loop_length = status_packet.content.wholeLoopLength 235 | if c.whole_loop_length != new_whole_loop_length: 236 | c.whole_loop_length = new_whole_loop_length 237 | client_changed = True 238 | 239 | if c.beat_count != new_beat_count: 240 | c.beat_count = new_beat_count 241 | client_changed = True 242 | 243 | if c.play_state != new_play_state: 244 | c.play_state = new_play_state 245 | client_changed = True 246 | 247 | c.fw = status_packet.content.firmware 248 | 249 | new_actual_pitch = status_packet.content.actual_pitch 250 | if c.actual_pitch != new_actual_pitch: 251 | c.actual_pitch = new_actual_pitch 252 | client_changed = True 253 | 254 | new_cue_distance = status_packet.content.cue_distance if status_packet.content.cue_distance != 511 else "-" 255 | if c.cue_distance != new_cue_distance: 256 | c.cue_distance = new_cue_distance 257 | client_changed = True 258 | 259 | new_usb_state = status_packet.content.usb_state 260 | if c.usb_state != new_usb_state: 261 | c.usb_state = new_usb_state 262 | if new_usb_state != "loaded": 263 | c.usb_info = {} 264 | else: 265 | self.prodj.vcdj.query_link_info(c.player_number, "usb") 266 | self.mediaChanged(c.player_number, "usb") 267 | new_sd_state = status_packet.content.sd_state 268 | if c.sd_state != new_sd_state: 269 | c.sd_state = new_sd_state 270 | if new_sd_state != "loaded": 271 | c.sd_info = {} 272 | else: 273 | self.prodj.vcdj.query_link_info(c.player_number, "sd") 274 | self.mediaChanged(c.player_number, "sd") 275 | c.track_number = status_packet.content.track_number 276 | c.loaded_player_number = status_packet.content.loaded_player_number 277 | c.loaded_slot = status_packet.content.loaded_slot 278 | c.track_analyze_type = status_packet.content.track_analyze_type 279 | 280 | new_track_id = status_packet.content.track_id 281 | if c.track_id != new_track_id: 282 | c.track_id = new_track_id 283 | client_changed = True 284 | c.metadata = None 285 | c.position = None 286 | if c.loaded_slot in ["usb", "sd"] and c.track_analyze_type == "rekordbox": 287 | if self.log_played_tracks: 288 | self.prodj.data.get_metadata(c.loaded_player_number, c.loaded_slot, c.track_id, self.logPlayedTrackCallback) 289 | if self.auto_request_beatgrid and c.track_id != 0: 290 | self.prodj.data.get_beatgrid(c.loaded_player_number, c.loaded_slot, c.track_id) 291 | if self.auto_track_download: 292 | logging.info("Automatic download of track in player %d", c.player_number) 293 | self.prodj.data.get_mount_info(c.loaded_player_number, c.loaded_slot, 294 | c.track_id, self.prodj.nfs.enqueue_download_from_mount_info) 295 | 296 | c.updateTtl() 297 | if self.client_change_callback and client_changed: 298 | self.client_change_callback(c.player_number) 299 | 300 | # checks ttl and clears expired clients 301 | def gc(self): 302 | cur_clients = self.clients 303 | self.clients = [] 304 | for client in cur_clients: 305 | if not client.ttlExpired(): 306 | self.clients += [client] 307 | else: 308 | logging.info("Player {} dropped due to timeout".format(client.player_number)) 309 | if self.client_change_callback: 310 | self.client_change_callback(client.player_number) 311 | 312 | # returns a list of ips of all clients (used to guess own ip) 313 | def getClientIps(self): 314 | return [client.ip_addr for client in self.clients] 315 | 316 | class Client: 317 | def __init__(self): 318 | # device specific 319 | self.type = "" # cdj, djm, rekordbox (currently rekordbox is detected as djm) 320 | self.model = "" 321 | self.fw = "" 322 | self.ip_addr = "" 323 | self.mac_addr = "" 324 | self.player_number = 0 325 | # play state 326 | self.bpm = None 327 | self.key = None 328 | self.key_shift = None 329 | self.loop_start = None 330 | self.loop_end = None 331 | self.whole_loop_length = None 332 | self.pitch = 1 333 | self.actual_pitch = 1 334 | self.beat = 0 335 | self.beat_count = None 336 | self.cue_distance = None 337 | self.play_state = "no_track" 338 | self.usb_state = "not_loaded" 339 | self.usb_info = {} 340 | self.sd_state = "not_loaded" 341 | self.sd_info = {} 342 | self.loaded_player_number = 0 343 | self.loaded_slot = "empty" 344 | self.track_analyze_type = "unknown" 345 | self.state = [] 346 | self.track_number = None 347 | self.track_id = 0 348 | self.position = None # position in track in seconds, 0 if not determinable 349 | self.position_timestamp = None 350 | self.on_air = False 351 | # internal use 352 | self.metadata = None 353 | self.status_packet_received = False # ignore play state from beat packets 354 | self.supports_absolute_position_packets = False 355 | self.ttl = time.time() 356 | 357 | # calculate the current position by linear interpolation 358 | def updatePositionByPitch(self): 359 | if not self.position or self.actual_pitch == 0: 360 | return 361 | pitch = self.actual_pitch 362 | if self.play_state in ["cued"]: 363 | pitch = 0 364 | now = time.time() 365 | self.position += pitch*(now-self.position_timestamp) 366 | self.position_timestamp = now 367 | #logging.debug("Track position inc %f actual_pitch %.6f play_state %s beat %d", self.position, self.actual_pitch, self.play_state, self.beat_count) 368 | return self.position 369 | 370 | def updateTtl(self): 371 | self.ttl = time.time() 372 | 373 | # drop clients after 5 seconds without keepalive packet 374 | def ttlExpired(self): 375 | return time.time()-self.ttl > 5 376 | -------------------------------------------------------------------------------- /prodj/core/prodj.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import logging 3 | from threading import Thread 4 | from select import select 5 | from enum import Enum 6 | 7 | from prodj.core.clientlist import ClientList 8 | from prodj.core.vcdj import Vcdj 9 | from prodj.data.dataprovider import DataProvider 10 | from prodj.network.nfsclient import NfsClient 11 | from prodj.network.ip import guess_own_iface 12 | from prodj.network import packets 13 | from prodj.network import packets_dump 14 | 15 | class OwnIpStatus(Enum): 16 | notNeeded = 1, 17 | waiting = 2, 18 | acquired = 3 19 | 20 | class ProDj(Thread): 21 | def __init__(self): 22 | super().__init__() 23 | self.cl = ClientList(self) 24 | self.data = DataProvider(self) 25 | self.vcdj = Vcdj(self) 26 | self.nfs = NfsClient(self) 27 | self.keepalive_ip = "0.0.0.0" 28 | self.keepalive_port = 50000 29 | self.beat_ip = "0.0.0.0" 30 | self.beat_port = 50001 31 | self.status_ip = "0.0.0.0" 32 | self.status_port = 50002 33 | self.need_own_ip = OwnIpStatus.notNeeded 34 | self.own_ip = None 35 | 36 | def start(self): 37 | self.keepalive_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 38 | self.keepalive_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 39 | self.keepalive_sock.bind((self.keepalive_ip, self.keepalive_port)) 40 | logging.info("Listening on {}:{} for keepalive packets".format(self.keepalive_ip, self.keepalive_port)) 41 | self.beat_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 42 | self.beat_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 43 | self.beat_sock.bind((self.beat_ip, self.beat_port)) 44 | logging.info("Listening on {}:{} for beat packets".format(self.beat_ip, self.beat_port)) 45 | self.status_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 46 | self.status_sock.bind((self.status_ip, self.status_port)) 47 | logging.info("Listening on {}:{} for status packets".format(self.status_ip, self.status_port)) 48 | self.socks = [self.keepalive_sock, self.beat_sock, self.status_sock] 49 | self.keep_running = True 50 | self.data.start() 51 | self.nfs.start() 52 | super().start() 53 | 54 | def stop(self): 55 | self.keep_running = False 56 | self.nfs.stop() 57 | self.data.stop() 58 | self.vcdj_disable() 59 | self.join() 60 | self.keepalive_sock.close() 61 | self.beat_sock.close() 62 | 63 | def vcdj_set_player_number(self, vcdj_player_number=5): 64 | logging.info("Player number set to {}".format(vcdj_player_number)) 65 | self.vcdj.player_number = vcdj_player_number 66 | #self.data.dbc.own_player_number = vcdj_player_number 67 | 68 | def vcdj_enable(self): 69 | self.vcdj_set_iface() 70 | self.vcdj.start() 71 | 72 | def vcdj_disable(self): 73 | self.vcdj.stop() 74 | self.vcdj.join() 75 | 76 | def vcdj_set_iface(self): 77 | if self.own_ip is not None: 78 | self.vcdj.set_interface_data(*self.own_ip[1:4]) 79 | 80 | def run(self): 81 | logging.debug("starting main loop") 82 | while self.keep_running: 83 | rdy = select(self.socks,[],[],1)[0] 84 | for sock in rdy: 85 | if sock == self.keepalive_sock: 86 | data, addr = self.keepalive_sock.recvfrom(128) 87 | self.handle_keepalive_packet(data, addr) 88 | elif sock == self.beat_sock: 89 | data, addr = self.beat_sock.recvfrom(128) 90 | self.handle_beat_packet(data, addr) 91 | elif sock == self.status_sock: 92 | data, addr = self.status_sock.recvfrom(1158) # max size of status packet (CDJ-3000), can also be smaller 93 | self.handle_status_packet(data, addr) 94 | self.cl.gc() 95 | logging.debug("main loop finished") 96 | 97 | def handle_keepalive_packet(self, data, addr): 98 | #logging.debug("Broadcast keepalive packet from {}".format(addr)) 99 | try: 100 | packet = packets.KeepAlivePacket.parse(data) 101 | except Exception as e: 102 | logging.warning("Failed to parse keepalive packet from {}, {} bytes: {}".format(addr, len(data), e)) 103 | packets_dump.dump_packet_raw(data) 104 | return 105 | # both packet types give us enough information to store the client 106 | if packet["type"] in ["type_ip", "type_status", "type_change"]: 107 | self.cl.eatKeepalive(packet) 108 | if self.own_ip is None and len(self.cl.getClientIps()) > 0: 109 | self.own_ip = guess_own_iface(self.cl.getClientIps()) 110 | if self.own_ip is not None: 111 | logging.info("Guessed own interface {} ip {} mask {} mac {}".format(*self.own_ip)) 112 | self.vcdj_set_iface() 113 | packets_dump.dump_keepalive_packet(packet) 114 | 115 | def handle_beat_packet(self, data, addr): 116 | #logging.debug("Broadcast beat packet from {}".format(addr)) 117 | try: 118 | packet = packets.BeatPacket.parse(data) 119 | except Exception as e: 120 | logging.warning("Failed to parse beat packet from {}, {} bytes: {}".format(addr, len(data), e)) 121 | packets_dump.dump_packet_raw(data) 122 | return 123 | if packet["type"] in ["type_beat", "type_absolute_position", "type_mixer"]: 124 | self.cl.eatBeat(packet) 125 | packets_dump.dump_beat_packet(packet) 126 | 127 | def handle_status_packet(self, data, addr): 128 | #logging.debug("Broadcast status packet from {}".format(addr)) 129 | try: 130 | packet = packets.StatusPacket.parse(data) 131 | except Exception as e: 132 | logging.warning("Failed to parse status packet from {}, {} bytes: {}".format(addr, len(data), e)) 133 | packets_dump.dump_packet_raw(data) 134 | return 135 | self.cl.eatStatus(packet) 136 | packets_dump.dump_status_packet(packet) 137 | 138 | # called whenever a keepalive packet is received 139 | # arguments of cb: this clientlist object, player number of changed client 140 | def set_client_keepalive_callback(self, cb=None): 141 | self.cl.client_keepalive_callback = cb 142 | 143 | # called whenever a status update of a known client is received 144 | # arguments of cb: this clientlist object, player number of changed client 145 | def set_client_change_callback(self, cb=None): 146 | self.cl.client_change_callback = cb 147 | 148 | # called when a player media changes 149 | # arguments of cb: this clientlist object, player_number, changed slot 150 | def set_media_change_callback(self, cb=None): 151 | self.cl.media_change_callback = cb 152 | -------------------------------------------------------------------------------- /prodj/core/vcdj.py: -------------------------------------------------------------------------------- 1 | from threading import Event, Thread 2 | from ipaddress import IPv4Network 3 | from construct import byte2int 4 | import logging 5 | import traceback 6 | 7 | from prodj.network import packets 8 | 9 | class Vcdj(Thread): 10 | def __init__(self, prodj): 11 | super().__init__() 12 | self.prodj = prodj 13 | self.player_number = 5 14 | self.model = "Virtual CDJ" 15 | self.packet_interval = 1.5 16 | self.event = Event() 17 | self.ip_addr = "" 18 | self.mac_addr = "" 19 | self.broadcast_addr = "" 20 | 21 | def start(self): 22 | self.event.clear() 23 | super().start() 24 | 25 | def stop(self): 26 | self.event.set() 27 | 28 | def run(self): 29 | logging.info("Starting virtual cdj with player number {}".format(self.player_number)) 30 | try: 31 | while not self.event.wait(self.packet_interval): 32 | self.send_keepalive_packet() 33 | except Exception as e: 34 | logging.critical("Exception in vcdj.run: "+str(e)+"\n"+traceback.format_exc()) 35 | 36 | def set_interface_data(self, ip, netmask, mac): 37 | self.ip_addr = ip 38 | self.mac_addr = mac 39 | n = IPv4Network(ip+"/"+netmask, strict=False) 40 | self.broadcast_addr = str(n.broadcast_address) 41 | 42 | def send_keepalive_packet(self): 43 | if len(self.ip_addr) == 0 or len(self.mac_addr) == 0: 44 | return 45 | data = { 46 | "type": "type_status", 47 | "subtype": "stype_status", 48 | "model": self.model, 49 | "content": { 50 | "player_number": self.player_number, 51 | "ip_addr": self.ip_addr, 52 | "mac_addr": self.mac_addr 53 | } 54 | } 55 | #logging.debug("send keepalive data: %s", str(data)) 56 | raw = packets.KeepAlivePacket.build(data) 57 | self.prodj.keepalive_sock.sendto(raw, (self.broadcast_addr, self.prodj.keepalive_port)) 58 | 59 | def query_link_info(self, player_number, slot): 60 | cl = self.prodj.cl.getClient(player_number) 61 | if cl is None: 62 | logging.warning("Failed to get player %d", player_number) 63 | return 64 | slot_id = byte2int(packets.PlayerSlot.build(slot)) 65 | cmd = { 66 | "type": "link_query", 67 | "model": self.model, 68 | "player_number": self.player_number, 69 | "extra": { 70 | "source_ip": self.ip_addr 71 | }, 72 | "content": { 73 | "remote_player_number": player_number, 74 | "slot": slot_id 75 | } 76 | } 77 | data = packets.StatusPacket.build(cmd) 78 | logging.debug("sending link info query to %s", cl.ip_addr) 79 | self.prodj.status_sock.sendto(data, (cl.ip_addr, self.prodj.status_port)) 80 | 81 | def command_load_track(self, player_number, load_player_number, load_slot, load_track_id): 82 | cl = self.prodj.cl.getClient(player_number) 83 | if cl is None: 84 | logging.warning("Failed to get player %d", player_number) 85 | return 86 | load_slot_id = byte2int(packets.PlayerSlot.build(load_slot)) 87 | cmd = { 88 | "type": "load_cmd", 89 | "model": self.model, 90 | "player_number": self.player_number, # our player number -> we receive confirmation packet 91 | "extra": None, 92 | "content": { 93 | "load_player_number": load_player_number, 94 | "load_slot": load_slot_id, 95 | "load_track_id": load_track_id 96 | } 97 | } 98 | data = packets.StatusPacket.build(cmd) 99 | logging.debug("send load packet to %s struct %s", cl.ip_addr, str(cmd)) 100 | self.prodj.status_sock.sendto(data, (cl.ip_addr, self.prodj.status_port)) 101 | 102 | # if start is True, start the player, otherwise stop the player 103 | def command_fader_start_single(self, player_number, start=True): 104 | player_commands = ["ignore"]*4 105 | player_commands[player_number-1] = "start" if start is True else "stop" 106 | self.command_fader_start(player_commands) 107 | 108 | # player_commands is an array of size 4 containing "start", "stop" or "ignore" 109 | def command_fader_start(self, player_commands): 110 | cmd = { 111 | "type": "type_fader_start", 112 | "subtype": "stype_fader_start", 113 | "model": self.model, 114 | "player_number": self.player_number, 115 | "content": { 116 | "player": player_commands 117 | } 118 | } 119 | data = packets.BeatPacket.build(cmd) 120 | self.prodj.beat_sock.sendto(data, (self.broadcast_addr, self.prodj.beat_port)) 121 | -------------------------------------------------------------------------------- /prodj/curses/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/curses/__init__.py -------------------------------------------------------------------------------- /prodj/curses/loghandler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | try: 4 | unicode 5 | _unicode = True 6 | except NameError: 7 | _unicode = False 8 | 9 | class CursesHandler(logging.Handler): 10 | def __init__(self, screen): 11 | logging.Handler.__init__(self) 12 | self.screen = screen 13 | def emit(self, record): 14 | msg = self.format(record) 15 | self.screen.addstr('\n{}'.format(msg)) 16 | self.screen.refresh() 17 | return 18 | try: 19 | msg = self.format(record) 20 | screen = self.screen 21 | fs = "\n%s" 22 | if not _unicode: #if no unicode support... 23 | screen.addstr(fs % msg) 24 | screen.refresh() 25 | else: 26 | try: 27 | if (isinstance(msg, unicode)): 28 | ufs = u'\n%s' 29 | try: 30 | screen.addstr(ufs % msg) 31 | screen.refresh() 32 | except UnicodeEncodeError: 33 | screen.addstr((ufs % msg).encode(code)) 34 | screen.refresh() 35 | else: 36 | screen.addstr(fs % msg) 37 | screen.refresh() 38 | except UnicodeError: 39 | screen.addstr(fs % msg.encode("UTF-8")) 40 | screen.refresh() 41 | except (KeyboardInterrupt, SystemExit): 42 | raise 43 | except: 44 | self.handleError(record) 45 | -------------------------------------------------------------------------------- /prodj/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/data/__init__.py -------------------------------------------------------------------------------- /prodj/data/dataprovider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from threading import Thread 4 | from queue import Empty, Queue 5 | 6 | from .datastore import DataStore 7 | from .dbclient import DBClient 8 | from .pdbprovider import PDBProvider 9 | from .exceptions import TemporaryQueryError, FatalQueryError 10 | 11 | class DataProvider(Thread): 12 | def __init__(self, prodj): 13 | super().__init__() 14 | self.prodj = prodj 15 | self.queue = Queue() 16 | self.keep_running = True 17 | 18 | self.pdb_enabled = True 19 | self.pdb = PDBProvider(prodj) 20 | 21 | self.dbc_enabled = True 22 | self.dbc = DBClient(prodj) 23 | 24 | # db queries seem to work if we submit player number 0 everywhere (NOTE: this seems to work only if less than 4 players are on the network) 25 | # however, this messes up rendering on the players sometimes (i.e. when querying metadata and player has browser opened) 26 | # alternatively, we can use a player number from 1 to 4 without rendering issues, but then only max. 3 real players can be used 27 | self.own_player_number = 0 28 | self.request_retry_count = 3 29 | 30 | self.metadata_store = DataStore() # map of player_number,slot,track_id: metadata 31 | self.artwork_store = DataStore() # map of player_number,slot,artwork_id: artwork_data 32 | self.waveform_store = DataStore() # map of player_number,slot,track_id: waveform_data 33 | self.preview_waveform_store = DataStore() # map of player_number,slot,track_id: preview_waveform_data 34 | self.color_waveform_store = DataStore() # map of player_number,slot,track_id: color_waveform_data 35 | self.color_preview_waveform_store = DataStore() # map of player_number,slot,track_id: color_preview_waveform_data 36 | self.beatgrid_store = DataStore() # map of player_number,slot,track_id: beatgrid_data 37 | 38 | def start(self): 39 | self.keep_running = True 40 | super().start() 41 | 42 | def stop(self): 43 | self.keep_running = False 44 | self.pdb.stop() 45 | self.metadata_store.stop() 46 | self.artwork_store.stop() 47 | self.waveform_store.stop() 48 | self.preview_waveform_store.stop() 49 | self.color_waveform_store.stop() 50 | self.color_preview_waveform_store.stop() 51 | self.beatgrid_store.stop() 52 | self.join() 53 | 54 | def cleanup_stores_from_changed_media(self, player_number, slot): 55 | self.metadata_store.removeByPlayerSlot(player_number, slot) 56 | self.artwork_store.removeByPlayerSlot(player_number, slot) 57 | self.waveform_store.removeByPlayerSlot(player_number, slot) 58 | self.preview_waveform_store.removeByPlayerSlot(player_number, slot) 59 | self.color_waveform_store.removeByPlayerSlot(player_number, slot) 60 | self.color_preview_waveform_store.removeByPlayerSlot(player_number, slot) 61 | self.beatgrid_store.removeByPlayerSlot(player_number, slot) 62 | self.pdb.cleanup_stores_from_changed_media(player_number, slot) 63 | 64 | # called from outside, enqueues request 65 | def get_metadata(self, player_number, slot, track_id, callback=None): 66 | self._enqueue_request("metadata", self.metadata_store, (player_number, slot, track_id), callback) 67 | 68 | def get_root_menu(self, player_number, slot, callback=None): 69 | self._enqueue_request("root_menu", None, (player_number, slot), callback) 70 | 71 | def get_titles(self, player_number, slot, sort_mode="default", callback=None): 72 | self._enqueue_request("title", None, (player_number, slot, sort_mode), callback) 73 | 74 | def get_titles_by_album(self, player_number, slot, album_id, sort_mode="default", callback=None): 75 | self._enqueue_request("title_by_album", None, (player_number, slot, sort_mode, [album_id]), callback) 76 | 77 | def get_titles_by_artist_album(self, player_number, slot, artist_id, album_id, sort_mode="default", callback=None): 78 | self._enqueue_request("title_by_artist_album", None, (player_number, slot, sort_mode, [artist_id, album_id]), callback) 79 | 80 | def get_titles_by_genre_artist_album(self, player_number, slot, genre_id, artist_id, album_id, sort_mode="default", callback=None): 81 | self._enqueue_request("title_by_genre_artist_album", None, (player_number, slot, sort_mode, [genre_id, artist_id, album_id]), callback) 82 | 83 | def get_artists(self, player_number, slot, callback=None): 84 | self._enqueue_request("artist", None, (player_number, slot), callback) 85 | 86 | def get_artists_by_genre(self, player_number, slot, genre_id, callback=None): 87 | self._enqueue_request("artist_by_genre", None, (player_number, slot, [genre_id]), callback) 88 | 89 | def get_albums(self, player_number, slot, callback=None): 90 | self._enqueue_request("album", None, (player_number, slot), callback) 91 | 92 | def get_albums_by_artist(self, player_number, slot, artist_id, callback=None): 93 | self._enqueue_request("album_by_artist", None, (player_number, slot, [artist_id]), callback) 94 | 95 | def get_albums_by_genre_artist(self, player_number, slot, genre_id, artist_id, callback=None): 96 | self._enqueue_request("album_by_genre_artist", None, (player_number, slot, [genre_id, artist_id]), callback) 97 | 98 | def get_genres(self, player_number, slot, callback=None): 99 | self._enqueue_request("genre", None, (player_number, slot), callback) 100 | 101 | def get_playlist_folder(self, player_number, slot, folder_id=0, callback=None): 102 | self._enqueue_request("playlist_folder", None, (player_number, slot, folder_id), callback) 103 | 104 | def get_playlist(self, player_number, slot, playlist_id, sort_mode="default", callback=None): 105 | self._enqueue_request("playlist", None, (player_number, slot, sort_mode, playlist_id), callback) 106 | 107 | def get_artwork(self, player_number, slot, artwork_id, callback=None): 108 | self._enqueue_request("artwork", self.artwork_store, (player_number, slot, artwork_id), callback) 109 | 110 | def get_waveform(self, player_number, slot, track_id, callback=None): 111 | self._enqueue_request("waveform", self.waveform_store, (player_number, slot, track_id), callback) 112 | 113 | def get_preview_waveform(self, player_number, slot, track_id, callback=None): 114 | self._enqueue_request("preview_waveform", self.preview_waveform_store, (player_number, slot, track_id), callback) 115 | 116 | def get_color_waveform(self, player_number, slot, track_id, callback=None): 117 | self._enqueue_request("color_waveform", self.color_waveform_store, (player_number, slot, track_id), callback) 118 | 119 | def get_color_preview_waveform(self, player_number, slot, track_id, callback=None): 120 | self._enqueue_request("color_preview_waveform", self.color_preview_waveform_store, (player_number, slot, track_id), callback) 121 | 122 | def get_beatgrid(self, player_number, slot, track_id, callback=None): 123 | self._enqueue_request("beatgrid", self.beatgrid_store, (player_number, slot, track_id), callback) 124 | 125 | def get_mount_info(self, player_number, slot, track_id, callback=None): 126 | self._enqueue_request("mount_info", None, (player_number, slot, track_id), callback) 127 | 128 | def get_track_info(self, player_number, slot, track_id, callback=None): 129 | self._enqueue_request("track_info", None, (player_number, slot, track_id), callback) 130 | 131 | def _enqueue_request(self, request, store, params, callback): 132 | player_number = params[0] 133 | if player_number == 0 or player_number > 4: 134 | logging.warning("invalid %s request parameters", request) 135 | return 136 | logging.debug("enqueueing %s request with params %s", request, str(params)) 137 | self.queue.put((request, store, params, callback, self.request_retry_count)) 138 | 139 | def _handle_request_from_store(self, store, params): 140 | if len(params) != 3: 141 | logging.error("unable to handle request from store with != 3 arguments") 142 | return None 143 | if params in store: 144 | return store[params] 145 | return None 146 | 147 | def _handle_request_from_pdb(self, request, params): 148 | return self.pdb.handle_request(request, params) 149 | 150 | def _handle_request_from_dbclient(self, request, params): 151 | return self.dbc.handle_request(request, params) 152 | 153 | def _handle_request(self, request, store, params, callback): 154 | #logging.debug("handling %s request params %s", request, str(params)) 155 | reply = None 156 | answered_by_store = False 157 | if store is not None: 158 | logging.debug("trying request %s %s from store", request, str(params)) 159 | reply = self._handle_request_from_store(store, params) 160 | if reply is not None: 161 | answered_by_store = True 162 | if self.pdb_enabled and reply is None: 163 | try: 164 | logging.debug("trying request %s %s from pdb", request, str(params)) 165 | reply = self._handle_request_from_pdb(request, params) 166 | except FatalQueryError as e: # on a fatal error, continue with dbc 167 | logging.warning("pdb failed [%s]", str(e)) 168 | if not self.dbc_enabled: 169 | raise 170 | if self.dbc_enabled and reply is None: 171 | logging.debug("trying request %s %s from dbc", request, str(params)) 172 | reply = self._handle_request_from_dbclient(request, params) 173 | 174 | if reply is None: 175 | raise FatalQueryError("DataStore: request returned none, see log for details") 176 | 177 | # special call for metadata since it is expected to be part of the client status 178 | if request == "metadata": 179 | self.prodj.cl.storeMetadataByLoadedTrack(*params, reply) 180 | 181 | if store is not None and answered_by_store == False: 182 | store[params] = reply 183 | 184 | # TODO: synchronous mode 185 | if callback is not None: 186 | callback(request, *params, reply) 187 | 188 | def _retry_request(self, request): 189 | self.queue.task_done() 190 | if request[-1] > 0: 191 | if request[0] == "color_waveform": 192 | logging.info("Color waveform request failed, trying normal waveform instead") 193 | request = ("waveform", *request[1:]) 194 | elif request[0] == "color_preview_waveform": 195 | logging.info("Color preview waveform request failed, trying normal waveform instead") 196 | request = ("preview_waveform", *request[1:]) 197 | else: 198 | logging.info("retrying %s request", request[0]) 199 | self.queue.put((*request[:-1], request[-1]-1)) 200 | time.sleep(1) # yes, this is dirty, but effective to work around timing problems on failed request 201 | else: 202 | logging.info("%s request failed %d times, giving up", request[0], self.request_retry_count) 203 | 204 | def gc(self): 205 | self.dbc.gc() 206 | 207 | def run(self): 208 | logging.debug("DataProvider starting") 209 | while self.keep_running: 210 | try: 211 | request = self.queue.get(timeout=1) 212 | except Empty: 213 | self.gc() 214 | continue 215 | try: 216 | self._handle_request(*request[:-1]) 217 | self.queue.task_done() 218 | except TemporaryQueryError as e: 219 | logging.warning("%s request failed: %s", request[0], e) 220 | self._retry_request(request) 221 | except FatalQueryError as e: 222 | logging.error("%s request failed: %s", request[0], e) 223 | self.queue.task_done() 224 | logging.debug("DataProvider shutting down") 225 | -------------------------------------------------------------------------------- /prodj/data/datastore.py: -------------------------------------------------------------------------------- 1 | from threading import Event, Thread 2 | import logging 3 | import time 4 | 5 | # this implements a least recently used cache 6 | # stores key -> (timestamp, val) and updates timestamp on every access 7 | class DataStore(Thread, dict): 8 | def __init__(self, size_limit=15, gc_interval=30): 9 | super().__init__() 10 | self.gc_interval = gc_interval 11 | self.size_limit = size_limit 12 | self.event = Event() 13 | self.start() 14 | 15 | # make this class hashable 16 | def __eq__(self, other): 17 | return self is other 18 | 19 | def __hash__(self): 20 | return hash(id(self)) 21 | 22 | def __getitem__(self, key): 23 | val = dict.__getitem__(self, key)[1] 24 | #logging.debug("get %s = %s, update timestamp", str(key), str(val)) 25 | self.__setitem__(key, val) # update timestamp 26 | return val 27 | 28 | def __setitem__(self, key, val): 29 | #logging.debug("set %s = %s", str(key), str(val)) 30 | dict.__setitem__(self, key, (time.time(), val)) 31 | 32 | def start(self): 33 | self.event.clear() 34 | super().start() 35 | 36 | def stop(self): 37 | self.event.set() 38 | 39 | def run(self): 40 | logging.debug("%s initialized", hex(id(self))) 41 | while not self.event.wait(self.gc_interval): 42 | self.gc() 43 | logging.debug("%s stopped", hex(id(self))) 44 | 45 | def gc(self): 46 | if len(self) <= self.size_limit: 47 | return 48 | logging.debug("garbage collection (max %d, cur %d)", self.size_limit, len(self)) 49 | oldest_items = sorted(self.items(), key=lambda x: x[1][0]) 50 | for delete_item in oldest_items[0:len(self)-self.size_limit]: 51 | logging.debug("delete %s due to age", str(delete_item[0])) 52 | del self[delete_item[0]] 53 | 54 | def removeByPlayerSlot(self, player_number, slot): 55 | for keys in list(self): 56 | if keys[0] == player_number and keys[1] == slot: 57 | logging.debug("delete %s due to media change on player %d slot %s", str(keys), player_number, slot) 58 | del self[keys] 59 | -------------------------------------------------------------------------------- /prodj/data/exceptions.py: -------------------------------------------------------------------------------- 1 | class TemporaryQueryError(Exception): 2 | pass 3 | 4 | class FatalQueryError(Exception): 5 | pass -------------------------------------------------------------------------------- /prodj/data/pdbprovider.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from prodj.data.exceptions import FatalQueryError 5 | from prodj.data.datastore import DataStore 6 | from prodj.pdblib.pdbdatabase import PDBDatabase 7 | from prodj.pdblib.usbanlzdatabase import UsbAnlzDatabase 8 | from prodj.network.rpcreceiver import ReceiveTimeout 9 | 10 | colors = ["none", "pink", "red", "orange", "yellow", "green", "aqua", "blue", "purple"] 11 | 12 | class InvalidPDBDatabase: 13 | def __init__(self, reason): 14 | self.reason = reason 15 | 16 | def __str__(self): 17 | return self.reason 18 | 19 | def wrap_get_name_from_db(call, id): 20 | if id == 0: 21 | return "" 22 | try: 23 | return call(id).name 24 | except KeyError as e: 25 | logging.warning(f'Broken database: {e}') 26 | return "?" 27 | 28 | class PDBProvider: 29 | def __init__(self, prodj): 30 | self.prodj = prodj 31 | self.dbs = DataStore() # (player_number,slot) -> PDBDatabase 32 | self.usbanlz = DataStore() # (player_number, slot, track_id) -> UsbAnlzDatabase 33 | 34 | def cleanup_stores_from_changed_media(self, player_number, slot): 35 | self.dbs.removeByPlayerSlot(player_number, slot) 36 | self.usbanlz.removeByPlayerSlot(player_number, slot) 37 | 38 | def stop(self): 39 | self.dbs.stop() 40 | self.usbanlz.stop() 41 | 42 | def delete_pdb(self, filename): 43 | try: 44 | os.remove(filename) 45 | except OSError: 46 | pass 47 | 48 | def download_pdb(self, player_number, slot): 49 | player = self.prodj.cl.getClient(player_number) 50 | if player is None: 51 | raise FatalQueryError("player {} not found in clientlist".format(player_number)) 52 | filename = "databases/player-{}-{}.pdb".format(player_number, slot) 53 | self.delete_pdb(filename) 54 | try: 55 | try: 56 | self.prodj.nfs.enqueue_download(player.ip_addr, slot, "/PIONEER/rekordbox/export.pdb", filename, sync=True) 57 | except FileNotFoundError as e: 58 | logging.debug("default pdb path not found on player %d, trying MacOS path", player_number) 59 | self.prodj.nfs.enqueue_download(player.ip_addr, slot, "/.PIONEER/rekordbox/export.pdb", filename, sync=True) 60 | except (RuntimeError, ReceiveTimeout) as e: 61 | raise FatalQueryError("database download from player {} failed: {}".format(player_number, e)) 62 | return filename 63 | 64 | def download_and_parse_pdb(self, player_number, slot): 65 | filename = self.download_pdb(player_number, slot) 66 | db = PDBDatabase() 67 | try: 68 | db.load_file(filename) 69 | except RuntimeError as e: 70 | raise FatalQueryError("PDBProvider: failed to parse \"{}\": {}".format(filename, e)) 71 | return db 72 | 73 | def get_db(self, player_number, slot): 74 | if (player_number, slot) not in self.dbs: 75 | try: 76 | db = self.download_and_parse_pdb(player_number, slot) 77 | except FatalQueryError as e: 78 | db = InvalidPDBDatabase(str(e)) 79 | finally: 80 | self.dbs[player_number, slot] = db 81 | else: 82 | db = self.dbs[player_number, slot] 83 | if isinstance(db, InvalidPDBDatabase): 84 | raise FatalQueryError(f'PDB database not available: {db}') 85 | return db 86 | 87 | def download_and_parse_usbanlz(self, player_number, slot, anlz_path): 88 | player = self.prodj.cl.getClient(player_number) 89 | if player is None: 90 | raise FatalQueryError("player {} not found in clientlist".format(player_number)) 91 | dat = self.prodj.nfs.enqueue_buffer_download(player.ip_addr, slot, anlz_path) 92 | ext = self.prodj.nfs.enqueue_buffer_download(player.ip_addr, slot, anlz_path.replace("DAT", "EXT")) 93 | db = UsbAnlzDatabase() 94 | if dat is not None and ext is not None: 95 | db.load_dat_buffer(dat) 96 | db.load_ext_buffer(ext) 97 | else: 98 | logging.warning("missing DAT or EXT data, returning empty UsbAnlzDatabase") 99 | return db 100 | 101 | def get_anlz(self, player_number, slot, track_id): 102 | if (player_number, slot, track_id) not in self.usbanlz: 103 | db = self.get_db(player_number, slot) 104 | track = db.get_track(track_id) 105 | self.usbanlz[player_number, slot, track_id] = self.download_and_parse_usbanlz(player_number, slot, track.analyze_path) 106 | return self.usbanlz[player_number, slot, track_id] 107 | 108 | def get_metadata(self, player_number, slot, track_id): 109 | db = self.get_db(player_number, slot) 110 | track = db.get_track(track_id) 111 | artist = wrap_get_name_from_db(db.get_artist, track.artist_id) 112 | album = wrap_get_name_from_db(db.get_album, track.album_id) 113 | key = wrap_get_name_from_db(db.get_key, track.key_id) 114 | genre = wrap_get_name_from_db(db.get_genre, track.genre_id) 115 | color_text = wrap_get_name_from_db(db.get_color, track.color_id) 116 | 117 | color_name = "" 118 | if track.color_id in range(1, len(colors)): 119 | color_name = colors[track.color_id] 120 | 121 | metadata = { 122 | "track_id": track.id, 123 | "title": track.title, 124 | "artist_id": track.artist_id, 125 | "artist": artist, 126 | "album_id": track.album_id, 127 | "album": album, 128 | "key_id": track.key_id, 129 | "key": key, 130 | "genre_id": track.genre_id, 131 | "genre": genre, 132 | "duration": track.duration, 133 | "comment": track.comment, 134 | "date_added": track.date_added, 135 | "color": color_name, 136 | "color_text": color_text, 137 | "rating": track.rating, 138 | "artwork_id": track.artwork_id, 139 | "bpm": track.bpm_100/100 140 | } 141 | return metadata 142 | 143 | def get_artwork(self, player_number, slot, artwork_id): 144 | player = self.prodj.cl.getClient(player_number) 145 | if player is None: 146 | raise FatalQueryError("player {} not found in clientlist".format(player_number)) 147 | db = self.get_db(player_number, slot) 148 | try: 149 | artwork = db.get_artwork(artwork_id) 150 | except KeyError as e: 151 | logging.warning("No artwork for {}, returning empty data".format((player_number, slot, artwork_id))) 152 | return None 153 | return self.prodj.nfs.enqueue_buffer_download(player.ip_addr, slot, artwork.path) 154 | 155 | def get_waveform(self, player_number, slot, track_id): 156 | db = self.get_anlz(player_number, slot, track_id) 157 | try: 158 | return db.get_waveform() 159 | except KeyError as e: 160 | logging.warning("No waveform for {}, returning empty data".format((player_number, slot, track_id))) 161 | return None 162 | 163 | def get_preview_waveform(self, player_number, slot, track_id): 164 | db = self.get_anlz(player_number, slot, track_id) 165 | waveform_spread = b"" 166 | try: 167 | for line in db.get_preview_waveform(): 168 | waveform_spread += bytes([line & 0x1f, line>>5]) 169 | except KeyError as e: 170 | logging.warning("No preview waveform for {}, returning empty data".format((player_number, slot, track_id))) 171 | return None 172 | return waveform_spread 173 | 174 | def get_color_waveform(self, player_number, slot, track_id): 175 | db = self.get_anlz(player_number, slot, track_id) 176 | try: 177 | return db.get_color_waveform() 178 | except KeyError as e: 179 | logging.warning("No color waveform for {}, returning empty data".format((player_number, slot, track_id))) 180 | return None 181 | 182 | def get_color_preview_waveform(self, player_number, slot, track_id): 183 | db = self.get_anlz(player_number, slot, track_id) 184 | try: 185 | return db.get_color_preview_waveform() 186 | except KeyError as e: 187 | logging.warning("No color preview waveform for {}, returning empty data".format((player_number, slot, track_id))) 188 | return None 189 | 190 | def get_beatgrid(self, player_number, slot, track_id): 191 | db = self.get_anlz(player_number, slot, track_id) 192 | try: 193 | return db.get_beatgrid() 194 | except KeyError as e: 195 | logging.warning("No beatgrid for {}, returning empty data".format((player_number, slot, track_id))) 196 | return None 197 | 198 | def get_mount_info(self, player_number, slot, track_id): 199 | db = self.get_db(player_number, slot) 200 | track = db.get_track(track_id) 201 | 202 | # contains additional fields to mimic dbserver reply 203 | mount_info = { 204 | "track_id": track.id, 205 | "duration": track.duration, 206 | "bpm": track.bpm_100/100, 207 | "mount_path": track.path 208 | } 209 | return mount_info 210 | 211 | # returns a dummy root menu 212 | def get_root_menu(self): 213 | return [ 214 | {'name': '\ufffaTRACK\ufffb', 'menu_id': 4}, 215 | {'name': '\ufffaARTIST\ufffb', 'menu_id': 2}, 216 | {'name': '\ufffaALBUM\ufffb', 'menu_id': 3}, 217 | {'name': '\ufffaGENRE\ufffb', 'menu_id': 1}, 218 | {'name': '\ufffaKEY\ufffb', 'menu_id': 12}, 219 | {'name': '\ufffaPLAYLIST\ufffb', 'menu_id': 5}, 220 | {'name': '\ufffaHISTORY\ufffb', 'menu_id': 22}, 221 | {'name': '\ufffaSEARCH\ufffb', 'menu_id': 18}, 222 | {'name': '\ufffaFOLDER\ufffb', 'menu_id': 17} 223 | ] 224 | 225 | def convert_and_sort_track_list(self, db, track_list, sort_mode): 226 | converted = [] 227 | # we do not know the default sort mode from pdb, thus fall back to title 228 | if sort_mode in ["title", "default"]: 229 | col2_name = "artist" 230 | else: 231 | col2_name = sort_mode 232 | for track in track_list: 233 | if col2_name in ["title", "artist"]: 234 | col2_item = wrap_get_name_from_db(db.get_artist, track.artist_id) 235 | elif col2_name == "album": 236 | col2_item = wrap_get_name_from_db(db.get_album, track.album_id) 237 | elif col2_name == "genre": 238 | col2_item = wrap_get_name_from_db(db.get_genre, track.genre_id) 239 | elif col2_name == "label": 240 | col2_item = wrap_get_name_from_db(db.get_label, track.label_id) 241 | elif col2_name == "original_artist": 242 | col2_item = wrap_get_name_from_db(db.get_artist, track.original_artist_id) 243 | elif col2_name == "remixer": 244 | col2_item = wrap_get_name_from_db(db.get_artist, track.remixer_id) 245 | elif col2_name == "key": 246 | col2_item = wrap_get_name_from_db(db.get_key, track.key_id) 247 | elif col2_name == "bpm": 248 | col2_item = track.bpm_100/100 249 | elif col2_name in ["rating", "comment", "duration", "bitrate", "play_count"]: # 1:1 mappings 250 | col2_item = track[col2_name] 251 | else: 252 | raise FatalQueryError("unknown sort mode {}".format(sort_mode)) 253 | converted += [{ 254 | "title": track.title, 255 | col2_name: col2_item, 256 | "track_id": track.id, 257 | "artist_id": track.artist_id, 258 | "album_id": track.album_id, 259 | "artwork_id": track.artwork_id, 260 | "genre_id": track.genre_id}] 261 | if sort_mode == "default": 262 | return converted 263 | else: 264 | return sorted(converted, key=lambda key: key[sort_mode], reverse=sort_mode=="rating") 265 | 266 | # id_list empty -> list all titles 267 | # one id_list entry = album_id -> all titles in album 268 | # two id_list entries = artist_id,album_id -> all titles in album by artist 269 | # three id_list entries = genre_id,artist_id,album_id -> all titles in album by artist matching genre 270 | def get_titles(self, player_number, slot, sort_mode="default", id_list=[]): 271 | logging.debug("get_titles (%d, %s, %s) sort %s", player_number, slot, str(id_list), sort_mode) 272 | db = self.get_db(player_number, slot) 273 | if len(id_list) == 3: # genre, artist, album 274 | if id_list[1] == 0 and id_list[2] == 0: # any artist, any album 275 | ff = lambda track: track.genre_id == id_list[0] 276 | elif id_list[2] == 0: # any album 277 | ff = lambda track: track.genre_id == id_list[0] and track.artist_id == id_list[1] 278 | elif id_list[1] == 0: # any artist 279 | ff = lambda track: track.genre_id == id_list[0] and track.album_id == id_list[2] 280 | else: 281 | ff = lambda track: track.genre_id == id_list[0] and track.artist_id == id_list[1] and track.album_id == id_list[2] 282 | elif len(id_list) == 2: # artist, album 283 | if id_list[1] == 0: # any album 284 | ff = lambda track: track.artist_id == id_list[0] 285 | else: 286 | ff = lambda track: track.artist_id == id_list[0] and track.album_id == id_list[1] 287 | elif len(id_list) == 1: 288 | ff = lambda track: track.album_id == id_list[0] 289 | else: 290 | ff = None 291 | track_list = filter(ff, db["tracks"]) 292 | # on titles, fall back to "title" sort mode as we can't know the user's default choice 293 | if sort_mode == "default": 294 | sort_mode = "title" 295 | return self.convert_and_sort_track_list(db, track_list, sort_mode) 296 | 297 | # id_list empty -> list all artists 298 | # one id_list entry = genre_id -> all artists by genre 299 | def get_artists(self, player_number, slot, id_list=[]): 300 | logging.debug("get_artists (%d, %s, %s)", player_number, slot, str(id_list)) 301 | db = self.get_db(player_number, slot) 302 | if len(id_list) == 1: 303 | ff = lambda artist: any(artist.id == track.artist_id for track in db["tracks"] if track.genre_id == id_list[0]) 304 | prepend = [{"all": " ALL "}] 305 | else: 306 | ff = None 307 | prepend = [] 308 | artist_list = filter(ff, db["artists"]) 309 | artists = [{"artist": artist.name, "artist_id": artist.id} for artist in artist_list] 310 | return prepend+sorted(artists, key=lambda key: key["artist"]) 311 | 312 | # id_list empty -> list all albums 313 | # one id_list entry = artist_id -> all albums by artist 314 | # two id_list entries = genre_id, artist_id -> all albums by artist matching genre 315 | # two id_list entries = genre_id, 0 -> all albums matching genre 316 | def get_albums(self, player_number, slot, id_list=[]): 317 | logging.debug("get_albums (%d, %s, %s)", player_number, slot, str(id_list)) 318 | db = self.get_db(player_number, slot) 319 | if len(id_list) == 2: 320 | if id_list[1] == 0: 321 | ff = lambda album: any(album.id == track.album_id for track in db["tracks"] if track.genre_id == id_list[0]) 322 | else: 323 | ff = lambda album: any(album.id == track.album_id for track in db["tracks"] if track.artist_id == id_list[1] and track.genre_id == id_list[0]) 324 | prepend = [{"all": " ALL "}] 325 | elif len(id_list) == 1: 326 | ff = lambda album: any(album.id == track.album_id for track in db["tracks"] if track.artist_id == id_list[0]) 327 | prepend = [{"all": " ALL "}] 328 | else: 329 | ff = None 330 | prepend = [] 331 | album_list = filter(ff, db["albums"]) 332 | albums = [{"album": album.name, "album_id": album.id} for album in album_list] 333 | return prepend+sorted(albums, key=lambda key: key["album"]) 334 | 335 | # id_list empty -> list genres 336 | def get_genres(self, player_number, slot): 337 | logging.debug("get_genres (%d, %s)", player_number, slot) 338 | db = self.get_db(player_number, slot) 339 | genres = [{"genre": genre.name, "genre_id": genre.id} for genre in db["genres"]] 340 | sorted_genres = sorted(genres, key=lambda key: key["genre"]) 341 | return sorted(genres, key=lambda key: key["genre"]) 342 | 343 | def get_playlists(self, player_number, slot, folder_id): 344 | logging.debug("get_playlists (%d, %s, %d)", player_number, slot, folder_id) 345 | db = self.get_db(player_number, slot) 346 | playlists = [] 347 | for playlist in db.get_playlists(folder_id): 348 | if playlist.is_folder: 349 | playlists += [{"folder": playlist.name, "folder_id": playlist.id, "parend_id": playlist.folder_id}] 350 | else: 351 | playlists += [{"playlist": playlist.name, "playlist_id": playlist.id, "parend_id": playlist.folder_id}] 352 | return playlists 353 | 354 | def get_playlist(self, player_number, slot, sort_mode, playlist_id): 355 | logging.debug("get_playlist (%d, %s, %d, %s)", player_number, slot, playlist_id, sort_mode) 356 | db = self.get_db(player_number, slot) 357 | track_list = db.get_playlist(playlist_id) 358 | return self.convert_and_sort_track_list(db, track_list, sort_mode) 359 | #{'title': 'The Raven', 'artwork_id': 123, 'track_id': 225, 'artist_id': 4, 'key': '09A', 'key_id': 4} 360 | 361 | def handle_request(self, request, params): 362 | logging.debug("handling %s request params %s", request, str(params)) 363 | if request == "metadata": 364 | return self.get_metadata(*params) 365 | elif request == "root_menu": 366 | return self.get_root_menu() 367 | elif request == "title": 368 | return self.get_titles(*params) 369 | elif request == "title_by_album": 370 | return self.get_titles(*params) 371 | elif request == "title_by_artist_album": 372 | return self.get_titles(*params) 373 | elif request == "title_by_genre_artist_album": 374 | return self.get_titles(*params) 375 | elif request == "artist": 376 | return self.get_artists(*params) 377 | elif request == "artist_by_genre": 378 | return self.get_artists(*params) 379 | elif request == "album": 380 | return self.get_albums(*params) 381 | elif request == "album_by_artist": 382 | return self.get_albums(*params) 383 | elif request == "album_by_genre_artist": 384 | return self.get_albums(*params) 385 | elif request == "genre": 386 | return self.get_genres(*params) 387 | elif request == "playlist_folder": 388 | return self.get_playlists(*params) 389 | elif request == "playlist": 390 | return self.get_playlist(*params) 391 | elif request == "artwork": 392 | return self.get_artwork(*params) 393 | elif request == "waveform": 394 | return self.get_waveform(*params) 395 | elif request == "preview_waveform": 396 | return self.get_preview_waveform(*params) 397 | elif request == "color_waveform": 398 | return self.get_color_waveform(*params) 399 | elif request == "color_preview_waveform": 400 | return self.get_color_preview_waveform(*params) 401 | elif request == "beatgrid": 402 | return self.get_beatgrid(*params) 403 | elif request == "mount_info": 404 | return self.get_mount_info(*params) 405 | else: 406 | raise FatalQueryError("invalid request type {}".format(request)) 407 | -------------------------------------------------------------------------------- /prodj/gui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/gui/__init__.py -------------------------------------------------------------------------------- /prodj/gui/gui_browser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from PyQt5.QtWidgets import QComboBox, QHeaderView, QLabel, QPushButton, QSizePolicy, QTableView, QTextEdit, QHBoxLayout, QVBoxLayout, QWidget 3 | from PyQt5.QtGui import QPalette, QStandardItem, QStandardItemModel 4 | from PyQt5.QtCore import Qt, pyqtSignal 5 | 6 | from prodj.data.dbclient import sort_types 7 | 8 | # small helper functions 9 | def makeMediaInfo(info): 10 | if all(key in info for key in ["name", "track_count", "playlist_count", "bytes_total", "bytes_free"]): 11 | return "{}, {} tracks, {} playlists, {}/{}MB free".format(info["name"], info["track_count"], 12 | info["playlist_count"], info["bytes_free"]//1024//1024, info["bytes_total"]//1024//1024) 13 | else: 14 | return "No information available" 15 | 16 | def makeItem(text, data=None): 17 | item = QStandardItem(text) 18 | item.setFlags(Qt.ItemIsEnabled) 19 | item.setData(data) 20 | return item 21 | 22 | def ratingString(rating): 23 | if rating < 0 or rating > 5: 24 | return str(rating) 25 | stars = ["\u2605", "\u2606"] # black star, white star 26 | return "".join(rating*stars[0]+(5-rating)*stars[1]) 27 | 28 | def printableField(field): 29 | if field == "bpm": 30 | return field.upper() 31 | else: 32 | return field.replace("_", " ").title() 33 | 34 | class Browser(QWidget): 35 | handleRequestSignal = pyqtSignal() 36 | refreshMediaSignal = pyqtSignal(str) 37 | 38 | def __init__(self, prodj, player_number): 39 | super().__init__() 40 | self.prodj = prodj 41 | self.slot = None # set after selecting slot in media menu 42 | self.menu = "media" 43 | self.sort = "default" 44 | self.artist_id = None 45 | self.track_id = None 46 | self.genre_id = None 47 | self.playlist_folder_stack = [0] 48 | self.playlist_id = None 49 | self.path_stack = [] 50 | self.setPlayerNumber(player_number) 51 | 52 | self.request = None # requests are parsed on signaling handleRequestSignal 53 | self.handleRequestSignal.connect(self.handleRequest) 54 | self.refreshMediaSignal.connect(self.refreshMedia) 55 | 56 | self.setAutoFillBackground(True) 57 | 58 | # upper part 59 | self.path = QLabel(self) 60 | self.path.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Fixed) 61 | self.sort_box = QComboBox(self) 62 | for sort in sort_types: 63 | self.sort_box.addItem(printableField(sort), sort) 64 | self.sort_box.currentIndexChanged[int].connect(self.sortChanged) 65 | self.sort_box.setStyleSheet("QComboBox { padding: 2px; border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; }") 66 | self.back_button = QPushButton("Back", self) 67 | self.back_button.clicked.connect(self.backButtonClicked) 68 | self.back_button.setStyleSheet("QPushButton { padding: 2px; border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; }") 69 | 70 | top_layout = QHBoxLayout() 71 | top_layout.addWidget(self.path) 72 | top_layout.addWidget(self.sort_box) 73 | top_layout.addWidget(self.back_button) 74 | top_layout.setStretch(0, 1) 75 | 76 | # mid part 77 | self.model = QStandardItemModel(self) 78 | self.view = QTableView(self) 79 | self.view.setModel(self.model) 80 | self.view.verticalHeader().hide() 81 | #self.view.verticalHeader().setSectionResizeMode(QHeaderView.ResizeToContents); 82 | self.view.verticalHeader().setSectionResizeMode(QHeaderView.Fixed); 83 | self.view.verticalHeader().setDefaultSectionSize(18); # TODO replace by text bounding height 84 | self.view.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch); 85 | self.view.setStyleSheet("QTableView { border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; background-color: black; } QTableView::item { color: white; } QTableView::item:focus { background-color: darkslategray; selection-background-color: black; }") 86 | self.view.clicked.connect(self.tableItemClicked) 87 | 88 | # metadata 89 | self.metadata_label = QLabel("Metadata:", self) 90 | self.metadata_edit = QTextEdit() 91 | self.metadata_edit.setReadOnly(True) 92 | self.metadata_edit.setStyleSheet("QTextEdit { padding: 2px; border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; }") 93 | 94 | metadata_layout = QVBoxLayout() 95 | metadata_layout.addWidget(self.metadata_label) 96 | metadata_layout.addWidget(self.metadata_edit) 97 | 98 | mid_layout = QHBoxLayout() 99 | mid_layout.addWidget(self.view) 100 | mid_layout.addLayout(metadata_layout) 101 | 102 | # lower part (load buttons) 103 | buttons_layout = QHBoxLayout() 104 | self.load_buttons = [] 105 | for i in range(1,5): 106 | btn = QPushButton("Load Player {}".format(i), self) 107 | btn.setFlat(True) 108 | btn.setEnabled(False) 109 | btn.setStyleSheet("QPushButton { border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; }") 110 | btn.clicked.connect(lambda c,i=i: self.loadIntoPlayer(i)) 111 | buttons_layout.addWidget(btn) 112 | self.load_buttons += [btn] 113 | 114 | self.download_button = QPushButton("Download", self) 115 | self.download_button.setFlat(True) 116 | self.download_button.setStyleSheet("QPushButton { border-style: outset; border-radius: 2px; border-width: 1px; border-color: gray; }") 117 | self.download_button.clicked.connect(self.downloadTrack) 118 | buttons_layout.addWidget(self.download_button) 119 | 120 | layout = QVBoxLayout(self) 121 | layout.addLayout(top_layout) 122 | layout.addLayout(mid_layout) 123 | layout.addLayout(buttons_layout) 124 | 125 | self.updateButtons() 126 | self.mediaMenu() 127 | 128 | def setPlayerNumber(self, player_number): 129 | self.player_number = player_number 130 | self.setWindowTitle("Browse Player {}".format(player_number)) 131 | 132 | def updatePath(self, text=""): 133 | if text: 134 | self.path_stack.append(text) 135 | self.path.setText("\u27a4".join(self.path_stack)) 136 | 137 | def mediaMenu(self): 138 | c = self.prodj.cl.getClient(self.player_number) 139 | if c is None: 140 | logging.warning("failed to get client for player %d", self.player_number) 141 | return 142 | self.menu = "media" 143 | self.slot = None 144 | self.track_id = None 145 | self.path_stack.clear() 146 | self.path.setText("Media overview") 147 | self.model.clear() 148 | if c.usb_state != "loaded" and c.sd_state != "loaded": 149 | self.model.setHorizontalHeaderLabels(["Media"]) 150 | self.model.appendRow(makeItem("No media in player")) 151 | return 152 | self.model.setHorizontalHeaderLabels(["Media", "Info"]) 153 | if c.usb_state == "loaded": 154 | data = {"type": "media", "name": "usb"} 155 | self.model.appendRow([makeItem("USB", data), makeItem(makeMediaInfo(c.usb_info), data)]) 156 | if c.sd_state == "loaded": 157 | data = {"type": "media", "name": "sd"} 158 | self.model.appendRow([makeItem("SD Card", data), makeItem(makeMediaInfo(c.sd_info), data)]) 159 | 160 | def rootMenu(self, slot): 161 | self.prodj.data.get_root_menu(self.player_number, slot, self.storeRequest) 162 | 163 | def renderRootMenu(self, request, player_number, slot, reply): 164 | logging.debug("renderRootMenu %s %s", str(request), str(player_number)) 165 | if player_number != self.player_number: 166 | return 167 | self.menu = "root" 168 | self.slot = slot 169 | self.model.clear() 170 | self.model.setHorizontalHeaderLabels(["Category"]) 171 | for entry in reply: 172 | data = {"type": "root", "name": entry["name"][1:-1], "menu_id": entry["menu_id"]} 173 | self.model.appendRow(makeItem(data["name"], data)) 174 | 175 | def titleMenu(self): 176 | self.prodj.data.get_titles(self.player_number, self.slot, self.sort, self.storeRequest) 177 | 178 | def titleAlbumMenu(self, album_id): 179 | self.album_id = album_id 180 | self.prodj.data.get_titles_by_album(self.player_number, self.slot, album_id, self.sort, self.storeRequest) 181 | 182 | def titleAlbumArtistMenu(self, album_id): 183 | self.album_id = album_id 184 | self.prodj.data.get_titles_by_artist_album(self.player_number, self.slot, self.artist_id, album_id, self.sort, self.storeRequest) 185 | 186 | def titleAlbumArtistGenreMenu(self, album_id): 187 | self.album_id = album_id 188 | self.prodj.data.get_titles_by_genre_artist_album(self.player_number, self.slot, self.genre_id, self.artist_id, album_id, self.sort, self.storeRequest) 189 | 190 | def artistMenu(self): 191 | self.prodj.data.get_artists(self.player_number, self.slot, self.storeRequest) 192 | 193 | def artistGenreMenu(self, genre_id): 194 | self.genre_id = genre_id 195 | self.prodj.data.get_artists_by_genre(self.player_number, self.slot, genre_id, self.storeRequest) 196 | 197 | def albumMenu(self): 198 | self.prodj.data.get_albums(self.player_number, self.slot, self.storeRequest) 199 | 200 | def albumArtistMenu(self, artist_id): 201 | self.artist_id = artist_id 202 | self.prodj.data.get_albums_by_artist(self.player_number, self.slot, artist_id, self.storeRequest) 203 | 204 | def albumArtistGenreMenu(self, artist_id): 205 | self.artist_id = artist_id 206 | self.prodj.data.get_albums_by_genre_artist(self.player_number, self.slot, self.genre_id, artist_id, self.storeRequest) 207 | 208 | def genreMenu(self): 209 | self.prodj.data.get_genres(self.player_number, self.slot, self.storeRequest) 210 | 211 | def folderPlaylistMenu(self, folder_id=0): 212 | if folder_id == 0: 213 | self.playlist_folder_stack = [0] 214 | else: 215 | self.playlist_folder_stack.append(folder_id) 216 | self.playlist_id = 0 217 | self.prodj.data.get_playlist_folder(self.player_number, self.slot, folder_id, self.storeRequest) 218 | 219 | def titlePlaylistMenu(self, playlist_id=0): 220 | self.playlist_id = playlist_id 221 | self.prodj.data.get_playlist(self.player_number, self.slot, playlist_id, self.sort, self.storeRequest) 222 | 223 | def renderList(self, request, player_number, slot, reply): 224 | logging.debug("rendering %s list from player %d", request, player_number) 225 | if player_number != self.player_number: 226 | return 227 | self.menu = request 228 | self.slot = slot 229 | self.model.clear() 230 | # guess columns 231 | columns = [] 232 | if len(reply) > 0: 233 | guess = reply[0] 234 | if len(reply) > 1 and "all" in guess: 235 | guess = reply[1] 236 | for key in guess: 237 | if key[-3:] != "_id": 238 | columns += [key] 239 | self.model.setHorizontalHeaderLabels([printableField(x) for x in columns]) 240 | for entry in reply: 241 | data = {"type": request, **entry} 242 | row = [] 243 | if "all" in entry: # on the special "all" entry, set the id to 0 244 | data[columns[0]] = entry["all"][1:-1] 245 | data[columns[0]+"_id"] = 0 246 | row += [makeItem(entry["all"][1:-1], data)] 247 | else: 248 | for column in columns: 249 | if request == "playlist_folder" and column not in entry: 250 | column = "folder" if column == "playlist" else "playlist" 251 | if column == "rating": 252 | text = ratingString(entry[column]) 253 | else: 254 | text = str(entry[column]) 255 | row += [makeItem(text, data)] 256 | self.model.appendRow(row) 257 | 258 | def metadata(self, track_id): 259 | self.prodj.data.get_metadata(self.player_number, self.slot, track_id, self.storeRequest) 260 | 261 | def renderMetadata(self, request, source_player_number, slot, track_id, metadata): 262 | md = "" 263 | for key in [k for k in ["title", "artist", "album", "genre", "key", "bpm", "comment", "duration"] if k in metadata]: 264 | md += "{}:\t{}\n".format(printableField(key), metadata[key]) 265 | if "rating" in metadata: 266 | md += "{}:\t{}\n".format("Rating", ratingString(metadata["rating"])) 267 | self.metadata_edit.setText(md) 268 | self.track_id = track_id 269 | 270 | def backButtonClicked(self): 271 | if self.menu in ["title", "artist", "album", "genre"]: 272 | self.rootMenu(self.slot) 273 | elif self.menu == "title_by_artist_album": 274 | self.albumArtistMenu(self.artist_id) 275 | elif self.menu == "title_by_album": 276 | self.albumMenu() 277 | elif self.menu == "album_by_artist": 278 | self.artistMenu() 279 | elif self.menu == "artist_by_genre": 280 | self.genreMenu() 281 | elif self.menu == "album_by_genre_artist": 282 | self.artistGenreMenu(self.genre_id) 283 | elif self.menu == "title_by_genre_artist_album": 284 | self.albumArtistGenreMenu(self.artist_id) 285 | elif self.menu == "playlist_folder": 286 | if len(self.playlist_folder_stack) == 1: 287 | self.rootMenu(self.slot) 288 | else: 289 | self.playlist_folder_stack.pop() # pop the current directory 290 | self.folderPlaylistMenu(self.playlist_folder_stack.pop()) # display the current directory 291 | elif self.menu == "playlist": 292 | self.folderPlaylistMenu(self.playlist_folder_stack.pop()) 293 | elif self.menu == "root": 294 | self.mediaMenu() 295 | return 296 | elif self.menu == "media": 297 | return # no parent menu for media 298 | else: 299 | logging.debug("back button for %s not implemented yet", self.menu) 300 | return 301 | if self.path_stack: 302 | self.path_stack.pop() 303 | self.updatePath() 304 | 305 | def tableItemClicked(self, index): 306 | data = self.model.itemFromIndex(index).data() 307 | logging.debug("clicked data %s", data) 308 | if data is None: 309 | return 310 | if data["type"] == "media": 311 | self.updatePath(data["name"].upper()) 312 | self.rootMenu(data["name"]) 313 | elif data["type"] == "root": 314 | if data["name"] == "TRACK": 315 | self.updatePath("Tracks") 316 | self.titleMenu() 317 | elif data["name"] == "ARTIST": 318 | self.updatePath("Artists") 319 | self.artistMenu() 320 | elif data["name"] == "ALBUM": 321 | self.updatePath("Albums") 322 | self.albumMenu() 323 | elif data["name"] == "GENRE": 324 | self.updatePath("Genres") 325 | self.genreMenu() 326 | elif data["name"] == "PLAYLIST": 327 | self.updatePath("Playlists") 328 | self.folderPlaylistMenu() 329 | else: 330 | logging.warning("root menu type %s not implemented yet", data["name"]) 331 | elif data["type"] == "album": 332 | self.updatePath(data["album"]) 333 | self.titleAlbumMenu(data["album_id"]) 334 | elif data["type"] == "artist": 335 | self.updatePath(data["artist"]) 336 | self.albumArtistMenu(data["artist_id"]) 337 | elif data["type"] == "album_by_artist": 338 | self.updatePath(data["album"]) 339 | self.titleAlbumArtistMenu(data["album_id"]) 340 | elif data["type"] == "genre": 341 | self.updatePath(data["genre"]) 342 | self.artistGenreMenu(data["genre_id"]) 343 | elif data["type"] == "artist_by_genre": 344 | self.updatePath(data["artist"]) 345 | self.albumArtistGenreMenu(data["artist_id"]) 346 | elif data["type"] == "album_by_genre_artist": 347 | self.updatePath(data["album"]) 348 | self.titleAlbumArtistGenreMenu(data["album_id"]) 349 | elif data["type"] == "folder": 350 | self.updatePath(data["folder"]) 351 | self.folderPlaylistMenu(data["folder_id"]) 352 | elif data["type"] == "playlist_folder": 353 | if "playlist_id" in data: # playlist clicked 354 | self.updatePath(data["playlist"]) 355 | self.titlePlaylistMenu(data["playlist_id"]) 356 | else: # playlist folder clicked 357 | self.updatePath(data["folder"]) 358 | self.folderPlaylistMenu(data["folder_id"]) 359 | elif data["type"] in ["title", "title_by_album", "title_by_artist_album", "title_by_genre_artist_album", "playlist"]: 360 | self.metadata(data["track_id"]) 361 | else: 362 | logging.warning("unhandled click type %s", data["type"]) 363 | self.updateButtons() # update buttons for convenience 364 | 365 | def sortChanged(self): 366 | self.sort = self.sort_box.currentData() 367 | logging.debug("sort changed to %s", self.sort) 368 | if self.menu == "title": 369 | self.titleMenu() 370 | elif self.menu == "title_by_album": 371 | self.titleAlbumMenu(self.album_id) 372 | elif self.menu == "title_by_artist_album": 373 | self.titleAlbumArtistMenu(self.album_id) 374 | elif self.menu == "title_by_genre_artist_album": 375 | self.titleAlbumArtistGenreMenu(self.album_id) 376 | elif self.menu == "playlist": 377 | self.titlePlaylistMenu(self.playlist_id) 378 | else: 379 | logging.debug("unsortable menu type %s", self.menu) 380 | 381 | def loadIntoPlayer(self, player_number): 382 | if self.slot is None or self.track_id is None: 383 | return 384 | logging.debug("loading track (pn %d slot %s tid %d) into player %d", 385 | self.player_number, self.slot, self.track_id, player_number) 386 | self.prodj.vcdj.command_load_track(player_number, self.player_number, self.slot, self.track_id) 387 | 388 | def downloadTrack(self): 389 | if all([self.player_number, self.slot, self.track_id]): 390 | self.prodj.data.get_mount_info(self.player_number, self.slot, self.track_id, 391 | self.prodj.nfs.enqueue_download_from_mount_info) 392 | 393 | def updateButtons(self): 394 | for i in range(1,5): 395 | self.load_buttons[i-1].setEnabled(self.prodj.cl.getClient(i) is not None) 396 | 397 | # special request handling to get into qt gui thread 398 | # storeRequest is called from outside (non-qt gui) 399 | def storeRequest(self, request, *args): 400 | if self.request is not None: 401 | logging.debug("not storing request %s, other request pending", request) 402 | #logging.debug("storing request %s", request) 403 | self.request = (request, *args) 404 | self.handleRequestSignal.emit() 405 | 406 | # handleRequest is called by handleRequestSignal, from inside the gui thread 407 | def handleRequest(self): 408 | #logging.debug("handle request %s", str(self.request)) 409 | if self.request is None or self.request[-1] is None: 410 | return 411 | if self.request[0] == "root_menu": 412 | self.renderRootMenu(*self.request) 413 | elif self.request[0] in ["title", "artist", "album_by_artist", "title_by_artist_album", "album", "title_by_album", "genre", "artist_by_genre", "album_by_genre_artist", "title_by_genre_artist_album", "playlist_folder", "playlist"]: 414 | self.renderList(*self.request[:3], self.request[-1]) 415 | elif self.request[0] == "metadata": 416 | self.renderMetadata(*self.request) 417 | else: 418 | logging.warning("%s request not implemented", self.request[0]) 419 | self.request = None 420 | 421 | def refreshMedia(self, slot): 422 | if self.slot == slot or self.menu == "media": 423 | logging.info("slot %s changed, going back to media overview", slot) 424 | self.mediaMenu() 425 | else: 426 | logging.debug("ignoring %s change", slot) 427 | -------------------------------------------------------------------------------- /prodj/gui/preview_waveform_qt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | from threading import Lock 5 | from PyQt5.QtWidgets import QApplication, QHBoxLayout 6 | from PyQt5.QtWidgets import QWidget 7 | from PyQt5.QtGui import QColor, QPainter, QPixmap 8 | from PyQt5.QtCore import pyqtSignal, Qt, QSize 9 | 10 | from prodj.pdblib.usbanlzdatabase import UsbAnlzDatabase 11 | from .waveform_blue_map import blue_map 12 | 13 | class PreviewWaveformWidget(QWidget): 14 | redraw_signal = pyqtSignal() 15 | 16 | def __init__(self, parent): 17 | super().__init__(parent) 18 | self.pixmap_width = 400 19 | self.pixmap_height = 34 20 | self.top_offset = 8 21 | self.total_height = self.pixmap_height + self.top_offset 22 | self.setMinimumSize(self.pixmap_width, self.total_height) 23 | self.data = None 24 | self.pixmap = None 25 | self.pixmap_lock = Lock() 26 | self.position = 0 # relative, between 0 and 1 27 | self.loop = None # tuple(start_rel, end_rel) -> in [0, 1] 28 | self.redraw_signal.connect(self.update) 29 | self.colored_render_blue_only = False 30 | 31 | def clear(self): 32 | self.setData(None) 33 | 34 | def setData(self, data, colored=False): 35 | with self.pixmap_lock: 36 | self.data = data 37 | if colored: 38 | self.pixmap = self.drawColoredPreviewWaveformPixmap() 39 | else: 40 | self.pixmap = self.drawPreviewWaveformPixmap() 41 | self.redraw_signal.emit() 42 | 43 | def setPosition(self, relative): 44 | if relative != self.position: 45 | self.position = relative 46 | self.redraw_signal.emit() 47 | 48 | def setLoop(self, loop: tuple[float, float]): 49 | if self.loop != loop: 50 | self.loop = loop 51 | self.redraw_signal.emit() 52 | 53 | def sizeHint(self): 54 | return QSize(self.pixmap_width, self.total_height) 55 | 56 | def heightForWidth(self, width): 57 | return width * self.total_height // self.pixmap_width 58 | 59 | def paintEvent(self, e): 60 | painter = QPainter() 61 | painter.begin(self) 62 | with self.pixmap_lock: 63 | if self.pixmap is not None: 64 | scaled_pixmap = self.pixmap.scaled(self.size(), Qt.KeepAspectRatio) 65 | painter.drawPixmap(0, self.top_offset, scaled_pixmap) 66 | # draw 1px border around preview 67 | painter.setPen(QColor(255, 255, 255)) 68 | painter.setBrush(Qt.NoBrush) 69 | painter.drawRect(0, 0, scaled_pixmap.width(), self.height()) # we use the full height to have a little padding 70 | # draw loop overlay on top of waveform 71 | if self.loop: 72 | start_px = int(self.loop[0] * scaled_pixmap.width()) 73 | end_px = int(self.loop[1] * scaled_pixmap.width()) 74 | painter.fillRect(start_px, self.top_offset, end_px - start_px, scaled_pixmap.height(), QColor(255, 255, 0, 70)) 75 | # draw position marker 76 | height = scaled_pixmap.height() + self.top_offset 77 | marker_position = int(self.position * scaled_pixmap.width()) 78 | painter.fillRect(marker_position-1, 3, 3, height, Qt.black) 79 | painter.fillRect(marker_position-3, 3, 7, 7, Qt.black) 80 | painter.fillRect(marker_position-2, 4, 5, 5, Qt.white) 81 | painter.fillRect(marker_position, 4, 1, height, Qt.white) 82 | painter.end() 83 | 84 | def drawPreviewWaveformPixmap(self): 85 | if self.data is None: 86 | return None 87 | pixmap = QPixmap(self.pixmap_width, self.pixmap_height) 88 | pixmap.fill(Qt.black) 89 | painter = QPainter() 90 | painter.begin(pixmap) 91 | painter.setBrush(Qt.SolidPattern) 92 | if self.data and len(self.data) >= self.pixmap_width * 2: 93 | for x in range(self.pixmap_width): 94 | height = self.data[2*x] - 2 # only seen from 2..23 95 | # self.data[2*x+1] only seen from 1..6 96 | height = height if height > 0 else 0 97 | color = blue_map[2] if self.data[2*x+1] > 3 else blue_map[6] 98 | painter.setPen(QColor(*color)) 99 | painter.drawLine(x, 31, x, 31-height) 100 | painter.setPen(Qt.white) 101 | # base line 102 | painter.drawLine(0, 33, self.pixmap_width-1, 33) 103 | painter.end() 104 | return pixmap 105 | 106 | def drawColoredPreviewWaveformPixmap(self): 107 | if self.data is None: 108 | return None 109 | pixmap = QPixmap(self.pixmap_width, self.pixmap_height) 110 | pixmap.fill(Qt.black) 111 | painter = QPainter() 112 | painter.begin(pixmap) 113 | painter.setBrush(Qt.SolidPattern) 114 | 115 | w = 1200 116 | xr = self.pixmap_width / w 117 | if self.data and len(self.data) >= w: 118 | data = self.data 119 | 120 | # Get max_height to adjust waveform height 121 | max_height = 0 122 | for x in range(w): 123 | d3 = data[x * 6 + 3] 124 | d4 = data[x * 6 + 4] 125 | d5 = data[x * 6 + 5] 126 | max_height = max(max_height, d3, d4, d5) 127 | 128 | max_back_height = 0 129 | max_front_height = 0 130 | 131 | hr = 127 / max_height 132 | for x in range(w): 133 | # d0 & d1: max of d1 and d2 sets the steepness of the ramp of the blueness 134 | d0 = data[x * 6 + 0] 135 | d1 = data[x * 6 + 1] 136 | # d2: ""\__ blueness 137 | d2 = data[x * 6 + 2] 138 | # d3: "\___ red 139 | d3 = data[x * 6 + 3] 140 | # d4: _/"\_ green 141 | d4 = data[x * 6 + 4] 142 | # d5: ___/" blue and height of front waveform 143 | d5 = data[x * 6 + 5] 144 | 145 | # background waveform height is max height of d3, d4 (and d5 as it is foreground) 146 | back_height = max(d3, d4, d5) 147 | # front waveform height is d5 148 | front_height = d5 149 | 150 | if not self.colored_render_blue_only: # color 151 | if back_height > 0: 152 | red = d3 / back_height * 255 153 | green = d4 / back_height * 255 154 | blue = d5 / back_height * 255 155 | else: 156 | red = green = blue = 0 157 | else: # NXS2 blue 158 | # NOTE: the whole steepness and zero cutoff just don't seems to make any sense, however it looks as on CDJ 159 | # Maybe this is related to the bytes wrongly(?) interpreted as signed bytes instead of unsigned. 160 | steepness = max(d0, d1) 161 | blueness = d2 162 | color = 0 163 | if steepness > 0 and blueness > 0: 164 | color = min(int((blueness * (127 / steepness)) / 16), 7) 165 | red, green, blue = blue_map[color] 166 | back_height = int(back_height * hr) 167 | front_height = int(front_height * hr) 168 | max_back_height = max(back_height, max_back_height) 169 | max_front_height = max(front_height, max_back_height) 170 | xd = int(x * xr) 171 | if int((x + 1) * xr) > xd: 172 | painter.setPen(QColor(int(red * .75), int(green * .75), int(blue * .75))) 173 | painter.drawLine(xd, 31, xd, 31 - int(max_back_height / 4)) 174 | painter.setPen(QColor(int(red), int(green), int(blue))) 175 | painter.drawLine(xd, 31, xd, 31 - int(max_front_height / 4)) 176 | max_back_height = max_front_height = 0 177 | painter.setPen(Qt.white) 178 | painter.drawLine(0, 33, self.pixmap_width-1, 33) 179 | painter.end() 180 | return pixmap 181 | 182 | class Window(QWidget): 183 | def __init__(self): 184 | super(Window, self).__init__() 185 | 186 | self.setWindowTitle("Preview Waveform Test") 187 | self.previewWidget = PreviewWaveformWidget(self) 188 | 189 | mainLayout = QHBoxLayout() 190 | mainLayout.addWidget(self.previewWidget) 191 | self.setLayout(mainLayout) 192 | 193 | if __name__ == '__main__': 194 | app = QApplication([]) 195 | window = Window() 196 | base_path = "." 197 | dat = None 198 | ext = None 199 | 200 | if len(sys.argv) > 1: 201 | base_path = sys.argv[1] 202 | colored = len(sys.argv) > 2 and sys.argv[2] == "color" 203 | try: 204 | with open(base_path+"/ANLZ0000.DAT", "rb") as f: 205 | dat = f.read() 206 | except FileNotFoundError as e: 207 | print("No DAT file loaded") 208 | try: 209 | with open(base_path+"/ANLZ0000.EXT", "rb") as f: 210 | ext = f.read() 211 | except FileNotFoundError as e: 212 | print("No EXT file loaded") 213 | if dat is None and ext is None: 214 | print("Error: No ANLZ files loaded") 215 | sys.exit(1) 216 | db = UsbAnlzDatabase() 217 | if dat is not None: 218 | db.load_dat_buffer(dat) 219 | if ext is not None: 220 | db.load_ext_buffer(ext) 221 | if colored: 222 | window.previewWidget.setData(db.get_color_preview_waveform(), True) 223 | else: 224 | waveform_spread = b"" 225 | for line in db.get_preview_waveform(): 226 | waveform_spread += bytes([line & 0x1f, line>>5]) 227 | window.previewWidget.setData(waveform_spread) 228 | window.previewWidget.setPosition(0.2) 229 | 230 | window.show() 231 | app.exec_() 232 | -------------------------------------------------------------------------------- /prodj/gui/waveform_blue_map.py: -------------------------------------------------------------------------------- 1 | # levels of "blue" for monochrome waveform 2 | blue_map = [ 3 | (200, 224, 232), 4 | (136, 192, 232), 5 | (136, 192, 232), 6 | (120, 184, 216), 7 | (0, 184, 216), 8 | (0, 168, 232), 9 | (0, 136, 176), 10 | (0, 104, 144) 11 | ] 12 | -------------------------------------------------------------------------------- /prodj/gui/waveform_gl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | import logging 5 | from threading import Lock 6 | from PyQt5.QtCore import pyqtSignal, QSize, Qt 7 | from PyQt5.QtWidgets import QApplication, QHBoxLayout, QOpenGLWidget, QSlider, QWidget 8 | from PyQt5.QtGui import QSurfaceFormat 9 | import OpenGL.GL as gl 10 | 11 | from prodj.network.packets import PlayStatePlaying, PlayStateStopped 12 | from prodj.pdblib.usbanlzdatabase import UsbAnlzDatabase 13 | from .waveform_blue_map import blue_map 14 | 15 | class GLWaveformWidget(QOpenGLWidget): 16 | waveform_zoom_changed_signal = pyqtSignal(int) 17 | 18 | def __init__(self, parent=None): 19 | super().__init__(parent) 20 | 21 | # multisampling 22 | fmt = QSurfaceFormat(self.format()) 23 | fmt.setSamples(4) 24 | fmt.setSwapBehavior(QSurfaceFormat.DoubleBuffer) 25 | self.setFormat(fmt) 26 | 27 | self.lists = None 28 | self.clearLists = False 29 | self.waveform_data = None # if not none, it will be rendered and deleted (to None) 30 | self.beatgrid_data = None # if not none, it will be rendered and deleted (to None) 31 | self.waveform_colored = False 32 | self.data_lock = Lock() 33 | self.time_offset = 0 34 | self.zoom_seconds = 4 35 | self.loop = None # tuple(start_sec, end_sec) 36 | self.pitch = 1 # affects animation speed 37 | 38 | self.viewport = (50, 40) # viewport +- x, y 39 | self.waveform_lines_per_x = 150 40 | self.baseline_height = 0.2 41 | self.position_marker_width = 0.3 42 | 43 | self.waveform_zoom_changed_signal.connect(self.setZoom) 44 | 45 | self.autoUpdate = False 46 | self.update_interval_ms = 25 47 | self.timerId = self.startTimer(self.update_interval_ms) 48 | 49 | def changeAutoUpdate(self, autoUpdate): 50 | if autoUpdate != self.autoUpdate: 51 | self.autoUpdate = autoUpdate 52 | if not autoUpdate: 53 | self.timerId = self.startTimer(self.update_interval_ms) 54 | else: 55 | self.killTimer(self.timerId) 56 | 57 | def minimumSizeHint(self): 58 | return QSize(400, 75) 59 | 60 | def sizeHint(self): 61 | return QSize(500, 100) 62 | 63 | def clear(self): 64 | with self.data_lock: 65 | self.waveform_data = None 66 | self.beatgrid_data = None 67 | if self.lists is not None: 68 | self.clearLists = True 69 | self.update() 70 | 71 | def setData(self, waveform_data, colored=False): 72 | with self.data_lock: 73 | self.waveform_data = waveform_data 74 | self.waveform_colored = colored 75 | self.update() 76 | 77 | def setBeatgridData(self, beatgrid_data): 78 | with self.data_lock: 79 | self.beatgrid_data = beatgrid_data 80 | self.update() 81 | 82 | def setLoop(self, loop: tuple[float, float]): 83 | if self.loop != loop: 84 | self.loop = loop 85 | self.update() 86 | 87 | # current time in seconds at position marker 88 | def setPosition(self, position, pitch=1, state="playing"): 89 | logging.debug("setPosition {} pitch {} state {}".format(position, pitch, state)) 90 | if position is not None and pitch is not None: 91 | if state in PlayStateStopped: 92 | pitch = 0 93 | self.pitch = pitch 94 | if round(self.time_offset * 1000) != round(position * 1000): 95 | if self.autoUpdate: 96 | self.time_offset = position 97 | self.update() 98 | else: 99 | #logging.debug("time offset diff %.6f", position-self.time_offset) 100 | offset = abs(position - self.time_offset) 101 | if state in PlayStatePlaying and offset < 0.05: # ignore negligible offset 102 | return 103 | 104 | if state in PlayStatePlaying and offset < 0.1: # small enough to compensate by temporary pitch modification 105 | if position > self.time_offset: 106 | #logging.debug("increasing pitch to catch up") 107 | self.pitch += 0.01 108 | else: 109 | #logging.debug("decreasing pitch to fall behind") 110 | self.pitch -= 0.01 111 | else: # too large to compensate or non-monotonous -> direct assignment 112 | #logging.debug("offset %.6f, direct assignment", offset) 113 | self.time_offset = position 114 | self.update() 115 | else: 116 | self.offset = 0 117 | self.pitch = 0 118 | 119 | def wheelEvent(self, event): 120 | if event.angleDelta().y() > 0 and self.zoom_seconds > 2: 121 | self.waveform_zoom_changed_signal.emit(self.zoom_seconds-1) 122 | elif event.angleDelta().y() < 0 and self.zoom_seconds < 15: 123 | self.waveform_zoom_changed_signal.emit(self.zoom_seconds+1) 124 | 125 | # how many seconds to show left and right of the position marker 126 | def setZoom(self, seconds): 127 | if seconds != self.zoom_seconds: 128 | self.zoom_seconds = seconds 129 | self.update() 130 | 131 | def timerEvent(self, event): 132 | if self.pitch != 0: 133 | self.time_offset += self.pitch*self.update_interval_ms / 1000 134 | self.update() 135 | 136 | def initializeGL(self): 137 | logging.info("Renderer \"{}\" OpenGL \"{}\"".format( 138 | gl.glGetString(gl.GL_RENDERER).decode("ascii"), 139 | gl.glGetString(gl.GL_VERSION).decode("ascii"))) 140 | gl.glClearColor(0,0,0,255) 141 | gl.glShadeModel(gl.GL_FLAT) 142 | gl.glEnable(gl.GL_DEPTH_TEST) 143 | gl.glEnable(gl.GL_CULL_FACE) 144 | self.lists = gl.glGenLists(3) 145 | gl.glLineWidth(1.0) 146 | self.renderCrosshair() 147 | 148 | def updateViewport(self): 149 | gl.glMatrixMode(gl.GL_PROJECTION) 150 | gl.glLoadIdentity() 151 | gl.glOrtho(-self.viewport[0], self.viewport[0], -self.viewport[1], self.viewport[1], -2, 2) 152 | gl.glMatrixMode(gl.GL_MODELVIEW) 153 | 154 | def paintGL(self): 155 | self.updateViewport() 156 | gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) 157 | gl.glLoadIdentity() 158 | gl.glCallList(self.lists) 159 | 160 | gl.glScalef(self.viewport[0]/self.zoom_seconds, 1, 1) 161 | gl.glTranslatef(-self.time_offset, 0, 0) 162 | if self.clearLists: 163 | gl.glNewList(self.lists+1, gl.GL_COMPILE) 164 | gl.glEndList() 165 | gl.glNewList(self.lists+2, gl.GL_COMPILE) 166 | gl.glEndList() 167 | self.clearLists = False 168 | 169 | # draw waveform and beatgrid 170 | self.renderWaveform() 171 | self.renderBeatgrid() 172 | gl.glCallList(self.lists+1) 173 | gl.glCallList(self.lists+2) 174 | 175 | # draw loop overlay if set 176 | self.renderLoop() 177 | 178 | def resizeGL(self, width, height): 179 | gl.glViewport(0, 0, width, height) 180 | 181 | def renderCrosshair(self): 182 | gl.glNewList(self.lists, gl.GL_COMPILE) 183 | gl.glBegin(gl.GL_QUADS) 184 | # white baseline 185 | gl.glColor3f(1, 1, 1) 186 | gl.glVertex3f(-1*self.viewport[0], -1, -1) 187 | gl.glVertex3f(self.viewport[0], -1, -1) 188 | gl.glVertex3f(self.viewport[0], 1, -1) 189 | gl.glVertex3f(-1*self.viewport[0], 1, -1) 190 | gl.glEnd() 191 | 192 | gl.glBegin(gl.GL_QUADS) 193 | # red position marker 194 | gl.glColor3f(1, 0, 0) 195 | gl.glVertex3f(0, -self.viewport[1], 1) 196 | gl.glVertex3f(self.position_marker_width, -self.viewport[1], 1) 197 | gl.glVertex3f(self.position_marker_width, self.viewport[1], 1) 198 | gl.glVertex3f(0, self.viewport[1], 1) 199 | gl.glEnd() 200 | gl.glEndList() 201 | 202 | def renderWaveform(self): 203 | with self.data_lock: 204 | if self.waveform_data is None: 205 | return 206 | 207 | gl.glNewList(self.lists+1, gl.GL_COMPILE) 208 | gl.glEnable(gl.GL_MULTISAMPLE) 209 | 210 | if self.waveform_colored: 211 | self.renderColoredQuads() 212 | else: 213 | self.renderMonochromeQuads() 214 | 215 | gl.glEndList() 216 | self.waveform_data = None # delete data after rendering 217 | 218 | 219 | def renderMonochromeQuads(self): 220 | for x,v in enumerate(self.waveform_data): 221 | height = v & 0x1f 222 | whiteness = v >> 5 223 | 224 | gl.glBegin(gl.GL_QUADS) 225 | gl.glColor3ub(*blue_map[7-whiteness]) 226 | gl.glVertex3f(x/self.waveform_lines_per_x, -height-1, 0) 227 | gl.glVertex3f((x+1)/self.waveform_lines_per_x, -height-1, 0) 228 | gl.glVertex3f((x+1)/self.waveform_lines_per_x, height+1, 0) 229 | gl.glVertex3f(x/self.waveform_lines_per_x, height+1, 0) 230 | gl.glEnd() 231 | 232 | def renderColoredQuads(self): 233 | for x,v in enumerate(self.waveform_data): 234 | height = ((v >> 2) & 0x1F) 235 | blue = ((v >> 7) & 0x07) / 7 236 | green = ((v >> 10) & 0x07) / 7 237 | red = ((v >> 13) & 0x07) / 7 238 | 239 | gl.glBegin(gl.GL_QUADS) 240 | gl.glColor3f(red, green, blue) 241 | gl.glVertex3f(x/self.waveform_lines_per_x, -height-1, 0) 242 | gl.glVertex3f((x+1)/self.waveform_lines_per_x, -height-1, 0) 243 | gl.glVertex3f((x+1)/self.waveform_lines_per_x, height+1, 0) 244 | gl.glVertex3f(x/self.waveform_lines_per_x, height+1, 0) 245 | gl.glEnd() 246 | 247 | def renderBeatgrid(self): 248 | with self.data_lock: 249 | if self.beatgrid_data is None: 250 | return 251 | 252 | gl.glNewList(self.lists+2, gl.GL_COMPILE) 253 | gl.glDisable(gl.GL_MULTISAMPLE) 254 | gl.glBegin(gl.GL_LINES) 255 | 256 | for beat in self.beatgrid_data: 257 | if beat.beat == 1: 258 | gl.glColor3f(1, 0, 0) 259 | height = 8 260 | else: 261 | gl.glColor3f(1, 1, 1) 262 | height = 5 263 | x = beat.time/1000 264 | 265 | gl.glVertex3f(x, self.viewport[1]-height, 0) 266 | gl.glVertex3f(x, self.viewport[1], 0) 267 | gl.glVertex3f(x, -1*self.viewport[1], 0) 268 | gl.glVertex3f(x, -1*self.viewport[1]+height, 0) 269 | 270 | gl.glEnd() 271 | gl.glEndList() 272 | self.beatgrid_data = None # delete data after rendering 273 | def renderLoop(self): 274 | if not self.loop: 275 | return 276 | start, end = self.loop 277 | gl.glPushAttrib(gl.GL_ALL_ATTRIB_BITS) 278 | gl.glEnable(gl.GL_BLEND) 279 | gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) 280 | gl.glColor4f(1, 1, 0, 0.3) 281 | gl.glBegin(gl.GL_QUADS) 282 | gl.glVertex3f(start, -self.viewport[1], 0) 283 | gl.glVertex3f(end, -self.viewport[1], 0) 284 | gl.glVertex3f(end, self.viewport[1], 0) 285 | gl.glVertex3f(start, self.viewport[1], 0) 286 | gl.glEnd() 287 | gl.glPopAttrib() 288 | 289 | class Window(QWidget): 290 | def __init__(self): 291 | super(Window, self).__init__() 292 | 293 | self.setWindowTitle("GL Waveform Test") 294 | self.glWidget = GLWaveformWidget() 295 | 296 | self.timeSlider = QSlider(Qt.Vertical) 297 | self.timeSlider.setRange(0, 300) 298 | self.timeSlider.setSingleStep(1) 299 | self.timeSlider.setTickInterval(10) 300 | self.timeSlider.setTickPosition(QSlider.TicksRight) 301 | self.zoomSlider = QSlider(Qt.Vertical) 302 | self.zoomSlider.setRange(2, 10) 303 | self.zoomSlider.setSingleStep(1) 304 | self.zoomSlider.setTickInterval(1) 305 | self.zoomSlider.setTickPosition(QSlider.TicksRight) 306 | 307 | self.timeSlider.valueChanged.connect(self.glWidget.setPosition) 308 | self.zoomSlider.valueChanged.connect(self.glWidget.setZoom) 309 | 310 | mainLayout = QHBoxLayout() 311 | mainLayout.addWidget(self.glWidget) 312 | mainLayout.addWidget(self.timeSlider) 313 | mainLayout.addWidget(self.zoomSlider) 314 | self.setLayout(mainLayout) 315 | 316 | self.timeSlider.setValue(0) 317 | self.zoomSlider.setValue(4) 318 | 319 | if __name__ == '__main__': 320 | app = QApplication([]) 321 | window = Window() 322 | 323 | base_path = sys.argv[1] 324 | colored = len(sys.argv) > 2 and sys.argv[2] == "color" 325 | with open(base_path+"/ANLZ0000.DAT", "rb") as f: 326 | dat = f.read() 327 | with open(base_path+"/ANLZ0000.EXT", "rb") as f: 328 | ext = f.read() 329 | db = UsbAnlzDatabase() 330 | if dat is not None and ext is not None: 331 | db.load_dat_buffer(dat) 332 | db.load_ext_buffer(ext) 333 | if colored: 334 | window.glWidget.setData(db.get_color_waveform(), True) 335 | else: 336 | window.glWidget.setData(db.get_waveform(), False) 337 | window.glWidget.setBeatgridData(db.get_beatgrid()) 338 | 339 | window.show() 340 | app.exec_() 341 | -------------------------------------------------------------------------------- /prodj/gui/waveform_qt.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from PyQt5.QtWidgets import QWidget 4 | from PyQt5.QtGui import QColor, QPainter, QPixmap 5 | from PyQt5.QtCore import Qt 6 | 7 | class WaveformWidget(QWidget): 8 | def __init__(self, parent): 9 | super().__init__(parent) 10 | self.waveform_height = 75 11 | self.waveform_center = self.waveform_height//2 12 | self.waveform_px_per_s = 150 13 | self.setMinimumSize(3*self.waveform_px_per_s, self.waveform_height) 14 | self.waveform_data = None 15 | self.beatgrid_data = None 16 | self.pixmap = None 17 | self.offset = 0 # frames = pixels of waveform 18 | self.pitch = 0 19 | self.position_marker = 0.5 20 | self.setFrameCount(self.waveform_px_per_s*10) 21 | #self.setPositionMarkerOffset(0.5) 22 | self.update_interval_ms = 40 23 | self.startTimer(self.update_interval_ms) 24 | 25 | def setData(self, data): 26 | self.pixmap = None 27 | self.waveform_data = data[20:] 28 | self.renderWaveformPixmap() 29 | self.update() 30 | 31 | def setBeatgridData(self, beatgrid_data): 32 | self.beatgrid_data = beatgrid_data 33 | if self.waveform_data: 34 | self.renderWaveformPixmap() 35 | self.update() 36 | 37 | def setFrameCount(self, frames): # frames-to-show -> 150*10 = 10 seconds 38 | self.frames = frames 39 | self.setPositionMarkerOffset(self.position_marker) 40 | 41 | def setPositionMarkerOffset(self, relative): # relative location of position marker 42 | self.position_marker = relative 43 | self.position_marker_offset = int(relative*self.frames) 44 | 45 | # FIXME state dependant pitch 46 | def setPosition(self, position, pitch=1, state="playing"): 47 | # logging.debug("setPosition {} pitch {}".format(position, pitch)) 48 | if position is not None and pitch is not None: 49 | self.offset = int(self.waveform_px_per_s*position) 50 | self.pitch = pitch 51 | else: 52 | self.offset = 0 53 | self.pitch = 0 54 | 55 | def paintEvent(self, e): 56 | #logging.info("paintEvent {}".format(e.rect())) 57 | painter = QPainter() 58 | painter.begin(self) 59 | if self.pixmap: 60 | pixmap = self.pixmap.copy(self.offset, 0, self.frames, self.waveform_height) 61 | self.drawPositionMarker(pixmap) 62 | scaled_pixmap = pixmap.scaled(self.size(), Qt.IgnoreAspectRatio, Qt.SmoothTransformation) 63 | painter.drawPixmap(0, 0, scaled_pixmap) 64 | painter.end() 65 | 66 | # draw position marker into unscaled pixmap 67 | def drawPositionMarker(self, pixmap): 68 | pixmap_painter = QPainter() 69 | pixmap_painter.begin(pixmap) 70 | pixmap_painter.fillRect(self.position_marker_offset, 0, 4, self.waveform_height, Qt.red) 71 | pixmap_painter.end() 72 | 73 | # draw position marker into scaled pixmap 74 | def drawPositionMarkerScaled(self, painter): 75 | painter.fillRect(self.position_marker*self.size().width(), 0, 4, self.size().height(), Qt.red) 76 | 77 | def renderWaveformPixmap(self): 78 | logging.info("rendering waveform") 79 | self.pixmap = QPixmap(self.position_marker_offset+len(self.waveform_data), self.waveform_height) 80 | # background 81 | self.pixmap.fill(Qt.black) 82 | painter = QPainter() 83 | painter.begin(self.pixmap) 84 | painter.setBrush(Qt.SolidPattern) 85 | # vertical orientation line 86 | painter.setPen(Qt.white) 87 | painter.drawLine(0, self.waveform_center, self.pixmap.width(), self.waveform_center) 88 | # waveform data 89 | if self.waveform_data: 90 | for data_x in range(0, len(self.waveform_data)): 91 | draw_x = data_x + self.position_marker_offset 92 | height = self.waveform_data[data_x] & 0x1f 93 | whiteness = self.waveform_data[data_x] >> 5 94 | painter.setPen(QColor(36*whiteness, 36*whiteness, 255)) 95 | painter.drawLine(draw_x, self.waveform_center-height, draw_x, self.waveform_center+height) 96 | if self.beatgrid_data: 97 | for beat in self.beatgrid_data["beats"]: 98 | if beat["beat"] == 1: 99 | brush = Qt.red 100 | length = 8 101 | else: 102 | brush = Qt.white 103 | length = 5 104 | draw_x = beat["time"]*self.waveform_px_per_s//1000 + self.position_marker_offset 105 | painter.fillRect(draw_x-1, 0, 4, length, brush) 106 | painter.fillRect(draw_x-1, self.waveform_height-length, 4, length, brush) 107 | painter.end() 108 | logging.info("rendering waveform done") 109 | 110 | def timerEvent(self, event): 111 | if self.pitch > 0: 112 | self.offset += int(self.waveform_px_per_s*self.pitch*self.update_interval_ms/1000) 113 | self.update() 114 | -------------------------------------------------------------------------------- /prodj/midi/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/midi/__init__.py -------------------------------------------------------------------------------- /prodj/midi/midiclock_alsaseq.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from threading import Thread 4 | import time 5 | import math 6 | import alsaseq 7 | import logging 8 | import re 9 | 10 | class MidiClock(Thread): 11 | def __init__(self): 12 | super().__init__() 13 | self.keep_running = True 14 | self.client_id = None 15 | self.client_port = None 16 | self.time_s = 0 17 | self.time_ns = 0 18 | self.add_s = 0 19 | self.add_ns = 0 20 | self.enqueue_at_once = 24 21 | 22 | # this call causes /proc/asound/seq/clients to be created 23 | alsaseq.client('MidiClock', 0, 1, True) 24 | 25 | # this may only be called after creating this object 26 | def iter_alsa_seq_clients(self): 27 | client_re = re.compile('Client[ ]+(\d+) : "(.*)"') 28 | port_re = re.compile(' Port[ ]+(\d+) : "(.*)"') 29 | try: 30 | with open("/proc/asound/seq/clients", "r") as f: 31 | id = None 32 | name = "" 33 | ports = [] 34 | for line in f: 35 | match = client_re.match(line) 36 | if match: 37 | if id: 38 | yield (id, name, ports) 39 | id = int(match.groups()[0]) 40 | name = match.groups()[1] 41 | ports = [] 42 | else: 43 | match = port_re.match(line) 44 | if match: 45 | ports += [int(match.groups()[0])] 46 | if id: 47 | yield (id, name, ports) 48 | except FileNotFoundError: 49 | pass 50 | 51 | def open(self, preferred_name=None, preferred_port=0): 52 | clients_found = False 53 | for id, name, ports in self.iter_alsa_seq_clients(): 54 | clients_found = True 55 | logging.debug("midi device %d: %s [%s]", id, name, ','.join([str(x) for x in ports])) 56 | if (preferred_name is None and name != "Midi Through") or name == preferred_name: 57 | self.client_id = id 58 | if preferred_port not in ports: 59 | preferred_port = ports[0] 60 | logging.warning("Preferred port not found, using %d", preferred_port) 61 | self.client_port = preferred_port 62 | break 63 | if self.client_id is None: 64 | if clients_found: 65 | raise RuntimeError(f"Requested device {preferred_name} not found") 66 | else: 67 | raise RuntimeError("No sequencers found") 68 | logging.info("Using device %s at %d:%d", name, self.client_id, self.client_port) 69 | alsaseq.connectto(0, self.client_id, self.client_port) 70 | 71 | def advance_time(self): 72 | self.time_ns += self.add_ns 73 | if self.time_ns > 1000000000: 74 | self.time_s += 1 75 | self.time_ns -= 1000000000 76 | self.time_s = self.time_s + self.add_s 77 | 78 | def enqueue_events(self): 79 | for i in range(self.enqueue_at_once): 80 | send = (36, 1, 0, 0, (self.time_s, self.time_ns), (128,0), (self.client_id, self.client_port), None) 81 | alsaseq.output(send) 82 | self.advance_time() 83 | 84 | def send_note(self, note): 85 | alsaseq.output((6, 0, 0, 0, (0,0), (128,0), (self.client_id, self.client_port), (0,note,127,0,0))) 86 | 87 | def run(self): 88 | logging.info("Starting MIDI clock queue") 89 | self.enqueue_events() 90 | alsaseq.start() 91 | while self.keep_running: 92 | # not using alsaseq.syncoutput() here, as we would not be fast enough to enqueue more events after 93 | # the queue has flushed, thus sleep for half the approximate time the queue will need to drain 94 | time.sleep(self.enqueue_at_once/2*self.delay) 95 | status, time_t, events = alsaseq.status() 96 | if events >= self.enqueue_at_once: 97 | #logging.info("more than 24*4 events queued, skipping enqueue") 98 | continue 99 | self.enqueue_events() 100 | alsaseq.stop() 101 | logging.info("MIDI clock queue stopped") 102 | 103 | def stop(self): 104 | self.keep_running = False 105 | self.join() 106 | 107 | def setBpm(self, bpm): 108 | if bpm <= 0: 109 | logging.warning("Ignoring zero bpm") 110 | return 111 | self.delay = 60/bpm/24 112 | self.add_s = math.floor(self.delay) 113 | self.add_ns = math.floor(1e9*(self.delay-self.add_s)) 114 | logging.info("Midi BPM %d delay %.9fs", bpm, self.delay) 115 | 116 | if __name__ == "__main__": 117 | logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') 118 | mc = MidiClock() 119 | mc.open("CH345", 0) 120 | mc.setBpm(175) 121 | mc.start() 122 | try: 123 | mc.join() 124 | except KeyboardInterrupt: 125 | mc.stop() 126 | -------------------------------------------------------------------------------- /prodj/midi/midiclock_rtmidi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # NOTE: This module suffers from bad timing! 4 | # Use the alsaseq implementation if possible. 5 | 6 | from threading import Thread 7 | import time 8 | import rtmidi 9 | import logging 10 | 11 | class MidiClock(Thread): 12 | def __init__(self, preferred_port=None): 13 | super().__init__() 14 | self.keep_running = True 15 | self.delay = 1 16 | self.calibration_cycles = 60 17 | self.midiout = rtmidi.MidiOut() 18 | 19 | def open(self, preferred_name=None, preferred_port=0): 20 | available_ports = self.midiout.get_ports() 21 | if available_ports is None: 22 | raise Exception("No available midi ports") 23 | 24 | port_index = 0 25 | logging.debug("Available ports:") 26 | for index, port in enumerate(available_ports): 27 | logging.debug("- {}".format(port)) 28 | port_split = port.split(':') 29 | name = port_split[0] 30 | port = port_split[-1] 31 | if preferred_name is None or (name == preferred_name and port == preferred_port): 32 | port_index = index 33 | logging.info("Using port {}".format(preferred_port)) 34 | self.midiout.open_port(port_index) 35 | 36 | def run(self): 37 | cal = 0 38 | last = time.time() 39 | while self.keep_running: 40 | for n in range(self.calibration_cycles): 41 | self.midiout.send_message([0xF8]) 42 | time.sleep(self.delay-cal) 43 | now = time.time() 44 | cal = 0.3*cal+0.7*((now-last)/self.calibration_cycles-self.delay) 45 | last = now 46 | logging.debug(f'calibration data {cal}') 47 | 48 | def stop(self): 49 | self.keep_running = False 50 | self.join() 51 | 52 | def setBpm(self, bpm): 53 | if bpm <= 0: 54 | logging.warning("Ignoring zero bpm") 55 | return 56 | self.delay = 60/bpm/24 57 | logging.info("BPM {} delay {}s".format(bpm, self.delay)) 58 | 59 | if __name__ == "__main__": 60 | logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') 61 | mc = MidiClock("CH345:CH345 MIDI 1 28:0") 62 | mc.setBpm(175) 63 | mc.start() 64 | try: 65 | mc.join() 66 | except KeyboardInterrupt: 67 | mc.stop() 68 | -------------------------------------------------------------------------------- /prodj/network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/network/__init__.py -------------------------------------------------------------------------------- /prodj/network/ip.py: -------------------------------------------------------------------------------- 1 | import netifaces as ni 2 | from ipaddress import IPv4Address, IPv4Network 3 | import logging 4 | 5 | def guess_own_iface(match_ips): 6 | if len(match_ips) == 0: 7 | return None 8 | 9 | for iface in ni.interfaces(): 10 | ifa = ni.ifaddresses(iface) 11 | 12 | if ni.AF_LINK not in ifa or len(ifa[ni.AF_LINK]) == 0: 13 | logging.debug("{} is has no MAC address, skipped.".format(iface)) 14 | continue 15 | if ni.AF_INET not in ifa or len(ifa[ni.AF_INET]) == 0: 16 | logging.warning("{} has no IPv4 address".format(iface)) 17 | continue 18 | 19 | mac = ifa[ni.AF_LINK][0]['addr'] 20 | for addr in ifa[ni.AF_INET]: 21 | if 'addr' not in addr or 'netmask' not in addr: 22 | continue 23 | net = IPv4Network(addr['addr']+"/"+addr['netmask'], strict=False) 24 | if any([IPv4Address(ip) in net for ip in match_ips]): 25 | return iface, addr['addr'], addr['netmask'], mac 26 | 27 | return None 28 | -------------------------------------------------------------------------------- /prodj/network/nfsclient.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import socket 5 | import time 6 | from construct import Aligned, GreedyBytes 7 | from threading import Thread 8 | 9 | from .packets_nfs import getNfsCallStruct, getNfsResStruct, MountMntArgs, MountMntRes, MountVersion, NfsVersion, PortmapArgs, PortmapPort, PortmapVersion, PortmapRes, RpcMsg 10 | from .rpcreceiver import RpcReceiver 11 | from .nfsdownload import NfsDownload, generic_file_download_done_callback 12 | 13 | class NfsClient: 14 | def __init__(self, prodj): 15 | self.prodj = prodj 16 | self.loop = asyncio.new_event_loop() 17 | self.receiver = RpcReceiver() 18 | 19 | self.rpc_auth_stamp = 0xdeadbeef 20 | self.rpc_sock = None 21 | self.xid = 1 22 | self.download_file_handle = None 23 | self.default_download_directory = "./downloads/" 24 | self.export_by_slot = { 25 | "sd": "/B/", 26 | "usb": "/C/" 27 | } 28 | 29 | self.setDownloadChunkSize(1280) # + 142 bytes total overhead is still safe below 1500 30 | 31 | def start(self): 32 | self.openSockets() 33 | self.loop_thread = Thread(target=self.loop.run_forever) 34 | self.loop_thread.start() 35 | self.receiver.start(self.loop) 36 | 37 | def stop(self): 38 | self.receiver.stop(self.loop) 39 | self.loop.call_soon_threadsafe(self.loop.stop) 40 | self.loop_thread.join() 41 | self.loop.close() 42 | self.closeSockets() 43 | 44 | def openSockets(self): 45 | self.rpc_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 46 | self.rpc_sock.bind(("0.0.0.0", 0)) 47 | self.loop.add_reader(self.rpc_sock, self.receiver.socketRead, self.rpc_sock) 48 | 49 | def closeSockets(self): 50 | self.loop.remove_reader(self.rpc_sock) 51 | if self.rpc_sock is not None: 52 | self.rpc_sock.close() 53 | 54 | def getXid(self): 55 | self.xid += 1 56 | return self.xid 57 | 58 | def setDownloadChunkSize(self, chunk_size): 59 | self.download_chunk_size = chunk_size 60 | self.receiver.recv_size = chunk_size + 160 61 | 62 | async def RpcCall(self, host, prog, vers, proc, data): 63 | # logging.debug("RpcCall ip %s prog \"%s\" proc \"%s\"", host, prog, proc) 64 | rpccall = { 65 | "xid": self.getXid(), 66 | "type": "call", 67 | "content": { 68 | "prog": prog, 69 | "proc": proc, 70 | "vers": vers, 71 | "cred": { 72 | "flavor": "unix", 73 | "content": { 74 | "stamp": self.rpc_auth_stamp 75 | } 76 | }, 77 | "verf": { 78 | "flavor": "null", 79 | "content": None 80 | } 81 | } 82 | } 83 | rpcdata = RpcMsg.build(rpccall) 84 | payload = Aligned(4, GreedyBytes).build(data) 85 | future_reply = asyncio.wrap_future(self.receiver.addCall(rpccall['xid'])) 86 | self.rpc_sock.sendto(rpcdata + payload, host) 87 | return await future_reply 88 | 89 | async def PortmapCall(self, ip, proc, data): 90 | return await self.RpcCall((ip, PortmapPort), "portmap", PortmapVersion, proc, data) 91 | 92 | async def PortmapGetPort(self, ip, prog, vers, prot): 93 | call = { 94 | "prog": prog, 95 | "vers": vers, 96 | "prot": prot 97 | } 98 | data = PortmapArgs.build(call) 99 | reply = await self.PortmapCall(ip, "getport", data) 100 | port = PortmapRes.parse(reply) 101 | if port == 0: 102 | raise RuntimeError("PortmapGetPort failed: Program not available") 103 | return port 104 | 105 | async def MountMnt(self, host, path): 106 | data = MountMntArgs.build(path) 107 | reply = await self.RpcCall(host, "mount", MountVersion, "mnt", data) 108 | result = MountMntRes.parse(reply) 109 | if result.status != 0: 110 | raise RuntimeError("MountMnt failed with error {}".format(result.status)) 111 | return result.fhandle 112 | 113 | async def NfsCall(self, host, proc, data): 114 | nfsdata = getNfsCallStruct(proc).build(data) 115 | reply = await self.RpcCall(host, "nfs", NfsVersion, proc, nfsdata) 116 | nfsreply = getNfsResStruct(proc).parse(reply) 117 | if nfsreply.status != "ok": 118 | raise RuntimeError("NFS call failed: " + nfsreply.status) 119 | return nfsreply.content 120 | 121 | async def NfsLookup(self, host, name, fhandle): 122 | nfscall = { 123 | "fhandle": fhandle, 124 | "name": name 125 | } 126 | return await self.NfsCall(host, "lookup", nfscall) 127 | 128 | async def NfsLookupPath(self, ip, mount_handle, path): 129 | tree = filter(None, path.split("/")) 130 | for item in tree: 131 | logging.debug("looking up \"%s\"", item) 132 | nfsreply = await self.NfsLookup(ip, item, mount_handle) 133 | mount_handle = nfsreply["fhandle"] 134 | return nfsreply 135 | 136 | async def NfsReadData(self, host, fhandle, offset, size): 137 | nfscall = { 138 | "fhandle": fhandle, 139 | "offset": offset, 140 | "count": size, 141 | "totalcount": 0 142 | } 143 | return await self.NfsCall(host, "read", nfscall) 144 | 145 | # download file at src_path from player with ip from slot 146 | # save to dst_path if it is not empty, otherwise return a buffer 147 | # in both cases, return a future representing the download result 148 | # if sync is true, wait for the result and return it directly (30 seconds timeout) 149 | def enqueue_download(self, ip, slot, src_path, dst_path=None, sync=False): 150 | logging.debug("enqueueing download of %s from %s", src_path, ip) 151 | future = asyncio.run_coroutine_threadsafe( 152 | self.handle_download(ip, slot, src_path, dst_path), self.loop) 153 | if sync: 154 | return future.result(timeout=30) 155 | return future 156 | 157 | # download path from player with ip after trying to mount slot 158 | # this call blocks until the download is finished and returns the downloaded bytes 159 | def enqueue_buffer_download(self, ip, slot, src_path): 160 | future = self.enqueue_download(ip, slot, src_path) 161 | try: 162 | return future.result(timeout=30) 163 | except RuntimeError as e: 164 | logging.warning("returning empty buffer because: %s", e) 165 | return None 166 | 167 | # can be used as a callback for DataProvider.get_mount_info 168 | def enqueue_download_from_mount_info(self, request, player_number, slot, id_list, mount_info): 169 | if request != "mount_info" or "mount_path" not in mount_info: 170 | logging.error("not enqueueing non-mount_info request") 171 | return 172 | c = self.prodj.cl.getClient(player_number) 173 | if c is None: 174 | logging.error("player %d unknown", player_number) 175 | return 176 | src_path = mount_info["mount_path"] 177 | dst_path = self.default_download_directory + os.path.split(src_path)[1] 178 | future = self.enqueue_download(c.ip_addr, slot, src_path, dst_path) 179 | future.add_done_callback(generic_file_download_done_callback) 180 | return future 181 | 182 | async def handle_download(self, ip, slot, src_path, dst_path): 183 | logging.info("handling download of %s@%s:%s to %s", 184 | ip, slot, src_path, dst_path) 185 | if slot not in self.export_by_slot: 186 | raise RuntimeError("Unable to download from slot %s", slot) 187 | export = self.export_by_slot[slot] 188 | 189 | mount_port = await self.PortmapGetPort(ip, "mount", MountVersion, "udp") 190 | logging.debug("mount port of player %s: %d", ip, mount_port) 191 | 192 | nfs_port = await self.PortmapGetPort(ip, "nfs", NfsVersion, "udp") 193 | logging.debug("nfs port of player %s: %d", ip, nfs_port) 194 | 195 | mount_handle = await self.MountMnt((ip, mount_port), export) 196 | download = NfsDownload(self, (ip, nfs_port), mount_handle, src_path) 197 | if dst_path is not None: 198 | download.setFilename(dst_path) 199 | 200 | # TODO: NFS UMNT 201 | return await download.start() 202 | -------------------------------------------------------------------------------- /prodj/network/nfsdownload.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import logging 4 | import os 5 | import time 6 | from concurrent.futures import Future 7 | from enum import Enum 8 | 9 | class NfsDownloadType(Enum): 10 | buffer = 1, 11 | file = 2, 12 | failed = 3 13 | 14 | class NfsDownload: 15 | def __init__(self, nfsclient, host, mount_handle, src_path): 16 | self.nfsclient = nfsclient 17 | self.host = host # tuple of (ip, port) 18 | self.mount_handle = mount_handle 19 | self.src_path = src_path 20 | self.dst_path = None 21 | self.fhandle = None # set by lookupCallback 22 | self.progress = -3 23 | self.started_at = 0 # set by start 24 | self.last_write_at = None 25 | self.speed = 0 26 | self.future = Future() 27 | 28 | self.max_in_flight = 4 # values > 4 did not increase read speed in my tests 29 | self.in_flight = 0 30 | self.single_request_timeout = 2 # retry read after n seconds 31 | self.max_read_retries = 5 32 | self.read_retries = 0 33 | 34 | self.size = 0 35 | self.read_offset = 0 36 | self.write_offset = 0 37 | self.type = NfsDownloadType.buffer 38 | self.download_buffer = b"" 39 | self.download_file_handle = None 40 | 41 | # maps offset -> data of blocks, written 42 | # when continuously available 43 | self.blocks = dict() 44 | 45 | async def start(self): 46 | lookup_result = await self.nfsclient.NfsLookupPath(self.host, self.mount_handle, self.src_path) 47 | self.size = lookup_result.attrs.size 48 | self.fhandle = lookup_result.fhandle 49 | self.started_at = time.time() 50 | self.sendReadRequests() 51 | return await asyncio.wrap_future(self.future) 52 | 53 | def setFilename(self, dst_path=""): 54 | self.dst_path = dst_path 55 | 56 | if os.path.exists(self.dst_path): 57 | raise FileExistsError(f"file already exists: {self.dst_path}") 58 | 59 | # create download directory if nonexistent 60 | dirname = os.path.dirname(self.dst_path) 61 | if dirname: 62 | os.makedirs(dirname, exist_ok=True) 63 | 64 | self.download_file_handle = open(self.dst_path, "wb") 65 | self.type = NfsDownloadType.file 66 | 67 | def sendReadRequest(self, offset): 68 | remaining = self.size - offset 69 | chunk = min(self.nfsclient.download_chunk_size, remaining) 70 | # logging.debug("sending read request @ %d for %d bytes [%d in flight]", offset, chunk, self.in_flight) 71 | self.in_flight += 1 72 | task = asyncio.create_task(self.nfsclient.NfsReadData(self.host, self.fhandle, offset, chunk)) 73 | task.add_done_callback(functools.partial(self.readCallback, offset)) 74 | return chunk 75 | 76 | def sendReadRequests(self): 77 | if self.last_write_at is not None and self.last_write_at + self.single_request_timeout < time.time(): 78 | if self.read_retries > self.max_read_retries: 79 | self.fail_download("read requests timed out %d times, aborting download", self.max_read_retries) 80 | return 81 | else: 82 | logging.warning("read at offset %d timed out, retrying request", self.write_offset) 83 | self.sendReadRequest(self.write_offset) 84 | self.read_retries += 1 85 | 86 | while self.in_flight < self.max_in_flight and self.read_offset < self.size: 87 | self.read_offset += self.sendReadRequest(self.read_offset) 88 | 89 | def readCallback(self, offset, task): 90 | # logging.debug("readCallback @ %d/%d [%d in flight]", offset, self.size, self.in_flight) 91 | self.in_flight = max(0, self.in_flight-1) 92 | if self.write_offset <= offset: 93 | try: 94 | reply = task.result() 95 | except Exception as e: 96 | self.fail_download(str(e)) 97 | return 98 | self.blocks[offset] = reply.data 99 | else: 100 | logging.warning("Offset %d received twice, ignoring", offset) 101 | 102 | self.writeBlocks() 103 | 104 | self.updateProgress(offset) 105 | 106 | if self.write_offset == self.size: 107 | self.finish() 108 | else: 109 | self.sendReadRequests() 110 | 111 | def updateProgress(self, offset): 112 | new_progress = int(100*offset/self.size) 113 | if new_progress > self.progress+3 or new_progress == 100: 114 | self.progress = new_progress 115 | self.speed = offset/(time.time()-self.started_at)/1024/1024 116 | logging.info("download progress %d%% (%d/%d Bytes, %.2f MiB/s)", 117 | self.progress, offset, self.size, self.speed) 118 | 119 | def writeBlocks(self): 120 | # logging.debug("writing %d blocks @ %d [%d in flight]", 121 | # len(self.blocks), self.write_offset, self.in_flight) 122 | while self.write_offset in self.blocks: 123 | data = self.blocks.pop(self.write_offset) 124 | expected_length = min(self.nfsclient.download_chunk_size, self.size-self.write_offset) 125 | if len(data) != expected_length: 126 | logging.warning("Received %d bytes instead %d as requested. Try to decrease "\ 127 | "the download chunk size!", len(data), expected_length) 128 | if self.type == NfsDownloadType.buffer: 129 | self.downloadToBufferHandler(data) 130 | elif self.type == NfsDownloadType.file: 131 | self.downloadToFileHandler(data) 132 | else: 133 | logging.debug("dropping write @ %d", self.write_offset) 134 | self.write_offset += len(data) 135 | self.last_write_at = time.time() 136 | if len(self.blocks) > 0: 137 | logging.debug("%d blocks still in queue, first is %d", 138 | len(self.blocks), self.blocks.keys()[0]) 139 | 140 | def downloadToFileHandler(self, data): 141 | self.download_file_handle.write(data) 142 | 143 | def downloadToBufferHandler(self, data): 144 | self.download_buffer += data 145 | 146 | def finish(self): 147 | logging.info("finished downloading %s to %s, %d bytes, %.2f MiB/s", 148 | self.src_path, self.dst_path, self.write_offset, self.speed) 149 | if self.in_flight > 0: 150 | logging.error("BUG: finishing download of %s but packets are still in flight", self.src_path) 151 | if self.type == NfsDownloadType.buffer: 152 | self.future.set_result(self.download_buffer) 153 | elif self.type == NfsDownloadType.file: 154 | self.download_file_handle.close() 155 | self.future.set_result(self.dst_path) 156 | 157 | def fail_download(self, message="Unknown error"): 158 | self.type = NfsDownloadType.failed 159 | self.future.set_exception(RuntimeError(message)) 160 | 161 | def generic_file_download_done_callback(future): 162 | if future.exception() is not None: 163 | logging.error("download failed: %s", future.exception()) 164 | -------------------------------------------------------------------------------- /prodj/network/packets_dump.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def pretty_flags(flags): 4 | return "|".join(x for x,y in flags.items() if y and x[0] != "_") 5 | 6 | # dump functions for debugging 7 | def dump_keepalive_packet(packet): 8 | if logging.getLogger().getEffectiveLevel() > 5: 9 | return 10 | if packet.subtype == "stype_status": 11 | logging.log(5, "keepalive {} model {} ({}) player {} ip {} mac {} devcnt {} u2 {} flags {}".format( 12 | packet.subtype, packet.model, packet.device_type, packet.content.player_number, packet.content.ip_addr, 13 | packet.content.mac_addr, packet.content.device_count, packet.content.u2, pretty_flags(packet.content.flags) 14 | )) 15 | elif packet.subtype == "stype_ip": 16 | logging.log(5, "keepalive {} model {} ({}) player {} ip {} mac {} iteration {} assignment {} flags {}".format( 17 | packet.subtype, packet.model, packet.device_type, packet.content.player_number, packet.content.ip_addr, 18 | packet.content.mac_addr, packet.content.iteration, packet.content.player_number_assignment, pretty_flags(packet.content.flags) 19 | )) 20 | elif packet.subtype == "stype_mac": 21 | logging.log(5, "keepalive {} model {} ({}) mac {} iteration {} flags {}".format( 22 | packet.subtype, packet.model, packet.device_type, packet.content.mac_addr, 23 | packet.content.iteration, pretty_flags(packet.content.flags) 24 | )) 25 | elif packet.subtype == "stype_number": 26 | logging.log(5, "keepalive {} model {} ({}) proposed_player_number {} iteration {}".format( 27 | packet.subtype, packet.model, packet.device_type, packet.content.proposed_player_number, 28 | packet.content.iteration 29 | )) 30 | elif packet.subtype == "stype_hello": 31 | logging.log(5, "keepalive {} model {} ({}) u2 {}".format( 32 | packet.subtype, packet.model, packet.device_type, packet.content.u2 33 | )) 34 | else: 35 | logging.warning("BUG: unhandled packet type {}".format(packet.subtype)) 36 | 37 | def dump_beat_packet(packet): 38 | if logging.getLogger().getEffectiveLevel() > 5: 39 | return 40 | if packet.type == "type_beat": 41 | logging.log(5, "beat {} player {} actual_pitch {:.3f} bpm {:.2f} beat {} player2 {} distances {}".format( 42 | packet.model, packet.player_number, packet.content.pitch, packet.content.bpm, packet.content.beat, 43 | packet.content.player_number2, "/".join([str(y) for x,y in packet.content.distances.items()]) 44 | )) 45 | 46 | def dump_status_packet(packet): 47 | if logging.getLogger().getEffectiveLevel() > 5 or packet.type not in ["djm", "cdj"]: 48 | return 49 | logging.log(5, "type {} model \"{}\" pn {} u1 {} u2 {} remaining_bytes {}".format(packet.type, packet.model, 50 | packet.player_number, packet.u1, packet.u2, packet.extra.remaining_bytes if "remaining_bytes" in packet.extra else "N/A")) 51 | logging.log(5, "state {} pitch {:.2f} bpm {} beat {} u5 {}".format( 52 | ",".join(x for x,y in packet.content.state.items() if y==True), 53 | packet.content.physical_pitch, packet.content.bpm, packet.content.beat, packet.content.u5)) 54 | if packet.type == "cdj": 55 | logging.log(5, "active {} ldpn {} lds {} tat {} tid {} tn {} link {} tmc {} fw {} usb {}/{}".format( 56 | packet.content.activity, packet.content.loaded_player_number, packet.content.loaded_slot, 57 | packet.content.track_analyze_type, packet.content.track_id, packet.content.track_number, packet.content.link_available, 58 | packet.content.tempo_master_count, packet.content.firmware, packet.content.usb_state, packet.content.usb_active)) 59 | logging.log(5, "pstate {} pstate2 {} pstate3 {} pitch {:.2f} {:.2f} {:.2f} {:.2f} bpm {} ({}) beat {}/{} cue {}".format( 60 | packet.content.play_state, packet.content.play_state2, packet.content.play_state3, 61 | packet.content.actual_pitch, packet.content.actual_pitch2, packet.content.physical_pitch, packet.content.physical_pitch2, 62 | packet.content.bpm, packet.content.bpm_state, packet.content.beat_count, packet.content.beat, packet.content.cue_distance)) 63 | logging.log(5, "u5 {} u6 {} u7 {} u8 {} u9 {} u10 {} u11 {} is_nexus {:x}".format(packet.content.u5, packet.content.u6, 64 | packet.content.u7, packet.content.u8, packet.content.u9, packet.content.u10, packet.content.u11, packet.content.is_nexus)) 65 | 66 | def dump_packet_raw(data): 67 | # warning level to get message in case of decoding errors 68 | logging.warning(" ".join("{:02x}".format(b) for b in data)) 69 | -------------------------------------------------------------------------------- /prodj/network/packets_nfs.py: -------------------------------------------------------------------------------- 1 | from construct import Bytes, Const, Default, Enum, FocusedSeq, GreedyBytes, If, Int32ub, Pass, PascalString, Prefixed, Struct, Switch, this 2 | 3 | RpcMsgType = Enum(Int32ub, 4 | call = 0, 5 | reply = 1 6 | ) 7 | 8 | RpcReplyStat = Enum(Int32ub, 9 | accepted = 0, 10 | denied = 1 11 | ) 12 | 13 | RpcAcceptStat = Enum(Int32ub, 14 | success = 0, 15 | prog_unavail = 1, 16 | prog_mismatch = 2, 17 | prog_unsupp = 3, 18 | garbage_args = 4 19 | ) 20 | 21 | RpcRejectStat = Enum(Int32ub, 22 | rpc_mismatch = 0, 23 | auth_error = 1 24 | ) 25 | 26 | RpcAuthStat = Enum(Int32ub, 27 | badcred = 0, 28 | rejectedcred = 1, 29 | badverf = 2, 30 | rejectedverf = 3, 31 | tooweak = 4 32 | ) 33 | 34 | RpcAuthFlavor = Enum(Int32ub, 35 | null = 0, 36 | unix = 1, 37 | short = 2, 38 | des = 3 39 | ) 40 | 41 | RpcAuthUnix = Struct( 42 | "stamp" / Int32ub, 43 | "machine_name" / Default(PascalString(Int32ub, encoding="ascii"), ""), 44 | "uid" / Default(Int32ub, 0), 45 | "gid" / Default(Int32ub, 0), 46 | "gids" / Default(Int32ub, 0) # should be length-prefixed array? 47 | ) 48 | 49 | RpcAuthShort = Pass 50 | RpcAuthDes = Pass 51 | 52 | RpcOpaqueAuth = Struct( 53 | "flavor" / Default(RpcAuthFlavor, "null"), 54 | "content" / Prefixed(Int32ub, Switch(this.flavor, { 55 | "null": Pass, 56 | "unix": RpcAuthUnix, 57 | "short": RpcAuthShort, 58 | "des": RpcAuthDes 59 | })) 60 | ) 61 | 62 | PortmapPort = 111 63 | PortmapVersion = 2 64 | PortmapProcedure = Enum(Int32ub, 65 | null = 0, 66 | set = 1, 67 | unset = 2, 68 | getport = 3, 69 | dump = 4, 70 | call_result = 5 71 | ) 72 | 73 | PortmapProtocol = Enum(Int32ub, 74 | ip = 6, 75 | udp = 17 76 | ) 77 | 78 | RpcProgram = Enum(Int32ub, 79 | portmap = 100000, 80 | nfs = 100003, 81 | mount = 100005 82 | ) 83 | 84 | PortmapArgs = Struct( 85 | "prog" / RpcProgram, 86 | "vers" / Int32ub, 87 | "prot" / PortmapProtocol, 88 | "port" / Default(Int32ub, 0) 89 | ) 90 | 91 | PortmapRes = Int32ub 92 | 93 | NfsVersion = 2 94 | NfsProcedure = Enum(Int32ub, 95 | null = 0, 96 | getattr = 1, 97 | sattrargs = 2, 98 | root = 3, 99 | lookup = 4, 100 | readlink = 5, 101 | read = 6, 102 | writecache = 7, 103 | write = 8, 104 | create = 9, 105 | remove = 10, 106 | rename = 11, 107 | link = 12, 108 | symlink = 13, 109 | mkdir = 14, 110 | rmdir = 15, 111 | readdir = 16, 112 | statfs = 17 113 | ) 114 | 115 | MountVersion = 1 116 | MountProcedure = Enum(Int32ub, 117 | null = 0, 118 | mnt = 1, 119 | dump = 2, 120 | umnt = 3, 121 | umntall = 4, 122 | export = 5 123 | ) 124 | 125 | MountMntArgs = PascalString(Int32ub, encoding="utf-16-le") 126 | 127 | NfsFhandle = Bytes(32) 128 | 129 | MountMntRes = Struct( 130 | "status" / Int32ub, 131 | "fhandle" / If(this.status == 0, NfsFhandle) 132 | ) 133 | 134 | RpcCall = Struct( 135 | "rpcvers" / Const(2, Int32ub), 136 | "prog" / RpcProgram, 137 | "vers" / Default(Int32ub, 2), 138 | "proc" / Switch(this.prog, { 139 | "portmap": PortmapProcedure, 140 | "nfs": NfsProcedure, 141 | "mount": MountProcedure 142 | }), 143 | "cred" / RpcOpaqueAuth, 144 | "verf" / RpcOpaqueAuth 145 | ) 146 | 147 | RpcMismatchInfo = Struct( 148 | "low" / Int32ub, 149 | "high" / Int32ub 150 | ) 151 | 152 | RpcRejectedReply = Struct( 153 | "reject_stat" / RpcRejectStat, 154 | "content" / Switch(this.reject_stat, { 155 | "rpc_mismatch": RpcMismatchInfo, 156 | "auth_error": RpcAuthStat 157 | }) 158 | ) 159 | 160 | RpcAcceptedReply = Struct( 161 | "verf" / RpcOpaqueAuth, 162 | "accept_stat" / RpcAcceptStat, 163 | "content" / Switch(this.accept_stat, { 164 | "success": GreedyBytes, # content appended 165 | "prog_mismatch": RpcMismatchInfo 166 | }, 167 | default=Pass 168 | ) 169 | ) 170 | 171 | RpcReply = Struct( 172 | "reply_stat" / RpcReplyStat, 173 | "content" / Switch(this.reply_stat, { 174 | "accepted": RpcAcceptedReply, 175 | "denied": RpcRejectedReply 176 | }) 177 | ) 178 | 179 | RpcMsg = Struct( 180 | "xid" / Int32ub, 181 | "type" / RpcMsgType, 182 | "content" / Switch(this.type, { 183 | "call": RpcCall, 184 | "reply": RpcReply 185 | }) 186 | ) 187 | 188 | PortmapProc = Struct 189 | 190 | NfsStatus = Enum(Int32ub, 191 | ok = 0, 192 | err_perm = 1, 193 | err_noent = 2, 194 | err_io = 5, 195 | err_nxio = 6, 196 | err_acces = 13, 197 | err_exist = 17, 198 | err_nodev = 19, 199 | err_notdir = 20, 200 | err_isdir = 21, 201 | err_fbig = 27, 202 | err_nospc = 28, 203 | err_rofs = 30, 204 | err_nametoolong = 63, 205 | err_notempty = 66, 206 | err_dquot = 69, 207 | err_stale = 70, 208 | err_wflush = 99 209 | ) 210 | 211 | NfsFtype = Enum(Int32ub, 212 | none = 0, 213 | file = 1, 214 | dir = 2, 215 | block = 3, 216 | char = 4, 217 | link = 5 218 | ) 219 | 220 | NfsTime = Struct( 221 | "seconds" / Int32ub, 222 | "useconds" / Int32ub 223 | ) 224 | 225 | NfsFattr = Struct( 226 | "type" / NfsFtype, 227 | "mode" / Int32ub, 228 | "nlink" / Int32ub, 229 | "uid" / Int32ub, 230 | "gid" / Int32ub, 231 | "size" / Int32ub, 232 | "blocksize" / Int32ub, 233 | "rdev" / Int32ub, 234 | "blocks" / Int32ub, 235 | "fsid" / Int32ub, 236 | "fileid" / Int32ub, 237 | "atime" / NfsTime, 238 | "mtime" / NfsTime, 239 | "ctime" / NfsTime 240 | ) 241 | 242 | NfsSattr = Struct( 243 | "mode" / Int32ub, 244 | "uid" / Int32ub, 245 | "gid" / Int32ub, 246 | "size" / Int32ub, 247 | "atime" / NfsTime, 248 | "mtime" / NfsTime 249 | ) 250 | 251 | NfsDiropArgs = Struct( 252 | "fhandle" / NfsFhandle, 253 | "name" / PascalString(Int32ub, encoding="utf-16-le") 254 | ) 255 | 256 | NfsFileopArgs = Struct( 257 | "fhandle" / NfsFhandle, 258 | "offset" / Int32ub, 259 | "count" / Int32ub, 260 | "totalcount" / Int32ub 261 | ) 262 | 263 | def getNfsCallStruct(procedure): 264 | if procedure == "lookup": 265 | callStruct = NfsDiropArgs 266 | elif procedure == "getattr": 267 | callStruct = NfsFhandle 268 | elif procedure == "read": 269 | callStruct = NfsFileopArgs 270 | else: 271 | raise RuntimeError("NFS call procedure {} not implemented".format(procedure)) 272 | return callStruct 273 | 274 | NfsDiropRes = Struct( 275 | "fhandle" / NfsFhandle, 276 | "attrs" / NfsFattr 277 | ) 278 | 279 | NfsFileopRes = Struct( 280 | "attrs" / NfsFattr, 281 | "data" / FocusedSeq("data", 282 | "length" / Int32ub, 283 | "data" / Bytes(this.length) 284 | ) 285 | ) 286 | 287 | def getNfsResStruct(procedure): 288 | if procedure == "lookup": 289 | resStruct = NfsDiropRes 290 | elif procedure == "getattr": 291 | resStruct = NfsFhandle 292 | elif procedure == "read": 293 | resStruct = NfsFileopRes 294 | else: 295 | raise RuntimeError("NFS result procedure {} not implemented".format(procedure)) 296 | return Struct( 297 | "status" / NfsStatus, 298 | "content" / If(this.status == "ok", resStruct) 299 | ) 300 | -------------------------------------------------------------------------------- /prodj/network/rpcreceiver.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import time 4 | from concurrent.futures import Future 5 | from select import select 6 | from threading import Thread 7 | 8 | from .packets_nfs import getNfsCallStruct, getNfsResStruct, MountMntArgs, MountMntRes, MountVersion, NfsVersion, PortmapArgs, PortmapPort, PortmapVersion, PortmapRes, RpcMsg 9 | 10 | class ReceiveTimeout(Exception): 11 | pass 12 | 13 | class RpcReceiver: 14 | def __init__(self): 15 | super().__init__() 16 | self.requests = dict() 17 | self.request_timeout = 10 18 | self.recv_size = 4096 19 | self.check_timeouts_task = None 20 | self.check_timeouts_sleep = None 21 | 22 | def addCall(self, xid): 23 | if xid in self.requests: 24 | raise RuntimeError(f"Download xid {xid} already taken") 25 | future = Future() 26 | self.requests[xid] = (future, time.time()) 27 | return future 28 | 29 | def start(self, loop): 30 | asyncio.run_coroutine_threadsafe(self.checkTimeoutsTask(), loop) 31 | 32 | def stop(self, loop): 33 | asyncio.run_coroutine_threadsafe(self.stopCheckTimeoutsTask(), loop).result() 34 | if self.requests: 35 | logging.warning("stopped but still %d in queue", len(self.requests)) 36 | 37 | async def checkTimeoutsTask(self): 38 | self.check_timeouts_task = asyncio.current_task() 39 | while True: 40 | try: 41 | self.check_timeouts_sleep = asyncio.create_task(asyncio.sleep(1)) 42 | await self.check_timeouts_sleep 43 | self.checkTimeouts() 44 | except asyncio.CancelledError: 45 | return 46 | 47 | async def stopCheckTimeoutsTask(self): 48 | if self.check_timeouts_sleep is None or self.check_timeouts_task is None: 49 | logging.warning("unable to properly stop checkTimeoutsTask") 50 | return 51 | self.check_timeouts_sleep.cancel() 52 | await self.check_timeouts_task 53 | 54 | def socketRead(self, sock): 55 | self.handleReceivedData(sock.recv(self.recv_size)) 56 | 57 | def handleReceivedData(self, data): 58 | if len(data) == 0: 59 | logging.error("BUG: no data received!") 60 | 61 | try: 62 | rpcreply = RpcMsg.parse(data) 63 | except Exception as e: 64 | logging.warning("Failed to parse RPC reply: %s", e) 65 | return 66 | 67 | if not rpcreply.xid in self.requests: 68 | logging.warning("Ignoring unknown RPC XID %d", rpcreply.xid) 69 | return 70 | result_future, _ = self.requests.pop(rpcreply.xid) 71 | 72 | if rpcreply.content.reply_stat != "accepted": 73 | result_future.set_exception(RuntimeError("RPC call denied: "+rpcreply.content.reject_stat)) 74 | if rpcreply.content.content.accept_stat != "success": 75 | result_future.set_exception(RuntimeError("RPC call unsuccessful: "+rpcreply.content.content.accept_stat)) 76 | 77 | result_future.set_result(rpcreply.content.content.content) 78 | 79 | def checkTimeouts(self): 80 | deadline = time.time() - self.request_timeout 81 | for id, (future, started_at) in list(self.requests.items()): 82 | if started_at < deadline: 83 | logging.warning("Removing XID %d which has timed out", id) 84 | future.set_exception(ReceiveTimeout(f"Request timed out after {self.request_timeout} seconds")) 85 | del self.requests[id] 86 | -------------------------------------------------------------------------------- /prodj/pdblib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/prodj/pdblib/__init__.py -------------------------------------------------------------------------------- /prodj/pdblib/__init__.py.save: -------------------------------------------------------------------------------- 1 | from .fileheader import * 2 | from .page import * 3 | from .track import * 4 | from .pdbfile import * 5 | from .pdbdatabase import * 6 | from .usbanlzdatabase import * 7 | -------------------------------------------------------------------------------- /prodj/pdblib/album.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int8ul, Int16ul, Int32ul, Const, Padding, Tell, this 2 | from .piostring import OffsetPioString 3 | 4 | ALBUM_ENTRY_MAGIC = 0x80 5 | 6 | Album = Struct( 7 | "entry_start" / Tell, 8 | "magic" / Const(ALBUM_ENTRY_MAGIC, Int16ul), 9 | "index_shift" / Int16ul, 10 | Padding(4), 11 | "album_artist_id" / Int32ul, 12 | "id" / Int32ul, 13 | Padding(4), 14 | "unknown" / Int8ul, # always 0x03, maybe an unindexed empty string 15 | "name_idx" / Int8ul, 16 | "name" / OffsetPioString(this.name_idx) 17 | ) 18 | -------------------------------------------------------------------------------- /prodj/pdblib/artist.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int8ul, Int16ul, Int32ul, OneOf, IfThenElse, Tell, this 2 | from .piostring import OffsetPioString 3 | 4 | ARTIST_ENTRY_MAGIC = 0x60 5 | LONG_ARTIST_ENTRY_MAGIC = 0x64 6 | 7 | Artist = Struct( 8 | "entry_start" / Tell, 9 | "magic" / OneOf(Int16ul, [ARTIST_ENTRY_MAGIC, LONG_ARTIST_ENTRY_MAGIC]), 10 | "index_shift" / Int16ul, 11 | "id" / Int32ul, 12 | "unknown" / IfThenElse(this.magic == LONG_ARTIST_ENTRY_MAGIC, Int16ul, Int8ul), # always 0x03, maybe an unindexed empty string 13 | "name_idx" / IfThenElse(this.magic == LONG_ARTIST_ENTRY_MAGIC, Int16ul, Int8ul), 14 | "name" / OffsetPioString(this.name_idx) 15 | ) 16 | -------------------------------------------------------------------------------- /prodj/pdblib/artwork.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul 2 | from .piostring import PioString 3 | 4 | Artwork = Struct( 5 | "id" / Int32ul, 6 | "path" / PioString 7 | ) 8 | -------------------------------------------------------------------------------- /prodj/pdblib/color.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int8ul, Padding 2 | from .piostring import PioString 3 | 4 | Color = Struct( 5 | Padding(4), 6 | "id_dup" / Int8ul, # set on some dbs, equals id 7 | "id" / Int8ul, 8 | Padding(2), 9 | "name" / PioString 10 | ) 11 | -------------------------------------------------------------------------------- /prodj/pdblib/genre.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul 2 | from .piostring import PioString 3 | 4 | Genre = Struct( 5 | "id" / Int32ul, 6 | "name" / PioString 7 | ) 8 | -------------------------------------------------------------------------------- /prodj/pdblib/key.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul 2 | from .piostring import PioString 3 | 4 | Key = Struct( 5 | "id" / Int32ul, 6 | "id2" / Int32ul, # a duplicate of id 7 | "name" / PioString 8 | ) 9 | -------------------------------------------------------------------------------- /prodj/pdblib/label.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul 2 | from .piostring import PioString 3 | 4 | Label = Struct( 5 | "id" / Int32ul, 6 | "name" / PioString 7 | ) 8 | -------------------------------------------------------------------------------- /prodj/pdblib/page.py: -------------------------------------------------------------------------------- 1 | from construct import Sequence, Struct, Int8ul, Int16ul, Int32ul, Switch, Const, Array, Padded, Padding, Pass, Computed, Tell, Pointer, this, Seek, Bitwise, Flag, ByteSwapped, BitsSwapped, FocusedSeq, RepeatUntil 2 | from .pagetype import PageTypeEnum 3 | from .track import Track 4 | from .artist import Artist 5 | from .album import Album 6 | from .playlist import Playlist 7 | from .playlist_map import PlaylistMap 8 | from .artwork import Artwork 9 | from .color import Color 10 | from .genre import Genre 11 | from .key import Key 12 | from .label import Label 13 | 14 | # a strange page exists for every (?) page type, header.u9 is 1004 and page is filled with 0xf8ffff1f 15 | StrangePage = Struct( 16 | "strange_header" / Struct( 17 | "index" / Int32ul, # page index (same as header?) 18 | "next_index" / Int32ul, # index of next page containing real data or 0x3ffffff if next page empty 19 | Const(0x3fffffff, Int32ul), 20 | Padding(4), 21 | "entry_count" / Int16ul, # number of 4-byte values 22 | "u2" / Int16ul, # always 8191? 23 | ), 24 | Array(1004, Int32ul), 25 | Padding(20) 26 | ) 27 | 28 | ReverseIndexedEntry = FocusedSeq("entry", 29 | "entry_offset" / Int16ul, 30 | "entry" / Pointer(this._._.entries_start+this.entry_offset, 31 | Switch(lambda ctx: "strange" if ctx._._.is_strange_page else ctx._._.page_type, { 32 | "block_tracks": Track, 33 | "block_artists": Artist, 34 | "block_albums": Album, 35 | "block_playlists": Playlist, 36 | "block_playlist_map": PlaylistMap, 37 | "block_artwork": Artwork, 38 | "block_colors": Color, 39 | "block_genres": Genre, 40 | "block_keys": Key, 41 | "block_labels": Label, 42 | #"strange": StrangePage, 43 | }, default = Computed("page type not implemented")), 44 | ) 45 | ) 46 | 47 | # unfortunately, the entry_enabled field contains unexistant entries for the last entry 48 | # entry_enabled[:-1] matches revidx[:-1], 49 | # but len(entry_enabled)==16 while len(revidx)<=16 50 | 51 | ReverseIndexArray = Struct( 52 | "entry_count" / Computed(lambda ctx: min([16, ctx._.entry_count-16*ctx._._index])), 53 | Seek(-4-2*this.entry_count, 1), # jump back the size of this struct 54 | "entries" / Array(this.entry_count, ReverseIndexedEntry), 55 | "entry_enabled" / ByteSwapped(Bitwise(Array(16, Flag))), 56 | "entry_enabled_override" / ByteSwapped(Bitwise(Array(16, Flag))), 57 | Seek(-36 if this.entry_count == 16 else 0, 1) # jump back once again for the next read or 0 if finished 58 | ) 59 | 60 | PageFooter = RepeatUntil(lambda x,lst,ctx: len(lst)*16 > ctx.entry_count, ReverseIndexArray) 61 | 62 | AlignedPage = Struct( 63 | "page_start" / Tell, 64 | Padding(4), # always 0 65 | "index" / Int32ul, # in units of 4096 bytes 66 | "page_type" / PageTypeEnum, 67 | "next_index" / Int32ul, # in units of 4096 bytes, finally points to empty page, even outside of file 68 | "u1" / Int32ul, # sequence number (0->1: 8->13, 1->2: 22, 2->3: 27) 69 | Padding(4), 70 | "entry_count_small" / Int8ul, 71 | "u3" / Int8ul, # a bitmask (1st track: 32) 72 | "u4" / Int8ul, # often 0, sometimes larger, esp. for pages with high entry_count_small (e.g. 12 for 101 entries) 73 | "u5" / Int8ul, # strange pages: 0x44, 0x64; otherwise seen: 0x24, 0x34 74 | "free_size" / Int16ul, # excluding data at page end 75 | "payload_size" / Int16ul, 76 | "overridden_entries" / Int16ul, # number of additional entries which override rows of previous blocks (ignore if 8191) 77 | "entry_count_large" / Int16ul, # usually <= entry_count except for playlist_map? 78 | "u9" / Int16ul, # 1004 for strange blocks, 0 otherwise 79 | "u10" / Int16ul, # always 0 except 1 for synchistory, entry count for strange pages? 80 | "is_strange_page" / Computed(lambda ctx: ctx.index != 0 and ctx.u5 & 0x40), 81 | "is_empty_page" / Computed(lambda ctx: ctx.index == 0 and ctx.u9 == 0), 82 | # this is fishy: artwork and playlist_map pages have much more entries than set in entry_count_small 83 | # so use the entry_count_large if applicable, but ignore if it is 8191 84 | # there are even some normal track pages where entry_count_large is 8191, so catch this as well 85 | "entry_count" / Computed(lambda ctx: ctx.entry_count_large if ctx.entry_count_small < ctx.entry_count_large and not ctx.is_strange_page and not ctx.is_empty_page and not ctx.entry_count_large == 8191 else ctx.entry_count_small), 86 | "entries_start" / Tell, # reverse index is relative to this position 87 | # this expression jumps to the end of the section and parses the reverse index 88 | # TODO: calculation does not work on block_playlist_map 89 | "entry_list" / Pointer(this.page_start+4096, PageFooter), # jump to page end, PageFooter seeks backwards itself 90 | Seek(this.page_start+0x1000), # always jump to next page 91 | "page_end" / Tell 92 | ) 93 | -------------------------------------------------------------------------------- /prodj/pdblib/pagetype.py: -------------------------------------------------------------------------------- 1 | from construct import Enum, Int32ul 2 | 3 | PageTypeEnum = Enum(Int32ul, 4 | block_tracks = 0, 5 | block_genres = 1, 6 | block_artists = 2, 7 | block_albums = 3, 8 | block_labels = 4, 9 | block_keys = 5, 10 | block_colors = 6, 11 | block_playlists = 7, 12 | block_playlist_map = 8, 13 | block_unknown4 = 9, 14 | block_unknown5 = 10, 15 | block_unknown6 = 11, 16 | block_unknown7 = 12, 17 | block_artwork = 13, 18 | block_unknown8 = 14, 19 | block_unknown9 = 15, 20 | block_columns = 16, 21 | block_unknown1 = 17, 22 | block_unknown2 = 18, 23 | block_synchistory = 19 24 | ) 25 | -------------------------------------------------------------------------------- /prodj/pdblib/pdbdatabase.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from .pdbfile import PDBFile 5 | 6 | class PDBDatabase(dict): 7 | def __init__(self): 8 | super().__init__(self, tracks=[], artists=[], albums=[], playlists=[], playlist_map=[], artwork=[], colors=[], genres=[], labels=[], key_names=[]) 9 | self.parsed = None 10 | 11 | def get_track(self, track_id): 12 | for track in self["tracks"]: 13 | if track.id == track_id: 14 | return track 15 | raise KeyError("PDBDatabase: track {} not found".format(track_id)) 16 | 17 | def get_artist(self, artist_id): 18 | for artist in self["artists"]: 19 | if artist.id == artist_id: 20 | return artist 21 | raise KeyError("PDBDatabase: artist {} not found".format(artist_id)) 22 | 23 | def get_album(self, album_id): 24 | for album in self["albums"]: 25 | if album.id == album_id: 26 | return album 27 | raise KeyError("PDBDatabase: album {} not found".format(album_id)) 28 | 29 | def get_key(self, key_id): 30 | for key in self["key_names"]: 31 | if key.id == key_id: 32 | return key 33 | raise KeyError("PDBDatabase: key {} not found".format(key_id)) 34 | 35 | def get_genre(self, genre_id): 36 | for genre in self["genres"]: 37 | if genre.id == genre_id: 38 | return genre 39 | raise KeyError("PDBDatabase: genre {} not found".format(genre_id)) 40 | 41 | def get_label(self, label_id): 42 | for label in self["labels"]: 43 | if label.id == label_id: 44 | return label 45 | raise KeyError("PDBDatabase: label {} not found".format(genre_id)) 46 | 47 | def get_color(self, color_id): 48 | for color in self["colors"]: 49 | if color.id == color_id: 50 | return color 51 | raise KeyError("PDBDatabase: color {} not found".format(color_id)) 52 | 53 | def get_artwork(self, artwork_id): 54 | for artwork in self["artwork"]: 55 | if artwork.id == artwork_id: 56 | return artwork 57 | raise KeyError("PDBDatabase: artwork {} not found".format(artwork_id)) 58 | 59 | # returns all playlists in folder "folder_id", sorted by the user-defined sort order 60 | def get_playlists(self, folder_id): 61 | ff = lambda pl: pl.folder_id == folder_id 62 | sf = lambda pl: pl.sort_order 63 | return sorted(filter(ff, self["playlists"]), key=sf) 64 | 65 | # returns all tracks in playlist "playlist_id", sorted by the user-defined sort order 66 | def get_playlist(self, playlist_id): 67 | pms = filter(lambda pm: pm.playlist_id == playlist_id, self["playlist_map"]) 68 | sorted_pms = sorted(pms, key=lambda pm: pm.entry_index) 69 | tracks = filter(lambda t: any(t.id == pm.track_id for pm in sorted_pms), self["tracks"]) 70 | return list(tracks) 71 | 72 | def collect_entries(self, page_type, target): 73 | for page in filter(lambda x: x.page_type == page_type, self.parsed.pages): 74 | #logging.debug("parsing page %s %d", page.page_type, page.index) 75 | for entry_block in page.entry_list: 76 | for entry,enabled in zip(reversed(entry_block.entries), reversed(entry_block.entry_enabled)): 77 | if not enabled: 78 | continue 79 | self[target] += [entry] 80 | logging.debug("done collecting {}".format(target)) 81 | 82 | def load_file(self, filename): 83 | logging.info("Loading database \"%s\"", filename) 84 | stat = os.stat(filename) 85 | fh = PDBFile 86 | with open(filename, "rb") as f: 87 | self.parsed = fh.parse_stream(f); 88 | 89 | if stat.st_size != self.parsed["file_size"]: 90 | raise RuntimeError("failed to parse the complete file ({}/{} bytes parsed)".format(self.parsed["file_size"], stat.st_size)) 91 | 92 | self.collect_entries("block_tracks", "tracks") 93 | self.collect_entries("block_artists", "artists") 94 | self.collect_entries("block_albums", "albums") 95 | self.collect_entries("block_playlists", "playlists") 96 | self.collect_entries("block_playlist_map", "playlist_map") 97 | self.collect_entries("block_artwork", "artwork") 98 | self.collect_entries("block_colors", "colors") 99 | self.collect_entries("block_genres", "genres") 100 | self.collect_entries("block_keys", "key_names") 101 | self.collect_entries("block_labels", "labels") 102 | 103 | logging.info("Loaded %d pages, %d tracks, %d playlists", len(self.parsed.pages), len(self["tracks"]), len(self["playlists"])) 104 | -------------------------------------------------------------------------------- /prodj/pdblib/pdbfile.py: -------------------------------------------------------------------------------- 1 | from construct import Array, Const, GreedyRange, Int32ul, Padding, Struct, Tell 2 | from .page import AlignedPage 3 | from .pagetype import PageTypeEnum 4 | 5 | FileHeaderEntry = Struct( 6 | "page_type" / PageTypeEnum, 7 | "empty_candidate" / Int32ul, 8 | "first_page" / Int32ul, # always points to a strange page, which then links to a real data page 9 | "last_page" / Int32ul 10 | ) 11 | 12 | PDBFile = Struct( 13 | Padding(4), # always 0 14 | "page_size" / Const(4096, Int32ul), 15 | "page_entries" / Int32ul, # FileHeaderEntry follow, usually 20 16 | "next_unused_page" / Int32ul, # even unreferenced -> not used as any "empty_candidate", points "out of file" 17 | "unknown1" / Int32ul, # (5,4,4,1,1,1...) 18 | "sequence" / Int32ul, # sequence number, always incremented by 1 (sometimes 2/3) 19 | Padding(4), # always 0 20 | "entries" / Array(lambda ctx: ctx.page_entries, FileHeaderEntry), 21 | "length" / Tell, # usually 348 when page_entries=20 22 | Padding(lambda ctx: ctx.page_size-ctx.length), 23 | "pages" / GreedyRange(AlignedPage), 24 | "file_size" / Tell 25 | ) 26 | -------------------------------------------------------------------------------- /prodj/pdblib/piostring.py: -------------------------------------------------------------------------------- 1 | from construct import Bytes, Computed, ExprAdapter, FocusedSeq, Int8ul, Int16ul, Int24ul, Padding, Pointer, PaddedString, Restreamed, Switch, this 2 | 3 | PioString = FocusedSeq("data", 4 | "padded_length" / Int8ul, 5 | "data" / Switch(this.padded_length, { 6 | # string longer than 127 bytes, prefixed with 3 bytes length 7 | 0x40: FocusedSeq("text", 8 | "actual_length" / ExprAdapter(Int16ul, lambda o,c: o-4, lambda o,c: o+4), 9 | Padding(1), 10 | "text" / PaddedString(this.actual_length, encoding="ascii")), 11 | # utf-16 text 12 | 0x90: FocusedSeq("text", 13 | "actual_length" / ExprAdapter(Int16ul, lambda o,c: o-4, lambda o,c: o+4), 14 | "text" / PaddedString(this.actual_length, "utf-16-be")), 15 | }, default= # just ascii text 16 | FocusedSeq("text", 17 | "actual_length" / Computed((this._.padded_length-1)//2-1), 18 | "text" / PaddedString(this.actual_length, encoding="ascii")) 19 | )) 20 | 21 | # parses a PioString relative to entry start using an str_idx array 22 | def OffsetPioString(index): 23 | return Pointer(this.entry_start+index, PioString) 24 | 25 | # parses a PioString relative to entry start using an str_idx array 26 | def IndexedPioString(index): 27 | return Pointer(this.entry_start+this.str_idx[index], PioString) 28 | -------------------------------------------------------------------------------- /prodj/pdblib/playlist.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul, Padding 2 | from .piostring import PioString 3 | 4 | Playlist = Struct( 5 | "folder_id" / Int32ul, # id of parent folder, 0 for root 6 | Padding(4), 7 | "sort_order" / Int32ul, 8 | "id" / Int32ul, 9 | "is_folder" / Int32ul, # 1 for folder, 0 for playlist 10 | "name" / PioString 11 | ) 12 | -------------------------------------------------------------------------------- /prodj/pdblib/playlist_map.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int32ul 2 | 3 | PlaylistMap = Struct( 4 | "entry_index" / Int32ul, 5 | "track_id" / Int32ul, 6 | "playlist_id" / Int32ul 7 | ) 8 | -------------------------------------------------------------------------------- /prodj/pdblib/track.py: -------------------------------------------------------------------------------- 1 | from construct import Struct, Int8ul, Int16ul, Int32ul, Array, Const, Tell, Default 2 | from .piostring import PioString, IndexedPioString 3 | 4 | TRACK_ENTRY_MAGIC = 0x24 5 | 6 | Track = Struct( 7 | "entry_start" / Tell, 8 | "magic" / Const(TRACK_ENTRY_MAGIC, Int16ul), 9 | "index_shift" / Int16ul, # the index inside the page <<5 (0x00, 0x20, 0x40, ...) 10 | "bitmask" / Int32ul, 11 | "sample_rate" / Int32ul, 12 | "composer_index" / Int32ul, 13 | "file_size" / Int32ul, 14 | "u1" / Int32ul, # some id? 15 | "u2" / Int16ul, # always 19048? 16 | "u3" / Int16ul, # always 30967? 17 | "artwork_id" / Int32ul, 18 | "key_id" / Int32ul, # not sure 19 | "original_artist_id" / Int32ul, 20 | "label_id" / Int32ul, 21 | "remixer_id" / Int32ul, 22 | "bitrate" / Int32ul, 23 | "track_number" / Int32ul, 24 | "bpm_100" / Int32ul, 25 | "genre_id" / Int32ul, 26 | "album_id" / Int32ul, # album artist is set in album entry 27 | "artist_id" / Int32ul, 28 | "id" / Int32ul, # the rekordbox track id 29 | "disc_number" / Int16ul, 30 | "play_count" / Int16ul, 31 | "year" / Int16ul, 32 | "sample_depth" / Int16ul, # not sure 33 | "duration" / Int16ul, 34 | "u4" / Int16ul, # always 41? 35 | "color_id" / Int8ul, 36 | "rating" / Int8ul, 37 | "u5" / Default(Int16ul, 1), # always 1? 38 | "u6" / Int16ul, # alternating 2 or 3 39 | "str_idx" / Array(21, Int16ul), 40 | "str_u1" / IndexedPioString(0), # empty 41 | "texter" / IndexedPioString(1), 42 | "str_u2" / IndexedPioString(2), # thought tracknumber -> wrong! 43 | "str_u3" / IndexedPioString(3), # strange strings, often zero length, sometimes low binary values 0x01/0x02 as content 44 | "str_u4" / IndexedPioString(4), # strange strings, often zero length, sometimes low binary values 0x01/0x02 as content 45 | "message" / IndexedPioString(5), 46 | "kuvo_public" / IndexedPioString(6), # "ON" or empty 47 | "autoload_hotcues" / IndexedPioString(7), # "ON" or empty 48 | "str_u5" / IndexedPioString(8), # 8 49 | "str_u6" / IndexedPioString(9), # empty 50 | "date_added" / IndexedPioString(10), 51 | "release_date" / IndexedPioString(11), 52 | "mix_name" / IndexedPioString(12), 53 | "str_u7" / IndexedPioString(13), # empty 54 | "analyze_path" / IndexedPioString(14), 55 | "analyze_date" / IndexedPioString(15), 56 | "comment" / IndexedPioString(16), 57 | "title" / IndexedPioString(17), 58 | "str_u8" / IndexedPioString(18), # always empty; only in newer versions? 59 | "filename" / IndexedPioString(19), 60 | "path" / IndexedPioString(20) 61 | ) 62 | -------------------------------------------------------------------------------- /prodj/pdblib/usbanlz.py: -------------------------------------------------------------------------------- 1 | from construct import Array, Const, Default, Enum, GreedyRange, Int8sb, Int8ub, Int16ub, Int32ub, Padding, PrefixedArray, PaddedString, Struct, Switch, this 2 | 3 | # file format from https://reverseengineering.stackexchange.com/questions/4311/help-reversing-a-edb-database-file-for-pioneers-rekordbox-software 4 | 5 | AnlzTagPath = Struct( 6 | "payload_size" / Int32ub, # is 0 for some tag types 7 | "path" / PaddedString(this.payload_size-2, encoding="utf-16-be"), 8 | Padding(2) 9 | ) 10 | 11 | AnlzTagVbr = Struct( 12 | Padding(4), 13 | "idx" / Array(400, Int32ub), 14 | "unknown" / Int32ub 15 | ) 16 | 17 | AnlzQuantizeTick = Struct( 18 | "beat" / Int16ub, 19 | "bpm_100" / Int16ub, 20 | "time" / Int32ub # in ms from start 21 | ) 22 | 23 | AnlzTagQuantize = Struct( 24 | Padding(4), 25 | "unknown" / Const(0x80000, Int32ub), 26 | "entries" / PrefixedArray(Int32ub, AnlzQuantizeTick) 27 | ) 28 | 29 | AnlzTagQuantize2 = Struct( 30 | Padding(4), 31 | "u1" / Const(0x01000002, Int32ub), # maybe this encodes the count of "bpm" objects below 32 | Padding(4), 33 | "bpm" / Array(2, AnlzQuantizeTick), 34 | "entry_count" / Int32ub, # 680 35 | "u3" / Int32ub, 36 | "u4" / Int32ub, 37 | "u5" / Int32ub, 38 | "u6" / Int32ub, 39 | Padding(8) 40 | ) 41 | 42 | AnlzTagWaveform = Struct( 43 | "payload_size" / Int32ub, # is 0 for some tag types 44 | "unknown" / Const(0x10000, Int32ub), 45 | "entries" / Array(this.payload_size, Int8ub) 46 | ) 47 | AnlzTagBigWaveform = Struct( 48 | "u1" / Const(1, Int32ub), 49 | "payload_size" / Int32ub, 50 | "u2" / Const(0x960000, Int32ub), 51 | "entries" / Array(this.payload_size, Int8ub) 52 | ) 53 | AnlzTagColorWaveform = Struct( 54 | "payload_word_size" / Const(0x06, Int32ub), 55 | "payload_size" / Int32ub, 56 | "unknown" / Const(0x00, Int32ub), 57 | "entries" / Array(this.payload_word_size * this.payload_size, Int8sb), # See doubts about signed in ColorPreviewWaveformWidget 58 | ) 59 | AnlzTagColorBigWaveform = Struct( 60 | "payload_word_size" / Const(0x02, Int32ub), 61 | "payload_size" / Int32ub, 62 | "unknown" / Int32ub, 63 | "entries" / Array(this.payload_size, Int16ub), 64 | ) 65 | 66 | AnlzCuePointType = Enum(Int8ub, 67 | single = 1, 68 | loop = 2 69 | ) 70 | 71 | AnlzCuePointStatus = Enum(Int32ub, 72 | disabled = 0, 73 | enabled = 4 74 | ) 75 | 76 | # unfortunately, this can't be embedded into AnlzTag due to the recursive 77 | # dependency between AnlzTag and AnlzTagCueObject 78 | AnlzCuePoint = Struct( 79 | "type" / Const("PCPT", PaddedString(4, encoding="ascii")), 80 | "head_size" / Int32ub, 81 | "tag_size" / Int32ub, 82 | "hotcue_number" / Int32ub, # 0 for memory 83 | "status" / AnlzCuePointStatus, 84 | "u1" / Const(0x10000, Int32ub), 85 | "order_first" / Int16ub, # 0xffff for first cue, 0,1,3 for next 86 | "order_last" / Int16ub, # 1,2,3 for first, second, third cue, 0xffff for last 87 | "type" / AnlzCuePointType, 88 | Padding(1), 89 | "u3" / Const(1000, Int16ub), 90 | "time" / Int32ub, 91 | "time_end" / Default(Int32ub, -1), 92 | Padding(16) 93 | ) 94 | 95 | AnlzTagCueObjectType = Enum(Int32ub, 96 | memory = 0, 97 | hotcue = 1 98 | ) 99 | 100 | AnlzTagCueObject = Struct( 101 | "type" / AnlzTagCueObjectType, 102 | "count" / Int32ub, 103 | "memory_count" / Int32ub, 104 | "entries" / Array(this.count, AnlzCuePoint) 105 | ) 106 | 107 | AnlzCuePoint2 = Struct( 108 | "type" / Const("PCP2", PaddedString(4, encoding="ascii")), 109 | "head_size" / Int32ub, 110 | "tag_size" / Int32ub, 111 | "hotcue_number" / Int32ub, # 0 for memory 112 | "u2" / Int32ub, # spotted: 0x010003e8 0x020003e8 113 | "time" / Int32ub, 114 | "time_end" / Default(Int32ub, -1), 115 | "u1" / Int32ub, # spotted: 0x00010000 0x00010247 116 | Padding(56) 117 | ) 118 | 119 | AnlzTagCueObject2 = Struct( 120 | "type" / AnlzTagCueObjectType, 121 | "count" / Int16ub, 122 | "unknown" / Int16ub, 123 | "entries" / Array(this.count, AnlzCuePoint2) 124 | ) 125 | 126 | AnlzTag = Struct( 127 | "type" / PaddedString(4, encoding="ascii"), 128 | "head_size" / Int32ub, 129 | "tag_size" / Int32ub, 130 | "content" / Switch(this.type, { 131 | "PPTH": AnlzTagPath, 132 | "PVBR": AnlzTagVbr, 133 | "PQTZ": AnlzTagQuantize, 134 | "PWAV": AnlzTagWaveform, 135 | "PWV2": AnlzTagWaveform, 136 | "PCOB": AnlzTagCueObject, # seen in both DAT and EXT files 137 | "PWV3": AnlzTagBigWaveform, # seen in EXT files 138 | "PWV4": AnlzTagColorWaveform, # seen in EXT files 139 | "PWV5": AnlzTagColorBigWaveform, # seen in EXT files 140 | "PCO2": AnlzTagCueObject2, # seen in EXT files 141 | }, default=Padding(this.tag_size-12)) 142 | ) 143 | 144 | AnlzFile = Struct( 145 | "type" / Const("PMAI", PaddedString(4, encoding="ascii")), 146 | "head_size" / Int32ub, 147 | "file_size" / Int32ub, 148 | "u1" / Int32ub, 149 | "u2" / Int32ub, 150 | "u3" / Int32ub, 151 | "u4" / Int32ub, 152 | "tags" / GreedyRange(AnlzTag) 153 | #"tags" / Array(8, AnlzTag) 154 | ) 155 | -------------------------------------------------------------------------------- /prodj/pdblib/usbanlzdatabase.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .usbanlz import AnlzFile 4 | 5 | class UsbAnlzDatabase(dict): 6 | def __init__(self): 7 | super().__init__(self) 8 | self.parsed = None 9 | 10 | def get_beatgrid(self): 11 | if not "beatgrid" in self: 12 | raise KeyError("UsbAnlzDatabase: no beatgrid found") 13 | return self["beatgrid"] 14 | 15 | def get_cue_points(self): 16 | if not "cue_points" in self: 17 | raise KeyError("UsbAnlzDatabase: no cue points found") 18 | return self["cue_points"] 19 | 20 | def get_waveform(self): 21 | if not "waveform" in self: 22 | raise KeyError("UsbAnlzDatabase: no waveform found") 23 | return self["waveform"] 24 | 25 | def get_preview_waveform(self): 26 | if not "preview_waveform" in self: 27 | raise KeyError("UsbAnlzDatabase: no preview waveform found") 28 | return self["preview_waveform"] 29 | 30 | def get_color_preview_waveform(self): 31 | if not "color_preview_waveform" in self: 32 | raise KeyError("UsbAnlzDatabase: no color preview waveform found") 33 | return self["color_preview_waveform"] 34 | 35 | def get_color_waveform(self): 36 | if not "color_waveform" in self: 37 | raise KeyError("UsbAnlzDatabase: no color waveform found") 38 | return self["color_waveform"] 39 | 40 | def collect_entries(self, tag, target): 41 | obj = next((t for t in self.parsed.tags if t.type == tag), None) 42 | if obj is None: 43 | logging.warning("tag %s not found in file", tag) 44 | return 45 | self[target] = obj.content.entries 46 | 47 | def _load_file(self, filename): 48 | with open(filename, "rb") as f: 49 | self.parsed = AnlzFile.parse_stream(f); 50 | 51 | def _load_buffer(self, data): 52 | self.parsed = AnlzFile.parse(data); 53 | 54 | def _parse_dat(self): 55 | logging.debug("Loaded %d tags", len(self.parsed.tags)) 56 | self.collect_entries("PWAV", "preview_waveform") 57 | self.collect_entries("PCOB", "cue_points") 58 | self.collect_entries("PQTZ", "beatgrid") 59 | self.parsed = None 60 | 61 | def _parse_ext(self): 62 | logging.debug("Loaded %d tags", len(self.parsed.tags)) 63 | self.collect_entries("PWV3", "waveform") 64 | self.collect_entries("PWV4", "color_preview_waveform") 65 | self.collect_entries("PWV5", "color_waveform") 66 | # TODO: collect PCOB here as well? 67 | # self.collect_entries("PCOB", "cue_points") 68 | self.parsed = None 69 | 70 | def load_dat_buffer(self, data): 71 | logging.debug("Loading DAT from buffer") 72 | self._load_buffer(data) 73 | self._parse_dat() 74 | 75 | def load_dat_file(self, filename): 76 | logging.debug("Loading DAT file \"%s\"", filename) 77 | self._load_file(filename) 78 | self._parse_dat() 79 | 80 | def load_ext_buffer(self, data): 81 | logging.debug("Loading EXT from buffer") 82 | self._load_buffer(data) 83 | self._parse_ext() 84 | 85 | def load_ext_file(self, filename): 86 | logging.debug("Loading EXT file \"%s\"", filename) 87 | self._load_file(filename) 88 | self._parse_ext() 89 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | construct==2.10.70 2 | netifaces==0.11.0 3 | PyOpenGL==3.1.9 4 | PyQt5==5.15.11 5 | PyQt5-sip==12.17.0 6 | alsaseq==0.4.2 -------------------------------------------------------------------------------- /test_runner.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import unittest 4 | import os 5 | 6 | 7 | test_root_directory = os.path.join(os.path.dirname(__file__), 'tests') 8 | 9 | 10 | def suite(): 11 | suite = unittest.TestSuite() 12 | loader = unittest.TestLoader() 13 | module_tests = loader.discover(test_root_directory) 14 | 15 | suite.addTest(module_tests) 16 | 17 | return suite 18 | 19 | 20 | if __name__ == '__main__': 21 | runner = unittest.TextTestRunner() 22 | runner.run(suite()) 23 | -------------------------------------------------------------------------------- /tests/blobs/pdb_artists_common.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/tests/blobs/pdb_artists_common.bin -------------------------------------------------------------------------------- /tests/blobs/pdb_artists_strange_string.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flesniak/python-prodj-link/b08921e91a72f0f170916f425ad79e8ed4bd153b/tests/blobs/pdb_artists_strange_string.bin -------------------------------------------------------------------------------- /tests/test_dbclient.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from prodj.network.packets import DBMessage 3 | 4 | class DbclientTestCase(unittest.TestCase): 5 | def test_parsing_root_menu_rendering_request(self): 6 | raw_data = bytes([ 7 | 0x11, 0x87, 0x23, 0x49, 0xae, 0x11, 0x05, 0x80, 8 | 0x00, 0x0f, 0x10, 0x30, 0x00, 0x0f, 0x06, 0x14, 9 | 0x00, 0x00, 0x00, 0x0c, 0x06, 0x06, 0x06, 0x06, 10 | 0x06, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 11 | 0x11, 0x02, 0x01, 0x04, 0x01, 12 | 0x11, 0x00, 0x00, 0x00, 0x00, 13 | 0x11, 0x00, 0x00, 0x00, 0x07, 14 | 0x11, 0x00, 0x00, 0x00, 0x00, 15 | 0x11, 0x00, 0x00, 0x00, 0x08, 16 | 0x11, 0x00, 0x00, 0x00, 0x00, 17 | ]) 18 | 19 | parsed = DBMessage.parse(raw_data) 20 | -------------------------------------------------------------------------------- /tests/test_nfsclient.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | import socket 4 | 5 | from prodj.network.nfsclient import NfsClient 6 | from prodj.network.packets_nfs import RpcMsg 7 | 8 | class MockSock(Mock): 9 | def __init__(self, inet, type): 10 | assert inet == socket.AF_INET 11 | assert type == socket.SOCK_DGRAM 12 | self.sent = list() 13 | 14 | def sendto(self, data, host): 15 | msg = RpcMsg.parse(data) 16 | self.sent += msg 17 | print(msg) 18 | 19 | class DbclientTestCase(unittest.TestCase): 20 | def setUp(self): 21 | self.nc = NfsClient(None) # prodj object only required for enqueue_download_from_mount_info 22 | # TODO: use unittest.mock for replacing socket module 23 | # self.sock = MockSock 24 | # NfsClient.socket.socket = self.sock 25 | 26 | # assert self.sock.binto.called 27 | 28 | @patch('socket.socket', new=MockSock) 29 | @patch('prodj.network.nfsclient.select') 30 | def test_buffer_download(self, select): 31 | self.nc.enqueue_buffer_download("1.1.1.1", "usb", "/folder/file") 32 | -------------------------------------------------------------------------------- /tests/test_packets.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from prodj.network.packets import DBField, DBMessage 3 | from construct import Container 4 | 5 | class PacketsTestCase(unittest.TestCase): 6 | def test_string_parsing(self): 7 | self.assertEqual( 8 | DBField.parse( 9 | b"\x26\x00\x00\x00\x0a\xff\xfa\x00\x48\x00\x49\x00" + 10 | b"\x53\x00\x54\x00\x4f\x00\x52\x00\x59\xff\xfb\x00\x00" 11 | ), 12 | Container(type='string')(value="\ufffaHISTORY\ufffb"), 13 | ) 14 | 15 | self.assertEqual( 16 | DBField.parse( 17 | b"\x26\x00\x00\x00\x0b\xff\xfa\x00\x50\x00\x4c\x00" + 18 | b"\x41\x00\x59\x00\x4c\x00\x49\x00\x53\x00\x54\xff\xfb" + 19 | b"\x00\x00" 20 | ), 21 | Container(type='string')(value="\ufffaPLAYLIST\ufffb"), 22 | ) 23 | 24 | self.assertEqual( 25 | DBField.parse(bytes([ 26 | 0x26, 0x00, 0x00, 0x00, 0x09, 0xff, 0xfa, 0x00, 0x41, 27 | 0x00, 0x52, 0x00, 0x54, 0x00, 0x49, 0x00, 0x53, 28 | 0x00, 0x54, 0xff, 0xfb, 0x00, 0x00, 29 | ])), 30 | Container(type='string')(value="\ufffaARTIST\ufffb")) 31 | 32 | def test_building_root_menu_request_menu_item_part(self): 33 | data = bytes([ 34 | 0x11, 0x87, 0x23, 0x49, 0xae, 35 | 0x11, 0x05, 0x80, 0x00, 0x01, 36 | 0x10, 0x41, 0x01, 37 | 0x0f, 0x0c, 0x14, 0x00, 0x00, 0x00, 0x0c, 0x06, 0x06, 0x06, 0x02, 38 | 0x06, 0x02, 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, 39 | 0x11, 0x00, 0x00, 0x00, 0x00, 40 | 0x11, 0x00, 0x00, 0x00, 0x16, 41 | 0x11, 0x00, 0x00, 0x00, 0x14, 42 | 0x26, 0x00, 0x00, 0x00, 0x09, 0xff, 0xfa, 0x00, 0x41, 43 | 0x00, 0x52, 0x00, 0x54, 0x00, 0x49, 0x00, 0x53, 44 | 0x00, 0x54, 0xff, 0xfb, 0x00, 0x00, 45 | 0x11, 0x00, 0x00, 0x00, 0x02, 46 | 0x26, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 47 | 0x11, 0x00, 0x00, 0x00, 0x95, 48 | 0x11, 0x00, 0x00, 0x00, 0x00, 49 | 0x11, 0x00, 0x00, 0x00, 0x00, 50 | 0x11, 0x00, 0x00, 0x00, 0x00, 51 | 0x11, 0x00, 0x00, 0x00, 0x00, 52 | 0x11, 0x00, 0x00, 0x00, 0x00, 53 | ]) 54 | 55 | message = DBMessage.parse(data) 56 | 57 | self.assertEqual(message.type, 'menu_item') 58 | self.assertEqual( 59 | message, 60 | (Container 61 | (magic=2267236782) 62 | (transaction_id=92274689) 63 | (type='menu_item') 64 | (argument_count=12) 65 | (arg_types=[ 66 | 'int32', 'int32', 'int32', 'string', 'int32', 'string', 67 | 'int32', 'int32', 'int32', 'int32', 'int32', 'int32', 68 | ]) 69 | (args=[ 70 | Container(type='int32')(value=0), 71 | Container(type='int32')(value=22), 72 | Container(type='int32')(value=20), 73 | Container(type='string')(value='\ufffaARTIST\ufffb'), 74 | Container(type='int32')(value=2), 75 | Container(type='string')(value=''), 76 | Container(type='int32')(value=149), 77 | Container(type='int32')(value=0), 78 | Container(type='int32')(value=0), 79 | Container(type='int32')(value=0), 80 | Container(type='int32')(value=0), 81 | Container(type='int32')(value=0), 82 | ]) 83 | ) 84 | ) 85 | -------------------------------------------------------------------------------- /tests/test_pdb.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from prodj.pdblib.artist import Artist 3 | from prodj.pdblib.page import AlignedPage 4 | 5 | class ArtistPageTestCase(unittest.TestCase): 6 | def test_artist_row(self): 7 | data = bytes([ 8 | 0x60, 0x00, 0xe0, 0x03, 0x10, 0x03, 0x00, 0x00, 9 | 0x03, 0x0a, 0x15, 0x41, 0x69, 0x72, 0x73, 0x74, 10 | 0x72, 0x69, 0x6b, 0x65, 0x00, 0x00, 0x00, 0x00, 11 | 0x00, 0x00, 0x00, 0x00 12 | ]) 13 | parsed = Artist.parse(data) 14 | 15 | self.assertEqual(parsed.entry_start, 0) 16 | self.assertEqual(parsed.magic, 96) 17 | self.assertEqual(parsed.id, 784) 18 | self.assertEqual(parsed.name, u'Airstrike') 19 | 20 | def test_artist_page(self): 21 | file = open("tests/blobs/pdb_artists_common.bin", "rb") 22 | parsed = AlignedPage.parse_stream(file) 23 | 24 | self.assertEqual(parsed.index, 165) 25 | self.assertEqual(parsed.page_type, "block_artists") 26 | self.assertEqual(parsed.entry_count_small, 115) 27 | self.assertEqual(parsed.entry_count, 115) 28 | 29 | entries = 0 30 | for batch in parsed.entry_list: 31 | self.assertEqual(batch.entry_count, len(batch.entries)) 32 | entries += batch.entry_count 33 | self.assertEqual(parsed.entry_count, entries) 34 | self.assertEqual(parsed.entry_count_small, entries) 35 | 36 | entry = parsed.entry_list[0].entries[0] 37 | self.assertEqual(entry.entry_start, 496) 38 | self.assertEqual(entry.id, 768) 39 | self.assertEqual(entry.name_idx, 10) 40 | self.assertEqual(entry.name, u'Gerwin ft. LaMeduza') 41 | 42 | def test_artist_page_with_strange_strings(self): 43 | file = open("tests/blobs/pdb_artists_strange_string.bin", "rb") 44 | parsed = AlignedPage.parse_stream(file) 45 | 46 | self.assertEqual(parsed.index, 725) 47 | self.assertEqual(parsed.page_type, "block_artists") 48 | self.assertEqual(parsed.entry_count_small, 4) 49 | self.assertEqual(parsed.entry_count, 4) 50 | self.assertEqual(len(parsed.entry_list), 1) 51 | self.assertEqual(parsed.entry_list[0].entry_count, 4) 52 | self.assertEqual(len(parsed.entry_list[0].entries), 4) 53 | 54 | long_entry = parsed.entry_list[0].entries[0] 55 | self.assertEqual(long_entry.entry_start, 184) 56 | self.assertEqual(long_entry.id, 1446) 57 | self.assertEqual(long_entry.name_idx, 12) 58 | self.assertEqual(long_entry.name, u'Nasri Atweh/Louis Bell/Hiten Bharadia/Mark Bradford/Frank Buelles/Clifton Dillon/Ryan Dillon/Björn Djupström/Sly Dunbar/Nathan') 59 | --------------------------------------------------------------------------------