picard

GitHub

Picard is a cross-platform music tagger powered by the MusicBrainz database

RAW Rules

AGENTS.md

# AI Assistant Context for MusicBrainz Picard

> **Purpose:** Essential patterns, conventions, and gotchas for AI assistants.
> For architecture, dependencies, and component details, explore the codebase
> or read existing docs (`README.md`, `CONTRIBUTING.md`, `INSTALL.md`).

**What is Picard?** Cross-platform audio tagger for MusicBrainz. Tags music files using the MusicBrainz database, supports fingerprinting (AcoustID), and has a powerful plugin system.

---

## Quick Facts

- **Language:** Python 3.10+, PyQt6, Mutagen
- **Size:** Event-driven MVC with plugin system
- **Entry Point:** `picard/tagger.py:main()` → `Tagger` singleton
- **Tests:** `test/` directory (pytest)
- **Docs:** <https://picard-docs.musicbrainz.org/>
- **Tickets:** <https://tickets.metabrainz.org/projects/PICARD>
- **Chat:** [Matrix #musicbrainz-picard-dev](https://matrix.to/#/#musicbrainz-picard-dev:chatbrainz.org)

---

## Critical Patterns & Gotchas

### Threading Rules
```python
# ❌ NEVER access UI from background threads
def background_task():
    self.label.setText("Done")  # CRASH!


# ✅ Use signals or run_task callback
from picard.util.thread import run_task


def callback(result):
    self.label.setText(result)  # Safe - runs in main thread


run_task(heavy_operation, callback=callback)
```

### Metadata Changes
```python
# ❌ Silent changes won't update UI
metadata['artist'] = 'New Artist'

# ✅ Always emit signals
metadata['artist'] = 'New Artist'
file.metadata_updated.emit()
```

### Metadata Access
```python
# Metadata is always stored internally as a list of strings.

# ❌ Don't use get() + split on MULTI_VALUED_JOINER to get individual values
values = metadata.get('artist', '').split(MULTI_VALUED_JOINER)

# ✅ Use getall() to get individual values as a list
values = metadata.getall('artist')  # Returns: ['Artist 1', 'Artist 2']

# ✅ Use get() or [] only for display (returns joined string)
display = metadata['artist']  # Returns: 'Artist 1; Artist 2'

# ✅ Set multiple values by passing a list (don't join them)
metadata['artist'] = ['Artist 1', 'Artist 2']
```

### Configuration Access
```python
# ✅ Always use config.setting
from picard import config

value = config.setting['option_name']

# ❌ Don't access internal structures directly
```

### Async Operations
```python
# ✅ Use thread pool for I/O
from picard.util.thread import run_task

run_task(process_file, file, callback)

# ❌ Don't block the main thread
result = slow_network_call()  # UI freezes!
```

### Import Rules
```python
# ❌ Don't use inline imports (unless breaking circular dependencies)
def my_function():
    from picard.some_module import something

    return something()


# ❌ Don't put multiple imports on one line
from picard.plugin3.validator import generate_uuid, validate_manifest_dict

# ✅ Imports go at the top of the file, each imported name on its own line with trailing commas
from picard.plugin3.validator import (
    generate_uuid,
    validate_manifest_dict,
)
```

**Exception:** Inline imports are acceptable only to break circular dependencies. Place them as close to usage as possible with a comment explaining why.

---

## Plugin System (v3)

### Structure
```text
my_plugin/
├── __init__.py       # Plugin code
├── MANIFEST.toml     # Metadata (UPPERCASE!)
└── ui_options.py     # Optional settings
```

### MANIFEST.toml
```toml
uuid = "unique-uuid"
name = "Plugin Name"
authors = ["Your Name"]
description = "Description"
api = ["3.0"]
categories = ["metadata"]
license = "GPL-2.0-or-later"
license_url = "https://www.gnu.org/licenses/gpl-2.0.html"
```

### Plugin Code
```python
from picard.plugin3.api import PluginApi


def enable(api: PluginApi):
    """Called when plugin is enabled."""
    api.plugin_config.register_option("my_option", "default")
    api.register_track_metadata_processor(process_metadata)


def process_metadata(api, album, metadata, track, release):
    metadata['custom'] = api.plugin_config['my_option']
```

### Plugin Management CLI
```bash
# List installed plugins
picard-cli plugins list

# Install plugin from registry
picard-cli plugins install plugin-name

# Install from URL or local path
picard-cli plugins install https://github.com/user/plugin.git
picard-cli plugins install /path/to/plugin

# Update plugins
picard-cli plugins update plugin-name

# Remove plugin
picard-cli plugins remove plugin-name

# Search registry
picard-cli plugins search keyword
```

**Key Files:**
- API: `picard/plugin3/api_impl.py`
- Manager: `picard/plugin3/manager/__init__.py`
- CLI: `picard/plugin3/cli.py`
- Example: <https://github.com/rdswift/picard-plugin-format-performer-tags>

**Documentation:**
- Plugin v3 docs: `docs/PLUGINSV3/` (API, CLI, manifest, translations, etc.)
- Migration guide: `docs/Plugin2to3MigrationGuide.md`
- Plugin registry: <https://github.com/metabrainz/picard-plugins-registry>

---

## Development Workflow

### Setup
```bash
git clone https://github.com/metabrainz/picard.git
cd picard

# Preferred
uv sync

# Alternative
pip install -r requirements.txt

python setup.py build_ui  # Compile Qt forms
python tagger.py          # Run from source
```

### Before Committing
```bash
# 1. Format code
ruff format picard/

# 2. Check for issues
ruff check picard/

# 3. Run tests
pytest test/

# Or specific tests
pytest test/test_metadata.py
```

**Important:** Always run `ruff format` after making code changes to ensure consistent formatting.

### AI Assistant Guidelines
When making code changes:
1. **Imports go at the top of files** - local (inline) imports are acceptable only to break circular dependencies; in that case, place them as close to usage as possible with a comment explaining why
2. **Bug fixes: test first** - write a test that reproduces the bug before fixing it, then verify the fix makes the test pass; keep the test as a permanent regression test when feasible
3. **Run `ruff format` after all changes** - ensures code follows project style guidelines
4. **Run `ruff check` to catch issues** - fix any linting errors before committing
5. **Never commit editor or agent-specific files** - Do not `git add` files from AI tool directories (`.kiro/`, `.cursor/`, `.aider*`, etc.) or any files not part of the project. Review `git diff --cached` before committing to verify only intended files are staged. When in doubt, ask the user before committing.
6. **Don't push to remote** - without explicit user approval
7. **Never use `git commit --no-verify`** - pre-commit hooks run `ruff format` and `ruff check` automatically; skipping them leads to CI failures. If using fixup/rebase workflows, always run `ruff format` and `ruff check` manually before the final push.
8. **Run `ty check` on modified files** - verify no new type errors are introduced (pre-existing errors in the codebase can be ignored).
9. **`gh pr edit` may fail with a GraphQL Projects deprecation error** - use `gh api repos/OWNER/REPO/pulls/NUMBER -X PATCH -F "[email protected]"` as a workaround to update PR descriptions.

### Contributing
1. **Create ticket first:** <https://tickets.metabrainz.org/projects/PICARD>
2. Create feature branch
3. Make changes
4. Run pre-commit checks (above)
5. **PR title must start with:** `PICARD-XXXX:` (ticket number)
6. **Use the PR template** - fill in `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Problem, Solution, AI Usage, Action sections)
7. **Don't auto-create tickets/PRs** - let humans handle this
8. **Don't push to remote** - without explicit user approval

---

## Code Style

### Conventions
- **Standard:** PEP 8, 120 char lines (flexible)
- **Linter:** Ruff
- **Type hints:** Recommended for new code (existing code has limited coverage)
- **Naming:** `PascalCase` classes, `snake_case` functions, `UPPER_SNAKE` constants

### Internationalization (i18n)
```python
# Import translation functions
from picard.i18n import gettext as _
from picard.i18n import N_

# Translatable strings (runtime)
message = _("Save file")
error = _("Failed to load: %s") % filename

# Mark for extraction only (constants, class attributes)
TITLE = N_("Options")  # Translated at usage: _(TITLE)

# Plugin translations (Plugin v3)
from picard.plugin3.api import t_


class ManifestTranslations:
    NAME = t_("manifest.name", "My Plugin")
    DESC = t_("manifest.description", "Plugin description")
```

**Translation workflow:**
- Strings marked with `_()` or `N_()` are extracted to `po/picard.pot`
- Regenerate: `python setup.py regen_pot_file`
- Translations managed via [Weblate](https://translations.metabrainz.org/projects/picard/)
- See `po/README.md` for details

### Qt UI Files
```bash
# ❌ Don't edit picard/ui/ui_*.py directly (auto-generated)

# ✅ Edit .ui files with Qt Designer
qt6-tools designer ui/options_metadata.ui

# ✅ Regenerate Python files
python setup.py build_ui
```

### Qt Signals
```python
from PyQt6.QtCore import pyqtSignal


class MyWidget(QWidget):
    # Define signals
    value_changed = pyqtSignal(str)
    item_selected = pyqtSignal(object)

    def some_action(self):
        # Emit signals
        self.value_changed.emit("new value")
```

### Readability
- Use descriptive names
- Comment complex logic
- Break long functions
- Follow existing style in the file

---

## Key Locations

### Core Components
- **Main app:** `picard/tagger.py`
- **File handling:** `picard/file.py`
- **Album/Track:** `picard/album.py`, `picard/track.py`
- **Metadata:** `picard/metadata.py`

### Format Handlers
- **Registry:** `picard/formats/registry.py`
- **ID3 (MP3):** `picard/formats/id3.py`
- **Vorbis (FLAC/Ogg):** `picard/formats/vorbis.py`
- **MP4:** `picard/formats/mp4.py`

### Plugin System
- **API:** `picard/plugin3/api_impl.py`
- **Manager:** `picard/plugin3/manager/__init__.py`
- **Registry:** `picard/plugin3/registry.py`

### UI
- **Main window:** `picard/ui/mainwindow/`
- **Options:** `picard/ui/options/`
- **Metadata box:** `picard/ui/metadatabox/`
- **Script editor:** `picard/ui/scripteditor/`

### Scripting
- **Parser:** `picard/script/parser.py`
- **Functions:** `picard/script/functions.py`

### Web Services
- **HTTP client:** `picard/webservice/__init__.py`
- **APIs:** `picard/webservice/api_helpers.py`

---

## Common Tasks

### Add Audio Format
1. Create handler in `picard/formats/`
2. Inherit from `File`
3. Implement `_load()` and `_save()`
4. Define `EXTENSIONS` and `NAME`
5. Call `register_format(MyFormat)`

### Add Script Function
```python
from picard.script import script_function


@script_function
def func_myfunction(parser, arg1, arg2='default'):
    """$myfunction(arg1,arg2) - Description"""
    return result
```

### Add Option
1. Define in `picard/options.py`
2. Create UI page in `picard/ui/options/`
3. Implement `load()` and `save()` methods
4. Register with `register_options_page()`

### Add Metadata Processor (Core)
```python
from picard.extension_points.metadata import register_track_metadata_processor


def my_processor(album, metadata, track, release):
    metadata['custom'] = 'value'


register_track_metadata_processor(my_processor)
```

**For plugins:** Use `api.register_track_metadata_processor()` instead (see Plugin System section).

---

## Testing

```bash
# All tests
pytest test/

# Specific file
pytest test/test_metadata.py

# With coverage
pytest --cov=picard test/
```

### Test Structure
```python
from test.picardtestcase import PicardTestCase


class TestMyFeature(PicardTestCase):
    def setUp(self):
        super().setUp()

    def test_something(self):
        self.assertEqual(expected, actual)
```

### Qt Widget Tests
A session-scoped `qapp` fixture in `test/conftest.py` provides a shared
`QApplication` for the entire test run. Always use it for widget tests:

```python
@pytest.fixture()
def my_widget(qapp):
    from picard.ui.widgets.mywidget import MyWidget

    return MyWidget()
```

**Never** create a local `QApplication` or `QCoreApplication` in tests — it causes
crashes with random test ordering. For unittest-based tests that need an event loop,
use `QCoreApplication.instance()` and only create one if it returns `None`.

---

## Debugging

```bash
# Enable debug logging
picard --debug

# Enable specific debug options (comma-separated)
picard --debug-opts=option1,option2
```

```python
# In code
from picard import log

log.debug('Debug: %s', value)
log.error('Error', exc_info=True)
```

---

## Resources

- **User Docs:** <https://picard-docs.musicbrainz.org/>
- **Website:** <https://picard.musicbrainz.org/>
- **GitHub:** <https://github.com/metabrainz/picard>
- **Forum:** <https://community.metabrainz.org/c/picard>
- **Contributing:** See `CONTRIBUTING.md`
- **Installation:** See `INSTALL.md`

---

**Note:** This file contains only essential patterns. For architecture, dependencies, and component details, explore the codebase directly or read existing documentation (`README.md`, `CONTRIBUTING.md`, `INSTALL.md`, user docs).