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.
- scoreseek-0.0.2/.gitignore +14 -0
- scoreseek-0.0.2/LICENSE +21 -0
- scoreseek-0.0.2/PKG-INFO +122 -0
- scoreseek-0.0.2/README.md +89 -0
- scoreseek-0.0.2/pyproject.toml +173 -0
- scoreseek-0.0.2/scoreseek/__init__.py +120 -0
- scoreseek-0.0.2/scoreseek/base.py +84 -0
- scoreseek-0.0.2/scoreseek/registry.py +57 -0
- scoreseek-0.0.2/scoreseek/sources/__init__.py +11 -0
- scoreseek-0.0.2/scoreseek/sources/base.py +56 -0
- scoreseek-0.0.2/scoreseek/sources/local.py +110 -0
- scoreseek-0.0.2/scoreseek/sources/thesession.py +108 -0
scoreseek-0.0.2/LICENSE
ADDED
|
@@ -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.
|
scoreseek-0.0.2/PKG-INFO
ADDED
|
@@ -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
|