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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ While **iOS, macOS and Linux** builds can be compiled, they are **not officially
- Web app: Stateless edge proxy with no data retention ✅
- All code is open source and auditable ✅

> [!NOTE]
> Due to Odesli API restrictions, the application is limited to **10 requests per minute**. A rate limiting mechanism is implemented to ensure smooth operation within this constraint.

## Privacy

Music Sharity **does not collect or store any personal data**.
Expand Down
24 changes: 0 additions & 24 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,28 +1,4 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
45 changes: 44 additions & 1 deletion lib/pages/conversion_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import '../models/music_link.dart';
import '../utils/link_validator.dart';
import '../utils/ui_helpers.dart';
import '../widgets/platform_card.dart';
import '../widgets/rate_limit_indicator.dart';
import '../services/music_converter_service.dart';
import '../services/rate_limiter_service.dart';
import '../pages/home_page.dart';

class ConversionPage extends StatefulWidget {
Expand Down Expand Up @@ -98,6 +100,39 @@ class _ConversionPageState extends State<ConversionPage> {
),
);
}
} on RateLimitException catch (e) {
if (!mounted) return;

setState(() {
_isConverting = false;
});

final seconds = e.waitTime.inSeconds;
final message = seconds < 60
? 'Rate limit reached. Please wait $seconds seconds.'
: 'Rate limit reached. Please wait ${(seconds / 60).ceil()} minute(s).';

scaffoldMessenger.showSnackBar(
SnackBar(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.schedule, size: 18, color: Colors.white),
SizedBox(width: 8),
Text('Too many requests'),
],
),
const SizedBox(height: 4),
Text(message, style: const TextStyle(fontSize: 12)),
],
),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
} catch (e) {
if (!mounted) return;

Expand All @@ -114,7 +149,15 @@ class _ConversionPageState extends State<ConversionPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Choose destination platform')),
appBar: AppBar(
title: const Text('Choose destination platform'),
actions: const [
Padding(
padding: EdgeInsets.only(right: 12.0),
child: Center(child: RateLimitIndicator()),
),
],
),
body: Stack(
children: [
Padding(
Expand Down
74 changes: 59 additions & 15 deletions lib/services/odesli_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:http/http.dart' as http;
import '../models/track_metadata.dart';
import 'rate_limiter_service.dart';

class OdesliResult {
final Map<String, String> platformLinks;
Expand All @@ -36,29 +37,61 @@ class OdesliService {
factory OdesliService() => _instance;
OdesliService._internal();

final RateLimiterService _rateLimiter = RateLimiterService();
final Map<String, _CacheEntry> _cache = {};

static const Duration _cacheDuration = Duration(hours: 1);

Future<OdesliResult> convertLink(String sourceUrl) async {
final encodedUrl = Uri.encodeComponent(sourceUrl);
_cleanExpiredCache();

final String finalUrl = kIsWeb
? '$_cloudflareWorkerUrl?url=$encodedUrl'
: '$_baseUrl?url=$encodedUrl';
final cachedResult = _cache[sourceUrl];
if (cachedResult != null && !cachedResult.isExpired) {
return cachedResult.result;
}

final response = await http.get(Uri.parse(finalUrl));
return await _rateLimiter.executeWithRateLimit(() async {
final encodedUrl = Uri.encodeComponent(sourceUrl);

if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final String finalUrl = kIsWeb
? '$_cloudflareWorkerUrl?url=$encodedUrl'
: '$_baseUrl?url=$encodedUrl';

final platformLinks = _extractPlatformLinks(data);
final metadata = _extractMetadata(data);
final response = await http.get(Uri.parse(finalUrl));

return OdesliResult(platformLinks: platformLinks, metadata: metadata);
} else {
throw Exception(
'Odesli API error: ${response.statusCode} - ${response.body}',
);
}
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final platformLinks = _extractPlatformLinks(data);
final metadata = _extractMetadata(data);
final result = OdesliResult(
platformLinks: platformLinks,
metadata: metadata,
);

_cache[sourceUrl] = _CacheEntry(result);

return result;
} else if (response.statusCode == 429) {
throw RateLimitException(
'Rate limit exceeded by server. Please try again in a moment.',
const Duration(seconds: 60),
);
} else {
throw Exception(
'Odesli API error: ${response.statusCode} - ${response.body}',
);
}
});
}

void _cleanExpiredCache() {
_cache.removeWhere((key, value) => value.isExpired);
}

int get remainingQuota => _rateLimiter.remainingQuota;

Duration? get timeUntilNextRequest => _rateLimiter.timeUntilNextAvailable;

Map<String, String> _extractPlatformLinks(Map<String, dynamic> data) {
final linksByPlatform = data['linksByPlatform'] as Map<String, dynamic>?;

Expand Down Expand Up @@ -121,3 +154,14 @@ class OdesliService {
}
}
}

class _CacheEntry {
final OdesliResult result;
final DateTime timestamp;

_CacheEntry(this.result) : timestamp = DateTime.now();

bool get isExpired {
return DateTime.now().difference(timestamp) > OdesliService._cacheDuration;
}
}
162 changes: 162 additions & 0 deletions lib/services/rate_limiter_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Music Sharity - Share music across all platforms
* Copyright (C) 2026 Sikelio (Byte Roast)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'package:shared_preferences/shared_preferences.dart';

class RateLimiterService {
static const int maxRequests = 10;
static const Duration windowDuration = Duration(minutes: 1);
static const String _storageKey = 'rate_limiter_timestamps';

final List<DateTime> _requestTimestamps = [];
final StreamController<int> _quotaController =
StreamController<int>.broadcast();

bool _isInitialized = false;

static final RateLimiterService _instance = RateLimiterService._internal();

factory RateLimiterService() => _instance;

RateLimiterService._internal() {
_loadTimestamps();
}

Future<void> _loadTimestamps() async {
if (_isInitialized) return;

try {
final prefs = await SharedPreferences.getInstance();
final List<String>? savedTimestamps = prefs.getStringList(_storageKey);

if (savedTimestamps != null) {
_requestTimestamps.clear();

for (final timestamp in savedTimestamps) {
final dateTime = DateTime.parse(timestamp);

if (DateTime.now().difference(dateTime) <= windowDuration) {
_requestTimestamps.add(dateTime);
}
}

_requestTimestamps.sort();
}
} catch (e) {
// We do nothing and continue
}

_isInitialized = true;
_quotaController.add(remainingQuota);
}

Future<void> _saveTimestamps() async {
try {
final prefs = await SharedPreferences.getInstance();
final timestamps = _requestTimestamps
.map((dt) => dt.toIso8601String())
.toList();

await prefs.setStringList(_storageKey, timestamps);
} catch (e) {
// We do nothing and continue
}
}

Stream<int> get quotaStream => _quotaController.stream;

void _cleanOldTimestamps() {
final now = DateTime.now();

_requestTimestamps.removeWhere(
(timestamp) => now.difference(timestamp) > windowDuration,
);
}

Future<void> ensureInitialized() async {
await _loadTimestamps();
}

int get remainingQuota {
_cleanOldTimestamps();

final remaining = maxRequests - _requestTimestamps.length;

return remaining < 0 ? 0 : remaining;
}

Duration? get timeUntilNextAvailable {
_cleanOldTimestamps();

if (_requestTimestamps.isEmpty || _requestTimestamps.length < maxRequests) {
return null;
}

final oldestRequest = _requestTimestamps.first;
final timeElapsed = DateTime.now().difference(oldestRequest);
final waitTime = windowDuration - timeElapsed;

return waitTime.isNegative ? Duration.zero : waitTime;
}

bool canMakeRequest() {
_cleanOldTimestamps();

return _requestTimestamps.length < maxRequests;
}

void recordRequest() {
_requestTimestamps.add(DateTime.now());
_quotaController.add(remainingQuota);

_saveTimestamps();
}

Future<T> executeWithRateLimit<T>(Future<T> Function() request) async {
await _loadTimestamps();

if (!canMakeRequest()) {
final waitTime = timeUntilNextAvailable;

if (waitTime != null) {
throw RateLimitException(
'Rate limit exceeded. Please wait ${waitTime.inSeconds} seconds.',
waitTime,
);
}
}

recordRequest();

return await request();
}

void dispose() {
_quotaController.close();
}
}

class RateLimitException implements Exception {
final String message;
final Duration waitTime;

RateLimitException(this.message, this.waitTime);

@override
String toString() => message;
}
Loading