Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@

lastwave = pkgs.flutter.buildFlutterApplication {
pname = "lastwave";
version = "1.2.0";
version = "1.3.0";
src = ./.;

autoPubspecLock = ./pubspec.lock;
Expand Down
12 changes: 12 additions & 0 deletions lib/core/audio/stream_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ class PlayableTrack {
playbackUrl: playbackUrl ?? this.playbackUrl,
playbackMimeType: playbackMimeType ?? this.playbackMimeType,
);

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PlayableTrack &&
runtimeType == other.runtimeType &&
queueKey == other.queueKey &&
videoId == other.videoId &&
album == other.album;

@override
int get hashCode => Object.hash(queueKey, videoId, album);
}

/// A resolved playable stream (lossless or YouTube).
Expand Down
58 changes: 47 additions & 11 deletions lib/features/addons/addon_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,57 @@ class AddonApi implements LosslessSource {
String get _secret => AppEnv.addonClientSecret;

@override
bool get isConfigured =>
bases.isNotEmpty && _secret.isNotEmpty;
bool get isConfigured => bases.isNotEmpty;

// -- URL parsing ------------------------------------------------------------

/// Accepts a full addon root (trailing slash optional), a manifest
/// URL, or a deeper addon URL (stream/…). Bare domains and
/// non-hex tokens are rejected (the server would 404 them anyway).
/// URL, or a deeper addon URL (stream/…). Supports tokenized addon
/// roots (`{base}/a/<token>/`) and standalone/worker addon roots
/// (`https://host/`).
static ({String root, String token})? parseAddonUrl(
String raw) {
var v = raw.trim().replaceAll(RegExp(r'/+$'), '');
var v = raw.trim();
if (v.isEmpty) return null;
v = v.replaceAll(RegExp(r'/manifest(\.json)?$'), '');

final uri = Uri.tryParse(v);
if (uri == null ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
return null;
}

v = v.replaceAll(RegExp(r'/+$'), '');
v = v.replaceAll(RegExp(r'/manifest(\.json)?$', caseSensitive: false), '');

final m =
RegExp(r'^(https?://[^/?#]+)/a/([^/?#]+)').firstMatch(v);
if (m == null) return null;
final token = m.group(2)!;
if (!RegExp(r'^[0-9a-fA-F]{16,}$').hasMatch(token)) {
if (m != null) {
final token = m.group(2)!;
if (!RegExp(r'^[0-9a-fA-F]{16,}$').hasMatch(token)) {
return null;
}
return (root: '${m.group(1)}/a/$token/', token: token);
}

// Malformed /a/ paths without a valid token are rejected.
if (RegExp(r'^(https?://[^/?#]+)/a(/.*)?$').hasMatch(v)) {
return null;
}
return (root: '${m.group(1)}/a/$token/', token: token);

var cleanPath = uri.path.replaceAll(RegExp(r'/+$'), '');
cleanPath = cleanPath.replaceAll(
RegExp(r'/manifest(\.json)?$', caseSensitive: false), '');
cleanPath = cleanPath.replaceAll(
RegExp(r'/(stream|search)(/.*)?$', caseSensitive: false), '');

final origin =
'${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}';
final root = cleanPath.isEmpty || cleanPath == '/'
? '$origin/'
: '$origin$cleanPath/';

return (root: root, token: '');
}

// -- request signing (one-way lock) ------------------------------------------
Expand Down Expand Up @@ -112,13 +142,19 @@ class AddonApi implements LosslessSource {

Map<String, String> _signHeaders(
String method, String root, String path) {
final token = tokenOfRoot(root);
if (_secret.isEmpty || token.isEmpty) {
return {
'User-Agent': 'LastWave-Player/1.0',
};
}
final ts =
(DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
return signFor(
secret: _secret,
method: method,
path: path,
token: tokenOfRoot(root),
token: token,
ts: ts);
}

Expand Down
2 changes: 1 addition & 1 deletion lib/features/feed/feed_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ String normalizeArtistKey(String artist) {
// Parenthesised credits: "Artist (feat. X)", "[ft. X]", "(with X)"
k = k
.replaceAll(
RegExp(r'\s*[\(\[]\s*(feat\.?\vert{}ft\.?\vert{}featuring\vert{}with\vert{}prod\.?\vert{}produced by)\b[^\)\]]*[\)\]]'), '')
RegExp(r'\s*[\(\[]\s*(feat\.?|ft\.?|featuring|with|prod\.?|produced by)\b[^\)\]]*[\)\]]'), '')
.trim();

// Trailing collab & featured credits: "A feat. B", "A ft B", "A featuring B", "A with B", "A prod. B"
Expand Down
94 changes: 94 additions & 0 deletions lib/features/innertube/innertube_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3260,6 +3260,100 @@ class InnerTubeMusicApi {
return out.take(limit).toList();
}

/// Plain lyrics from a video's YouTube Music "Lyrics" tab, when it has one.
Future<String?> fetchLyricsTabText(String videoId) async {
final id = videoId.trim();
if (id.isEmpty) return null;
await _ensureConfig();
try {
final next = await _post(
'$musicApi/next?key=$_apiKey&prettyPrint=false',
body: {
'context': _webContext(_clientVersion, _visitorData),
'videoId': id,
'isAudioOnly': true,
},
clientName: 'WEB_REMIX',
clientVersion: _clientVersion,
userAgent: webUserAgent,
);
final tabs = <Map<String, dynamic>>[];
_collectObjects(next, 'tabRenderer', tabs);
Map<String, dynamic>? lyricsTab;
for (final tab in tabs) {
final title = _runsText(tab['title']) ?? tab['title']?.toString() ?? '';
if (title.toLowerCase().contains('lyrics')) {
lyricsTab = tab;
break;
}
}
dynamic endpoint = lyricsTab?['endpoint']?['browseEndpoint'];
if (endpoint == null && tabs.length > 1) {
endpoint = tabs[1]['endpoint']?['browseEndpoint'];
}
if (endpoint is! Map) return null;
final browseId = endpoint['browseId']?.toString();
if (browseId == null || browseId.isEmpty) return null;
final params = endpoint['params']?.toString();

final page = await _post(
'$musicApi/browse?key=$_apiKey&prettyPrint=false',
body: {
'context': _webContext(_clientVersion, _visitorData),
'browseId': browseId,
if (params != null && params.isNotEmpty) 'params': params,
},
clientName: 'WEB_REMIX',
clientVersion: _clientVersion,
userAgent: webUserAgent,
);
final shelves = <Map<String, dynamic>>[];
_collectObjects(page, 'musicDescriptionShelfRenderer', shelves);
for (final shelf in shelves) {
final descObj = shelf['description'];
final desc = descObj is Map ? _runsText(descObj['runs']) : _runsText(descObj);
if (desc != null && desc.trim().isNotEmpty) return desc.trim();
}
} catch (_) {}
return null;
}

/// Timed transcript cues from a video, when it has them.
Future<List<({int timeMs, String text})>?> fetchTranscriptCues(String videoId) async {
final id = videoId.trim();
if (id.isEmpty) return null;
await _ensureConfig();
try {
final idBytes = utf8.encode(id);
final paramBytes = [0x0A, idBytes.length, ...idBytes];
final params = base64.encode(paramBytes);
final res = await _post(
'$musicApi/get_transcript?key=$_apiKey&prettyPrint=false',
body: {
'context': _webContext(_clientVersion, _visitorData),
'params': params,
},
clientName: 'WEB_REMIX',
clientVersion: _clientVersion,
userAgent: webUserAgent,
);
final cues = <Map<String, dynamic>>[];
_collectObjects(res, 'transcriptCueRenderer', cues);
final lines = <({int timeMs, String text})>[];
for (final cue in cues) {
final startOffset = int.tryParse(cue['startOffsetMs']?.toString() ?? '');
if (startOffset == null) continue;
final cueObj = cue['cue'];
final text = (cueObj is Map ? _runsText(cueObj['runs']) : _runsText(cueObj)) ??
cueObj?.toString() ?? '';
if (text.trim().isEmpty) continue;
lines.add((timeMs: startOffset, text: text.trim()));
}
if (lines.isNotEmpty) return lines;
} catch (_) {}
return null;
}

/// Parse one `musicTwoRowItemRenderer`.
///
/// [kind] defaults to [YouTubeEntityKind.album] because every caller
Expand Down
Loading
Loading