Metadata-Version: 2.4
Name: musicdropper
Version: 1.0.1
Summary: KDE drop target that copies music to a folder/device, converting it to a chosen format
Author-email: Adrian Knagg-Baugh <10564093-aje.baugh@users.noreply.gitlab.com>
License: GPL-3.0-or-later
Project-URL: Homepage, https://gitlab.com/aje.baugh/musicdropper
Project-URL: Issues, https://gitlab.com/aje.baugh/musicdropper/-/issues
Project-URL: Changelog, https://gitlab.com/aje.baugh/musicdropper/-/blob/main/CHANGELOG.md
Keywords: music,mp3,convert,transcode,mtp,kde,playlist
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: X11 Applications :: KDE
Classifier: Environment :: X11 Applications :: Qt
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: PyQt6>=6.5
Requires-Dist: imageio-ffmpeg>=0.5
Dynamic: license-file

<div align="center">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/branding/musicdropper-logo-dark.svg">
  <img src="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/branding/musicdropper-logo.svg" alt="MusicDropper" width="394">
</picture>
</div>

# MusicDropper

[![pipeline status](https://gitlab.com/aje.baugh/musicdropper/badges/main/pipeline.svg)](https://gitlab.com/aje.baugh/musicdropper/-/pipelines)

A small KDE Plasma app to drop music onto a target folder (a mounted MP3 player,
phone, SD card, car USB stick…). Dropped files are **copied, never moved**, and
converted to a chosen format. The default format is high-quality MP3 (LAME VBR V0).

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/main-window-dark.png">
  <img src="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/main-window.png" alt="MusicDropper converting and copying music to a Garmin watch" width="600">
</picture>

## Behaviour

| Dropped file | What happens |
|---|---|
| Different codec (FLAC, WAV, Ogg, AAC… when the target is MP3) | Converted; tags and cover art kept (cover art isn't kept for Ogg/Opus targets) |
| Already in the target codec (e.g. any MP3 when the target is MP3) | Copied byte-for-byte, **whatever its bitrate**: re-encoding a 128k MP3 gains nothing, and re-encoding a 320k one loses quality. The one exception is a sample rate above the limit (below) |
| Right codec, wrong container (e.g. raw `.aac` with the AAC/M4A target) | Remuxed into the right container, without re-encoding |
| Already exists at the destination | Skipped by default (or overwrite / keep both) |
| Not audio, or a hidden file | Ignored |

**Device bitrate limit** (off by default): for devices that can't play files above
some bitrate. When it is on, files already in the target format whose average
bitrate is above the limit are re-encoded. All conversions are capped at the
limit as well. For VBR profiles this means switching to the highest standard
constant bitrate within the limit, because VBR frames can peak above the average
(MP3 VBR peaks at 320 kbps).

**Max sample rate** (48 kHz by default, saved between sessions): files with a higher
sample rate are resampled, including files already in the target format, which are
otherwise copied unchanged. Some devices mishandle hi-res audio; Garmin watches, for
example, are reported to play 96 kHz files at the wrong speed. Sources in the 44.1 kHz
family (88.2, 176.4 kHz) go to 44.1 kHz, and others to 48 kHz, or the highest rate
within a lower limit. Choose **No limit** to keep every file's rate where the format
allows it. Opus always plays at 48 kHz, so this setting doesn't affect it, and MP3
can't exceed 48 kHz anyway.

**Replace existing music** (button; off by default and *not* saved): for the next
drop only, first delete the music already in the target, then copy the dropped
music. This is useful when a watch or player has little storage. It works like this:

* A confirmation dialog shows how many files will go and lists them. Cancel is the
  default button.
* Only audio files are deleted, including those in subfolders. Other files
  (playlists, device settings) are kept, and folders are removed only once they are empty.
* The app refuses if any dropped music lives inside the target, because deleting
  would destroy the source.
* The button switches itself off after every drop, whether you confirmed or cancelled.
* Command line: `--batch --replace-existing` asks you to type `yes`. `--yes` skips the
  question, and the command refuses when not run in a terminal.

**Playlists**: drop an `.m3u`, `.m3u8` or `.pls` file and the tracks it lists are
copied (and converted) like dropped files. Then the playlist is written to the target:

* If you set your **music library** folder (in the window, or `--library DIR`), a track
  inside it is copied to the same path relative to the target, so the target mirrors your
  library: `Björk/Possibly Maybe.mp3`, `Artist/Album/CD1/01.mp3`. Otherwise, and for
  tracks outside the library, a track is filed as `Artist/Album/file`, taken from its last
  two folders, plus a disc folder such as `CD1`. That rule can't tell a loose track in an
  artist folder from one in an album folder, so `Björk/Possibly Maybe.mp3` would land in
  `<library folder name>/Björk/`.
* Either way a song lands in the same place whichever playlist lists it, so playlists
  that share songs don't duplicate them.
* The playlist goes in the target folder as UTF-8 `.m3u8`, named after the dropped
  playlist, with paths relative to itself. It points at the files as written, with
  converted extensions, FAT-safe names and any "(1)" renames.
* It is written after its tracks are done. Tracks that are missing or failed are left
  out and listed under the playlist in the window. Stream URLs and Windows paths
  (`C:\…`) are left out too.
* A track already on the target is kept in the playlist. If you also drop it directly,
  it is copied once, to wherever the direct drop puts it.
* The playlist file follows the "Existing files" option: by default an existing playlist
  of the same name is left alone, so choose **Overwrite** to update one.
* Playlists inside a dropped *folder* are ignored; drop the playlist itself.

<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/playlist-dark.png">
  <img src="https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/playlist.png" alt="A dropped playlist written to the watch, with a stream URL left out" width="600">
</picture>

Garmin lists `.m3u` and `.m3u8` among the playlist formats its music watches support
natively, so playlists copied this way shouldn't need Garmin Express.

**Free space**: before copying anything, MusicDropper estimates how much space the drop
will take and compares it with what's free on the target. Files copied unchanged count
at their own size, and converted files at duration × output bitrate. 32 MB plus 2% is
kept free as a margin.

* If it won't fit, the window asks **Copy Anyway / Cancel**, with Cancel the default
  (screenshot: [light](https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/free-space.png), [dark](https://gitlab.com/aje.baugh/musicdropper/-/raw/main/screenshots/free-space-dark.png)). In
  batch mode you're asked in the terminal. When there's nobody to ask, the batch stops,
  unless `--ignore-space` is given.
* Each file is checked again just before it is written. If the target fills up anyway,
  copying stops there and the remaining files are reported as cancelled.
* Free space comes from the filesystem for folders and mounted devices, and from KDE's
  MTP service (`kmtpd`) for `mtp:` phones and watches. `smb:` and `sftp:` targets can't
  report it, so they aren't checked.

Other details:
* A dropped folder keeps its name and inner structure at the target.
* Output goes to a hidden temp file that is renamed into place only on success. A
  failed or cancelled conversion leaves no partial files behind. Sources are only
  ever read.
* "FAT-safe file names" (on by default) replaces `<>:"\|?*` in output names, because
  most players use FAT/exFAT.
* Several files are converted in parallel, one per CPU core.

## Phones, watches and other KIO targets

The target can be a local/mounted folder or any KDE (KIO) URL. Pick it in the
folder dialog, or type it, e.g. `mtp:/fenix 8 - 47mm/Internal Storage/Music`,
`smb://nas/music` or `sftp://host/music`. KIO targets are written through
`kioclient`, which uses the same KIO workers as Dolphin.

MTP behaviour seen on a real device (Garmin fenix 8), and how it's handled:

* **Copy to a device from one program at a time.** Two transfers to the same device at
  once can lose or corrupt files, while each reports success. MusicDropper looks after
  itself: one MusicDropper at a time uses a device. If another one (say a `--batch` run
  while the window is copying) is already using it, the window offers to **Wait** until
  it has finished, and `--batch` stops without copying anything (`--wait` makes it wait
  instead). Other programs, such as Dolphin or Garmin Express, don't know about this:
  **don't copy to the device with them while MusicDropper is copying to it**, or the
  other way round. (Mistakes are unlikely to be silent, as each upload is size-checked,
  but a file written by the other program at the very name MusicDropper is copying
  could be lost.)

* **Concurrent transfers silently lose files.** Uploads are reported as
  successful, yet the files are missing or truncated. Conversions still run in
  parallel locally, but every device operation is serialised, and other MusicDropper
  processes are kept off the device (above). Each upload is checked against the size
  the device reports, and retried once if it is wrong.
* Existing files can't be overwritten, not even by renaming onto them. So "Overwrite"
  uploads the new file under a hidden temporary name (`.musicdropper-…`) and checks it,
  then removes the old file and renames the new one into place: a failed upload never
  costs the old file. If the rename then fails, the new file is uploaded again under
  its real name; if even that fails, the hidden copy is kept and its name reported, so
  the music is never lost from the device. New files are copied straight to their names.
* `mkdir` isn't recursive, so folders are created level by level.
* Names that differ only in case collide, so existence checks ignore case.
* A missing target folder is never silently recreated. It usually means the device
  was unplugged or is locked.
* Cancelled or failed uploads are removed from the device. Exceptions, in case another
  program has just written that name: if the device refuses a copy and a file then has
  that name, it is left alone and reported as possibly incomplete; and after a cancel,
  a file there at least as big as MusicDropper's is left alone.
* The watch's Music folder only accepts audio files and `.m3u`/`.m3u8` playlists; other
  files (`.txt`, `.pls`, images) are refused. MusicDropper only writes those two kinds.
* Unplugging during a batch fails the remaining files with "could not write to file".
  The fenix 8 discards the file that was being written, so plug back in and drop the
  same music again: what's already there is skipped and the rest is copied. Other devices
  may behave differently; if one keeps a partial file, delete it before dropping again.
* If MusicDropper itself is killed or crashes mid-transfer, a hidden `.musicdropper-…`
  file may be left in the target folder (for a local folder too). MusicDropper ignores
  such files, but other software, possibly the device itself, might show them; they
  can simply be deleted.

## Sources: network shares and symlinks

* Mounted shares (fstab, `mount.cifs`, NFS, `/run/media/…`) are ordinary folders.
* Network locations browsed in Dolphin without mounting (`smb://`, `nfs://`, `sftp://`…)
  can be dragged in or passed on the command line. They are mapped to local paths
  through **kio-fuse** (`kio-fuse` package, part of a standard Plasma install). `mtp:`
  devices can only be used as targets.
* Symbolic links are followed, including symlinked album folders inside a dropped
  folder. Each real folder is visited once, so link loops are harmless, and music
  reachable by several links is copied once.
* Broken links and unreadable files are reported as errors, not skipped silently.
* If the *target* is on a network filesystem that refuses permission or time changes,
  the files are still written; only that metadata is skipped.

## KDE integration

* Application menu entry. "Open With" works for audio files too.
* Dolphin context menu: **Copy & Convert with MusicDropper** on audio files, playlists and folders.
* Drag files onto the window, onto its **task manager entry**, or onto a pinned
  launcher/desktop icon.
* Single instance: further drops and context-menu calls go to the open window.
* Progress is shown on the Plasma task manager entry. A desktop notification
  appears when a batch finishes while the window isn't focused.
* Uses the system Qt when available, so it gets the Breeze theme, icons and native KDE file dialogs.
* `install.sh` installs the app icon into `~/.local/share/icons/hicolor`. Without it the
  window and notifications use the copy bundled in `musicdropper/icons/`.

## Dependencies

MusicDropper is a Linux app, built for KDE Plasma.

**Required**

* **Python 3.10 or later.**
* **PyQt6** and **imageio-ffmpeg** from PyPI. They are listed in `requirements.txt`,
  `pyproject.toml` and the PEP 723 block in `run.py`. imageio-ffmpeg provides a static
  **ffmpeg** with the LAME (MP3), Vorbis and Opus encoders, so no system ffmpeg is needed.

You don't have to install these yourself: `install.sh` does it, or `uv run` when running
from the checkout (see [Installing](#installing)).

**Recommended: [uv](https://docs.astral.sh/uv/)** to manage the Python packages.
Without it everything still works through `python3 -m venv` and pip, but uv:

* is much faster at creating the environment and installing the packages;
* doesn't need the `python3-venv` package (Debian and Ubuntu leave it out of `python3`);
* can download a suitable Python if the system's is older than 3.10;
* runs the app straight from the checkout (`uv run run.py`) and runs the tests against
  the locked versions in `uv.lock`.

Install it from your distribution if it packages uv, with
`curl -LsSf https://astral.sh/uv/install.sh | sh`, or with `pipx install uv`.

**Optional system packages** (Debian/Ubuntu names; a standard Plasma desktop has
most of them)

* `python3-pyqt6`: PyQt6 built against the system Qt. With it the app uses the
  Breeze theme and native KDE file dialogs; the PyPI PyQt6 brings its own Qt and
  doesn't. The installer uses it when it's present, with or without uv.
* `ffmpeg`: used instead of the bundled build when it's on `PATH` (with its `ffprobe`
  for reading files; without ffprobe the same details come from `ffmpeg -i`). To choose
  a build, set `MUSICDROPPER_FFMPEG`, and `MUSICDROPPER_FFPROBE` (empty means never
  use ffprobe).
* `kde-cli-tools` (kioclient) and `kio-extras`: needed for phones and watches (`mtp:`)
  and other KIO targets.
* `kio-fuse`: lets you drop music from `smb:`, `sftp:` and `nfs:` locations.
* `python3-venv`: only needed without uv, so `install.sh` can create a virtual
  environment with the standard library's `venv`.

## Installing

Get a release from the [releases page](https://gitlab.com/aje.baugh/musicdropper/-/releases)
and unpack it, or clone the current release with git:

```sh
git clone --branch v1.0.1 https://gitlab.com/aje.baugh/musicdropper.git
cd musicdropper
```

Then, from that folder:

```sh
./install.sh            # per-user: ~/.local/bin, app menu, Dolphin action, icon, metainfo
./install.sh --venv     # the same, but always use a virtual environment
./install.sh --uninstall
```

The installer sets up the Python packages using the first of these that works:

1. **The system Python as it is**, if it already has PyQt6 and an ffmpeg is available.
   Nothing is installed.
2. Otherwise **a virtual environment** in `~/.local/share/musicdropper/venv`:
   * **with uv (preferred)** if it is installed;
   * otherwise with `python3 -m venv` and pip.

   A system PyQt6 is still used if there is one, so only the missing packages are
   downloaded.

**From PyPI.** `uv tool install musicdropper` (or `pipx install musicdropper`) installs
the `musicdropper` command, with everything it needs from PyPI, including ffmpeg. It
doesn't add the application-menu entry, the Dolphin action or the icon, and the
PyPI PyQt6 doesn't use the Breeze theme or KDE's file dialogs: for those, use
`install.sh` as above.

Running `./install.sh` again (after installing uv, say) brings the environment up to
date. `--uninstall` removes it along with the launcher, menu entries, icon and metainfo;
your settings are kept. The metainfo (`~/.local/share/metainfo`) describes MusicDropper to
software centres such as Discover.

The installed `musicdropper` launcher (`bin/musicdropper`) uses the system Python if it
is complete, else the installer's environment, else `uv run`. Because the launcher is a
link into this checkout, a `git pull` updates the installed app; run `./install.sh`
again if `requirements.txt` changed.

To run from the checkout without installing anything, use uv. It fetches the
packages on first run:

```sh
uv run run.py
```

## Running

```sh
musicdropper [FILES/FOLDERS...]   # opens the window (or forwards to the open one) and copies them
musicdropper --batch -t /media/me/PLAYER/Music ~/Music/Album   # headless
musicdropper --batch -t DIR -f opus-160 --max-kbps 128 FILES   # other format, with limit
musicdropper --batch -t DIR -f flac --max-rate none FILES      # keep hi-res sample rates
musicdropper --batch -t DIR ~/Playlists/Running.m3u            # a playlist and its tracks
musicdropper --batch -t DIR --library ~/Music Running.m3u      # tracks mirror ~/Music's layout
musicdropper --batch -t "mtp:/fenix 8 - 47mm/Internal Storage/Music" *.flac   # to a watch
musicdropper --batch --wait -t "mtp:/…" *.flac   # wait if another MusicDropper is using it
musicdropper --list-formats
```

`uv run run.py` takes the same arguments.

The **About** button, to the right of the target folder buttons, shows the version,
licence and project links, plus the Python, Qt, PyQt and ffmpeg versions in use. Please
include those versions when reporting a problem.

Settings (target, recent targets, format, options, window size) are stored in
`~/.config/musicdropper/settings.json`. Command-line options in `--batch` mode
override them for that run only.

## Tests

With uv (preferred):

```sh
uv run --group dev pytest
uv run --group dev ruff check .     # lint, as CI does
```

Without uv, use the installer's environment (or any virtual environment with the
`requirements.txt` packages) and add pytest:

```sh
~/.local/share/musicdropper/venv/bin/python -m pip install pytest
~/.local/share/musicdropper/venv/bin/python -m pytest
```

The tests generate small audio fixtures with ffmpeg. The GUI tests run on Qt's offscreen
platform. If the sample `.flac`/`.mp3` files are in the project root (they are
git-ignored), an extra integration test runs on them. KIO handling is tested against a
fake `kioclient` (`tests/fake_kioclient.py`) that reproduces the MTP quirks above. To
round-trip through a real device (the test creates a scratch subfolder and removes it):

```sh
MUSICDROPPER_TEST_KIO_URL="mtp:/<device>/Internal Storage/Music" uv run --group dev pytest -k real_device
```

GitLab CI (`.gitlab-ci.yml`) runs on every push and merge request:

* **lint:** ruff, the lockfile, the `.desktop` file and shell syntax.
* **test:** the whole suite on Python 3.10 and 3.14 with Debian's ffmpeg.
* **test-pypi:** the whole suite again with only PyPI packages: the bundled ffmpeg and no
  ffprobe.
* **install-no-uv:** runs `install.sh` without uv, converts a file with the installed
  launcher, then uninstalls.

The real-device tests are skipped there.

The screenshots are rendered by `python3 tools/screenshots.py`. It uses the system
PyQt6 with Breeze Light and Breeze Dark at 2× scale, needs no display, and should be
re-run after UI changes.

Changes are recorded in [CHANGELOG.md](https://gitlab.com/aje.baugh/musicdropper/-/blob/main/CHANGELOG.md). Source files carry SPDX licence
lines, and a test checks that every tracked source file has them.

## Layout

```
musicdropper/profiles.py   output formats and their encoder settings
musicdropper/probe.py      finding ffmpeg, reading file details (ffprobe or ffmpeg -i)
musicdropper/core.py       expanding drops, decisions, naming, conflict handling, copy/transcode (no Qt)
musicdropper/playlists.py  reading dropped playlists, writing them to the target
musicdropper/space.py      free space on the target, size estimates
musicdropper/targets.py    local folder vs KIO URL targets (kioclient, MTP workarounds)
musicdropper/settings.py   persisted settings
musicdropper/gui.py        PyQt6 window, drop zone and About dialog
musicdropper/kde.py        D-Bus notifications, task-manager progress, app icon
musicdropper/icons/        app icon (freedesktop hicolor layout)
musicdropper/app.py        CLI, single-instance handling
bin/musicdropper           launcher (system Python, installer's venv, or uv)
install.sh                 per-user install/uninstall (see Installing)
run.py                     uv entry point with PEP 723 dependencies
requirements.txt           dependencies for pip installs (kept in sync with pyproject.toml)
pyproject.toml, uv.lock    package metadata (version from musicdropper/__init__.py) and locked dev environment
CHANGELOG.md               release notes
.gitlab-ci.yml             CI: lint, tests (two Pythons, PyPI-only ffmpeg), install without uv
data/                      .desktop file, Dolphin service menu, AppStream metainfo
screenshots/               README and AppStream screenshots (tools/screenshots.py)
tools/                     developer scripts
tests/                     pytest suite, fake kioclient
branding/                  logos and project avatar (see branding/README.md)
```
