ubuntu-flutter-community / musicpod

Music, radio, television and podcast player for Ubuntu, Windows, MacOs and maybe soon Android
GNU General Public License v3.0
449 stars 54 forks source link

feat: Lastfm integration #1004

Closed ClementBeal closed 1 week ago

ClementBeal commented 1 week ago

Lastfm is a service that a lot of people use to track what they listen. They can know that they have listen to Highway to Hell 12 times, that they listen a lot of ABBA or that's Salsa is their favorite genre.

It can make report like the ones by Spotify.

It would be nice if you add Lastfm. Can be cool for your users!

I drop the code that I have used for my own music player. The user has to provide an API key and a shared secret that he gets from the Lastfm UI. For the rest, open the LastfmDialog and it should be clear enough (lol it is not).

In your code, you will track when the a new music is played and when it finished.

When a new music is played, we send the information. After 15s or at the end of the song, we "scrobble" It means we send a track info to the Lastfm server.

Something like that

  LastfmService();
  AudioPlayerService().onTrackFinished = (track) {
    _appKey.currentContext?.read<AudioPlayerCubit>().nextFinishedTrack();

    LastfmService().scrobble(track);
  };
  AudioPlayerService().onTrackStarted = (track) {
    LastfmService().updatePlayingSong(track);
  };
import 'dart:collection';
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:tyche/domains/album.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:xml/xml.dart';

class LastfmService {
  static LastfmService? _instance;

  factory LastfmService() => _instance ??= LastfmService._();

  LastfmService._() {
    SharedPreferences.getInstance().then((prefs) {
      _apiKey = prefs.getString("lastfmKey");
      _secret = prefs.getString("lastfmSecret");
      _session = prefs.getString("lastfm_session");
      _token = prefs.getString("lastFmToken");
    });
  }

  final url = "ws.audioscrobbler.com";
  final userAgent = "Henere/1.0.0";

  String? _apiKey;
  String? _session;
  String? _token;
  String? _secret;

  Future<String?> connectDesktop(String apiKey, String password) async {
    // lastFM = LastFMUnauthorized(apiKey, password);
    _secret = password;
    _token = await _getAuthToken(apiKey);

    if (_token != null) {
      final prefs = await SharedPreferences.getInstance();
      prefs.setString("lastFmToken", _token!);

      _apiKey = apiKey;

      final uri = Uri.http(
          "www.last.fm", "/api/auth/", {"api_key": apiKey, "token": _token});
      await launchUrl(uri);

      return uri.toString();
    }

    return null;
  }

  Future<void> finishConnectDesktop(String apiKey) async {
    Uri endpoint = Uri.http(
      url,
      "2.0/",
      _getParameters({
        "method": "auth.getSession",
        "api_key": apiKey,
        "token": _token,
        // "format": "json",
      }),
    );

    final response =
        await http.get(endpoint, headers: {"User-Agent": userAgent});

    if (response.statusCode == 200) {
      final doc = XmlDocument.parse(response.body);

      (await SharedPreferences.getInstance()).setString("lastfm_session",
          doc.rootElement.firstElementChild!.getElement('key')!.innerText);
    } else {}
  }

  Map<String, dynamic> _getParameters(Map<String, dynamic> parameters) {
    final a = SplayTreeMap.from(parameters);
    final signature = a.entries
        .map((e) => "${e.key}${e.value}")
        // .cast<String>()
        .toList()
      ..add(_secret!);

    return {
      ...parameters,
      "api_sig": md5.convert(utf8.encode(signature.join(""))).toString()
    };
  }

  Future<String?> _getAuthToken(String apiKey) async {
    Uri endpoint = Uri.http(url, "2.0", {
      "method": "auth.gettoken",
      "api_key": apiKey,
      "format": "json",
    });

    final response =
        await http.get(endpoint, headers: {"User-Agent": userAgent});

    if (response.statusCode == 200) {
      final body = jsonDecode(response.body) as Map<String, dynamic>;
      return body["token"];
    }

    return null;
  }

  Future<void> updatePlayingSong(Track track) async {
    if (_session == null) return;
    Uri endpoint = Uri.http(
      url,
      "2.0/",
    );

    final response = await http.post(
      endpoint,
      headers: {"User-Agent": userAgent},
      body: _getParameters(
        {
          "method": "track.updateNowPlaying",
          "api_key": _apiKey,
          "token": _token,
          "artist": track.artist ?? "",
          "track": track.name ?? "",
          "sk": _session,
        },
      ),
    );

    print(response.body);
    print(response.statusCode);
  }

  Future<void> scrobble(Track track) async {
    if (_session == null) return;
    Uri endpoint = Uri.http(
      url,
      "2.0/",
    );

    final response = await http.post(
      endpoint,
      headers: {"User-Agent": userAgent},
      body: _getParameters(
        {
          "method": "track.scrobble",
          "api_key": _apiKey,
          "token": _token,
          "sk": _session,
          "artist": track.artist ?? "",
          "track": track.name ?? "",
          "timestamp":
              (DateTime.timestamp().millisecondsSinceEpoch / 1000).toString(),
          ...((track.album != null) ? {"album": track.album} : {}),
        },
      ),
    );

    print(response.statusCode);
    print(response.body);
  }
}

And that's the widget I use to connect to Lastfm.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tyche/services/lastfm/lastfm_service.dart';
import 'package:shared_preferences/shared_preferences.dart';

class LastfmConnectionDialog extends StatefulWidget {
  const LastfmConnectionDialog({super.key});

  @override
  State<LastfmConnectionDialog> createState() => _LastfmConnectionDialogState();
}

class _LastfmConnectionDialogState extends State<LastfmConnectionDialog> {
  int _currentStep = 0;
  bool _allStepDone = false;
  final bool _isSuccess = false;

  String _tabLink =
      "https://api.flutter.dev/flutter/material/SelectableText-class.html";

  @override
  Widget build(BuildContext context) {
    return Dialog(
      child: Container(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Stepper(
              currentStep: _currentStep,
              steps: [
                Step(
                  state: (_currentStep > 0)
                      ? StepState.complete
                      : StepState.indexed,
                  isActive: _currentStep >= 0,
                  title: Text('Get authentication token'),
                  content: Column(
                    children: const [],
                  ),
                ),
                Step(
                  isActive: _currentStep >= 1,
                  title: Text('Check your browser and accept'),
                  content: Column(
                    children: [
                      Text(' The link if it didn\'t open a tab'),
                      SelectableText(
                        _tabLink,
                      ),
                    ],
                  ),
                ),
                Step(
                  isActive: _allStepDone,
                  title: Text('Get session token'),
                  content: Column(
                    children: const [],
                  ),
                  state: _allStepDone ? StepState.complete : StepState.indexed,
                ),
              ],
              onStepContinue: () {
                if (_currentStep < 2) {
                  setState(() {
                    _currentStep++;
                  });
                } else {
                  setState(() {
                    _allStepDone = true;
                  });
                }
              },
              onStepCancel: () {
                if (_currentStep > 0) {
                  setState(() {
                    _currentStep--;
                    _allStepDone = false;
                  });
                }
              },
              onStepTapped: (value) async {
                await Future.delayed(Duration(seconds: 2));
                print("lol");
              },
              controlsBuilder: (context, details) {
                return Row(
                  children: <Widget>[
                    TextButton(
                      onPressed: () async {
                        if (_currentStep == 0) {
                          final prefs = await SharedPreferences.getInstance();
                          final tabUrl = await LastfmService().connectDesktop(
                            prefs.getString("lastfmKey") ?? "",
                            prefs.getString("lastfmSecret") ?? "",
                          );

                          if (tabUrl != null) {
                            setState(() {
                              _tabLink = tabUrl;
                            });
                            details.onStepContinue?.call();
                          }
                        } else if (_currentStep == 1) {
                          details.onStepContinue?.call();
                        } else if (_currentStep == 2) {
                          final prefs = await SharedPreferences.getInstance();

                          await LastfmService().finishConnectDesktop(
                              prefs.getString("lastfmKey") ?? "");
                          details.onStepContinue?.call();

                          context.pop();
                        }
                      },
                      child: (details.stepIndex == 2)
                          ? Text("Get the session and close")
                          : Text('Continue to Step ${details.stepIndex + 2}'),
                    ),
                    if (details.stepIndex > 0)
                      TextButton(
                        onPressed: details.onStepCancel,
                        child: Text('Back to Step ${details.stepIndex}'),
                      ),
                  ],
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}
Feichtmeier commented 1 week ago

hey @ClementBeal thanks for you code!

@CosmicRaptor has a take at this, see #1000

ClementBeal commented 1 week ago

Ah yeah, seems better and clearer too!