mycelium-ai 0.5.0__py3-none-any.whl
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.
- mycelium/__init__.py +0 -0
- mycelium/api/__init__.py +0 -0
- mycelium/api/app.py +1147 -0
- mycelium/api/client_app.py +170 -0
- mycelium/api/generated_sources/__init__.py +0 -0
- mycelium/api/generated_sources/server_schemas/__init__.py +97 -0
- mycelium/api/generated_sources/server_schemas/api/__init__.py +5 -0
- mycelium/api/generated_sources/server_schemas/api/default_api.py +2473 -0
- mycelium/api/generated_sources/server_schemas/api_client.py +766 -0
- mycelium/api/generated_sources/server_schemas/api_response.py +25 -0
- mycelium/api/generated_sources/server_schemas/configuration.py +434 -0
- mycelium/api/generated_sources/server_schemas/exceptions.py +166 -0
- mycelium/api/generated_sources/server_schemas/models/__init__.py +41 -0
- mycelium/api/generated_sources/server_schemas/models/api_section.py +71 -0
- mycelium/api/generated_sources/server_schemas/models/chroma_section.py +69 -0
- mycelium/api/generated_sources/server_schemas/models/clap_section.py +75 -0
- mycelium/api/generated_sources/server_schemas/models/compute_on_server200_response.py +79 -0
- mycelium/api/generated_sources/server_schemas/models/compute_on_server_request.py +67 -0
- mycelium/api/generated_sources/server_schemas/models/compute_text_search_request.py +69 -0
- mycelium/api/generated_sources/server_schemas/models/config_request.py +81 -0
- mycelium/api/generated_sources/server_schemas/models/config_response.py +107 -0
- mycelium/api/generated_sources/server_schemas/models/create_playlist_request.py +71 -0
- mycelium/api/generated_sources/server_schemas/models/get_similar_by_track200_response.py +143 -0
- mycelium/api/generated_sources/server_schemas/models/library_stats_response.py +77 -0
- mycelium/api/generated_sources/server_schemas/models/logging_section.py +67 -0
- mycelium/api/generated_sources/server_schemas/models/media_server_section.py +67 -0
- mycelium/api/generated_sources/server_schemas/models/playlist_response.py +73 -0
- mycelium/api/generated_sources/server_schemas/models/plex_section.py +71 -0
- mycelium/api/generated_sources/server_schemas/models/processing_response.py +90 -0
- mycelium/api/generated_sources/server_schemas/models/save_config_response.py +73 -0
- mycelium/api/generated_sources/server_schemas/models/scan_library_response.py +75 -0
- mycelium/api/generated_sources/server_schemas/models/search_result_response.py +75 -0
- mycelium/api/generated_sources/server_schemas/models/server_section.py +67 -0
- mycelium/api/generated_sources/server_schemas/models/stop_processing_response.py +71 -0
- mycelium/api/generated_sources/server_schemas/models/task_status_response.py +87 -0
- mycelium/api/generated_sources/server_schemas/models/track_database_stats.py +75 -0
- mycelium/api/generated_sources/server_schemas/models/track_response.py +77 -0
- mycelium/api/generated_sources/server_schemas/models/tracks_list_response.py +81 -0
- mycelium/api/generated_sources/server_schemas/rest.py +329 -0
- mycelium/api/generated_sources/server_schemas/test/__init__.py +0 -0
- mycelium/api/generated_sources/server_schemas/test/test_api_section.py +57 -0
- mycelium/api/generated_sources/server_schemas/test/test_chroma_section.py +55 -0
- mycelium/api/generated_sources/server_schemas/test/test_clap_section.py +60 -0
- mycelium/api/generated_sources/server_schemas/test/test_compute_on_server200_response.py +52 -0
- mycelium/api/generated_sources/server_schemas/test/test_compute_on_server_request.py +53 -0
- mycelium/api/generated_sources/server_schemas/test/test_compute_text_search_request.py +54 -0
- mycelium/api/generated_sources/server_schemas/test/test_config_request.py +66 -0
- mycelium/api/generated_sources/server_schemas/test/test_config_response.py +97 -0
- mycelium/api/generated_sources/server_schemas/test/test_create_playlist_request.py +60 -0
- mycelium/api/generated_sources/server_schemas/test/test_default_api.py +150 -0
- mycelium/api/generated_sources/server_schemas/test/test_get_similar_by_track200_response.py +61 -0
- mycelium/api/generated_sources/server_schemas/test/test_library_stats_response.py +63 -0
- mycelium/api/generated_sources/server_schemas/test/test_logging_section.py +53 -0
- mycelium/api/generated_sources/server_schemas/test/test_media_server_section.py +53 -0
- mycelium/api/generated_sources/server_schemas/test/test_playlist_response.py +58 -0
- mycelium/api/generated_sources/server_schemas/test/test_plex_section.py +56 -0
- mycelium/api/generated_sources/server_schemas/test/test_processing_response.py +61 -0
- mycelium/api/generated_sources/server_schemas/test/test_save_config_response.py +58 -0
- mycelium/api/generated_sources/server_schemas/test/test_scan_library_response.py +61 -0
- mycelium/api/generated_sources/server_schemas/test/test_search_result_response.py +69 -0
- mycelium/api/generated_sources/server_schemas/test/test_server_section.py +53 -0
- mycelium/api/generated_sources/server_schemas/test/test_stop_processing_response.py +55 -0
- mycelium/api/generated_sources/server_schemas/test/test_task_status_response.py +71 -0
- mycelium/api/generated_sources/server_schemas/test/test_track_database_stats.py +60 -0
- mycelium/api/generated_sources/server_schemas/test/test_track_response.py +63 -0
- mycelium/api/generated_sources/server_schemas/test/test_tracks_list_response.py +75 -0
- mycelium/api/generated_sources/worker_schemas/__init__.py +61 -0
- mycelium/api/generated_sources/worker_schemas/api/__init__.py +5 -0
- mycelium/api/generated_sources/worker_schemas/api/default_api.py +318 -0
- mycelium/api/generated_sources/worker_schemas/api_client.py +766 -0
- mycelium/api/generated_sources/worker_schemas/api_response.py +25 -0
- mycelium/api/generated_sources/worker_schemas/configuration.py +434 -0
- mycelium/api/generated_sources/worker_schemas/exceptions.py +166 -0
- mycelium/api/generated_sources/worker_schemas/models/__init__.py +23 -0
- mycelium/api/generated_sources/worker_schemas/models/save_config_response.py +73 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_clap_section.py +75 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_client_api_section.py +69 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_client_section.py +79 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_config_request.py +73 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_config_response.py +89 -0
- mycelium/api/generated_sources/worker_schemas/models/worker_logging_section.py +67 -0
- mycelium/api/generated_sources/worker_schemas/rest.py +329 -0
- mycelium/api/generated_sources/worker_schemas/test/__init__.py +0 -0
- mycelium/api/generated_sources/worker_schemas/test/test_default_api.py +45 -0
- mycelium/api/generated_sources/worker_schemas/test/test_save_config_response.py +58 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_clap_section.py +60 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_client_api_section.py +55 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_client_section.py +65 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_config_request.py +59 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_config_response.py +89 -0
- mycelium/api/generated_sources/worker_schemas/test/test_worker_logging_section.py +53 -0
- mycelium/api/worker_models.py +99 -0
- mycelium/application/__init__.py +11 -0
- mycelium/application/job_queue.py +323 -0
- mycelium/application/library_management_use_cases.py +292 -0
- mycelium/application/search_use_cases.py +96 -0
- mycelium/application/services.py +340 -0
- mycelium/client.py +554 -0
- mycelium/client_config.py +251 -0
- mycelium/client_frontend_dist/404.html +1 -0
- mycelium/client_frontend_dist/_next/static/a4iyRdfsvkjdyMAK9cE9Y/_buildManifest.js +1 -0
- mycelium/client_frontend_dist/_next/static/a4iyRdfsvkjdyMAK9cE9Y/_ssgManifest.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/4bd1b696-cf72ae8a39fa05aa.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/964-830f77d7ce1c2463.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/app/_not-found/page-d25eede5a9099bd3.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/app/layout-9b3d32f96dfe13b6.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/app/page-cc6bad295789134e.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/framework-7c95b8e5103c9e90.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/main-6b37be50736577a2.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/main-app-4153d115599d3126.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/pages/_app-0a0020ddd67f79cf.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/pages/_error-03529f2c21436739.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- mycelium/client_frontend_dist/_next/static/chunks/webpack-c81e624915b2ea70.js +1 -0
- mycelium/client_frontend_dist/_next/static/css/1eb7f0e2c78e0734.css +1 -0
- mycelium/client_frontend_dist/favicon.ico +0 -0
- mycelium/client_frontend_dist/file.svg +1 -0
- mycelium/client_frontend_dist/globe.svg +1 -0
- mycelium/client_frontend_dist/index.html +1 -0
- mycelium/client_frontend_dist/index.txt +20 -0
- mycelium/client_frontend_dist/next.svg +1 -0
- mycelium/client_frontend_dist/vercel.svg +1 -0
- mycelium/client_frontend_dist/window.svg +1 -0
- mycelium/config.py +346 -0
- mycelium/domain/__init__.py +13 -0
- mycelium/domain/models.py +71 -0
- mycelium/domain/repositories.py +98 -0
- mycelium/domain/worker.py +77 -0
- mycelium/frontend_dist/404.html +1 -0
- mycelium/frontend_dist/_next/static/chunks/4bd1b696-cf72ae8a39fa05aa.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/964-830f77d7ce1c2463.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/app/_not-found/page-d25eede5a9099bd3.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/app/layout-9b3d32f96dfe13b6.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/app/page-a761463485e0540b.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/framework-7c95b8e5103c9e90.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/main-6b37be50736577a2.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/main-app-4153d115599d3126.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/pages/_app-0a0020ddd67f79cf.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/pages/_error-03529f2c21436739.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- mycelium/frontend_dist/_next/static/chunks/webpack-c81e624915b2ea70.js +1 -0
- mycelium/frontend_dist/_next/static/css/1eb7f0e2c78e0734.css +1 -0
- mycelium/frontend_dist/_next/static/glVJ0yJSL0zWN7anTTG3_/_buildManifest.js +1 -0
- mycelium/frontend_dist/_next/static/glVJ0yJSL0zWN7anTTG3_/_ssgManifest.js +1 -0
- mycelium/frontend_dist/favicon.ico +0 -0
- mycelium/frontend_dist/file.svg +1 -0
- mycelium/frontend_dist/globe.svg +1 -0
- mycelium/frontend_dist/index.html +10 -0
- mycelium/frontend_dist/index.txt +20 -0
- mycelium/frontend_dist/next.svg +1 -0
- mycelium/frontend_dist/vercel.svg +1 -0
- mycelium/frontend_dist/window.svg +1 -0
- mycelium/infrastructure/__init__.py +17 -0
- mycelium/infrastructure/chroma_adapter.py +232 -0
- mycelium/infrastructure/clap_adapter.py +280 -0
- mycelium/infrastructure/plex_adapter.py +145 -0
- mycelium/infrastructure/track_database.py +467 -0
- mycelium/main.py +183 -0
- mycelium_ai-0.5.0.dist-info/METADATA +312 -0
- mycelium_ai-0.5.0.dist-info/RECORD +164 -0
- mycelium_ai-0.5.0.dist-info/WHEEL +5 -0
- mycelium_ai-0.5.0.dist-info/entry_points.txt +2 -0
- mycelium_ai-0.5.0.dist-info/licenses/LICENSE +21 -0
- mycelium_ai-0.5.0.dist-info/top_level.txt +1 -0
mycelium/config.py
ADDED
@@ -0,0 +1,346 @@
|
|
1
|
+
"""Configuration management for Mycelium"""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
from dataclasses import dataclass, asdict
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Optional
|
8
|
+
|
9
|
+
import yaml
|
10
|
+
|
11
|
+
from mycelium.domain.models import MediaServerType
|
12
|
+
|
13
|
+
|
14
|
+
def get_user_data_dir() -> Path:
|
15
|
+
"""Get the user data directory for Mycelium (platform-specific)."""
|
16
|
+
if os.name == 'nt': # Windows
|
17
|
+
base_dir = os.getenv('LOCALAPPDATA', os.path.expanduser('~/AppData/Local'))
|
18
|
+
elif os.uname().sysname == 'Darwin': # macOS
|
19
|
+
base_dir = os.path.expanduser('~/Library/Application Support')
|
20
|
+
else: # Linux/Unix
|
21
|
+
base_dir = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
22
|
+
|
23
|
+
data_dir = Path(base_dir) / 'mycelium'
|
24
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
25
|
+
return data_dir
|
26
|
+
|
27
|
+
|
28
|
+
def get_user_log_dir() -> Path:
|
29
|
+
"""Get the user log directory for Mycelium (platform-specific)."""
|
30
|
+
if os.name == 'nt': # Windows
|
31
|
+
base_dir = os.getenv('LOCALAPPDATA', os.path.expanduser('~/AppData/Local'))
|
32
|
+
elif os.uname().sysname == 'Darwin': # macOS
|
33
|
+
base_dir = os.path.expanduser('~/Library/Logs')
|
34
|
+
else: # Linux/Unix
|
35
|
+
base_dir = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
|
36
|
+
|
37
|
+
log_dir = Path(base_dir) / 'mycelium'
|
38
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
39
|
+
return log_dir
|
40
|
+
|
41
|
+
|
42
|
+
def get_config_dir() -> Path:
|
43
|
+
"""Get the configuration directory for Mycelium."""
|
44
|
+
if os.name == 'nt': # Windows
|
45
|
+
base_dir = os.getenv('APPDATA', os.path.expanduser('~/AppData/Roaming'))
|
46
|
+
else: # macOS and Linux/Unix
|
47
|
+
base_dir = os.path.expanduser('~/.config')
|
48
|
+
|
49
|
+
config_dir = Path(base_dir) / 'mycelium'
|
50
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
51
|
+
return config_dir
|
52
|
+
|
53
|
+
|
54
|
+
def get_config_file_path() -> Path:
|
55
|
+
"""Get the configuration file path."""
|
56
|
+
return get_config_dir() / 'config.yml'
|
57
|
+
|
58
|
+
|
59
|
+
@dataclass
|
60
|
+
class PlexConfig:
|
61
|
+
"""Configuration for Plex connection."""
|
62
|
+
url: str = "http://localhost:32400"
|
63
|
+
token: Optional[str] = None
|
64
|
+
music_library_name: str = "Music"
|
65
|
+
|
66
|
+
@dataclass
|
67
|
+
class ServerConfig:
|
68
|
+
"""Configuration for server settings."""
|
69
|
+
gpu_batch_size: int = 16
|
70
|
+
|
71
|
+
@dataclass
|
72
|
+
class CLAPConfig:
|
73
|
+
"""Configuration for CLAP model."""
|
74
|
+
model_id: str = "laion/larger_clap_music_and_speech"
|
75
|
+
target_sr: int = 48000
|
76
|
+
chunk_duration_s: int = 10
|
77
|
+
num_chunks: int = 3
|
78
|
+
max_load_duration_s: Optional[int] = 120
|
79
|
+
|
80
|
+
@dataclass
|
81
|
+
class MediaServerConfig:
|
82
|
+
"""Configuration for media server."""
|
83
|
+
type: MediaServerType
|
84
|
+
|
85
|
+
def __post_init__(self):
|
86
|
+
"""Convert string type to MediaServerType enum if needed."""
|
87
|
+
if isinstance(self.type, str):
|
88
|
+
self.type = MediaServerType(self.type.lower())
|
89
|
+
|
90
|
+
@dataclass
|
91
|
+
class ChromaConfig:
|
92
|
+
"""Configuration for ChromaDB."""
|
93
|
+
collection_name: str = "my_music_library"
|
94
|
+
batch_size: int = 1000
|
95
|
+
|
96
|
+
@staticmethod
|
97
|
+
def get_db_path() -> str:
|
98
|
+
"""Get the actual database path for ChromaDB."""
|
99
|
+
return str(get_user_data_dir() / "music_vector_db")
|
100
|
+
|
101
|
+
|
102
|
+
@dataclass
|
103
|
+
class DatabaseConfig:
|
104
|
+
"""Configuration for track metadata database."""
|
105
|
+
|
106
|
+
@staticmethod
|
107
|
+
def get_db_path() -> str:
|
108
|
+
"""Get the actual database path for track metadata."""
|
109
|
+
return str(get_user_data_dir() / "mycelium_tracks.db")
|
110
|
+
|
111
|
+
|
112
|
+
@dataclass
|
113
|
+
class APIConfig:
|
114
|
+
"""Configuration for API server."""
|
115
|
+
host: str = "0.0.0.0"
|
116
|
+
port: int = 8000
|
117
|
+
reload: bool = False
|
118
|
+
|
119
|
+
|
120
|
+
@dataclass
|
121
|
+
class LoggingConfig:
|
122
|
+
"""Configuration for logging system."""
|
123
|
+
level: str = "INFO"
|
124
|
+
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
125
|
+
file: Optional[str] = None
|
126
|
+
|
127
|
+
|
128
|
+
@dataclass
|
129
|
+
class MyceliumConfig:
|
130
|
+
"""Main configuration class."""
|
131
|
+
server: ServerConfig
|
132
|
+
media_server: MediaServerConfig
|
133
|
+
plex: PlexConfig
|
134
|
+
clap: CLAPConfig
|
135
|
+
chroma: ChromaConfig
|
136
|
+
database: DatabaseConfig
|
137
|
+
api: APIConfig
|
138
|
+
logging: LoggingConfig
|
139
|
+
|
140
|
+
@classmethod
|
141
|
+
def load_from_yaml(cls, config_path: Optional[Path] = None) -> "MyceliumConfig":
|
142
|
+
"""Load configuration"""
|
143
|
+
if config_path is None:
|
144
|
+
config_path = get_config_file_path()
|
145
|
+
|
146
|
+
# Load YAML config if it exists
|
147
|
+
config_data = {}
|
148
|
+
config_exists = config_path.exists()
|
149
|
+
if config_exists:
|
150
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
151
|
+
config_data = yaml.safe_load(f) or {}
|
152
|
+
|
153
|
+
plex_config = PlexConfig(
|
154
|
+
url=config_data.get("plex", {}).get("url", "http://localhost:32400"),
|
155
|
+
token=config_data.get("plex", {}).get("token", "replace_with_your_token"),
|
156
|
+
music_library_name=config_data.get("plex", {}).get("music_library_name", "Music")
|
157
|
+
)
|
158
|
+
|
159
|
+
server_config = ServerConfig(
|
160
|
+
gpu_batch_size=config_data.get("server", {}).get("gpu_batch_size", 16)
|
161
|
+
)
|
162
|
+
|
163
|
+
clap_config = CLAPConfig(
|
164
|
+
model_id=config_data.get("clap", {}).get("model_id", "laion/larger_clap_music_and_speech"),
|
165
|
+
target_sr=config_data.get("clap", {}).get("target_sr", 48000),
|
166
|
+
chunk_duration_s=config_data.get("clap", {}).get("chunk_duration_s", 10),
|
167
|
+
num_chunks=config_data.get("clap", {}).get("num_chunks", 3),
|
168
|
+
max_load_duration_s=config_data.get("clap", {}).get("max_load_duration_s", 120)
|
169
|
+
)
|
170
|
+
|
171
|
+
chroma_config = ChromaConfig(
|
172
|
+
collection_name=config_data.get("chroma", {}).get("collection_name", "my_music_library"),
|
173
|
+
batch_size=config_data.get("chroma", {}).get("batch_size", 1000)
|
174
|
+
)
|
175
|
+
|
176
|
+
media_server = MediaServerConfig(
|
177
|
+
type=MediaServerType[config_data.get("media_server", {}).get("type", "plex").upper()]
|
178
|
+
)
|
179
|
+
|
180
|
+
database_config = DatabaseConfig()
|
181
|
+
|
182
|
+
api_config = APIConfig(
|
183
|
+
host=config_data.get("api", {}).get("host", "0.0.0.0"),
|
184
|
+
port=config_data.get("api", {}).get("port", 8000),
|
185
|
+
reload=config_data.get("api", {}).get("reload", False)
|
186
|
+
)
|
187
|
+
|
188
|
+
# Handle logging configuration with default log file path
|
189
|
+
logging_data = config_data.get("logging", {})
|
190
|
+
log_file = logging_data.get("file")
|
191
|
+
if log_file is None:
|
192
|
+
log_file = str(get_user_log_dir() / "mycelium.log")
|
193
|
+
|
194
|
+
logging_config = LoggingConfig(
|
195
|
+
level=logging_data.get("level", "INFO"),
|
196
|
+
format=logging_data.get("format", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"),
|
197
|
+
file=log_file
|
198
|
+
)
|
199
|
+
|
200
|
+
cfg = cls(
|
201
|
+
media_server=media_server,
|
202
|
+
plex=plex_config,
|
203
|
+
clap=clap_config,
|
204
|
+
chroma=chroma_config,
|
205
|
+
database=database_config,
|
206
|
+
api=api_config,
|
207
|
+
logging=logging_config,
|
208
|
+
server=server_config
|
209
|
+
)
|
210
|
+
|
211
|
+
# If no config file existed, create one with current values for convenience
|
212
|
+
if not config_exists:
|
213
|
+
# Ensure config directory exists
|
214
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
215
|
+
try:
|
216
|
+
cfg.save_to_yaml(config_path)
|
217
|
+
except Exception:
|
218
|
+
# Best-effort; ignore failures to avoid blocking startup
|
219
|
+
pass
|
220
|
+
|
221
|
+
return cfg
|
222
|
+
|
223
|
+
def save_to_yaml(self, config_path: Optional[Path] = None) -> None:
|
224
|
+
"""Save configuration to YAML file."""
|
225
|
+
if config_path is None:
|
226
|
+
config_path = get_config_file_path()
|
227
|
+
|
228
|
+
def custom_dict_factory(data):
|
229
|
+
result = []
|
230
|
+
for k, v in data:
|
231
|
+
if isinstance(v, MediaServerType):
|
232
|
+
result.append((k, v.value))
|
233
|
+
else:
|
234
|
+
result.append((k, v))
|
235
|
+
return dict(result)
|
236
|
+
|
237
|
+
config_dict = {
|
238
|
+
"media_server": asdict(self.media_server, dict_factory=custom_dict_factory),
|
239
|
+
"plex": asdict(self.plex),
|
240
|
+
"clap": asdict(self.clap),
|
241
|
+
"chroma": asdict(self.chroma),
|
242
|
+
"database": asdict(self.database),
|
243
|
+
"api": asdict(self.api),
|
244
|
+
"logging": asdict(self.logging),
|
245
|
+
"server": asdict(self.server)
|
246
|
+
}
|
247
|
+
|
248
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
249
|
+
yaml.dump(config_dict, f, default_flow_style=False, indent=2)
|
250
|
+
|
251
|
+
def to_dict(self) -> dict:
|
252
|
+
"""Convert configuration to dictionary format"""
|
253
|
+
return {
|
254
|
+
"media_server": {
|
255
|
+
"type": self.media_server.type.value
|
256
|
+
},
|
257
|
+
"plex": {
|
258
|
+
"url": self.plex.url,
|
259
|
+
"token": self.plex.token,
|
260
|
+
"music_library_name": self.plex.music_library_name
|
261
|
+
},
|
262
|
+
"server": {
|
263
|
+
"gpu_batch_size": self.server.gpu_batch_size
|
264
|
+
},
|
265
|
+
"api": {
|
266
|
+
"host": self.api.host,
|
267
|
+
"port": self.api.port,
|
268
|
+
"reload": self.api.reload
|
269
|
+
},
|
270
|
+
"chroma": {
|
271
|
+
"collection_name": self.chroma.collection_name,
|
272
|
+
"batch_size": self.chroma.batch_size
|
273
|
+
},
|
274
|
+
"clap": {
|
275
|
+
"model_id": self.clap.model_id,
|
276
|
+
"target_sr": self.clap.target_sr,
|
277
|
+
"chunk_duration_s": self.clap.chunk_duration_s,
|
278
|
+
"num_chunks": self.clap.num_chunks,
|
279
|
+
"max_load_duration_s": self.clap.max_load_duration_s
|
280
|
+
},
|
281
|
+
"logging": {
|
282
|
+
"level": self.logging.level
|
283
|
+
}
|
284
|
+
}
|
285
|
+
|
286
|
+
def setup_logging(self) -> None:
|
287
|
+
"""Setup logging configuration."""
|
288
|
+
# Configure the root logger
|
289
|
+
level = getattr(logging, self.logging.level.upper(), logging.INFO)
|
290
|
+
|
291
|
+
# Create log directory if needed
|
292
|
+
log_file_path = Path(self.logging.file)
|
293
|
+
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
294
|
+
|
295
|
+
# Get the root logger
|
296
|
+
root_logger = logging.getLogger()
|
297
|
+
|
298
|
+
# Remove all existing handlers to ensure clean reconfiguration
|
299
|
+
for handler in root_logger.handlers[:]:
|
300
|
+
root_logger.removeHandler(handler)
|
301
|
+
handler.close()
|
302
|
+
|
303
|
+
# Set the new level on the root logger
|
304
|
+
root_logger.setLevel(level)
|
305
|
+
|
306
|
+
# Create and add new handlers
|
307
|
+
file_handler = logging.FileHandler(self.logging.file)
|
308
|
+
file_handler.setLevel(level)
|
309
|
+
file_handler.setFormatter(logging.Formatter(self.logging.format))
|
310
|
+
|
311
|
+
console_handler = logging.StreamHandler()
|
312
|
+
console_handler.setLevel(level)
|
313
|
+
console_handler.setFormatter(logging.Formatter(self.logging.format))
|
314
|
+
|
315
|
+
root_logger.addHandler(file_handler)
|
316
|
+
root_logger.addHandler(console_handler)
|
317
|
+
|
318
|
+
# Set log level for third-party libraries to reduce noise
|
319
|
+
logging.getLogger('chromadb').setLevel(logging.WARNING)
|
320
|
+
logging.getLogger('urllib3').setLevel(logging.WARNING)
|
321
|
+
logging.getLogger('requests').setLevel(logging.WARNING)
|
322
|
+
|
323
|
+
# Configure Uvicorn loggers to prevent unwanted access logs
|
324
|
+
logging.getLogger('uvicorn.access').setLevel(logging.WARNING)
|
325
|
+
logging.getLogger('uvicorn').setLevel(logging.WARNING)
|
326
|
+
|
327
|
+
# Disable verbose numba logging
|
328
|
+
logging.getLogger('numba.core').setLevel(logging.WARNING)
|
329
|
+
|
330
|
+
|
331
|
+
# Export all necessary components
|
332
|
+
__all__ = [
|
333
|
+
"MediaServerConfig",
|
334
|
+
"MyceliumConfig",
|
335
|
+
"PlexConfig",
|
336
|
+
"CLAPConfig",
|
337
|
+
"ChromaConfig",
|
338
|
+
"DatabaseConfig",
|
339
|
+
"APIConfig",
|
340
|
+
"LoggingConfig",
|
341
|
+
"ServerConfig",
|
342
|
+
"get_config_file_path",
|
343
|
+
"get_user_data_dir",
|
344
|
+
"get_user_log_dir",
|
345
|
+
"get_config_dir"
|
346
|
+
]
|
@@ -0,0 +1,13 @@
|
|
1
|
+
"""Domain package initialization."""
|
2
|
+
|
3
|
+
from .models import Track, TrackEmbedding, SearchResult
|
4
|
+
from .repositories import MediaServerRepository, EmbeddingRepository, EmbeddingGenerator
|
5
|
+
|
6
|
+
__all__ = [
|
7
|
+
"Track",
|
8
|
+
"TrackEmbedding",
|
9
|
+
"SearchResult",
|
10
|
+
"MediaServerRepository",
|
11
|
+
"EmbeddingRepository",
|
12
|
+
"EmbeddingGenerator",
|
13
|
+
]
|
@@ -0,0 +1,71 @@
|
|
1
|
+
"""Domain models for the Mycelium application."""
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from enum import Enum
|
5
|
+
from pathlib import Path
|
6
|
+
from typing import List, Optional
|
7
|
+
from datetime import datetime
|
8
|
+
|
9
|
+
|
10
|
+
class MediaServerType(Enum):
|
11
|
+
"""Supported media server types."""
|
12
|
+
PLEX = "plex"
|
13
|
+
JELLYFIN = "jellyfin"
|
14
|
+
|
15
|
+
|
16
|
+
@dataclass
|
17
|
+
class Track:
|
18
|
+
"""Represents a music track from a media server."""
|
19
|
+
|
20
|
+
media_server_rating_key: str
|
21
|
+
media_server_type: MediaServerType
|
22
|
+
artist: str = ""
|
23
|
+
album: str = ""
|
24
|
+
title: str = ""
|
25
|
+
filepath: Optional[Path] = None
|
26
|
+
|
27
|
+
@property
|
28
|
+
def display_name(self) -> str:
|
29
|
+
"""Get a display-friendly name for the track."""
|
30
|
+
return f"{self.artist} - {self.title}"
|
31
|
+
|
32
|
+
@property
|
33
|
+
def unique_id(self) -> str:
|
34
|
+
"""Get a unique identifier for the track across media servers."""
|
35
|
+
return f"{self.media_server_type.value}:{self.media_server_rating_key}"
|
36
|
+
|
37
|
+
|
38
|
+
|
39
|
+
|
40
|
+
@dataclass
|
41
|
+
class TrackEmbedding:
|
42
|
+
"""Represents a track with its embedding from a specific model."""
|
43
|
+
|
44
|
+
track: Track
|
45
|
+
embedding: List[float]
|
46
|
+
model_id: str
|
47
|
+
processed_at: Optional[datetime] = None
|
48
|
+
|
49
|
+
|
50
|
+
@dataclass
|
51
|
+
class SearchResult:
|
52
|
+
"""Represents a search result with similarity score."""
|
53
|
+
|
54
|
+
track: Track
|
55
|
+
similarity_score: float
|
56
|
+
distance: float
|
57
|
+
|
58
|
+
|
59
|
+
@dataclass
|
60
|
+
class Playlist:
|
61
|
+
"""Represents a playlist created from recommendations."""
|
62
|
+
|
63
|
+
name: str
|
64
|
+
tracks: List[Track]
|
65
|
+
created_at: Optional[datetime] = None
|
66
|
+
server_id: Optional[str] = None
|
67
|
+
|
68
|
+
@property
|
69
|
+
def track_count(self) -> int:
|
70
|
+
"""Get the number of tracks in the playlist."""
|
71
|
+
return len(self.tracks)
|
@@ -0,0 +1,98 @@
|
|
1
|
+
"""Repository interfaces for domain layer."""
|
2
|
+
|
3
|
+
from abc import ABC, abstractmethod
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import List, Optional
|
6
|
+
|
7
|
+
from .models import Track, TrackEmbedding, SearchResult, Playlist
|
8
|
+
|
9
|
+
|
10
|
+
class MediaServerRepository(ABC):
|
11
|
+
"""Interface for media server operations (Plex, Jellyfin, etc.)."""
|
12
|
+
|
13
|
+
@abstractmethod
|
14
|
+
def get_all_tracks(self) -> List[Track]:
|
15
|
+
"""Get all tracks from the music library."""
|
16
|
+
pass
|
17
|
+
|
18
|
+
@abstractmethod
|
19
|
+
def get_track_by_id(self, track_id: str) -> Optional[Track]:
|
20
|
+
"""Get a specific track by its ID."""
|
21
|
+
pass
|
22
|
+
|
23
|
+
@abstractmethod
|
24
|
+
def create_playlist(self, playlist: Playlist, batch_size: int = 100) -> Playlist:
|
25
|
+
"""Create a playlist on the media server.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
playlist: The playlist to create
|
29
|
+
batch_size: Number of tracks to add per batch for large playlists (default: 100)
|
30
|
+
"""
|
31
|
+
pass
|
32
|
+
|
33
|
+
|
34
|
+
class EmbeddingRepository(ABC):
|
35
|
+
"""Interface for managing track embeddings."""
|
36
|
+
|
37
|
+
@abstractmethod
|
38
|
+
def save_embeddings(self, embeddings: List[TrackEmbedding]) -> None:
|
39
|
+
"""Save track embeddings to storage."""
|
40
|
+
pass
|
41
|
+
|
42
|
+
@abstractmethod
|
43
|
+
def save_embedding(self, track_embedding: TrackEmbedding) -> None:
|
44
|
+
"""Save track embeddings to storage."""
|
45
|
+
pass
|
46
|
+
|
47
|
+
@abstractmethod
|
48
|
+
def search_by_embedding(self, embedding: List[float], n_results: int = 10) -> List[SearchResult]:
|
49
|
+
"""Search for similar tracks by embedding."""
|
50
|
+
pass
|
51
|
+
|
52
|
+
@abstractmethod
|
53
|
+
def get_embedding_count(self) -> int:
|
54
|
+
"""Get the total number of embeddings stored."""
|
55
|
+
pass
|
56
|
+
|
57
|
+
@abstractmethod
|
58
|
+
def get_embedding_by_track_id(self, track_id: str) -> Optional[List[float]]:
|
59
|
+
"""Get embedding for a specific track by its ID."""
|
60
|
+
pass
|
61
|
+
|
62
|
+
@abstractmethod
|
63
|
+
def has_embedding(self, track_id: str) -> bool:
|
64
|
+
"""Check if an embedding exists for a specific track."""
|
65
|
+
pass
|
66
|
+
|
67
|
+
|
68
|
+
class EmbeddingGenerator(ABC):
|
69
|
+
"""Interface for generating embeddings from audio files."""
|
70
|
+
|
71
|
+
@abstractmethod
|
72
|
+
def generate_embedding(self, filepath: Path) -> Optional[List[float]]:
|
73
|
+
"""Generate embedding for an audio file."""
|
74
|
+
pass
|
75
|
+
|
76
|
+
@abstractmethod
|
77
|
+
def generate_embedding_batch(self, filepaths: List[Path]) -> List[Optional[List[float]]]:
|
78
|
+
"""Generate embeddings for multiple audio files in a batch."""
|
79
|
+
pass
|
80
|
+
|
81
|
+
@abstractmethod
|
82
|
+
def generate_text_embedding(self, text: str) -> Optional[List[float]]:
|
83
|
+
"""Generate embedding for text description."""
|
84
|
+
pass
|
85
|
+
|
86
|
+
@abstractmethod
|
87
|
+
def generate_text_embedding_batch(self, texts: List[str]) -> List[Optional[List[float]]]:
|
88
|
+
"""Generate embeddings for multiple text queries in a batch."""
|
89
|
+
pass
|
90
|
+
|
91
|
+
@staticmethod
|
92
|
+
def get_best_device() -> str:
|
93
|
+
"""Get the best device for running the embedding model (e.g., 'cpu', 'cuda')."""
|
94
|
+
pass
|
95
|
+
|
96
|
+
@staticmethod
|
97
|
+
def can_use_half_precision() -> bool:
|
98
|
+
"""Checks once if the device supports half precision."""
|
@@ -0,0 +1,77 @@
|
|
1
|
+
"""Worker domain models for client-server coordination."""
|
2
|
+
|
3
|
+
from dataclasses import dataclass
|
4
|
+
from datetime import datetime
|
5
|
+
from enum import Enum
|
6
|
+
from typing import List, Optional
|
7
|
+
|
8
|
+
from mycelium.domain import SearchResult
|
9
|
+
|
10
|
+
|
11
|
+
class TaskType(str, Enum):
|
12
|
+
"""Type of tasks that can be assigned to workers."""
|
13
|
+
COMPUTE_TEXT_EMBEDDING = "compute_text_embedding"
|
14
|
+
COMPUTE_AUDIO_EMBEDDING = "compute_audio_embedding"
|
15
|
+
|
16
|
+
class ContextType(str, Enum):
|
17
|
+
"""Type of context for the task."""
|
18
|
+
AUDIO_SEARCH = "audio_search"
|
19
|
+
AUDIO_PROCESSING = "audio_processing"
|
20
|
+
TEXT_SEARCH = "text_search"
|
21
|
+
SIMILAR_TRACKS = "similar_tracks"
|
22
|
+
|
23
|
+
|
24
|
+
class TaskStatus(str, Enum):
|
25
|
+
"""Status of a task."""
|
26
|
+
PENDING = "pending"
|
27
|
+
IN_PROGRESS = "in_progress"
|
28
|
+
SUCCESS = "success"
|
29
|
+
FAILED = "failed"
|
30
|
+
|
31
|
+
|
32
|
+
@dataclass
|
33
|
+
class Worker:
|
34
|
+
"""Represents a worker in the system."""
|
35
|
+
id: str
|
36
|
+
ip_address: str
|
37
|
+
registration_time: datetime
|
38
|
+
last_heartbeat: datetime
|
39
|
+
is_active: bool = True
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class Task:
|
44
|
+
"""Represents a task to be processed by a worker."""
|
45
|
+
task_id: str
|
46
|
+
task_type: TaskType
|
47
|
+
context_type: ContextType
|
48
|
+
track_id: str
|
49
|
+
download_url: str
|
50
|
+
status: TaskStatus = TaskStatus.PENDING
|
51
|
+
assigned_worker_id: Optional[str] = None
|
52
|
+
created_at: Optional[datetime] = None
|
53
|
+
started_at: Optional[datetime] = None
|
54
|
+
completed_at: Optional[datetime] = None
|
55
|
+
error_message: Optional[str] = None
|
56
|
+
# Additional fields for search tasks
|
57
|
+
text_query: Optional[str] = None # For text search tasks
|
58
|
+
audio_filename: Optional[str] = None # For audio search tasks
|
59
|
+
n_results: Optional[int] = None # For search tasks - number of results to return
|
60
|
+
# Results storage for search tasks
|
61
|
+
search_results: Optional[List[SearchResult]] = None
|
62
|
+
|
63
|
+
def __post_init__(self):
|
64
|
+
if self.created_at is None:
|
65
|
+
self.created_at = datetime.now()
|
66
|
+
|
67
|
+
|
68
|
+
@dataclass
|
69
|
+
class TaskResult:
|
70
|
+
"""Result of a completed task."""
|
71
|
+
task_id: str
|
72
|
+
track_id: str
|
73
|
+
status: TaskStatus
|
74
|
+
embedding: Optional[List[float]] = None
|
75
|
+
error_message: Optional[str] = None
|
76
|
+
# For search tasks, we store the search results directly
|
77
|
+
search_results: Optional[List[dict]] = None
|
@@ -0,0 +1 @@
|
|
1
|
+
<!DOCTYPE html><!--glVJ0yJSL0zWN7anTTG3_--><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/1eb7f0e2c78e0734.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-c81e624915b2ea70.js"/><script src="/_next/static/chunks/4bd1b696-cf72ae8a39fa05aa.js" async=""></script><script src="/_next/static/chunks/964-830f77d7ce1c2463.js" async=""></script><script src="/_next/static/chunks/main-app-4153d115599d3126.js" async=""></script><meta name="robots" content="noindex"/><title>404: This page could not be found.</title><title>Mycelium - AI Music Discovery</title><meta name="description" content="Discover your music collection like never before with AI-powered embeddings"/><link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body class="antialiased"><div hidden=""><!--$--><!--/$--></div><div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><!--$--><!--/$--><script src="/_next/static/chunks/webpack-c81e624915b2ea70.js" id="_R_" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0])</script><script>self.__next_f.push([1,"1:\"$Sreact.fragment\"\n2:I[7555,[],\"\"]\n3:I[1295,[],\"\"]\n4:I[9665,[],\"OutletBoundary\"]\n6:I[4911,[],\"AsyncMetadataOutlet\"]\n8:I[9665,[],\"ViewportBoundary\"]\na:I[9665,[],\"MetadataBoundary\"]\nb:\"$Sreact.suspense\"\nd:I[8393,[],\"\"]\n:HL[\"/_next/static/css/1eb7f0e2c78e0734.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"0:{\"P\":null,\"b\":\"glVJ0yJSL0zWN7anTTG3_\",\"p\":\"\",\"c\":[\"\",\"_not-found\"],\"i\":false,\"f\":[[[\"\",{\"children\":[\"/_not-found\",{\"children\":[\"__PAGE__\",{}]}]},\"$undefined\",\"$undefined\",true],[\"\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"link\",\"0\",{\"rel\":\"stylesheet\",\"href\":\"/_next/static/css/1eb7f0e2c78e0734.css\",\"precedence\":\"next\",\"crossOrigin\":\"$undefined\",\"nonce\":\"$undefined\"}]],[\"$\",\"html\",null,{\"lang\":\"en\",\"children\":[\"$\",\"body\",null,{\"className\":\"antialiased\",\"children\":[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]}]}]]}],{\"children\":[\"/_not-found\",[\"$\",\"$1\",\"c\",{\"children\":[null,[\"$\",\"$L2\",null,{\"parallelRouterKey\":\"children\",\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L3\",null,{}],\"templateStyles\":\"$undefined\",\"templateScripts\":\"$undefined\",\"notFound\":\"$undefined\",\"forbidden\":\"$undefined\",\"unauthorized\":\"$undefined\"}]]}],{\"children\":[\"__PAGE__\",[\"$\",\"$1\",\"c\",{\"children\":[[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]],null,[\"$\",\"$L4\",null,{\"children\":[\"$L5\",[\"$\",\"$L6\",null,{\"promise\":\"$@7\"}]]}]]}],{},null,false]},null,false]},null,false],[\"$\",\"$1\",\"h\",{\"children\":[[\"$\",\"meta\",null,{\"name\":\"robots\",\"content\":\"noindex\"}],[[\"$\",\"$L8\",null,{\"children\":\"$L9\"}],null],[\"$\",\"$La\",null,{\"children\":[\"$\",\"div\",null,{\"hidden\":true,\"children\":[\"$\",\"$b\",null,{\"fallback\":null,\"children\":\"$Lc\"}]}]}]]}],false]],\"m\":\"$undefined\",\"G\":[\"$d\",[]],\"s\":false,\"S\":true}\n"])</script><script>self.__next_f.push([1,"9:[[\"$\",\"meta\",\"0\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"1\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]\n5:null\n"])</script><script>self.__next_f.push([1,"e:I[8175,[],\"IconMark\"]\n7:{\"metadata\":[[\"$\",\"title\",\"0\",{\"children\":\"Mycelium - AI Music Discovery\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"Discover your music collection like never before with AI-powered embeddings\"}],[\"$\",\"link\",\"2\",{\"rel\":\"icon\",\"href\":\"/favicon.ico\",\"type\":\"image/x-icon\",\"sizes\":\"16x16\"}],[\"$\",\"$Le\",\"3\",{}]],\"error\":null,\"digest\":\"$undefined\"}\n"])</script><script>self.__next_f.push([1,"c:\"$7:metadata\"\n"])</script></body></html>
|