Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions appxmanifest.xml.in
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
</uap:SupportedFileTypes>
</uap:FileTypeAssociation>
</uap:Extension>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="%(protocol)s">
<uap:Logo>Square44x44Logo.targetsize-44_altform-unplated.png</uap:Logo>
<uap:DisplayName>%(display-name)s</uap:DisplayName>
Comment thread
zas marked this conversation as resolved.
</uap:Protocol>
</uap:Extension>
</Extensions>
</Application>
</Applications>
Expand Down
7 changes: 7 additions & 0 deletions installer/picard-setup.nsi.in
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
!define PRODUCT_DESCRIPTION "%(description)s"
!define PRODUCT_URL "%(url)s"
!define PRODUCT_HELP_URL "https://picard-docs.musicbrainz.org/"
!define PRODUCT_CUSTOM_PROTOCOL "%(protocol)s"
!define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}"
!define PRODUCT_UNINST_ROOT_KEY "HKLM"

Expand Down Expand Up @@ -142,6 +143,11 @@ Section "!$(SectionRequired)" required
; Write the installation path into the registry
WriteRegStr HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}" "InstallDir" "$INSTDIR"

; Register a custom protocol handler
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}" "URL Protocol" ""
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}\DefaultIcon" "" "$INSTDIR\picard.exe,1"
WriteRegStr HKCR "${PRODUCT_CUSTOM_PROTOCOL}\shell\open\command" "" "$INSTDIR\picard.exe %%1"

; Create uninstaller
Comment thread
phw marked this conversation as resolved.
WriteUninstaller "$INSTDIR\uninst.exe"
WriteRegStr ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}"
Expand Down Expand Up @@ -199,6 +205,7 @@ Section Uninstall

DeleteRegKey ${PRODUCT_UNINST_ROOT_KEY} "${PRODUCT_UNINST_KEY}"
DeleteRegKey HKLM "Software\${PRODUCT_PUBLISHER}\${PRODUCT_NAME}"
DeleteRegKey HKCR "${PRODUCT_CUSTOM_PROTOCOL}"

!insertmacro INSTALLOPTIONS_READ $R0 "removeSettings.ini" "Field 1" "State"
StrCmp $R0 "1" 0 +2
Expand Down
6 changes: 3 additions & 3 deletions org.musicbrainz.Picard.desktop.in
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
[Desktop Entry]
Name=MusicBrainz Picard
Comment=Tag your music with the next generation MusicBrainz tagger
Exec=picard %F
Exec=picard %U
Terminal=false
Type=Application
StartupNotify=true
StartupWMClass=Picard
Icon=org.musicbrainz.Picard
Categories=AudioVideo;Audio;AudioVideoEditing;
MimeType=application/ogg;application/x-flac;audio/aac;audio/ac3;audio/aiff;audio/ape;audio/dsf;audio/flac;audio/midi;audio/mp4;audio/mpeg;audio/mpeg4;audio/mpg;audio/ogg;audio/vorbis;audio/x-aac;audio/x-aiff;audio/x-ape;audio/x-flac;audio/x-flac+ogg;audio/x-m4a;audio/x-midi;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-ms-wma;audio/x-ms-wmv;audio/x-musepack;audio/x-oggflac;audio/x-speex;audio/x-speex+ogg;audio/x-tak;audio/x-tta;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-wavpack;audio/x-wma;video/x-ms-asf;video/x-theora;video/x-wmv;
MimeType=x-scheme-handler/org.musicbrainz.picard;application/ogg;application/x-flac;audio/aac;audio/ac3;audio/aiff;audio/ape;audio/dsf;audio/flac;audio/midi;audio/mp4;audio/mpeg;audio/mpeg4;audio/mpg;audio/ogg;audio/vorbis;audio/x-aac;audio/x-aiff;audio/x-ape;audio/x-flac;audio/x-flac+ogg;audio/x-m4a;audio/x-midi;audio/x-mp3;audio/x-mpc;audio/x-mpeg;audio/x-ms-wma;audio/x-ms-wmv;audio/x-musepack;audio/x-oggflac;audio/x-speex;audio/x-speex+ogg;audio/x-tak;audio/x-tta;audio/x-vorbis;audio/x-vorbis+ogg;audio/x-wav;audio/x-wavpack;audio/x-wma;video/x-ms-asf;video/x-theora;video/x-wmv;
Actions=new-window;

[Desktop Action new-window]
Name=New Window
Exec=picard --stand-alone-instance %F
Exec=picard --stand-alone-instance %U
8 changes: 8 additions & 0 deletions picard.spec
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ from picard import (
PICARD_APP_NAME,
PICARD_DISPLAY_NAME,
PICARD_ORG_NAME,
PICARD_PROTOCOL_SCHEME,
PICARD_VERSION,
__version__,
)
Expand Down Expand Up @@ -230,6 +231,13 @@ else:
'CFBundleTypeRole': 'Editor',
}
],
'CFBundleURLTypes': [
{
'CFBundleURLName': PICARD_APP_ID,
'CFBundleURLSchemes': [PICARD_PROTOCOL_SCHEME],
'CFBundleURLIconFile': 'picard',
}
],
}

# Add additional supported file types by extension
Expand Down
4 changes: 3 additions & 1 deletion picard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
PICARD_VERSION = Version(3, 0, 0, 'beta', 9)
COPYRIGHT_YEARS = "2004-2026"


PICARD_VERSION_STR = str(PICARD_VERSION)
__version__ = PICARD_VERSION.short_str()
PICARD_FANCY_VERSION_STR = __version__
Expand All @@ -63,6 +62,9 @@
__version__ = f"{__version__}+{PICARD_BUILD_VERSION_STR}"
PICARD_FANCY_VERSION_STR = f"{PICARD_FANCY_VERSION_STR} ({PICARD_BUILD_VERSION_STR})"

# Custom protocol for browser integration
PICARD_PROTOCOL_SCHEME = 'org.musicbrainz.picard'

# Keep those ordered
api_versions = ("3.0",)

Expand Down
70 changes: 55 additions & 15 deletions picard/browser/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@
)

from PyQt6 import QtCore
from PyQt6.QtGui import QDesktopServices

from picard import (
PICARD_APP_NAME,
PICARD_ORG_NAME,
PICARD_PROTOCOL_SCHEME,
PICARD_VERSION_STR,
log,
tagger_instance,
Expand Down Expand Up @@ -80,6 +82,8 @@ class BrowserIntegration(QtCore.QObject):
def __init__(self, parent=None):
super().__init__(parent)
self.server = None
self._action_handler = RequestActionHandler()
QDesktopServices.setUrlHandler(PICARD_PROTOCOL_SCHEME, self.url_handler)

@property
def host_address(self):
Expand All @@ -99,7 +103,7 @@ def is_running(self):

def start(self):
if self.server:
self.stop()
self.stop(stop_url_handler=False)

config = get_config()

Expand Down Expand Up @@ -134,7 +138,7 @@ def start(self):
except Exception:
log.error("%s: Failed to start listening on %s", LOG_PREFIX, host_address, exc_info=True)

def stop(self):
def stop(self, stop_url_handler=True):
if self.server:
try:
log.info("%s: Stopping", LOG_PREFIX)
Expand All @@ -147,6 +151,19 @@ def stop(self):
else:
log.debug("%s: inactive, no need to stop", LOG_PREFIX)

if stop_url_handler:
QDesktopServices.unsetUrlHandler(PICARD_PROTOCOL_SCHEME)

def url_handler(self, url: QtCore.QUrl):
"""URL handler used for custom protocol handling."""
log.debug("Received custom URL: %s", url.toString())

if url.scheme() != PICARD_PROTOCOL_SCHEME:
log.error("Invalid URL scheme: %s", url.scheme())
return

self._action_handler.handle_get(url.toString())


# From https://github.com/python/cpython/blob/f474264b1e3cd225b45cf2c0a91226d2a9d3ee9b/Lib/http/server.py#L570C1-L573C43
# https://en.wikipedia.org/wiki/List_of_Unicode_characters#Control_codes
Expand All @@ -159,6 +176,10 @@ def safe_message(message):


class RequestHandler(BaseHTTPRequestHandler):
def __init__(self, *args, **kwargs):
self._action_handler = RequestActionHandler(self._response)
super().__init__(*args, **kwargs)

def do_OPTIONS(self):
origin = self.headers['origin']
if _is_valid_origin(origin):
Expand All @@ -184,7 +205,7 @@ def do_GET(self):
return

try:
self._handle_get()
self._action_handler.handle_get(self.path)
except Exception:
log.error('%s: failed handling request', LOG_PREFIX, exc_info=True)
self._response(500, 'Unexpected request error')
Expand All @@ -203,10 +224,33 @@ def log_error(self, format, *args):
def log_message(self, format, *args):
self._log(log.info, format, args)

def _handle_get(self):
parsed = urlparse(self.path)
def _response(self, code, content='', content_type='text/plain'):
self.server_version = SERVER_VERSION
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Cache-Control', 'max-age=0')
origin = self.headers['origin']
if _is_valid_origin(origin):
self.send_header('Access-Control-Allow-Origin', clean_header(origin))
self.send_header('Vary', 'Origin')
self.end_headers()
self.wfile.write(content.encode())


class RequestActionHandler:
"""
Handles URL requests by executing the appropriate action based on URL path.
"""

def __init__(self, response_handler=None):
self._response_handler = response_handler

def handle_get(self, path):
parsed = urlparse(path)
args = parse_qs(parsed.query)
action = parsed.path
if not action.startswith('/'):
action = '/' + action

if action == '/':
self._response(200, SERVER_VERSION)
Expand All @@ -219,6 +263,7 @@ def _handle_get(self):
elif action == '/auth':
self._auth(args)
else:
log.error('Unknown browser action: %s', action)
self._response(404, 'Unknown action.')

def _load_mbid(self, type, args):
Expand Down Expand Up @@ -266,16 +311,11 @@ def _auth(self, args):
self._response(400, 'Missing parameter "code".')

def _response(self, code, content='', content_type='text/plain'):
self.server_version = SERVER_VERSION
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Cache-Control', 'max-age=0')
origin = self.headers['origin']
if _is_valid_origin(origin):
self.send_header('Access-Control-Allow-Origin', clean_header(origin))
self.send_header('Vary', 'Origin')
self.end_headers()
self.wfile.write(content.encode())
if not self._response_handler:
log.debug(f'Finished custom request with code {code}')
return

self._response_handler(code, content, content_type)


def clean_header(header):
Expand Down
4 changes: 4 additions & 0 deletions picard/remotecommands/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from urllib.parse import urlparse

from picard import (
PICARD_PROTOCOL_SCHEME,
log,
tagger_instance,
)
Expand All @@ -71,6 +72,7 @@ def __init__(self, items):
self.files = set()
self.mbids = set()
self.urls = set()
self.custom_urls = set()

for item in items:
parsed = urlparse(item)
Expand All @@ -85,6 +87,8 @@ def __init__(self, items):
elif parsed.scheme in {'http', 'https'}:
# .path returns / before actual link
self.urls.add(parsed.path[1:])
elif parsed.scheme == PICARD_PROTOCOL_SCHEME:
self.custom_urls.add(item)
elif IS_WIN and self.WINDOWS_DRIVE_TEST.match(item):
# Treat all single-character schemes as part of the file spec to allow
# specifying a drive identifier on Windows systems.
Expand Down
26 changes: 16 additions & 10 deletions picard/tagger.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
PICARD_APP_NAME,
PICARD_FANCY_VERSION_STR,
PICARD_ORG_NAME,
PICARD_PROTOCOL_SCHEME,
acoustid,
config as _cfg,
log,
Expand Down Expand Up @@ -847,7 +848,7 @@ def update_browser_integration(self):
if config.setting['browser_integration']:
self.browser_integration.start()
else:
self.browser_integration.stop()
self.browser_integration.stop(stop_url_handler=False)

_BATCH_TIME_BUDGET = 0.050 # 50ms per batch (~20 yields/sec)
_callback_queue: ClassVar[list] = []
Expand Down Expand Up @@ -887,14 +888,19 @@ def event(self, event):
self._callback_timer_running = True
QtCore.QTimer.singleShot(0, self._process_callback_batch)
elif event.type() == QtCore.QEvent.Type.FileOpen:
file = event.file()
self.add_paths([file])
if IS_HAIKU:
self.bring_tagger_front()
# We should just return True here, except that seems to
# cause the event's sender to get a -9874 error, so
# apparently there's some magic inside QFileOpenEvent...
return 1
url = event.url()
log.debug('Received file open event: %r', url)
if url.isLocalFile():
self.add_paths([url.toLocalFile()])
if IS_HAIKU:
self.bring_tagger_front()
# We should just return True here, except that seems to
# cause the event's sender to get a -9874 error, so
# apparently there's some magic inside QFileOpenEvent...
return 1
elif url.scheme() == PICARD_PROTOCOL_SCHEME:
self.browser_integration.url_handler(url)
return 1
return super().event(event)

def _process_callback_batch(self) -> None:
Expand Down Expand Up @@ -1720,7 +1726,7 @@ def process_cmdline_args():

args.processable = []
for path in args.FILE_OR_URL:
if not urlparse(path).netloc:
if not urlparse(path).scheme:
try:
path = os.path.abspath(path)
except FileNotFoundError:
Expand Down
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
PICARD_APP_NAME,
PICARD_DESKTOP_NAME,
PICARD_DISPLAY_NAME,
PICARD_PROTOCOL_SCHEME,
PICARD_VERSION,
)

Expand Down Expand Up @@ -164,6 +165,7 @@ def run(self):
installer_args = {
'display-name': PICARD_DISPLAY_NAME,
'file-version': file_version_str,
'protocol': PICARD_PROTOCOL_SCHEME,
}
if os.path.isfile('installer/picard-setup.nsi.in'):
generate_file(
Expand Down Expand Up @@ -419,6 +421,7 @@ def run(self):
'short-name': PICARD_APP_NAME,
'publisher': os.environ.get('PICARD_APPX_PUBLISHER', default_publisher),
'version': '.'.join(str(v) for v in store_version),
'protocol': PICARD_PROTOCOL_SCHEME,
},
)

Expand Down
Loading