scoreseek 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .coverage
9
+ coverage.xml
10
+ .DS_Store
11
+ *.abc
12
+ *.wav
13
+ scrap/
14
+ .claude/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: scoreseek
3
+ Version: 0.0.2
4
+ Summary: Find a musical score by metadata across pluggable sources (APIs, datasets, local folders)
5
+ Project-URL: Homepage, https://github.com/thorwhalen/scoreseek
6
+ Project-URL: Repository, https://github.com/thorwhalen/scoreseek
7
+ Project-URL: Documentation, https://thorwhalen.github.io/scoreseek
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: abc,midi,music,musicxml,public-domain,score,search,sheet-music
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Multimedia :: Sound/Audio
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
22
+ Requires-Dist: pytest>=7.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
24
+ Provides-Extra: docs
25
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
26
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
27
+ Provides-Extra: render
28
+ Requires-Dist: audiate; extra == 'render'
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest-cov>=4.0; extra == 'test'
31
+ Requires-Dist: pytest>=7.0; extra == 'test'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # scoreseek
35
+
36
+ Find a musical **score** by metadata — across many sources, behind one call.
37
+
38
+ ```python
39
+ import scoreseek
40
+
41
+ hits = scoreseek.search("Cooley's") # out of the box: The Session (CC-BY-SA)
42
+ ref = hits[0]
43
+ path = ref.fetch() # -> a local .abc file
44
+ # hand straight to audiate.render(path) to hear it
45
+ ```
46
+
47
+ Every hit is a license-tagged `ScoreRef`, so **public-domain / permissive**
48
+ results stay cleanly separated from **copyrighted / gray** ones — searches are
49
+ copyright-safe by default.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install scoreseek # zero required dependencies
55
+ pip install "scoreseek[render]" # + audiate, to render hits to audio
56
+ ```
57
+
58
+ ## Why
59
+
60
+ Scores live in many places — public-domain corpora (PDMX, IMSLP, OpenScore),
61
+ downloadable datasets, chord/lyric collections, and your own folders. `scoreseek`
62
+ puts them behind **one pluggable-source interface** and tags every result with a
63
+ license, so you can ask for "only what I can legally build on" or opt into
64
+ copyrighted material explicitly.
65
+
66
+ ## Sources
67
+
68
+ A *source* answers `search(query) -> [ScoreRef]` and optionally `fetch(ref) -> path`.
69
+ Register any object with that shape.
70
+
71
+ | Source | What | License | Status |
72
+ |---|---|---|---|
73
+ | `thesession` | thesession.org traditional tunes (ABC) | CC-BY-SA 4.0 | **built in, on by default** |
74
+ | `LocalFolderSource` | a folder of `.mid`/MusicXML/ABC/`kern` files | you set it | built in |
75
+ | PDMX · OpenScore Lieder · IMSLP · MusicBrainz (normalizer) | public-domain / CC0 corpora + APIs | PD/CC0 | planned |
76
+
77
+ ```python
78
+ from scoreseek import register_source, License
79
+ from scoreseek.sources import LocalFolderSource
80
+
81
+ register_source(LocalFolderSource("~/scores", license=License.PUBLIC_DOMAIN))
82
+
83
+ hits = scoreseek.search("prelude", sources=["local"])
84
+ hits = scoreseek.search("hey jude", allow_copyrighted=True) # opt into gray/copyrighted
85
+ ```
86
+
87
+ ### Write your own source
88
+
89
+ ```python
90
+ from scoreseek.sources import Source
91
+ from scoreseek.base import License
92
+
93
+
94
+ class MySource(Source):
95
+ name = "mine"
96
+ license = License.CC0
97
+
98
+ def search(self, query="", *, title="", composer="", limit=10):
99
+ return [self._ref(title="...", id="...", formats=("musicxml",))]
100
+
101
+ def fetch(self, ref, *, fmt=None):
102
+ ...
103
+ return "/path/to/score.musicxml"
104
+ ```
105
+
106
+ ## License tags
107
+
108
+ `License` classifies each hit: `PUBLIC_DOMAIN`, `CC0`, `PERMISSIVE` (CC-BY/-SA,
109
+ MIT, …), `NONCOMMERCIAL` (CC-BY-NC), `COPYRIGHTED`, `GRAY` (scrapes/unspecified),
110
+ `UNKNOWN`. `search(..., allow_copyrighted=False)` (the default) drops
111
+ `COPYRIGHTED` and `GRAY` hits.
112
+
113
+ ## Where it fits
114
+
115
+ `scoreseek` is the **search** stage of a larger pipeline:
116
+ **scoreseek** (find a score) → [`audiate`](https://github.com/thorwhalen/audiate)
117
+ (render to audio) → [`arioso`](https://github.com/thorwhalen/arioso)
118
+ (AI-enhance).
119
+
120
+ ## License
121
+
122
+ MIT
@@ -0,0 +1,89 @@
1
+ # scoreseek
2
+
3
+ Find a musical **score** by metadata — across many sources, behind one call.
4
+
5
+ ```python
6
+ import scoreseek
7
+
8
+ hits = scoreseek.search("Cooley's") # out of the box: The Session (CC-BY-SA)
9
+ ref = hits[0]
10
+ path = ref.fetch() # -> a local .abc file
11
+ # hand straight to audiate.render(path) to hear it
12
+ ```
13
+
14
+ Every hit is a license-tagged `ScoreRef`, so **public-domain / permissive**
15
+ results stay cleanly separated from **copyrighted / gray** ones — searches are
16
+ copyright-safe by default.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install scoreseek # zero required dependencies
22
+ pip install "scoreseek[render]" # + audiate, to render hits to audio
23
+ ```
24
+
25
+ ## Why
26
+
27
+ Scores live in many places — public-domain corpora (PDMX, IMSLP, OpenScore),
28
+ downloadable datasets, chord/lyric collections, and your own folders. `scoreseek`
29
+ puts them behind **one pluggable-source interface** and tags every result with a
30
+ license, so you can ask for "only what I can legally build on" or opt into
31
+ copyrighted material explicitly.
32
+
33
+ ## Sources
34
+
35
+ A *source* answers `search(query) -> [ScoreRef]` and optionally `fetch(ref) -> path`.
36
+ Register any object with that shape.
37
+
38
+ | Source | What | License | Status |
39
+ |---|---|---|---|
40
+ | `thesession` | thesession.org traditional tunes (ABC) | CC-BY-SA 4.0 | **built in, on by default** |
41
+ | `LocalFolderSource` | a folder of `.mid`/MusicXML/ABC/`kern` files | you set it | built in |
42
+ | PDMX · OpenScore Lieder · IMSLP · MusicBrainz (normalizer) | public-domain / CC0 corpora + APIs | PD/CC0 | planned |
43
+
44
+ ```python
45
+ from scoreseek import register_source, License
46
+ from scoreseek.sources import LocalFolderSource
47
+
48
+ register_source(LocalFolderSource("~/scores", license=License.PUBLIC_DOMAIN))
49
+
50
+ hits = scoreseek.search("prelude", sources=["local"])
51
+ hits = scoreseek.search("hey jude", allow_copyrighted=True) # opt into gray/copyrighted
52
+ ```
53
+
54
+ ### Write your own source
55
+
56
+ ```python
57
+ from scoreseek.sources import Source
58
+ from scoreseek.base import License
59
+
60
+
61
+ class MySource(Source):
62
+ name = "mine"
63
+ license = License.CC0
64
+
65
+ def search(self, query="", *, title="", composer="", limit=10):
66
+ return [self._ref(title="...", id="...", formats=("musicxml",))]
67
+
68
+ def fetch(self, ref, *, fmt=None):
69
+ ...
70
+ return "/path/to/score.musicxml"
71
+ ```
72
+
73
+ ## License tags
74
+
75
+ `License` classifies each hit: `PUBLIC_DOMAIN`, `CC0`, `PERMISSIVE` (CC-BY/-SA,
76
+ MIT, …), `NONCOMMERCIAL` (CC-BY-NC), `COPYRIGHTED`, `GRAY` (scrapes/unspecified),
77
+ `UNKNOWN`. `search(..., allow_copyrighted=False)` (the default) drops
78
+ `COPYRIGHTED` and `GRAY` hits.
79
+
80
+ ## Where it fits
81
+
82
+ `scoreseek` is the **search** stage of a larger pipeline:
83
+ **scoreseek** (find a score) → [`audiate`](https://github.com/thorwhalen/audiate)
84
+ (render to audio) → [`arioso`](https://github.com/thorwhalen/arioso)
85
+ (AI-enhance).
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,173 @@
1
+ [build-system]
2
+ requires = [
3
+ "hatchling",
4
+ ]
5
+ build-backend = "hatchling.build"
6
+
7
+ [project]
8
+ name = "scoreseek"
9
+ version = "0.0.2"
10
+ description = "Find a musical score by metadata across pluggable sources (APIs, datasets, local folders)"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ license = "MIT"
14
+ keywords = [
15
+ "music",
16
+ "score",
17
+ "sheet-music",
18
+ "search",
19
+ "musicxml",
20
+ "midi",
21
+ "abc",
22
+ "public-domain",
23
+ ]
24
+ authors = [
25
+ { name = "Thor Whalen" },
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 3 - Alpha",
29
+ "Intended Audience :: Developers",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.12",
33
+ "Topic :: Multimedia :: Sound/Audio",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ ]
36
+ dependencies = []
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/thorwhalen/scoreseek"
40
+ Repository = "https://github.com/thorwhalen/scoreseek"
41
+ Documentation = "https://thorwhalen.github.io/scoreseek"
42
+
43
+ [project.optional-dependencies]
44
+ render = [
45
+ "audiate",
46
+ ]
47
+ test = [
48
+ "pytest>=7.0",
49
+ "pytest-cov>=4.0",
50
+ ]
51
+ dev = [
52
+ "pytest>=7.0",
53
+ "pytest-cov>=4.0",
54
+ "ruff>=0.1.0",
55
+ ]
56
+ docs = [
57
+ "sphinx>=6.0",
58
+ "sphinx-rtd-theme>=1.0",
59
+ ]
60
+
61
+ [tool.hatch.build.targets.wheel]
62
+ packages = ["scoreseek"]
63
+
64
+ [tool.hatch.build]
65
+ include = [
66
+ "scoreseek/**/*.py",
67
+ ]
68
+
69
+ [tool.ruff]
70
+ line-length = 88
71
+ target-version = "py310"
72
+ exclude = [
73
+ "**/*.ipynb",
74
+ ".git",
75
+ ".venv",
76
+ "build",
77
+ "dist",
78
+ "tests",
79
+ "examples",
80
+ "scrap",
81
+ ]
82
+
83
+ [tool.ruff.lint]
84
+ select = [
85
+ "D100",
86
+ ]
87
+ ignore = [
88
+ "D203",
89
+ "E501",
90
+ "B905",
91
+ ]
92
+
93
+ [tool.ruff.lint.pydocstyle]
94
+ convention = "google"
95
+
96
+ [tool.ruff.lint.per-file-ignores]
97
+ "**/tests/*" = [
98
+ "D",
99
+ ]
100
+
101
+ [tool.pytest.ini_options]
102
+ minversion = "6.0"
103
+ testpaths = [
104
+ "tests",
105
+ ]
106
+
107
+ [tool.wads.ci]
108
+ project_name = ""
109
+
110
+ [tool.wads.ci.commands]
111
+ pre_test = []
112
+ test = []
113
+ post_test = []
114
+ lint = []
115
+ format = []
116
+
117
+ [tool.wads.ci.env]
118
+ required_envvars = []
119
+ test_envvars = []
120
+ extra_envvars = []
121
+
122
+ [tool.wads.ci.env.defaults]
123
+
124
+ [tool.wads.ci.quality.ruff]
125
+ enabled = true
126
+
127
+ [tool.wads.ci.quality.black]
128
+ enabled = false
129
+
130
+ [tool.wads.ci.quality.mypy]
131
+ enabled = false
132
+
133
+ [tool.wads.ci.install]
134
+ extras = "test"
135
+
136
+ [tool.wads.ci.testing]
137
+ python_versions = [
138
+ "3.10",
139
+ "3.12",
140
+ ]
141
+ pytest_args = [
142
+ "-v",
143
+ "--tb=short",
144
+ "tests",
145
+ ]
146
+ skip_doctests = true
147
+ coverage_enabled = true
148
+ coverage_threshold = 0
149
+ coverage_report_format = [
150
+ "term",
151
+ "xml",
152
+ ]
153
+ exclude_paths = [
154
+ "examples",
155
+ "scrap",
156
+ ]
157
+ test_on_windows = false
158
+
159
+ [tool.wads.ci.build]
160
+ sdist = true
161
+ wheel = true
162
+
163
+ [tool.wads.ci.publish]
164
+ enabled = true
165
+
166
+ [tool.wads.ci.docs]
167
+ enabled = false
168
+ builder = "epythet"
169
+ ignore_paths = [
170
+ "tests/",
171
+ "scrap/",
172
+ "examples/",
173
+ ]
@@ -0,0 +1,120 @@
1
+ """scoreseek -- find a musical score by metadata, across pluggable sources.
2
+
3
+ Search many score sources (APIs, downloadable datasets, local folders) through
4
+ one call and get back license-tagged :class:`~scoreseek.base.ScoreRef` hits you
5
+ can :meth:`~scoreseek.base.ScoreRef.fetch` on demand. Every hit carries a
6
+ :class:`~scoreseek.base.License` tag, so copyright-free / permissive results stay
7
+ cleanly separated from copyrighted-user-upload or gray results.
8
+
9
+ Simple things simple::
10
+
11
+ import scoreseek
12
+
13
+ hits = scoreseek.search("Cooley's") # out-of-the-box: The Session
14
+ ref = hits[0]
15
+ path = ref.fetch() # -> a local .abc file
16
+ # -> hand straight to audiate.render(path)
17
+
18
+ Complex things possible::
19
+
20
+ from scoreseek import register_source, License
21
+ from scoreseek.sources import LocalFolderSource
22
+
23
+ register_source(LocalFolderSource("~/my_scores", license=License.PUBLIC_DOMAIN))
24
+ hits = scoreseek.search("prelude", sources=["local"], allow_copyrighted=False)
25
+
26
+ This is the **search** stage of a larger song->score->audio->AI-enhanced
27
+ pipeline (render = ``audiate``, AI-enhance = ``arioso``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import List, Optional
33
+
34
+ from scoreseek.base import (
35
+ COMMERCIAL_SAFE,
36
+ RESTRICTED,
37
+ License,
38
+ ScoreRef,
39
+ )
40
+
41
+ # NOTE: exposed as ``source_registry`` (not ``sources``) on purpose -- the name
42
+ # ``scoreseek.sources`` belongs to the sources *subpackage* (the source classes),
43
+ # so binding the registry there would shadow it.
44
+ from scoreseek.registry import (
45
+ SourceRegistry,
46
+ register_source,
47
+ sources as source_registry,
48
+ )
49
+
50
+ __all__ = [
51
+ "search",
52
+ "list_sources",
53
+ "register_source",
54
+ "source_registry",
55
+ "ScoreRef",
56
+ "License",
57
+ "SourceRegistry",
58
+ ]
59
+
60
+
61
+ def _install_default_sources() -> None:
62
+ """Register the zero-config out-of-the-box source(s)."""
63
+ if "thesession" not in source_registry:
64
+ from scoreseek.sources.thesession import TheSessionSource
65
+
66
+ register_source(TheSessionSource())
67
+
68
+
69
+ _install_default_sources()
70
+
71
+
72
+ def list_sources() -> list:
73
+ """Return the names of the registered score sources (sorted)."""
74
+ return sorted(source_registry)
75
+
76
+
77
+ def search(
78
+ query: str = "",
79
+ *,
80
+ title: str = "",
81
+ composer: str = "",
82
+ sources: Optional[List[str]] = None,
83
+ allow_copyrighted: bool = False,
84
+ limit: int = 10,
85
+ ) -> List[ScoreRef]:
86
+ """Search registered sources and return license-filtered, ranked hits.
87
+
88
+ Args:
89
+ query: Free-text query (matched against title/composer per source).
90
+ title: Restrict to a title (source-dependent).
91
+ composer: Restrict to a composer/artist (source-dependent).
92
+ sources: Names of sources to query; ``None`` queries all registered.
93
+ allow_copyrighted: If ``False`` (default), copyrighted/gray-licensed hits
94
+ (see :data:`~scoreseek.base.RESTRICTED`) are dropped -- so results are
95
+ safe to redistribute/build on. Set ``True`` to include them.
96
+ limit: Max hits per source.
97
+
98
+ Returns:
99
+ A list of :class:`~scoreseek.base.ScoreRef`, best match first.
100
+ """
101
+ from scoreseek.registry import sources as _registry
102
+
103
+ names = sources if sources is not None else list(_registry)
104
+ hits: List[ScoreRef] = []
105
+ for name in names:
106
+ source = _registry[name] # raises KeyError with a helpful message
107
+ try:
108
+ found = source.search(query, title=title, composer=composer, limit=limit)
109
+ except Exception as e: # a flaky source shouldn't sink the whole search
110
+ import warnings
111
+
112
+ warnings.warn(f"source {name!r} failed: {e}", stacklevel=2)
113
+ continue
114
+ hits.extend(found)
115
+
116
+ if not allow_copyrighted:
117
+ hits = [h for h in hits if h.license not in RESTRICTED]
118
+
119
+ hits.sort(key=lambda h: h.score, reverse=True)
120
+ return hits
@@ -0,0 +1,84 @@
1
+ """Core types for scoreseek: :class:`ScoreRef`, :class:`License`, and the
2
+ license-partitioning constants.
3
+
4
+ A search returns :class:`ScoreRef` hits -- lightweight, license-tagged pointers
5
+ to a score that can be :meth:`ScoreRef.fetch`-ed on demand. The **license tag on
6
+ every hit** is the load-bearing design choice: it lets the facade keep
7
+ public-domain / permissive results cleanly separated from copyrighted-user-upload
8
+ or gray-licensed results (see :data:`COMMERCIAL_SAFE` / :data:`RESTRICTED`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from typing import Any, Optional
16
+
17
+
18
+ class License(str, Enum):
19
+ """A coarse license classification for a score, driving safe filtering."""
20
+
21
+ PUBLIC_DOMAIN = "public_domain"
22
+ CC0 = "cc0"
23
+ PERMISSIVE = "permissive" # CC-BY, MIT, BSD, Apache, CC-BY-SA, ...
24
+ NONCOMMERCIAL = "noncommercial" # CC-BY-NC(-*)
25
+ COPYRIGHTED = "copyrighted" # all rights reserved (e.g. user uploads)
26
+ GRAY = "gray" # scraped / unspecified terms
27
+ UNKNOWN = "unknown"
28
+
29
+
30
+ #: License tags safe to build products on (redistribution + commercial, w/ attribution).
31
+ COMMERCIAL_SAFE = frozenset({License.PUBLIC_DOMAIN, License.CC0, License.PERMISSIVE})
32
+
33
+ #: License tags that are copyrighted or gray -- gated behind ``allow_copyrighted``.
34
+ RESTRICTED = frozenset({License.COPYRIGHTED, License.GRAY})
35
+
36
+
37
+ @dataclass
38
+ class ScoreRef:
39
+ """A license-tagged pointer to a score returned by a search.
40
+
41
+ Attributes:
42
+ title: Score/song title.
43
+ source: Name of the source that produced this hit.
44
+ id: Source-specific identifier.
45
+ composer: Composer or artist.
46
+ formats: Available formats, e.g. ``("musicxml", "midi")``.
47
+ license: A :class:`License` classification.
48
+ url: A human-facing URL for the score, if any.
49
+ score: Match relevance in ``[0, 1]``.
50
+ metadata: Source-specific extra fields.
51
+ """
52
+
53
+ title: str
54
+ source: str
55
+ id: str = ""
56
+ composer: str = ""
57
+ formats: tuple = ()
58
+ license: License = License.UNKNOWN
59
+ url: str = ""
60
+ score: float = 0.0
61
+ metadata: dict = field(default_factory=dict)
62
+ _source_obj: Any = field(default=None, repr=False, compare=False)
63
+
64
+ @property
65
+ def is_commercial_safe(self) -> bool:
66
+ """True if this score's license permits redistribution/commercial use."""
67
+ return self.license in COMMERCIAL_SAFE
68
+
69
+ def fetch(self, *, fmt: Optional[str] = None):
70
+ """Retrieve the score content (returns a filesystem path).
71
+
72
+ Delegates to the originating source. The returned path can be handed
73
+ straight to ``audiate.render(...)``.
74
+
75
+ Raises:
76
+ NotImplementedError: If the originating source can't fetch (e.g. a
77
+ metadata-only source, or a bare ``ScoreRef`` with no source).
78
+ """
79
+ if self._source_obj is None or not hasattr(self._source_obj, "fetch"):
80
+ raise NotImplementedError(
81
+ f"ScoreRef {self.title!r} from source {self.source!r} cannot be "
82
+ "fetched (no fetch-capable source attached)."
83
+ )
84
+ return self._source_obj.fetch(self, fmt=fmt)
@@ -0,0 +1,57 @@
1
+ """Pluggable score-source registry.
2
+
3
+ A *source* answers ``search(query, ...) -> list[ScoreRef]`` and, optionally,
4
+ ``fetch(ref, ...) -> path``. Sources live in a :class:`SourceRegistry` (a
5
+ ``MutableMapping`` of ``name -> source``) so the set is open for extension: a
6
+ downloadable-dataset source, an API source, or a local folder are all just
7
+ objects registered here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import MutableMapping
13
+
14
+
15
+ class SourceRegistry(MutableMapping):
16
+ """A ``MutableMapping`` of source name -> source object."""
17
+
18
+ def __init__(self):
19
+ self._d: dict = {}
20
+
21
+ def __getitem__(self, key):
22
+ try:
23
+ return self._d[key]
24
+ except KeyError:
25
+ raise KeyError(
26
+ f"Unknown source {key!r}. Registered sources: {sorted(self._d)}"
27
+ ) from None
28
+
29
+ def __setitem__(self, key, value):
30
+ self._d[key] = value
31
+
32
+ def __delitem__(self, key):
33
+ del self._d[key]
34
+
35
+ def __iter__(self):
36
+ return iter(self._d)
37
+
38
+ def __len__(self):
39
+ return len(self._d)
40
+
41
+
42
+ #: The process-wide registry of score sources.
43
+ sources = SourceRegistry()
44
+
45
+
46
+ def register_source(source, *, name: str = None):
47
+ """Register a *source* object (must have a ``.name`` unless ``name`` given).
48
+
49
+ Returns the source, so it can be used inline::
50
+
51
+ local = register_source(LocalFolderSource("~/scores"))
52
+ """
53
+ key = name or getattr(source, "name", None)
54
+ if not key:
55
+ raise ValueError("source has no 'name'; pass name=... to register_source")
56
+ sources[key] = source
57
+ return source
@@ -0,0 +1,11 @@
1
+ """Built-in score sources.
2
+
3
+ Importing this package makes the built-in source classes available and (via
4
+ :mod:`scoreseek`) registers the default out-of-the-box source (The Session).
5
+ """
6
+
7
+ from scoreseek.sources.base import Source
8
+ from scoreseek.sources.local import LocalFolderSource
9
+ from scoreseek.sources.thesession import TheSessionSource
10
+
11
+ __all__ = ["Source", "LocalFolderSource", "TheSessionSource"]
@@ -0,0 +1,56 @@
1
+ """The ``Source`` base class that all score sources implement.
2
+
3
+ A source turns a query into :class:`~scoreseek.base.ScoreRef` hits and can
4
+ optionally fetch a hit's content. Subclass and implement :meth:`search` (and
5
+ :meth:`fetch` if the source hands back real files).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List, Optional
11
+
12
+ from scoreseek.base import License, ScoreRef
13
+
14
+
15
+ class Source:
16
+ """Base class for a score source.
17
+
18
+ Attributes:
19
+ name: Unique registry name.
20
+ license: Default :class:`~scoreseek.base.License` for this source's hits
21
+ (individual hits may override).
22
+ """
23
+
24
+ name: str = "source"
25
+ license: License = License.UNKNOWN
26
+
27
+ def search(
28
+ self,
29
+ query: str = "",
30
+ *,
31
+ title: str = "",
32
+ composer: str = "",
33
+ limit: int = 10,
34
+ ) -> List[ScoreRef]:
35
+ """Return up to ``limit`` :class:`ScoreRef` hits for the query.
36
+
37
+ Subclasses must implement this.
38
+ """
39
+ raise NotImplementedError
40
+
41
+ def fetch(self, ref: ScoreRef, *, fmt: Optional[str] = None) -> str:
42
+ """Retrieve ``ref``'s content and return a filesystem path.
43
+
44
+ Subclasses that can deliver content override this; the default raises.
45
+ """
46
+ raise NotImplementedError(
47
+ f"Source {self.name!r} is metadata-only and cannot fetch content."
48
+ )
49
+
50
+ def _ref(self, **kwargs) -> ScoreRef:
51
+ """Build a :class:`ScoreRef` for this source (fills in source/license)."""
52
+ kwargs.setdefault("source", self.name)
53
+ kwargs.setdefault("license", self.license)
54
+ ref = ScoreRef(**kwargs)
55
+ ref._source_obj = self
56
+ return ref
@@ -0,0 +1,110 @@
1
+ """A local-folder score source: index and search a directory of score files.
2
+
3
+ Zero network, zero external services -- point it at a folder of ``.mid`` /
4
+ MusicXML / ABC / ``kern`` files and search by filename. Fetching returns the
5
+ file path (ready for ``audiate.render``). This is the simplest possible source
6
+ and the reference implementation of the :class:`~scoreseek.sources.base.Source`
7
+ contract.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import List, Optional
15
+
16
+ from scoreseek.base import License, ScoreRef
17
+ from scoreseek.sources.base import Source
18
+
19
+ #: File extensions treated as scores, mapped to a format label.
20
+ _SCORE_EXTS = {
21
+ ".mid": "midi",
22
+ ".midi": "midi",
23
+ ".xml": "musicxml",
24
+ ".musicxml": "musicxml",
25
+ ".mxl": "musicxml",
26
+ ".abc": "abc",
27
+ ".krn": "kern",
28
+ ".kern": "kern",
29
+ ".mei": "mei",
30
+ }
31
+
32
+
33
+ class LocalFolderSource(Source):
34
+ """Search a local folder of score files by filename.
35
+
36
+ Args:
37
+ root: Directory to index (searched recursively).
38
+ name: Registry name (default ``'local'``).
39
+ license: License tag to attach to every hit (default ``UNKNOWN`` -- set
40
+ it if you know the folder's provenance, e.g. ``License.PUBLIC_DOMAIN``
41
+ for a PD corpus).
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ root,
47
+ *,
48
+ name: str = "local",
49
+ license: License = License.UNKNOWN,
50
+ ):
51
+ self.name = name
52
+ self.license = license
53
+ self.root = Path(os.path.expanduser(str(root)))
54
+ self._index: List[dict] = []
55
+ self.reindex()
56
+
57
+ def reindex(self) -> int:
58
+ """(Re)build the file index. Returns the number of scores found."""
59
+ self._index = []
60
+ if not self.root.exists():
61
+ return 0
62
+ for dirpath, _dirs, files in os.walk(self.root):
63
+ for fname in files:
64
+ ext = os.path.splitext(fname)[1].lower()
65
+ fmt = _SCORE_EXTS.get(ext)
66
+ if fmt is None:
67
+ continue
68
+ full = os.path.join(dirpath, fname)
69
+ self._index.append(
70
+ {
71
+ "title": os.path.splitext(fname)[0],
72
+ "path": full,
73
+ "format": fmt,
74
+ "rel": os.path.relpath(full, self.root),
75
+ }
76
+ )
77
+ return len(self._index)
78
+
79
+ def search(
80
+ self,
81
+ query: str = "",
82
+ *,
83
+ title: str = "",
84
+ composer: str = "",
85
+ limit: int = 10,
86
+ ) -> List[ScoreRef]:
87
+ """Substring-match the query against each file's title and relative path."""
88
+ needles = [t.lower() for t in (query, title, composer) if t]
89
+ hits = []
90
+ for entry in self._index:
91
+ haystack = (entry["title"] + " " + entry["rel"]).lower()
92
+ if all(n in haystack for n in needles):
93
+ hits.append(
94
+ self._ref(
95
+ title=entry["title"],
96
+ id=entry["rel"],
97
+ formats=(entry["format"],),
98
+ score=1.0 if needles else 0.5,
99
+ url=entry["path"],
100
+ metadata={"path": entry["path"]},
101
+ )
102
+ )
103
+ return hits[:limit]
104
+
105
+ def fetch(self, ref: ScoreRef, *, fmt: Optional[str] = None) -> str:
106
+ """Return the score file's path (fetching a local file is a no-op)."""
107
+ path = ref.metadata.get("path")
108
+ if not path or not os.path.exists(path):
109
+ raise FileNotFoundError(f"Score file for {ref.title!r} not found: {path!r}")
110
+ return path
@@ -0,0 +1,108 @@
1
+ """The Session (thesession.org) source: Irish/folk traditional tunes in ABC.
2
+
3
+ A real, zero-config, no-auth source with a clean JSON API and openly-licensed
4
+ content (CC-BY-SA 4.0). Search returns tune references; fetching writes a valid
5
+ ABC file (ready for ``audiate.render``). Networking uses only the standard
6
+ library, so scoreseek's core needs no third-party HTTP dependency.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import tempfile
14
+ import urllib.parse
15
+ import urllib.request
16
+ from typing import List, Optional
17
+
18
+ from scoreseek.base import License, ScoreRef
19
+ from scoreseek.sources.base import Source
20
+
21
+ _BASE = "https://thesession.org"
22
+ _UA = "scoreseek/0.0.1 (+https://github.com/thorwhalen/scoreseek)"
23
+
24
+ #: tune "type" -> a sensible default meter (for cleaner ABC rendering).
25
+ _METER = {
26
+ "reel": "4/4",
27
+ "jig": "6/8",
28
+ "slip jig": "9/8",
29
+ "hornpipe": "4/4",
30
+ "polka": "2/4",
31
+ "slide": "12/8",
32
+ "waltz": "3/4",
33
+ "march": "4/4",
34
+ "strathspey": "4/4",
35
+ "barndance": "4/4",
36
+ "mazurka": "3/4",
37
+ "three-two": "3/2",
38
+ }
39
+
40
+
41
+ class TheSessionSource(Source):
42
+ """Search thesession.org's traditional-tune database (ABC, CC-BY-SA 4.0)."""
43
+
44
+ name = "thesession"
45
+ license = License.PERMISSIVE # CC-BY-SA 4.0
46
+
47
+ def __init__(self, *, timeout: float = 15.0):
48
+ self.timeout = timeout
49
+
50
+ def _get_json(self, url: str) -> dict:
51
+ req = urllib.request.Request(
52
+ url, headers={"User-Agent": _UA, "Accept": "application/json"}
53
+ )
54
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
55
+ return json.loads(resp.read().decode("utf-8"))
56
+
57
+ def search(
58
+ self,
59
+ query: str = "",
60
+ *,
61
+ title: str = "",
62
+ composer: str = "",
63
+ limit: int = 10,
64
+ ) -> List[ScoreRef]:
65
+ """Search tunes by name. Returns ABC-format :class:`ScoreRef` hits."""
66
+ q = query or title or composer
67
+ if not q:
68
+ return []
69
+ url = f"{_BASE}/tunes/search?" + urllib.parse.urlencode(
70
+ {"q": q, "format": "json", "perpage": limit}
71
+ )
72
+ data = self._get_json(url)
73
+ hits = []
74
+ for tune in data.get("tunes", [])[:limit]:
75
+ hits.append(
76
+ self._ref(
77
+ title=tune.get("name", ""),
78
+ id=str(tune.get("id", "")),
79
+ composer="traditional",
80
+ formats=("abc",),
81
+ url=tune.get("url", ""),
82
+ score=1.0,
83
+ metadata={"type": tune.get("type", "")},
84
+ )
85
+ )
86
+ return hits
87
+
88
+ def fetch(self, ref: ScoreRef, *, fmt: Optional[str] = None) -> str:
89
+ """Fetch the tune's first ABC setting and write it to a temp ``.abc`` file."""
90
+ data = self._get_json(f"{_BASE}/tunes/{ref.id}?format=json")
91
+ settings = data.get("settings") or []
92
+ if not settings:
93
+ raise RuntimeError(f"No ABC settings for tune {ref.id!r} on thesession.org")
94
+ setting = settings[0]
95
+ name = data.get("name", ref.title)
96
+ tune_type = data.get("type", ref.metadata.get("type", ""))
97
+ header = f"X:1\nT:{name}\n"
98
+ if tune_type in _METER:
99
+ header += f"M:{_METER[tune_type]}\n"
100
+ if setting.get("key"):
101
+ header += f"K:{setting['key']}\n"
102
+ abc = header + (setting.get("abc", "") or "").replace("\r\n", "\n")
103
+
104
+ fd, path = tempfile.mkstemp(suffix=".abc")
105
+ os.close(fd)
106
+ with open(path, "w", encoding="utf-8") as f:
107
+ f.write(abc)
108
+ return path