mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-02-04 07:52:55 +00:00
Merge d13e9685f3 into b254ab6fe2
This commit is contained in:
commit
e4218499eb
271
LYRICS_PLUGIN_GUIDE.md
Normal file
271
LYRICS_PLUGIN_GUIDE.md
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
# Lyrics Plugin Development Guide
|
||||||
|
|
||||||
|
This guide explains how to create a lyrics plugin for Spotube, similar to the existing audio source plugins.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Spotube uses Hetu Script for its plugin system. Lyrics plugins are packaged as `.smplug` files (ZIP archives) containing:
|
||||||
|
- `plugin.json` - Plugin metadata and configuration
|
||||||
|
- `plugin.out` - Compiled Hetu bytecode
|
||||||
|
- `logo.png` - Optional plugin logo
|
||||||
|
|
||||||
|
## Plugin Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
your-lyrics-plugin/
|
||||||
|
├── plugin.json # Plugin configuration
|
||||||
|
├── src/
|
||||||
|
│ └── main.ht # Hetu script source
|
||||||
|
├── logo.png # Optional logo
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Create plugin.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "LrcLib Lyrics",
|
||||||
|
"author": "YourName",
|
||||||
|
"description": "Lyrics provider using LrcLib API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pluginApiVersion": "2.0.0",
|
||||||
|
"entryPoint": "LrcLibLyricsPlugin",
|
||||||
|
"repository": "https://github.com/yourusername/spotube-plugin-lrclib-lyrics",
|
||||||
|
"apis": [],
|
||||||
|
"abilities": ["lyrics"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Fields:
|
||||||
|
- `name`: Display name of your plugin
|
||||||
|
- `author`: Your name or organization
|
||||||
|
- `version`: Plugin version (semver)
|
||||||
|
- `pluginApiVersion`: Must be "2.0.0" for current Spotube
|
||||||
|
- `entryPoint`: Main class name in your Hetu script
|
||||||
|
- `abilities`: Must include "lyrics" for lyrics plugins
|
||||||
|
- `apis`: Empty array for lyrics plugins (used for metadata/audio plugins)
|
||||||
|
|
||||||
|
## 2. Write Hetu Script (src/main.ht)
|
||||||
|
|
||||||
|
```hetu
|
||||||
|
import 'package:spotube_plugin/spotube_plugin.dart'
|
||||||
|
|
||||||
|
class LrcLibLyricsPlugin {
|
||||||
|
|
||||||
|
// Called when plugin is loaded
|
||||||
|
LrcLibLyricsPlugin() {
|
||||||
|
print('LrcLib Lyrics Plugin initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics API endpoint
|
||||||
|
var lyrics = LyricsEndpoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
class LyricsEndpoint {
|
||||||
|
|
||||||
|
// Search for lyrics
|
||||||
|
// Returns: List<Map<String, dynamic>>
|
||||||
|
external fun search(Map params) async {
|
||||||
|
final trackName = params['trackName']
|
||||||
|
final artistName = params['artistName']
|
||||||
|
final albumName = params['albumName']
|
||||||
|
final duration = params['duration'] // in seconds
|
||||||
|
|
||||||
|
// Make HTTP request to LrcLib API
|
||||||
|
final url = 'https://lrclib.net/api/search?track_name=$trackName&artist_name=$artistName'
|
||||||
|
|
||||||
|
final response = await http.get(url)
|
||||||
|
final data = json.decode(response.body)
|
||||||
|
|
||||||
|
// Transform to Spotube format
|
||||||
|
final results = []
|
||||||
|
for (var item in data) {
|
||||||
|
results.add({
|
||||||
|
'id': item['id'].toString(),
|
||||||
|
'name': item['name'],
|
||||||
|
'uri': 'lrclib:${item['id']}',
|
||||||
|
'rating': item['rating'] ?? 0,
|
||||||
|
'provider': 'lrclib',
|
||||||
|
'lyrics': parseLrc(item['syncedLyrics'] ?? item['plainLyrics'])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lyrics by ID
|
||||||
|
// Returns: Map<String, dynamic> or null
|
||||||
|
external fun getById(String id) async {
|
||||||
|
final url = 'https://lrclib.net/api/get/$id'
|
||||||
|
|
||||||
|
final response = await http.get(url)
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = json.decode(response.body)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'id': data['id'].toString(),
|
||||||
|
'name': data['name'],
|
||||||
|
'uri': 'lrclib:${data['id']}',
|
||||||
|
'rating': data['rating'] ?? 0,
|
||||||
|
'provider': 'lrclib',
|
||||||
|
'lyrics': parseLrc(data['syncedLyrics'] ?? data['plainLyrics'])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if service is available
|
||||||
|
external fun isAvailable() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get('https://lrclib.net/api/health')
|
||||||
|
return response.statusCode == 200
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse LRC format to lyrics array
|
||||||
|
fun parseLrc(String lrcContent) {
|
||||||
|
if (lrcContent == null || lrcContent.isEmpty) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
final lines = lrcContent.split('\n')
|
||||||
|
final lyrics = []
|
||||||
|
|
||||||
|
for (var line in lines) {
|
||||||
|
// Parse [mm:ss.xx] format
|
||||||
|
final match = RegExp(r'\[(\d+):(\d+)\.(\d+)\](.*)').firstMatch(line)
|
||||||
|
if (match != null) {
|
||||||
|
final minutes = int.parse(match.group(1))
|
||||||
|
final seconds = int.parse(match.group(2))
|
||||||
|
final centiseconds = int.parse(match.group(3))
|
||||||
|
final text = match.group(4).trim()
|
||||||
|
|
||||||
|
final timeMs = (minutes * 60 + seconds) * 1000 + centiseconds * 10
|
||||||
|
|
||||||
|
lyrics.add({
|
||||||
|
'time': timeMs,
|
||||||
|
'text': text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Expected Data Format
|
||||||
|
|
||||||
|
### Search Parameters (input)
|
||||||
|
```dart
|
||||||
|
{
|
||||||
|
'trackName': String,
|
||||||
|
'artistName': String,
|
||||||
|
'albumName': String?, // optional
|
||||||
|
'duration': int? // optional, in seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Results (output)
|
||||||
|
```dart
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'id': String,
|
||||||
|
'name': String,
|
||||||
|
'uri': String,
|
||||||
|
'rating': int,
|
||||||
|
'provider': String,
|
||||||
|
'lyrics': [
|
||||||
|
{
|
||||||
|
'time': int, // milliseconds
|
||||||
|
'text': String
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Compile Plugin
|
||||||
|
|
||||||
|
### Install Hetu Compiler
|
||||||
|
```bash
|
||||||
|
dart pub global activate hetu_script_dev_tools
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile Script
|
||||||
|
```bash
|
||||||
|
hetu compile src/main.ht -o plugin.out
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Package Plugin
|
||||||
|
|
||||||
|
Create a ZIP file with `.smplug` extension:
|
||||||
|
```bash
|
||||||
|
zip your-plugin.smplug plugin.json plugin.out logo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Or using PowerShell:
|
||||||
|
```powershell
|
||||||
|
Compress-Archive -Path plugin.json,plugin.out,logo.png -DestinationPath your-plugin.smplug
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Test Plugin
|
||||||
|
|
||||||
|
1. Open Spotube
|
||||||
|
2. Go to Settings > Plugins
|
||||||
|
3. Click "Add Plugin"
|
||||||
|
4. Paste your plugin URL or select the `.smplug` file
|
||||||
|
5. Set as default lyrics provider
|
||||||
|
|
||||||
|
## 7. Distribute Plugin
|
||||||
|
|
||||||
|
### GitHub Release
|
||||||
|
1. Create a GitHub repository
|
||||||
|
2. Create a release with your `.smplug` file
|
||||||
|
3. Users can install by pasting the repo URL
|
||||||
|
|
||||||
|
### Direct Download
|
||||||
|
Host the `.smplug` file and share the direct download link
|
||||||
|
|
||||||
|
## Example Plugins to Reference
|
||||||
|
|
||||||
|
- **spotube-plugin-youtube-audio**: Audio source plugin
|
||||||
|
- **spotube-plugin-musicbrainz-listenbrainz**: Metadata plugin
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Available in Hetu Scripts
|
||||||
|
|
||||||
|
- `http.get(url)` - HTTP GET request
|
||||||
|
- `http.post(url, body)` - HTTP POST request
|
||||||
|
- `json.encode(obj)` - JSON encoding
|
||||||
|
- `json.decode(str)` - JSON decoding
|
||||||
|
- `localStorage.get(key)` - Get stored value
|
||||||
|
- `localStorage.set(key, value)` - Store value
|
||||||
|
- `print(message)` - Debug logging
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
1. **Plugin API Version Mismatch**: Ensure `pluginApiVersion` is "2.0.0"
|
||||||
|
2. **Entry Point Not Found**: Class name must match `entryPoint` in plugin.json
|
||||||
|
3. **Compilation Errors**: Check Hetu syntax, use `external fun` for async methods
|
||||||
|
4. **Missing Abilities**: Must include "lyrics" in abilities array
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [ ] Plugin loads without errors
|
||||||
|
- [ ] Search returns results for known songs
|
||||||
|
- [ ] Lyrics are time-synced correctly
|
||||||
|
- [ ] getById returns correct lyrics
|
||||||
|
- [ ] isAvailable returns true when service is up
|
||||||
|
- [ ] Handles network errors gracefully
|
||||||
|
- [ ] Works on all platforms (Windows, macOS, Linux, Android, iOS)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For questions and issues:
|
||||||
|
- Spotube Discord: https://discord.gg/spotube
|
||||||
|
- GitHub Issues: https://github.com/KRTirtho/spotube/issues
|
||||||
207
LYRICS_PLUGIN_MIGRATION.md
Normal file
207
LYRICS_PLUGIN_MIGRATION.md
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
# Lyrics Plugin System Migration
|
||||||
|
|
||||||
|
This document explains the migration from built-in lyrics to a plugin-based system.
|
||||||
|
|
||||||
|
## What Changed
|
||||||
|
|
||||||
|
### Before
|
||||||
|
- Lyrics providers were built into the main application
|
||||||
|
- Hard-coded implementations for each service
|
||||||
|
- Required app updates to add new providers
|
||||||
|
|
||||||
|
### After
|
||||||
|
- Lyrics are provided through plugins
|
||||||
|
- Community can create providers for any service
|
||||||
|
- No app updates needed for new providers
|
||||||
|
- Follows the same pattern as audio source plugins
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Spotube Main App │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────┐ │
|
||||||
|
│ │ Lyrics Plugin Interface │ │
|
||||||
|
│ │ (MetadataPluginLyricsEndpoint) │ │
|
||||||
|
│ └───────────────────────────────────┘ │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌───────────────────────────────────┐ │
|
||||||
|
│ │ Hetu Script Engine │ │
|
||||||
|
│ └───────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Lyrics Plugin (.smplug) │
|
||||||
|
│ │
|
||||||
|
│ - plugin.json (metadata) │
|
||||||
|
│ - plugin.out (compiled Hetu bytecode) │
|
||||||
|
│ - logo.png (optional) │
|
||||||
|
│ │
|
||||||
|
│ Implements: │
|
||||||
|
│ - search(params) → List<Lyrics> │
|
||||||
|
│ - getById(id) → Lyrics? │
|
||||||
|
│ - isAvailable() → bool │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ External Lyrics API │
|
||||||
|
│ (LrcLib, Musixmatch, Genius, etc.) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Main App Changes
|
||||||
|
|
||||||
|
### Added Files
|
||||||
|
- `lib/services/metadata/endpoints/lyrics.dart` - Lyrics endpoint interface
|
||||||
|
- `lib/provider/lyrics_plugin_provider.dart` - Riverpod providers for lyrics
|
||||||
|
- `lib/services/lyrics_provider/README.md` - Documentation
|
||||||
|
- `LYRICS_PLUGIN_GUIDE.md` - Plugin development guide
|
||||||
|
- `LYRICS_PLUGIN_TEMPLATE.md` - Template repository structure
|
||||||
|
- `LYRICS_PLUGIN_MIGRATION.md` - This file
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
- `lib/models/metadata/plugin.dart` - Added `lyrics` to `PluginAbilities` enum
|
||||||
|
- `lib/services/metadata/metadata.dart` - Added lyrics endpoint initialization
|
||||||
|
|
||||||
|
### Removed Files
|
||||||
|
- All built-in lyrics provider implementations (moved to plugins)
|
||||||
|
|
||||||
|
## Plugin Development
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
1. Clone template: `LYRICS_PLUGIN_TEMPLATE.md`
|
||||||
|
2. Implement Hetu script in `src/main.ht`
|
||||||
|
3. Compile: `hetu compile src/main.ht -o plugin.out`
|
||||||
|
4. Package: `zip plugin.smplug plugin.json plugin.out logo.png`
|
||||||
|
5. Publish on GitHub with releases
|
||||||
|
|
||||||
|
### Required Methods
|
||||||
|
|
||||||
|
```hetu
|
||||||
|
class LyricsEndpoint {
|
||||||
|
external fun search(Map params) async
|
||||||
|
external fun getById(String id) async
|
||||||
|
external fun isAvailable() async
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Format
|
||||||
|
|
||||||
|
**Input (search params):**
|
||||||
|
```dart
|
||||||
|
{
|
||||||
|
'trackName': String,
|
||||||
|
'artistName': String,
|
||||||
|
'albumName': String?,
|
||||||
|
'duration': int? // seconds
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output (lyrics):**
|
||||||
|
```dart
|
||||||
|
{
|
||||||
|
'uri': String,
|
||||||
|
'name': String,
|
||||||
|
'lyrics': [
|
||||||
|
{
|
||||||
|
'time': int, // milliseconds
|
||||||
|
'text': String
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'rating': int,
|
||||||
|
'provider': String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage in Main App
|
||||||
|
|
||||||
|
### Get Lyrics Plugin
|
||||||
|
```dart
|
||||||
|
final plugin = ref.watch(lyricsPluginProvider);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search Lyrics
|
||||||
|
```dart
|
||||||
|
final lyrics = await ref.read(lyricsSearchProvider({
|
||||||
|
'trackName': 'Song Name',
|
||||||
|
'artistName': 'Artist Name',
|
||||||
|
'albumName': 'Album Name',
|
||||||
|
'duration': Duration(seconds: 180),
|
||||||
|
}).future);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get by ID
|
||||||
|
```dart
|
||||||
|
final lyric = await ref.read(lyricsByIdProvider('lrclib:12345').future);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin Examples
|
||||||
|
|
||||||
|
### LrcLib Plugin
|
||||||
|
- Free, open-source
|
||||||
|
- No API key required
|
||||||
|
- Repository: Create at `spotube-plugin-lrclib-lyrics`
|
||||||
|
|
||||||
|
### Musixmatch Plugin
|
||||||
|
- Commercial service
|
||||||
|
- Requires API key
|
||||||
|
- Repository: Create at `spotube-plugin-musixmatch-lyrics`
|
||||||
|
|
||||||
|
### Genius Plugin
|
||||||
|
- Community lyrics
|
||||||
|
- Web scraping or API
|
||||||
|
- Repository: Create at `spotube-plugin-genius-lyrics`
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Extensibility**: Anyone can add new lyrics sources
|
||||||
|
2. **Maintainability**: Plugins maintained separately
|
||||||
|
3. **Flexibility**: Users choose their preferred provider
|
||||||
|
4. **Privacy**: No forced third-party services
|
||||||
|
5. **Community**: Encourages community contributions
|
||||||
|
6. **Updates**: Plugins update independently
|
||||||
|
|
||||||
|
## Migration Path for Users
|
||||||
|
|
||||||
|
1. Update Spotube to version with plugin support
|
||||||
|
2. Install lyrics plugin from Settings > Plugins
|
||||||
|
3. Choose default lyrics provider
|
||||||
|
4. Existing lyrics cache remains compatible
|
||||||
|
|
||||||
|
## For Plugin Developers
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
- Plugin Guide: `LYRICS_PLUGIN_GUIDE.md`
|
||||||
|
- Template: `LYRICS_PLUGIN_TEMPLATE.md`
|
||||||
|
- Example: Check `assets/plugins/spotube-plugin-youtube-audio/`
|
||||||
|
- Hetu Docs: https://hetu.dev
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
1. Compile plugin locally
|
||||||
|
2. Load in Spotube via file path
|
||||||
|
3. Test search and getById methods
|
||||||
|
4. Verify time-sync accuracy
|
||||||
|
5. Test error handling
|
||||||
|
|
||||||
|
### Publishing
|
||||||
|
1. Create GitHub repository
|
||||||
|
2. Add GitHub Actions for auto-build
|
||||||
|
3. Create release with `.smplug` file
|
||||||
|
4. Users install via repo URL
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- Discord: https://discord.gg/spotube
|
||||||
|
- GitHub: https://github.com/KRTirtho/spotube
|
||||||
|
- Docs: https://spotube.krtirtho.dev
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Multiple provider fallback
|
||||||
|
- Provider priority settings
|
||||||
|
- Lyrics caching system
|
||||||
|
- Offline lyrics support
|
||||||
|
- User-contributed lyrics
|
||||||
|
- Lyrics translation plugins
|
||||||
|
- Karaoke mode plugins
|
||||||
289
LYRICS_PLUGIN_TEMPLATE.md
Normal file
289
LYRICS_PLUGIN_TEMPLATE.md
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
# Lyrics Plugin Template Repository
|
||||||
|
|
||||||
|
This document provides a complete template for creating a lyrics plugin repository.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
spotube-plugin-lrclib-lyrics/
|
||||||
|
├── .github/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── release.yml
|
||||||
|
├── src/
|
||||||
|
│ └── main.ht
|
||||||
|
├── plugin.json
|
||||||
|
├── logo.png
|
||||||
|
├── README.md
|
||||||
|
├── LICENSE
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Contents
|
||||||
|
|
||||||
|
### plugin.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "LrcLib Lyrics",
|
||||||
|
"author": "YourName",
|
||||||
|
"description": "Free and open-source lyrics provider using LrcLib API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pluginApiVersion": "2.0.0",
|
||||||
|
"entryPoint": "LrcLibLyricsPlugin",
|
||||||
|
"repository": "https://github.com/yourusername/spotube-plugin-lrclib-lyrics",
|
||||||
|
"apis": [],
|
||||||
|
"abilities": ["lyrics"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### src/main.ht
|
||||||
|
|
||||||
|
```hetu
|
||||||
|
import 'package:spotube_plugin/spotube_plugin.dart'
|
||||||
|
|
||||||
|
class LrcLibLyricsPlugin {
|
||||||
|
|
||||||
|
LrcLibLyricsPlugin() {
|
||||||
|
print('LrcLib Lyrics Plugin v1.0.0 initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyrics = LyricsEndpoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
class LyricsEndpoint {
|
||||||
|
|
||||||
|
external fun search(Map params) async {
|
||||||
|
final trackName = params['trackName']
|
||||||
|
final artistName = params['artistName']
|
||||||
|
final albumName = params['albumName']
|
||||||
|
final duration = params['duration']
|
||||||
|
|
||||||
|
var url = 'https://lrclib.net/api/search?track_name=${Uri.encodeComponent(trackName)}&artist_name=${Uri.encodeComponent(artistName)}'
|
||||||
|
|
||||||
|
if (albumName != null && albumName.isNotEmpty) {
|
||||||
|
url += '&album_name=${Uri.encodeComponent(albumName)}'
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(url)
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
print('LrcLib API error: ${response.statusCode}')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = json.decode(response.body)
|
||||||
|
|
||||||
|
if (data is! List) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
final results = []
|
||||||
|
for (var item in data) {
|
||||||
|
final syncedLyrics = item['syncedLyrics']
|
||||||
|
final plainLyrics = item['plainLyrics']
|
||||||
|
|
||||||
|
if (syncedLyrics == null && plainLyrics == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results.add({
|
||||||
|
'uri': 'lrclib:${item['id']}',
|
||||||
|
'name': '${item['trackName']} - ${item['artistName']}',
|
||||||
|
'lyrics': parseLrc(syncedLyrics ?? plainLyrics ?? ''),
|
||||||
|
'rating': 5,
|
||||||
|
'provider': 'lrclib'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e) {
|
||||||
|
print('LrcLib search error: $e')
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun getById(String id) async {
|
||||||
|
final url = 'https://lrclib.net/api/get/$id'
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(url)
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = json.decode(response.body)
|
||||||
|
final syncedLyrics = data['syncedLyrics']
|
||||||
|
final plainLyrics = data['plainLyrics']
|
||||||
|
|
||||||
|
if (syncedLyrics == null && plainLyrics == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'uri': 'lrclib:${data['id']}',
|
||||||
|
'name': '${data['trackName']} - ${data['artistName']}',
|
||||||
|
'lyrics': parseLrc(syncedLyrics ?? plainLyrics ?? ''),
|
||||||
|
'rating': 5,
|
||||||
|
'provider': 'lrclib'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('LrcLib getById error: $e')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun isAvailable() async {
|
||||||
|
try {
|
||||||
|
final response = await http.get('https://lrclib.net/')
|
||||||
|
return response.statusCode == 200
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseLrc(String lrcContent) {
|
||||||
|
if (lrcContent.isEmpty) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
final lines = lrcContent.split('\n')
|
||||||
|
final lyrics = []
|
||||||
|
|
||||||
|
for (var line in lines) {
|
||||||
|
final timeMatch = RegExp(r'\[(\d+):(\d+)\.(\d+)\]').firstMatch(line)
|
||||||
|
|
||||||
|
if (timeMatch != null) {
|
||||||
|
final minutes = int.parse(timeMatch.group(1))
|
||||||
|
final seconds = int.parse(timeMatch.group(2))
|
||||||
|
final centiseconds = int.parse(timeMatch.group(3))
|
||||||
|
|
||||||
|
final text = line.substring(timeMatch.end).trim()
|
||||||
|
|
||||||
|
if (text.isNotEmpty) {
|
||||||
|
final timeMs = (minutes * 60 + seconds) * 1000 + centiseconds * 10
|
||||||
|
|
||||||
|
lyrics.add({
|
||||||
|
'time': timeMs,
|
||||||
|
'text': text
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lyrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### README.md
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# LrcLib Lyrics Plugin for Spotube
|
||||||
|
|
||||||
|
Free and open-source lyrics provider using the LrcLib API.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Time-synced lyrics
|
||||||
|
- Free and open-source
|
||||||
|
- No API key required
|
||||||
|
- Large lyrics database
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Open Spotube
|
||||||
|
2. Go to Settings > Plugins
|
||||||
|
3. Click "Add Plugin"
|
||||||
|
4. Paste: `https://github.com/yourusername/spotube-plugin-lrclib-lyrics`
|
||||||
|
5. Click Install
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Dart SDK
|
||||||
|
- Hetu Script Dev Tools
|
||||||
|
|
||||||
|
### Compile
|
||||||
|
```bash
|
||||||
|
dart pub global activate hetu_script_dev_tools
|
||||||
|
hetu compile src/main.ht -o plugin.out
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package
|
||||||
|
```bash
|
||||||
|
zip spotube-plugin-lrclib-lyrics.smplug plugin.json plugin.out logo.png
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
This plugin uses the [LrcLib API](https://lrclib.net/docs).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
```
|
||||||
|
|
||||||
|
### .github/workflows/release.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Release Plugin
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: dart-lang/setup-dart@v1
|
||||||
|
|
||||||
|
- name: Install Hetu
|
||||||
|
run: dart pub global activate hetu_script_dev_tools
|
||||||
|
|
||||||
|
- name: Compile Plugin
|
||||||
|
run: hetu compile src/main.ht -o plugin.out
|
||||||
|
|
||||||
|
- name: Package Plugin
|
||||||
|
run: zip spotube-plugin-lrclib-lyrics.smplug plugin.json plugin.out logo.png
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: spotube-plugin-lrclib-lyrics.smplug
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .gitignore
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin.out
|
||||||
|
*.smplug
|
||||||
|
.DS_Store
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
1. Create repository on GitHub
|
||||||
|
2. Push code
|
||||||
|
3. Create a tag: `git tag v1.0.0`
|
||||||
|
4. Push tag: `git push origin v1.0.0`
|
||||||
|
5. GitHub Actions will automatically build and release
|
||||||
|
|
||||||
|
## Testing Locally
|
||||||
|
|
||||||
|
1. Compile: `hetu compile src/main.ht -o plugin.out`
|
||||||
|
2. Package: `zip plugin.smplug plugin.json plugin.out logo.png`
|
||||||
|
3. In Spotube, add plugin via file path
|
||||||
|
|
||||||
|
## Example Plugins
|
||||||
|
|
||||||
|
- [spotube-plugin-youtube-audio](https://github.com/KRTirtho/spotube/tree/master/assets/plugins/spotube-plugin-youtube-audio)
|
||||||
|
- [spotube-plugin-musicbrainz-listenbrainz](https://github.com/KRTirtho/spotube/tree/master/assets/plugins/spotube-plugin-musicbrainz-listenbrainz)
|
||||||
@ -8,6 +8,7 @@ enum PluginAbilities {
|
|||||||
metadata,
|
metadata,
|
||||||
@JsonValue('audio-source')
|
@JsonValue('audio-source')
|
||||||
audioSource,
|
audioSource,
|
||||||
|
lyrics,
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
55
lib/provider/lyrics_plugin_provider.dart
Normal file
55
lib/provider/lyrics_plugin_provider.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:spotube/models/lyrics.dart';
|
||||||
|
import 'package:spotube/models/metadata/metadata.dart';
|
||||||
|
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
|
||||||
|
import 'package:spotube/services/metadata/metadata.dart';
|
||||||
|
|
||||||
|
final lyricsPluginProvider = FutureProvider<MetadataPlugin?>(
|
||||||
|
(ref) async {
|
||||||
|
final plugins = await ref.watch(metadataPluginsProvider.future);
|
||||||
|
|
||||||
|
final lyricsPlugin = plugins.plugins.firstWhere(
|
||||||
|
(p) => p.abilities.contains(PluginAbilities.lyrics),
|
||||||
|
orElse: () => throw Exception('No lyrics plugin found'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final pluginsNotifier = ref.read(metadataPluginsProvider.notifier);
|
||||||
|
final pluginByteCode = await pluginsNotifier.getPluginByteCode(lyricsPlugin);
|
||||||
|
final youtubeEngine = ref.read(youtubeEngineProvider);
|
||||||
|
|
||||||
|
return await MetadataPlugin.create(
|
||||||
|
youtubeEngine,
|
||||||
|
lyricsPlugin,
|
||||||
|
pluginByteCode,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final lyricsSearchProvider = FutureProvider.family<List<SubtitleSimple>, Map<String, dynamic>>(
|
||||||
|
(ref, params) async {
|
||||||
|
final plugin = await ref.watch(lyricsPluginProvider.future);
|
||||||
|
|
||||||
|
if (plugin == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return await plugin.lyrics.search(
|
||||||
|
trackName: params['trackName'] as String,
|
||||||
|
artistName: params['artistName'] as String,
|
||||||
|
albumName: params['albumName'] as String?,
|
||||||
|
duration: params['duration'] as Duration?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final lyricsByIdProvider = FutureProvider.family<SubtitleSimple?, String>(
|
||||||
|
(ref, id) async {
|
||||||
|
final plugin = await ref.watch(lyricsPluginProvider.future);
|
||||||
|
|
||||||
|
if (plugin == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await plugin.lyrics.getById(id);
|
||||||
|
},
|
||||||
|
);
|
||||||
79
lib/services/lyrics_provider/README.md
Normal file
79
lib/services/lyrics_provider/README.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Lyrics Provider System
|
||||||
|
|
||||||
|
This directory contains the lyrics provider plugin system for Spotube.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The lyrics system is designed to be fully plugin-based, similar to the audio source and metadata plugins.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **lyrics_provider.dart** - Abstract interface for lyrics providers
|
||||||
|
2. **lyrics_provider_manager.dart** - Manages multiple lyrics providers
|
||||||
|
3. **providers/** - Built-in provider stubs (to be implemented as plugins)
|
||||||
|
|
||||||
|
## Plugin Integration
|
||||||
|
|
||||||
|
Lyrics plugins are loaded through the Hetu script system and must implement the following interface:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class LyricsEndpoint {
|
||||||
|
Future<List<Map<String, dynamic>>> search(Map params);
|
||||||
|
Future<Map<String, dynamic>?> getById(String id);
|
||||||
|
Future<bool> isAvailable();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Request
|
||||||
|
↓
|
||||||
|
LyricsProviderManager
|
||||||
|
↓
|
||||||
|
Plugin System (Hetu)
|
||||||
|
↓
|
||||||
|
External API (LrcLib, Musixmatch, etc.)
|
||||||
|
↓
|
||||||
|
SubtitleSimple Model
|
||||||
|
↓
|
||||||
|
UI Display
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Lyrics Plugin
|
||||||
|
|
||||||
|
See `LYRICS_PLUGIN_GUIDE.md` in the root directory for detailed instructions.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
1. Create plugin.json with "abilities": ["lyrics"]
|
||||||
|
2. Implement LyricsEndpoint class in Hetu script
|
||||||
|
3. Compile to bytecode
|
||||||
|
4. Package as .smplug file
|
||||||
|
5. Distribute via GitHub releases
|
||||||
|
|
||||||
|
## Built-in Providers
|
||||||
|
|
||||||
|
The following providers are stubs that will be implemented as separate plugins:
|
||||||
|
|
||||||
|
- **LrcLibProvider** - Free, open lyrics database
|
||||||
|
- **MusixmatchProvider** - Commercial lyrics service (requires API key)
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
The old lyrics functionality has been removed from the main codebase and will be reimplemented as plugins. This allows:
|
||||||
|
|
||||||
|
- Community-contributed lyrics sources
|
||||||
|
- Easy addition of new providers
|
||||||
|
- No vendor lock-in
|
||||||
|
- Better separation of concerns
|
||||||
|
- Reduced main app size
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Multiple provider fallback
|
||||||
|
- Lyrics caching
|
||||||
|
- Offline lyrics support
|
||||||
|
- User-contributed lyrics
|
||||||
|
- Lyrics translation
|
||||||
|
- Karaoke mode
|
||||||
51
lib/services/metadata/endpoints/lyrics.dart
Normal file
51
lib/services/metadata/endpoints/lyrics.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
part of '../metadata.dart';
|
||||||
|
|
||||||
|
class MetadataPluginLyricsEndpoint {
|
||||||
|
final Hetu hetu;
|
||||||
|
|
||||||
|
MetadataPluginLyricsEndpoint(this.hetu);
|
||||||
|
|
||||||
|
HTInstance get hetuMetadataLyrics =>
|
||||||
|
(hetu.fetch("metadataPlugin") as HTInstance).memberGet("lyrics")
|
||||||
|
as HTInstance;
|
||||||
|
|
||||||
|
Future<List<SubtitleSimple>> search({
|
||||||
|
required String trackName,
|
||||||
|
required String artistName,
|
||||||
|
String? albumName,
|
||||||
|
Duration? duration,
|
||||||
|
}) async {
|
||||||
|
final result = await hetuMetadataLyrics.invoke(
|
||||||
|
"search",
|
||||||
|
positionalArgs: [
|
||||||
|
{
|
||||||
|
'trackName': trackName,
|
||||||
|
'artistName': artistName,
|
||||||
|
'albumName': albumName,
|
||||||
|
'duration': duration?.inSeconds,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) return [];
|
||||||
|
|
||||||
|
final list = result as List;
|
||||||
|
return list.map((item) => SubtitleSimple.fromJson(item)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SubtitleSimple?> getById(String id) async {
|
||||||
|
final result = await hetuMetadataLyrics.invoke(
|
||||||
|
"getById",
|
||||||
|
positionalArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result == null) return null;
|
||||||
|
|
||||||
|
return SubtitleSimple.fromJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> isAvailable() async {
|
||||||
|
final result = await hetuMetadataLyrics.invoke("isAvailable");
|
||||||
|
return result as bool? ?? false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,9 @@ import 'package:spotube/services/metadata/endpoints/search.dart';
|
|||||||
import 'package:spotube/services/metadata/endpoints/track.dart';
|
import 'package:spotube/services/metadata/endpoints/track.dart';
|
||||||
import 'package:spotube/services/metadata/endpoints/core.dart';
|
import 'package:spotube/services/metadata/endpoints/core.dart';
|
||||||
import 'package:spotube/services/metadata/endpoints/user.dart';
|
import 'package:spotube/services/metadata/endpoints/user.dart';
|
||||||
|
import 'package:spotube/services/metadata/endpoints/lyrics.dart';
|
||||||
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
|
import 'package:spotube/services/youtube_engine/youtube_engine.dart';
|
||||||
|
import 'package:spotube/models/lyrics.dart';
|
||||||
|
|
||||||
const defaultMetadataLimit = "20";
|
const defaultMetadataLimit = "20";
|
||||||
|
|
||||||
@ -164,6 +166,7 @@ class MetadataPlugin {
|
|||||||
late final MetadataPluginTrackEndpoint track;
|
late final MetadataPluginTrackEndpoint track;
|
||||||
late final MetadataPluginUserEndpoint user;
|
late final MetadataPluginUserEndpoint user;
|
||||||
late final MetadataPluginCore core;
|
late final MetadataPluginCore core;
|
||||||
|
late final MetadataPluginLyricsEndpoint lyrics;
|
||||||
|
|
||||||
MetadataPlugin._(this.hetu) {
|
MetadataPlugin._(this.hetu) {
|
||||||
auth = MetadataAuthEndpoint(hetu);
|
auth = MetadataAuthEndpoint(hetu);
|
||||||
@ -177,5 +180,6 @@ class MetadataPlugin {
|
|||||||
track = MetadataPluginTrackEndpoint(hetu);
|
track = MetadataPluginTrackEndpoint(hetu);
|
||||||
user = MetadataPluginUserEndpoint(hetu);
|
user = MetadataPluginUserEndpoint(hetu);
|
||||||
core = MetadataPluginCore(hetu);
|
core = MetadataPluginCore(hetu);
|
||||||
|
lyrics = MetadataPluginLyricsEndpoint(hetu);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user