Git Product home page Git Product logo

geo-martino / musify Goto Github PK

View Code? Open in Web Editor NEW
3.0 3.0 0.0 6.86 MB

A Swiss Army knife for programmatic music library management. Manages both local and music streaming service libraries.

Home Page: https://geo-martino.github.io/musify/

License: GNU Affero General Public License v3.0

Python 99.74% Batchfile 0.16% Makefile 0.10%
audio-manager audio-tagging metadata-management music-manager music-tagging playlist-generator playlist-manager spotify file-management file-metadata

musify's Introduction

Musify

PyPI Version Python Version Documentation
PyPI Downloads Code Size Contributors License
GitHub - Validate GitHub - Deployment GitHub - Documentation

A Swiss Army knife for music library management

Supporting local and music streaming service (remote) libraries.

  • Extract data for all item types from remote libraries, including following/saved items, such as: playlists, tracks, albums, artists, users, podcasts, audiobooks
  • Load local audio files, programmatically manipulate, and save tags/metadata/embedded images
  • Synchronise local tracks metadata with its matching track's metadata on supported music streaming services
  • Synchronise local playlists with playlists on supported music streaming services
  • Backup and restore track tags/metadata and playlists for local and remote libraries
  • Extract and save images from remote tracks or embedded in local tracks

Contents

Note

This readme provides a brief overview of the program. Read the docs for full reference documentation.

Installation

Install through pip using one of the following commands:

pip install musify
python -m pip install musify

There are optional dependencies that you may install for optional functionality. For the current list of optional dependency groups, read the docs

Quick Guides

These quick guides will help you get set up and going with Musify in just a few minutes. For more detailed guides, check out the documentation.

Tip

Set up logging to ensure you can see all info reported by the later operations. Libraries log info about loaded objects to the custom STAT level.

import logging
import sys
from musify.logger import STAT

logging.basicConfig(format="%(message)s", level=STAT, stream=sys.stdout)

Spotify

In this example, you will:

  • Authorise access to the Spotify Web API
  • Load your Spotify library
  • Load some other Spotify objects
  • Add some tracks to a playlist
  1. If you don't already have one, create a Spotify for Developers account.

  2. If you don't already have one, create an app. Select "Web API" when asked which APIs you are planning on using. To use this program, you will only need to take note of the client ID and client secret.

  3. Create a SpotifyAPI object and authorise the program access to Spotify data as follows:

    The scopes listed in this example will allow access to read your library data and write to your playlists. See Spotify Web API documentation for more information about scopes

     from musify.libraries.remote.spotify.api import SpotifyAPI
    
     spotify_api = SpotifyAPI(
         client_id="<YOUR CLIENT ID>",
         client_secret="<YOUR CLIENT SECRET>",
         scope=[
             "user-library-read",
             "user-follow-read",
             "playlist-read-collaborative",
             "playlist-read-private",
             "playlist-modify-public",
             "playlist-modify-private"
         ],
         # providing a `token_file_path` will save the generated token to your system 
         # for quicker authorisations in future
         token_file_path="<PATH TO JSON TOKEN>"  
     )
  4. Define helper functions for loading your SpotifyLibrary data:

     from musify.libraries.remote.spotify.library import SpotifyLibrary
    
    
     async def load_library(library: SpotifyLibrary) -> None:
         """Load the objects for a given ``library``. Does not enrich the loaded data."""
         # authorise the program to access your Spotify data in your web browser
         async with library:
             # if you have a very large library, this will take some time...
             await library.load()
    
    
     async def load_library_by_parts(library: SpotifyLibrary) -> None:
         """Load the objects for a given ``library`` by each of its distinct parts.  Does not enrich the loaded data."""
         # authorise the program to access your Spotify data in your web browser
         async with library:
             # load distinct sections of your library
             await library.load_playlists()
             await library.load_tracks()
             await library.load_saved_albums()
             await library.load_saved_artists()
    
    
     async def enrich_library(library: SpotifyLibrary) -> None:
         """Enrich the loaded objects in the given ``library``"""
         # authorise the program to access your Spotify data in your web browser
         async with library:
             # enrich the loaded objects; see each function's docstring for more info on arguments
             # each of these will take some time depending on the size of your library
             await library.enrich_tracks(features=True, analysis=False, albums=False, artists=False)
             await library.enrich_saved_albums()
             await library.enrich_saved_artists(tracks=True, types=("album", "single"))
    
    
     def log_library(library: SpotifyLibrary) -> None:
         """Log stats about the loaded ``library``"""
         library.log_playlists()
         library.log_tracks()
         library.log_albums()
         library.log_artists()
    
         # pretty print an overview of your library
         print(library)
  5. Define helper functions for loading some Spotify objects using any of the supported identifiers:

     from musify.libraries.remote.spotify.object import SpotifyTrack, SpotifyAlbum, SpotifyPlaylist, SpotifyArtist
    
    
     async def load_playlist(api: SpotifyAPI) -> SpotifyPlaylist:
         # authorise the program to access your Spotify data in your web browser
         async with api as a:
             playlist = await SpotifyPlaylist.load("spotify:playlist:37i9dQZF1E4zg1xOOORiP1", api=a, extend_tracks=True)
         return playlist
    
    
     async def load_tracks(api: SpotifyAPI) -> list[SpotifyTrack]:
         tracks = []
    
         # authorise the program to access your Spotify data in your web browser
         async with api as a:
             # load by ID
             tracks.append(await SpotifyTrack.load("6fWoFduMpBem73DMLCOh1Z", api=a))
             # load by URI
             tracks.append(await SpotifyTrack.load("spotify:track:4npv0xZO9fVLBmDS2XP9Bw", api=a))
             # load by open/external style URL
             tracks.append(await SpotifyTrack.load("https://open.spotify.com/track/1TjVbzJUAuOvas1bL00TiH", api=a))
             # load by API style URI
             tracks.append(await SpotifyTrack.load("https://api.spotify.com/v1/tracks/6pmSweeisgfxxsiLINILdJ", api=api))
    
         return tracks
    
    
     async def load_album(api: SpotifyAPI) -> SpotifyAlbum:
         # authorise the program to access your Spotify data in your web browser
         async with api as a:
             album = await SpotifyAlbum.load(
                 "https://open.spotify.com/album/0rAWaAAMfzHzCbYESj4mfx", api=a, extend_tracks=True
             )
         return album
    
    
     async def load_artist(api: SpotifyAPI) -> SpotifyArtist:
         # authorise the program to access your Spotify data in your web browser
         async with api as a:
             artist = await SpotifyArtist.load("1odSzdzUpm3ZEEb74GdyiS", api=a, extend_tracks=True)
         return artist
    
    
     async def load_objects(api: SpotifyAPI) -> None:
         playlist = await load_playlist(api)
         tracks = await load_tracks(api)
         album = await load_album(api)
         artist = await load_artist(api)
    
         # pretty print information about the loaded objects
         print(playlist, *tracks, album, artist, sep="\n")
  6. Define helper function for adding some tracks to a playlist in your library, synchronising with Spotify, and logging the results:

    NOTE: This step will only work if you chose to load either your playlists or your entire library in step 4.

     async def update_playlist(name: str, library: SpotifyLibrary) -> None:
         """Update a playlist with the given ``name`` in the given ``library``"""
         tracks = await load_tracks(library.api)
         album = await load_album(library.api)
         await load_library(library)
    
         my_playlist = library.playlists[name]
    
         # add a track to the playlist
         my_playlist.append(tracks[0])
    
         # add an album to the playlist using either of the following
         my_playlist.extend(album)
         my_playlist += album
    
         # sync the object with Spotify and log the results
         async with library:
             result = await my_playlist.sync(dry_run=False)
         library.log_sync(result)
     
     asyncio.run(update_playlist(spotify_api))
  7. Run the program:

     import asyncio
    
     asyncio.run(load_objects(api))
     asyncio.run(update_playlist("<YOUR PLAYLIST'S NAME>", api))  # case sensitive

Local

In this example, you will:

  • Load a local library
  • Modify the tags of some local tracks and save them
  • Modify a local playlist and save it
  1. Create one of the following supported LocalLibrary objects:

    Generic local library

    from musify.libraries.local.library import LocalLibrary
    
    library = LocalLibrary(
        library_folders=["<PATH TO YOUR LIBRARY FOLDER>", ...],
        playlist_folder="<PATH TO YOUR PLAYLIST FOLDER",
    )

    MusicBee

    You will need to install the musicbee optional dependency to work with MusicBee objects. Read the docs for more info.

    from musify.libraries.local.library import MusicBee
    
    library = MusicBee(musicbee_folder="<PATH TO YOUR MUSICBEE FOLDER>")
  2. Load your library:

    # if you have a very large library, this will take some time...
    library.load()
    
    # ...or you may also just load distinct sections of your library
    library.load_tracks()
    library.load_playlists()
    
    # optionally log stats about these sections
    library.log_tracks()
    library.log_playlists()
    
    # pretty print an overview of your library
    print(library)
  3. Get collections from your library:

    playlist = library.playlists["<NAME OF YOUR PLAYLIST>"]  # case sensitive
    album = next(album for album in library.albums if album.name == "<ALBUM NAME>")
    artist = next(artist for artist in library.artists if artist.name == "<ARTIST NAME>")
    folder = next(folder for folder in library.folders if folder.name == "<FOLDER NAME>")
    genre = next(genre for genre in library.genres if genre.name == "<GENRE NAME>")
    
    # pretty print information about the loaded objects
    print(playlist, album, artist, folder, genre, sep="\n")
  4. Get a track from your library using any of the following identifiers:

    # get a track via its title
    # if multiple tracks have the same title, the first matching one if returned
    track = library["<TRACK TITLE>"]
    
    # get a track via its path
    track = library["<PATH TO YOUR TRACK>"]  # must be an absolute path
    
    # get a track according to a specific tag
    track = next(track for track in library if track.artist == "<ARTIST NAME>")
    track = next(track for track in library if "<GENRE>" in (track.genres or []))
    
    # pretty print information about this track
    print(track)
  5. Change some tags:

    from datetime import date
    
    track.title = "new title"
    track.artist = "new artist"
    track.album = "new album"
    track.track_number = 200
    track.genres = ["super cool genre", "awesome genre"]
    track.key = "C#"
    track.bpm = 120.5
    track.date = date(year=2024, month=1, day=1)
    track.compilation = True
    track.image_links.update({
         "cover front": "https://i.scdn.co/image/ab67616d0000b2737f0918f1560fc4b40b967dd4",
         "cover back": "<PATH TO AN IMAGE ON YOUR LOCAL DRIVE>"
    })
    
    # see the updated information
    print(track)
  6. Save the tags to the file:

    from musify.libraries.local.track.field import LocalTrackField
    
    # you don't have to save all the tags you just modified
    # select which you wish to save first like so
    tags = [
         LocalTrackField.TITLE,
         LocalTrackField.GENRES,
         LocalTrackField.KEY,
         LocalTrackField.BPM,
         LocalTrackField.DATE,
         LocalTrackField.COMPILATION,
         LocalTrackField.IMAGES
    ]
    
    track.save(tags=tags, replace=True, dry_run=False)
  7. Add some tracks to one of your playlists and save it:

    my_playlist = library.playlists["<NAME OF YOUR PLAYLIST>"]  # case sensitive
    
    # add a track to the playlist
    my_playlist.append(track)
    
    # add album's and artist's tracks to the playlist using either of the following
    my_playlist.extend(album)
    my_playlist += artist
    
    result = my_playlist.save(dry_run=False)
    print(result)

Currently Supported

  • Music Streaming Services: Spotify
  • Audio filetypes: .flac .m4a .mp3 .wma
  • Local playlist filetypes: .m3u .xautopf
  • Local Libraries: MusicBee

Motivation and Aims

The key aim of this package is to provide a seamless framework for interoperability between all types of music libraries whether local or remote.

This framework should allow for the following key functionality between libraries:

  • Synchronise saved user data including:
    • playlists data (e.g. name, description, tracks)
    • saved tracks/albums/artists etc.
  • Synchronise metadata by allowing users to pull metadata from music streaming services and save this to local tracks
  • Provide tools to allow users to move from music streaming services to a local library by semi-automating the process of purchasing songs.

With this functionality, user's should then have the freedom to:

  • Switch between music streaming services with a few simple commands
  • Share local playlists and other local library data with friends over music streaming services without ever having to use them personally
  • Easily maintain a high-quality local library with complete metadata

Users should have the freedom to choose how and where they want to listen to their favourite artists.

Given the near non-existence of income these services provide to artists, user's should have the choice to compensate their favourite artists fairly for their work, choosing to switch to other services that do and/or choosing not to use music streaming services altogether because of this. Hopefully, by reintroducing this choice to users, the music industry will be forced to re-evaluate their complete devaluing of creative work in the rush to chase profits, and instead return to a culture of nurturing talent by providing artists with a basic income to survive on the work of their craft. One can dream.

Release History

For change and release history, check out the documentation.

Contributing and Reporting Issues

If you have any suggestions, wish to contribute, or have any issues to report, please do let me know via the issues tab or make a new pull request with your new feature for review.

For more info on how to contribute to Musify, check out the documentation.

Author notes

I initially developed this program for my own use so that I can share my local playlists with friends online. I have always maintained my own local library well and never saw the need to switch to music streaming services after their release. However, as an artist who has released music on all streaming services and after listening to the concerns many of the artists I follow have regarding these services, I started to refocus this project to be one that aims to break down the barriers between listening experiences for users. The ultimate aim being to make managing a local library as easy as any of the major music streaming services, allowing users the same conveniences while compensating artists fairly for their work.

I hope you enjoy using Musify!

musify's People

Contributors

dependabot[bot] avatar geo-martino avatar

Stargazers

 avatar  avatar  avatar

Watchers

 avatar  avatar  avatar

musify's Issues

Tracks not produced for playlists when generating json data for `LocalLibrary`

Musify version

1.0.1

What happened?

Backup of LocalLibrary produces null where tracks should be:

    "Inspo": {
      "name": "Inspo",
      "description": null,
      "tracks": [
        null,
        null
      ],
      "image_links": {},
      "has_image": false,

What do you think should have happened instead?

This list should be a list of the tracks in the playlist with either their path or full mapping representation

Please paste any logs that you see related to this issue here

No response

How to reproduce

Load a LocalLibrary and run .json() on it. Check 'playlists' key

Operating System

Arch Linux - 2024.06.01

Python version

3.12.4

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Make some dependencies optional

Description

Currently, musify is installed with all required dependencies for all functionality. This should be modularised such that users have the option to install dependecies for various specific features.

E.g.

  • tqdm can be made optional if the user does not need graphical display of progress
  • Various XML packages can be made optional if the user doesn't need to use any MusicBee related functionality
  • Pillow can be made optional if the user doesn't need image IO or processing
  • mutagen could be made optional if user doesn't require any local track functionality
  • requests could be made optional if the user requires no API functionality

These should be grouped as optional dependencies based on use case. Anall should also be included for all dependencies.

Use case/motivation

Reduce package bulk and unneccessary functionality for the user. Keep it simple stupid.

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

`ItemMatcher` processor is broken

Musify version

0.9.0

What happened?

When running the ItemMatcher processor as part of the RemoteItemSearcher processor, Musify now always gives incorrect matches back. This may be due to the recent implementation of concurrency to the processor.

What do you think should have happened instead?

ItemMatcher should give the best match back, not the worst....

Please paste any logs that you see related to this issue here

No response

How to reproduce

Run a RemoteItemSearcher process for some tracks

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Fix 'stdout' checking tests after implementation of `print_message` method

Musify version

1.0.0

What happened?

There are some tests which have failing assertions due to messages being printed out twice after running through the new print_message method. This method checks the current logger set up to determine what to send to stdout. As such, the outcomes of the tests are entirely dependant on how the user has set up the test logging. There is currently a workaround in place in master for my current set up, but this needs to be more generalised.

What do you think should have happened instead?

Tests should pass with expected assertions and no workarounds

Please paste any logs that you see related to this issue here

No response

How to reproduce

Just run the tests without the workarounds

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Expand `load` method on `RemoteCollectionLoader` to filter for all item types

Description

Currently, the load method is too 'tracks' specific meaning it can only be used by RemoteAlbum or RemotePlaylist types. This should be modified to work for all RemoteItemTypes so that RemoteArtist can also use this when extending on RemoteAlbum types.

Use case/motivation

Low priority, but it will allow the user to improve performance when loading RemoteArtist types

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Split `RequestHandler` to separate implementations for session management

Description

Currently, users have the option to use a non-cached or cached session to optimise performance when making API calls. However, users should also have the option to use concurrent logic for making these calls.

It would be too messy to implement concurrent logic with the current RequestHandler as is so the proposal is to convert the RequestHandler to an AbstractFactory and implement 3 separate implementations for non-cached, cached, and asynchronous request calls.

Equally, the RemoteAPI should no longer be responsible for creating the RequestHandler object i.e. the dependency should be inverted so that the user must give the RemoteAPI the desired RequestHandler on instantiation.

Use case/motivation

Users should also have the option to use concurrent logic for making API calls as well as non-cached and cached sessions as required.

Related issues

#13

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

`RequestHandler` is returning error responses after waiting in `_handle_wait_time`

Musify version

1.0.0

What happened?

KeyErrors appear after running some functions in the SpotifyAPI due to the fact that RequestHandler is returning JSON response data that is an error response with an "error" key present.

What do you think should have happened instead?

After waiting, it should request a new response and return that. It seems that it may be getting a new response and returning it anyway?

Please paste any logs that you see related to this issue here

2024-05-25 11:52:52.337 | [ WARNING] m.a.request.RequestHandler._log_response [ 227] | GET    : https://api.spotify.com/v1/albums/09NmDHOVRAVFjAoz0P6oaY/tracks?offset=0&limit=50 | Code: 429 | Response text and headers follow:
Response text:
{
  "error": {
    "status": 429,
    "message": "API rate limit exceeded"
  }
}
Headers:
{
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "Accept, App-Platform, Authorization, Content-Type, Origin, Retry-After, Spotify-App-Version, X-Cloud-Trace-Context, client-token, content-access-token",
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS, PUT, DELETE, PATCH",
  "Access-Control-Allow-Credentials": "true",
  "Access-Control-Max-Age": "604800",
  "Retry-After": "17",
  "Access-Control-Expose-Headers": "Retry-After",
  "Content-Encoding": "gzip",
  "strict-transport-security": "max-age=31536000",
  "x-content-type-options": "nosniff",
  "Date": "Sat, 25 May 2024 15:52:53 GMT",
  "Server": "envoy",
  "Via": "HTTP/2 edgeproxy, 1.1 google",
  "Alt-Svc": "h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000",
  "Transfer-Encoding": "chunked"
}
2024-05-25 11:52:52.343 | [    INFO] m.a.r.RequestHandler._handle_wait_time   [ 255] | Rate limit exceeded. Retrying again at 2024-05-25 11:53:09
2024-05-25 11:53:09.350 | [   DEBUG] musify.api.request.RequestHandler.log    [ 218] | GET    : https://api.spotify.com/v1/albums/09NmDHOVRAVFjAoz0P6oaY/tracks        | limit: 50   | offset: 0    |      5/5      tracks | Cached Request
2024-05-25 11:53:09.360 | [CRITICAL] m.processor.musify_cli.processor.main    [ 176] | Traceback (most recent call last):
  File "D:\Projects\musify-cli\musify_cli\__main__.py", line 174, in main
    await processor.run()
  File "D:\Projects\musify-cli\musify_cli\processor.py", line 84, in run
    await super().__call__()
  File "D:\Projects\musify-cli\musify_cli\processor.py", line 491, in new_music
    await self.manager.extend_albums(albums_to_extend)
  File "D:\Projects\musify-cli\musify_cli\manager\_core.py", line 281, in extend_albums
    await self.remote.api.extend_items(album.response, kind=kind, key=key)
  File "D:\Projects\musify\musify\libraries\remote\spotify\api\item.py", line 350, in extend_items
    self._enrich_with_parent_response(
  File "D:\Projects\musify\musify\libraries\remote\spotify\api\item.py", line 270, in _enrich_with_parent_response
    for item in response[self.items_key]:
                ~~~~~~~~^^^^^^^^^^^^^^^^
KeyError: 'items'

How to reproduce

Run enough requests through the API to trigger a rate limit that is within backoff boundaries. Check results

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Add context manager methods for objects which need to be closed

Description

Enhance classes which need to be closed such that they can be managed using with statements by adding __enter__ and __exit__ methods to them.
This will include any API related classes including the Authoriser and RequestHandler, and possibly any File subclasses.

Use case/motivation

More user-friendly and more more pythonic context management

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

`XAutoPF` loader sometimes doesn't load all tracks and their order is not the same as shown in MusicBee

Musify version

3.8.1

What happened?

Some XAutoPF playlists are loaded by musify with tracks missing and incorrect sort order.

What do you think should have happened instead?

Should show the same order and tracks as in MusicBee

Please paste any logs that you see related to this issue here

No response

How to reproduce

N/A

Operating System

WIndows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

`XAutoPF` file reader does not handle match conditions correctly

Musify version

0.8.0

What happened?

XAutoPF files do not handle match conditions correctly. They do not consider the sub-clauses of match conditions e.g.

      <Condition Field="Album" Comparison="StartsWith" Value="1970s - Rock" />
      <Condition Field="Album" Comparison="Is" Value="Queen">
        <And CombineMethod="Any">
          <Condition Field="ArtistPeople" Comparison="StartsWith" Value="Queen" />
        </And>
      </Condition>

What you think should happen instead?

These sub-clauses should also be considered when matching using FilterMatcher

Please paste any logs that you see related to this issue here

No response

How to reproduce

Load any XAutoPF file with these sub-conditions

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Documentation for past versions

Description

Past version documentation should also be available via GitHub hosted documentation. Currently only the latest version's documentation is shown

Use case/motivation

Full backward compatibility for users

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Method to add songs present in a `RemoteLibrary` but missing from a `LocalLibrary` to a 'missing' `RemotePlaylist`

Description

Some kind of function that creates/updates some kind of 'missing' dump playlist with all tracks
that a user has in their remote library, but not in their local library

Would also be cool if it could somehow update local library playlists after the user has downloaded them.

Use case/motivation

This can help the user track which songs they still have yet to download from their remote library to help them when moving from a remote library to a local library setup

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Integrate a service for getting track lyrics

Description

Would be cool if we could also get lyrics for all tracks automatically. Spotify does not provide this info at present so we may need to impement another API service for this data.

Use case/motivation

Complete tag coverage for track tags

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Printing a `LocalLibrary` object takes too long to execute

Musify version

1.0.0

What happened?

LocalLibrary of around 6000 tracks takes a few minutes to load on local. This is probably due to the LocalCollection objects that are created on execution of __str__

What do you think should have happened instead?

This shouldn't take longer than a second or 2

Please paste any logs that you see related to this issue here

No response

How to reproduce

Load a library of a large enough size and try to print it

Operating System

WIndows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Optimise async functions

Description

Currently, many async functions are executing in synchronous calls in a for loop e.g. extend_items, _get_items_batched and multi, save_responses etc.
Refactor these so they take full advantage of executing in an async environment.
Also check other RemoteAPI methods + any other async functions with loops.

Use case/motivation

Should provide large gains in speed for async workflows i.e. API requests + cache hits.

Related issues

Extension of #73

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Integrate an audio recognition API to the package

Description

Currently, in order to search for a local track on a remote API to link it, the local track has to have some tags filled so that Musify can use these tags to search for the track.

What if we integrated an audio recognition API service that can search for a track on various remote APIs for the user using only the audio itself?

Maybe audd.io ?

Use case/motivation

This will help massively when a user is trying to get metadata for tracks ripped from CDs. Often these have no tags assigned and simply show as 'Track 1', 'Track 2', ... etc. meaning the user still has to do some manual work to be able to fully take advantage of Musify.

Also may improve speed/accuracy of the searching process (it is currently quite slow)

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Add `get_or_create_playlist` method to `RemoteAPI`

Description

Method for safely handling playlist operations by getting a playlist if it exists, and creating it if it doesn't.
RemoteItemChecker should then use this method to create playlists to avoid accidental playlist creation explosion.
It should also be able to ignore any tracks that were present in the playlist at the start of its operation. Possibly by introducing another instance attribute to store initial tracks in the playlist at execution of this method.

Use case/motivation

Avoid accidental playlist creation explosion in RemoteItemChecker

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Comments are trimmed for MP3

Musify version

0.8.0

What happened?

Comments are trimmed in the file's metadata when trying to save MP3 files. Always trims to 28 characters.

I am trying to save a file with the comment: spotify:track:1DmW5Ep6ywYwxc2HMT5BG6. This comment still gets saved as is, but a trimmed version of the comment is also saved to the file of 28 characters i.e. spotify:track:1DmW5Ep6ywYwxc. Both versions of a comment are always saved.

This happens when ever I try to save any tag to the file. For example, if I am just trying to update the track's title tag, this issue on the comments still happens

What you think should happen instead?

Only the original comment should be saved. When not configured to save the comment, the comment should not be being updated.

Please paste any logs that you see related to this issue here

No response

How to reproduce

  • Load an MP3 file
  • Change the comment to a comment with length >28
  • Write any tags to the file
  • Check the tags on the file

Operating System

Windows

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

SpotifyAPI test sometimes fails

Musify version

0.7.6

What happened?

Workflow for validation occassionally fails: https://github.com/geo-martino/musify/actions/runs/7562747966/job/20593839371?pr=3

What you think should happen instead?

Should pass

Please paste any logs that you see related to this issue here

self = <test_spotify_playlist.TestSpotifyPlaylist object at 0x111224f20>
sync_playlist = SpotifyPlaylist({'name': 'qpVQgMrDeaXbqotmTvfmQNmHnDtStoAOe', 'description': 'HKuRJwwEyNMPvFiDxVOltpBjOIjerIK', 'track...otify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi', 'url_ext': 'https://open.spotify.com/playlist/PUnXWkGgUTniEziVdftzPi'})
sync_items = []
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x1120c3410>

    def test_sync_new(self, sync_playlist: RemotePlaylist, sync_items: list[RemoteTrack], api_mock: RemoteMock):
        sync_items_extended = sync_items + sync_playlist.tracks[:5]
        result = sync_playlist.sync(kind="new", items=sync_items_extended, reload=False, dry_run=False)
    
        assert result.start == len(sync_playlist)
        assert result.added == len(sync_items)
        assert result.removed == 0
        assert result.unchanged == result.start
        assert result.difference == result.added
        assert result.final == result.start + result.difference
    
        uri_add, uri_clear = self.get_sync_uris(url=sync_playlist.url, api_mock=api_mock)
        assert uri_add == [track.uri for track in sync_items]
        assert uri_clear == []
    
        # 1 for skip dupes check on add to playlist
>       self.assert_playlist_loaded(sync_playlist=sync_playlist, api_mock=api_mock, count=1)

tests/shared/remote/object.py:168: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

sync_playlist = SpotifyPlaylist({'name': 'qpVQgMrDeaXbqotmTvfmQNmHnDtStoAOe', 'description': 'HKuRJwwEyNMPvFiDxVOltpBjOIjerIK', 'track...otify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi', 'url_ext': 'https://open.spotify.com/playlist/PUnXWkGgUTniEziVdftzPi'})
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x1120c3410>, count = 1

    @staticmethod
    def assert_playlist_loaded(sync_playlist: RemotePlaylist, api_mock: RemoteMock, count: int = 1) -> None:
        """Assert the given playlist was fully reloaded through GET requests ``count`` number of times"""
        pages = api_mock.calculate_pages_from_response(sync_playlist.response)
    
        requests = api_mock.get_requests(url=sync_playlist.url, method="GET")
        requests += api_mock.get_requests(url=sync_playlist.url + "/tracks", method="GET")
    
>       assert len(requests) == pages * count
E       AssertionError

tests/shared/remote/object.py:98: AssertionError
---------------------------- Captured stdout setup -----------------------------
2024-01-17 22:51:45.979 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=50&limit=50           |    100/199    tracks
2024-01-17 22:51:45.995 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=100&limit=50          |    150/199    tracks
2024-01-17 22:51:46.110 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=150&limit=50          |    199/199    tracks
2024-01-17 22:51:46.640 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/me                                          
----------------------------- Captured stdout call -----------------------------
2024-01-17 22:51:46.740 | [   DEBUG] m.s.api.api.SpotifyAPI.add_to_playlist   [ 104] | SKIP   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks | No data given
____________________ TestSpotifyPlaylist.test_sync_refresh _____________________

self = <test_spotify_playlist.TestSpotifyPlaylist object at 0x1112251c0>
sync_playlist = SpotifyPlaylist({'name': 'qpVQgMrDeaXbqotmTvfmQNmHnDtStoAOe', 'description': 'HKuRJwwEyNMPvFiDxVOltpBjOIjerIK', 'track...otify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi', 'url_ext': 'https://open.spotify.com/playlist/PUnXWkGgUTniEziVdftzPi'})
sync_items = []
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x1120c3410>

    def test_sync_refresh(self, sync_playlist: RemotePlaylist, sync_items: list[RemoteTrack], api_mock: RemoteMock):
        start = len(sync_playlist)
        result = sync_playlist.sync(items=sync_items, kind="refresh", reload=True, dry_run=False)
    
        assert result.start == start
>       assert result.added == len(sync_items)
E       AssertionError

tests/shared/remote/object.py:175: AssertionError
---------------------------- Captured stdout setup -----------------------------
2024-01-17 22:51:46.131 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=50&limit=50           |    100/199    tracks
2024-01-17 22:51:46.147 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=100&limit=50          |    150/199    tracks
2024-01-17 22:51:46.163 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=150&limit=50          |    199/199    tracks
2024-01-17 22:51:46.222 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/me                                          
----------------------------- Captured stdout call -----------------------------
2024-01-17 22:51:46.234 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi | Playlists:    1 | Params: None
2024-01-17 22:51:46.243 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=50&limit=50           |    100/199    tracks
2024-01-17 22:51:46.258 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=100&limit=50          |    150/199    tracks
2024-01-17 22:51:46.274 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=150&limit=50          |    199/199    tracks
2024-01-17 22:51:46.288 | [   DEBUG] m.spotify.api.api.SpotifyAPI.get_items   [ 271] | DONE   : https://api.spotify.com/v1/playlists                                    | Retrieved    199 tracks across     1 playlists
2024-01-17 22:51:46.291 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | DELETE : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Clearing 199 tracks | Json: {'tracks': [{'uri': 'spotify:track:GaMdIquRlSgFTbVwynDxHs'}, {'uri': 'spotify:track:DZklxCDOdCjVtFTkIuUCbl'}, {'uri': 'spotify:track:SiTYKqtqYWYXbSHjvgLObS'}, {'uri': 'spotify:track:sQkaErChkYTZiaVbTrlmWk'}, {'uri': 'spotify:track:rTpwFsTcVbyTxXXfgiDiKD'}, {'uri': 'spotify:track:jxKqgLYReQTNERAyeBgyIQ'}, {'uri': 'spotify:track:IfFWVcioKyUfKZcmgBaVYe'}, {'uri': 'spotify:track:fiayayKTERWbljPEEoYCRB'}, {'uri': 'spotify:track:zZPBDXHzQdmmlxxGjoAGKZ'}, {'uri': 'spotify:track:WLEeJiqLAADWMoubcWyGmx'}, {'uri': 'spotify:track:GBeJlweLxTdNzACkOzLROm'}, {'uri': 'spotify:track:EpDmcDUtKELoRwfAkrEKJs'}, {'uri': 'spotify:track:fLZAROddxPOUiwnmsMimVE'}, {'uri': 'spotify:track:uALOGVsACYzvZHOvXzlOpv'}, {'uri': 'spotify:track:cUojVXCzQgRXuhEpABdhWl'}, {'uri': 'spotify:track:bwwChxDgxmzGRPSogTOtXc'}, {'uri': 'spotify:track:JERFYCiYZOILqvhzvXjsxR'}, {'uri': 'spotify:track:EqQWsgHFZdhKzsoFIKuQjt'}, {'uri': 'spotify:track:yRKXOfjpGMCiyZQkBNyBAb'}, {'uri': 'spotify:track:EYgxJFPSELHiZZYKdjXRsK'}, {'uri': 'spotify:track:dqGejHBhQJILIuLRcQybnP'}, {'uri': 'spotify:track:BFOUTASwRIlkIBQzyXZZej'}, {'uri': 'spotify:track:GICmflpYwGruqbjBAatGXH'}, {'uri': 'spotify:track:UFUKBPqbWNqqCFgaxGHwwj'}, {'uri': 'spotify:track:qSYVmhcjwJHCpSGMUxkPnx'}, {'uri': 'spotify:track:grwLAoxMhCgEhINCVcLWSc'}, {'uri': 'spotify:track:nVfWzJydpDMsrRGyuIQqVx'}, {'uri': 'spotify:track:VTvPHvPclaNkcLdNuOUGvD'}, {'uri': 'spotify:track:CBZxzArmmLPvvhMDVGTZmf'}, {'uri': 'spotify:track:FvdEHErZVhVMvMVzhsdJXv'}, {'uri': 'spotify:track:HOOxqDrVKMLqhKxbQWKUDq'}, {'uri': 'spotify:track:LaSJavvCrKffrhZJgUpQLJ'}, {'uri': 'spotify:track:euwDSQQyQImUHicWuOVkYh'}, {'uri': 'spotify:track:KWwBRaXRprtGMSNMHSveBm'}, {'uri': 'spotify:track:PUdehJWFmjfiaxMCyFMPpw'}, {'uri': 'spotify:track:YPkOXBsfGKBExyUSSmaShL'}, {'uri': 'spotify:track:WxcMRELjpEniEmXCHGGVqm'}, {'uri': 'spotify:track:vnDuNHdleQrKhjBlmjPQky'}, {'uri': 'spotify:track:exPVxuSmGiNIspaEbKnFvF'}, {'uri': 'spotify:track:nUPBJWJgGHvvbjYaenwnXJ'}, {'uri': 'spotify:track:iMfQnlISysusGEsfFCaUuV'}, {'uri': 'spotify:track:WtCBTpivjwvUNgtIoDFjqf'}, {'uri': 'spotify:track:qYvpvLKnHJBCUFmIyEIgpF'}, {'uri': 'spotify:track:NXCuBlAnsgIpWefrQKzeCW'}, {'uri': 'spotify:track:HpFdommNzmgiaKDMLSkTvs'}, {'uri': 'spotify:track:GVrfqtrdExgkLbUueDKwlB'}, {'uri': 'spotify:track:HgMgvtjBAYgILegTONzOVK'}, {'uri': 'spotify:track:gpEXPTHFBxjhytTDDJqBtk'}, {'uri': 'spotify:track:fORLBNIVbUKXykhLJtBBxU'}, {'uri': 'spotify:track:iHFTxzitovZxvMzCAvXvQO'}, {'uri': 'spotify:track:CGHngNnSZtpjXBodQxPlcM'}, {'uri': 'spotify:track:JpDWdJrddsDeixcWjfKfnE'}, {'uri': 'spotify:track:SQQstxXiXRgYxsxqqOtlQi'}, {'uri': 'spotify:track:WUmQKJqnYrpZTEZHRaqpky'}, {'uri': 'spotify:track:BzrMpqbFfeBflLvcIRQTUF'}, {'uri': 'spotify:track:OxbtSvJPhahWuOtBUfsZES'}, {'uri': 'spotify:track:BrbXDMacZPwNQYgnWLRmPr'}, {'uri': 'spotify:track:iBXztqNZWnujCvjHstyXJa'}, {'uri': 'spotify:track:EIuRzgaTNHAZDmdjBpOnLx'}, {'uri': 'spotify:track:fXlpVCGWoYQXAngdOUMLZF'}, {'uri': 'spotify:track:myYNgpvcFnEFatQVaZfBiL'}, {'uri': 'spotify:track:dKsoUmwOArXuctTsSjkDgJ'}, {'uri': 'spotify:track:ehOEMSUxzbjQKGmdVJmEqm'}, {'uri': 'spotify:track:HOyxmQtkbpptaUErtgYJha'}, {'uri': 'spotify:track:MlWuKWbHzfhIRtErPftEit'}, {'uri': 'spotify:track:IOxowKfSipemMFTsnXfFMb'}, {'uri': 'spotify:track:eiVJluBhDxihaueMHdHLSr'}, {'uri': 'spotify:track:YFmxrJIrcRVfwXMgozVdVK'}, {'uri': 'spotify:track:woVdocQabpQXMvWLAOTXdw'}, {'uri': 'spotify:track:GVBsqpBelkFqLUyjrVTigG'}, {'uri': 'spotify:track:uMYDlbWVXxrKxayOCfNwrW'}, {'uri': 'spotify:track:tTGXJxNPIvYdzRGFXrGpvX'}, {'uri': 'spotify:track:zioUXvXefZbNwtNYJgcAGm'}, {'uri': 'spotify:track:LxqPNsXJggpOOmZrkpYyxM'}, {'uri': 'spotify:track:veGAcghyUrcepryTGetNad'}, {'uri': 'spotify:track:ohdcsDSdTmolazftbiwKYj'}, {'uri': 'spotify:track:uTfgBcTHrmNduHsjLuLEpO'}, {'uri': 'spotify:track:ooNQcdLpzuwwQbhIRLhlZj'}, {'uri': 'spotify:track:eBRRTzDoEiSsUZrKenKKCj'}, {'uri': 'spotify:track:nmwHoxPgOYoxnAOjuiqBOL'}, {'uri': 'spotify:track:PCuLtTWDsZCYljXoqkIjXo'}, {'uri': 'spotify:track:cudtjWhxKxlfDheHdzxLDF'}, {'uri': 'spotify:track:pkSGirRzOaZRSqJVYAGrAz'}, {'uri': 'spotify:track:GbyFTHOHjZQbpvIWjcUVcZ'}, {'uri': 'spotify:track:HafhxHDnuLkceUFrYAfyWH'}, {'uri': 'spotify:track:yDsPnzdDHmFDETegQuxVSf'}, {'uri': 'spotify:track:qpGMoYUScHiYjVsmuoafsM'}, {'uri': 'spotify:track:MbTlldxGCAAZphkFZOFLWe'}, {'uri': 'spotify:track:dYruZvJzkBMhwyFdKOXHIA'}, {'uri': 'spotify:track:vAYkClNzbzSNSfEJWeEKnU'}, {'uri': 'spotify:track:ukWBqJuRDkNhrpAIKEyABB'}, {'uri': 'spotify:track:wJQWdtWIpTSCVEWDpYvhdm'}, {'uri': 'spotify:track:CZSrQdGDYaVKPsItCKZRoM'}, {'uri': 'spotify:track:AxKqMRoIBLxtvpOycRjARA'}, {'uri': 'spotify:track:ubBgMmQufKwGTQVamjALHG'}, {'uri': 'spotify:track:MVMRvgndvzySlvAPNOurak'}, {'uri': 'spotify:track:UqigKRpRSKUjUwhihddTPT'}, {'uri': 'spotify:track:hDiAIUofDlATrFQDbhYgdt'}, {'uri': 'spotify:track:yEarerxADqekbUMRqPlrmN'}, {'uri': 'spotify:track:ueXaZGYoMRLOIcTcHvYXTO'}]}
2024-01-17 22:51:46.296 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | DELETE : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Clearing 199 tracks | Json: {'tracks': [{'uri': 'spotify:track:PfjAidmtvgoMMcjrRQOadD'}, {'uri': 'spotify:track:MsdxdDSnpAMUIptuTmUNCg'}, {'uri': 'spotify:track:nADVKdgDDwKalQbRYGYITS'}, {'uri': 'spotify:track:KeQxqDUeQrpaTOvNEPmsQz'}, {'uri': 'spotify:track:ygJLKAeREmnZPRqrdKfuBp'}, {'uri': 'spotify:track:snBnfOyqUnaWXxpXOVgakv'}, {'uri': 'spotify:track:HBCRFZjicmaZfjzOViYdij'}, {'uri': 'spotify:track:nzVsSGCoQswPPXNBZqIipv'}, {'uri': 'spotify:track:tnwvRDMFPEglyPdhIKuqFK'}, {'uri': 'spotify:track:mDjNuipjgwOEEkOhfaZRdx'}, {'uri': 'spotify:track:VhIUYtCzKhbsxVrChuYzSg'}, {'uri': 'spotify:track:FLMgElZjeTKDqIQwduulxM'}, {'uri': 'spotify:track:YetCUBdLGHEAkVdeegAbSp'}, {'uri': 'spotify:track:oiJceBdOtTaDCEZAqfQXNN'}, {'uri': 'spotify:track:bGRvRhGtqulpphRENxUHRy'}, {'uri': 'spotify:track:OAIpZYpbMegMqgIiETvIeC'}, {'uri': 'spotify:track:cquylvLkYSWZFbbykeqgGx'}, {'uri': 'spotify:track:vMwQAGaGftOmoUfJZcFmWZ'}, {'uri': 'spotify:track:JpDAXdWoUWYyYSJUqdmonK'}, {'uri': 'spotify:track:rvRknSLDjBibLvkiVpeMnH'}, {'uri': 'spotify:track:LpcALPHwMcmtPxFjwDVPBa'}, {'uri': 'spotify:track:hFkxrjgrsOntuVPuCcHhUW'}, {'uri': 'spotify:track:kbYEUNVAanngPRxIiwPmwn'}, {'uri': 'spotify:track:YGjSTTUsaIRBVKdkWxjDtR'}, {'uri': 'spotify:track:CiUYCsQwZuoLutIfZXNZUu'}, {'uri': 'spotify:track:mrBhOJbiSvYkGidzZBPTGm'}, {'uri': 'spotify:track:SvzAsGnCWRsoEtASILsiLo'}, {'uri': 'spotify:track:juYwDpUpvJqtWkxddbxYyq'}, {'uri': 'spotify:track:VZuIbHycIOcAPcUbHmFPZu'}, {'uri': 'spotify:track:irXhkStyJcgEchsseshJjH'}, {'uri': 'spotify:track:cMFBAASpxYOjlxoAeDEHTi'}, {'uri': 'spotify:track:yhsvRDuxkEnYMxXViLDLje'}, {'uri': 'spotify:track:CmgEtYWdTaxcKmzGMIJPRb'}, {'uri': 'spotify:track:eFRcOldasHAQYzfRZvoSeM'}, {'uri': 'spotify:track:BtWggfmyUDOmlYfwPPHGEu'}, {'uri': 'spotify:track:kSsGAvZxGcSXrROevsocEg'}, {'uri': 'spotify:track:GMndkiqMAsxpDdYUmeLCsZ'}, {'uri': 'spotify:track:hRCuhcDJrAOrvFXtwPsDro'}, {'uri': 'spotify:track:cgXfDDAXuUHJhMCvxXBzbm'}, {'uri': 'spotify:track:yZtvXOmOceLPAduAkIuEFh'}, {'uri': 'spotify:track:ZhRSDvuZGMYWQBnRubOvas'}, {'uri': 'spotify:track:QpqxDYQAJKpyiSrlOLgRQG'}, {'uri': 'spotify:track:IHRpHvQgkkkOBhysexofLW'}, {'uri': 'spotify:track:rVDxzVHlvYexbdXpQAXSNJ'}, {'uri': 'spotify:track:lPQnGWomOlBdyXvtUYzFEg'}, {'uri': 'spotify:track:azBWWzrnJwJTZHlRgqrYms'}, {'uri': 'spotify:track:SplDaLOnmGNxblRBogyzsg'}, {'uri': 'spotify:track:KgyGxVpPYgeraPUzlKndQw'}, {'uri': 'spotify:track:DCGuNovTedaNKviMjAkmwK'}, {'uri': 'spotify:track:UUsbNAKeCUAltNhsSwBtsR'}, {'uri': 'spotify:track:iLRrSDUGbZDkUMqQPDdULl'}, {'uri': 'spotify:track:SgmmGhVdvkuQtCjqQpVZPT'}, {'uri': 'spotify:track:SoqRtrqYfMtaIKHYfRmTXG'}, {'uri': 'spotify:track:hEdjJEIAFppqfesVcnMXay'}, {'uri': 'spotify:track:jdACZmzbjtlwejdJNUcjko'}, {'uri': 'spotify:track:ZqcTPMZbfkBGYHhJZpwBcV'}, {'uri': 'spotify:track:PeiYeZpijHExlYPVMFvHyu'}, {'uri': 'spotify:track:ckNdonncGIJXWkpaTSyEVl'}, {'uri': 'spotify:track:ZNRSuXrZFlXHeuvqbtERKW'}, {'uri': 'spotify:track:LXcGpDhgtHXlHOnQzkIBoP'}, {'uri': 'spotify:track:wknBZvOfETLQmlOCTHmHWv'}, {'uri': 'spotify:track:sRfEwLXmWIExnYqpWTtuwQ'}, {'uri': 'spotify:track:qnrZltQBnhsyOLvIKUTQFT'}, {'uri': 'spotify:track:uZBCpYRxmlLiYmfZDDnCiB'}, {'uri': 'spotify:track:KfAHIREwRWGkzoiDMwssBr'}, {'uri': 'spotify:track:JNLpkZRENgMswnBtkCkxUd'}, {'uri': 'spotify:track:RahriXGPpcEHbLTXOVxqAt'}, {'uri': 'spotify:track:SWbLNgpACKaOmcvzMXWMZg'}, {'uri': 'spotify:track:brLgNEPlqupfXZIheNIlFe'}, {'uri': 'spotify:track:dOYBepkQHawotMDRWLpKSu'}, {'uri': 'spotify:track:dLqpPHfpCzLRyOsqdOACfX'}, {'uri': 'spotify:track:kBhQOvyPugFNQHVMqpuQrv'}, {'uri': 'spotify:track:iOemyzARbWINaMNdzDBwSn'}, {'uri': 'spotify:track:kYSSKGGSBKBxxOKtqYJnKA'}, {'uri': 'spotify:track:JibVhyLQGKhOacYxeNFWwA'}, {'uri': 'spotify:track:OwvDmJYGnFjXMZJdRrqenN'}, {'uri': 'spotify:track:nynJmpnwPihsjVFCuvdJQZ'}, {'uri': 'spotify:track:vkajboIDlNRqeRQaqYqVWl'}, {'uri': 'spotify:track:SkzxWJbhktwsqPJDwgBTvZ'}, {'uri': 'spotify:track:APSSGAPPDJeXzAlhxfcJit'}, {'uri': 'spotify:track:MPcgAIVKgEXHiMbcrQbZgt'}, {'uri': 'spotify:track:njcMhVHtJNlztCbBvjPHWH'}, {'uri': 'spotify:track:qKLpAslzTwbCVuFTMDkcJA'}, {'uri': 'spotify:track:xlMIbYvsMeDxdJwTqydBYp'}, {'uri': 'spotify:track:uSAXwzsAYZnaFHDLdSJaRS'}, {'uri': 'spotify:track:HIKZTdUJwGsdBeFbYKmFZa'}, {'uri': 'spotify:track:xgGBycFbdsIlzewIvubbyZ'}, {'uri': 'spotify:track:iDOynNriQZhzSgkyrSMDMF'}, {'uri': 'spotify:track:LypgQBnwxcPXlgATVzWNJQ'}, {'uri': 'spotify:track:qdmwPOgtQAVdcLVEAvjbOc'}, {'uri': 'spotify:track:YeDoPHJRBFRpAKoNIoIvzf'}, {'uri': 'spotify:track:WQANLzmErBfBsYcqCHrPay'}, {'uri': 'spotify:track:tEbfEddozxDevsYjOMLSaD'}, {'uri': 'spotify:track:afxluksqsDHtNIfnSjLdcV'}, {'uri': 'spotify:track:OsgxzlwzfDelTPJWgbqISP'}, {'uri': 'spotify:track:YsRRDqEabsfxznrlkVFGdO'}, {'uri': 'spotify:track:VWdFcFutfwFflLLbRrskyr'}, {'uri': 'spotify:track:KnnyfoHHkJIdenrgMxdoCt'}, {'uri': 'spotify:track:lFVlFwtxaslwdpHAecPPno'}]}
2024-01-17 22:51:46.300 | [   DEBUG] m.s.a.api.SpotifyAPI.clear_from_playlist [ 182] | DONE   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Cleared 199 tracks
2024-01-17 22:51:46.305 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | POST   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Adding    100 items | Json: {'uris': ('spotify:track:GaMdIquRlSgFTbVwynDxHs', 'spotify:track:DZklxCDOdCjVtFTkIuUCbl', 'spotify:track:SiTYKqtqYWYXbSHjvgLObS', 'spotify:track:sQkaErChkYTZiaVbTrlmWk', 'spotify:track:rTpwFsTcVbyTxXXfgiDiKD', 'spotify:track:jxKqgLYReQTNERAyeBgyIQ', 'spotify:track:IfFWVcioKyUfKZcmgBaVYe', 'spotify:track:fiayayKTERWbljPEEoYCRB', 'spotify:track:zZPBDXHzQdmmlxxGjoAGKZ', 'spotify:track:WLEeJiqLAADWMoubcWyGmx', 'spotify:track:GBeJlweLxTdNzACkOzLROm', 'spotify:track:EpDmcDUtKELoRwfAkrEKJs', 'spotify:track:fLZAROddxPOUiwnmsMimVE', 'spotify:track:uALOGVsACYzvZHOvXzlOpv', 'spotify:track:cUojVXCzQgRXuhEpABdhWl', 'spotify:track:bwwChxDgxmzGRPSogTOtXc', 'spotify:track:JERFYCiYZOILqvhzvXjsxR', 'spotify:track:EqQWsgHFZdhKzsoFIKuQjt', 'spotify:track:yRKXOfjpGMCiyZQkBNyBAb', 'spotify:track:EYgxJFPSELHiZZYKdjXRsK', 'spotify:track:dqGejHBhQJILIuLRcQybnP', 'spotify:track:BFOUTASwRIlkIBQzyXZZej', 'spotify:track:GICmflpYwGruqbjBAatGXH', 'spotify:track:UFUKBPqbWNqqCFgaxGHwwj', 'spotify:track:qSYVmhcjwJHCpSGMUxkPnx', 'spotify:track:grwLAoxMhCgEhINCVcLWSc', 'spotify:track:nVfWzJydpDMsrRGyuIQqVx', 'spotify:track:VTvPHvPclaNkcLdNuOUGvD', 'spotify:track:CBZxzArmmLPvvhMDVGTZmf', 'spotify:track:FvdEHErZVhVMvMVzhsdJXv', 'spotify:track:HOOxqDrVKMLqhKxbQWKUDq', 'spotify:track:LaSJavvCrKffrhZJgUpQLJ', 'spotify:track:euwDSQQyQImUHicWuOVkYh', 'spotify:track:KWwBRaXRprtGMSNMHSveBm', 'spotify:track:PUdehJWFmjfiaxMCyFMPpw', 'spotify:track:YPkOXBsfGKBExyUSSmaShL', 'spotify:track:WxcMRELjpEniEmXCHGGVqm', 'spotify:track:vnDuNHdleQrKhjBlmjPQky', 'spotify:track:exPVxuSmGiNIspaEbKnFvF', 'spotify:track:nUPBJWJgGHvvbjYaenwnXJ', 'spotify:track:iMfQnlISysusGEsfFCaUuV', 'spotify:track:WtCBTpivjwvUNgtIoDFjqf', 'spotify:track:qYvpvLKnHJBCUFmIyEIgpF', 'spotify:track:NXCuBlAnsgIpWefrQKzeCW', 'spotify:track:HpFdommNzmgiaKDMLSkTvs', 'spotify:track:GVrfqtrdExgkLbUueDKwlB', 'spotify:track:HgMgvtjBAYgILegTONzOVK', 'spotify:track:gpEXPTHFBxjhytTDDJqBtk', 'spotify:track:fORLBNIVbUKXykhLJtBBxU', 'spotify:track:iHFTxzitovZxvMzCAvXvQO', 'spotify:track:CGHngNnSZtpjXBodQxPlcM', 'spotify:track:JpDWdJrddsDeixcWjfKfnE', 'spotify:track:SQQstxXiXRgYxsxqqOtlQi', 'spotify:track:WUmQKJqnYrpZTEZHRaqpky', 'spotify:track:BzrMpqbFfeBflLvcIRQTUF', 'spotify:track:OxbtSvJPhahWuOtBUfsZES', 'spotify:track:BrbXDMacZPwNQYgnWLRmPr', 'spotify:track:iBXztqNZWnujCvjHstyXJa', 'spotify:track:EIuRzgaTNHAZDmdjBpOnLx', 'spotify:track:fXlpVCGWoYQXAngdOUMLZF', 'spotify:track:myYNgpvcFnEFatQVaZfBiL', 'spotify:track:dKsoUmwOArXuctTsSjkDgJ', 'spotify:track:ehOEMSUxzbjQKGmdVJmEqm', 'spotify:track:HOyxmQtkbpptaUErtgYJha', 'spotify:track:MlWuKWbHzfhIRtErPftEit', 'spotify:track:IOxowKfSipemMFTsnXfFMb', 'spotify:track:eiVJluBhDxihaueMHdHLSr', 'spotify:track:YFmxrJIrcRVfwXMgozVdVK', 'spotify:track:woVdocQabpQXMvWLAOTXdw', 'spotify:track:GVBsqpBelkFqLUyjrVTigG', 'spotify:track:uMYDlbWVXxrKxayOCfNwrW', 'spotify:track:tTGXJxNPIvYdzRGFXrGpvX', 'spotify:track:zioUXvXefZbNwtNYJgcAGm', 'spotify:track:LxqPNsXJggpOOmZrkpYyxM', 'spotify:track:veGAcghyUrcepryTGetNad', 'spotify:track:ohdcsDSdTmolazftbiwKYj', 'spotify:track:uTfgBcTHrmNduHsjLuLEpO', 'spotify:track:ooNQcdLpzuwwQbhIRLhlZj', 'spotify:track:eBRRTzDoEiSsUZrKenKKCj', 'spotify:track:nmwHoxPgOYoxnAOjuiqBOL', 'spotify:track:PCuLtTWDsZCYljXoqkIjXo', 'spotify:track:cudtjWhxKxlfDheHdzxLDF', 'spotify:track:pkSGirRzOaZRSqJVYAGrAz', 'spotify:track:GbyFTHOHjZQbpvIWjcUVcZ', 'spotify:track:HafhxHDnuLkceUFrYAfyWH', 'spotify:track:yDsPnzdDHmFDETegQuxVSf', 'spotify:track:qpGMoYUScHiYjVsmuoafsM', 'spotify:track:MbTlldxGCAAZphkFZOFLWe', 'spotify:track:dYruZvJzkBMhwyFdKOXHIA', 'spotify:track:vAYkClNzbzSNSfEJWeEKnU', 'spotify:track:ukWBqJuRDkNhrpAIKEyABB', 'spotify:track:wJQWdtWIpTSCVEWDpYvhdm', 'spotify:track:CZSrQdGDYaVKPsItCKZRoM', 'spotify:track:AxKqMRoIBLxtvpOycRjARA', 'spotify:track:ubBgMmQufKwGTQVamjALHG', 'spotify:track:MVMRvgndvzySlvAPNOurak', 'spotify:track:UqigKRpRSKUjUwhihddTPT', 'spotify:track:hDiAIUofDlATrFQDbhYgdt', 'spotify:track:yEarerxADqekbUMRqPlrmN', 'spotify:track:ueXaZGYoMRLOIcTcHvYXTO')}
2024-01-17 22:51:46.310 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | POST   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Adding     99 items | Json: {'uris': ('spotify:track:PfjAidmtvgoMMcjrRQOadD', 'spotify:track:MsdxdDSnpAMUIptuTmUNCg', 'spotify:track:nADVKdgDDwKalQbRYGYITS', 'spotify:track:KeQxqDUeQrpaTOvNEPmsQz', 'spotify:track:ygJLKAeREmnZPRqrdKfuBp', 'spotify:track:snBnfOyqUnaWXxpXOVgakv', 'spotify:track:HBCRFZjicmaZfjzOViYdij', 'spotify:track:nzVsSGCoQswPPXNBZqIipv', 'spotify:track:tnwvRDMFPEglyPdhIKuqFK', 'spotify:track:mDjNuipjgwOEEkOhfaZRdx', 'spotify:track:VhIUYtCzKhbsxVrChuYzSg', 'spotify:track:FLMgElZjeTKDqIQwduulxM', 'spotify:track:YetCUBdLGHEAkVdeegAbSp', 'spotify:track:oiJceBdOtTaDCEZAqfQXNN', 'spotify:track:bGRvRhGtqulpphRENxUHRy', 'spotify:track:OAIpZYpbMegMqgIiETvIeC', 'spotify:track:cquylvLkYSWZFbbykeqgGx', 'spotify:track:vMwQAGaGftOmoUfJZcFmWZ', 'spotify:track:JpDAXdWoUWYyYSJUqdmonK', 'spotify:track:rvRknSLDjBibLvkiVpeMnH', 'spotify:track:LpcALPHwMcmtPxFjwDVPBa', 'spotify:track:hFkxrjgrsOntuVPuCcHhUW', 'spotify:track:kbYEUNVAanngPRxIiwPmwn', 'spotify:track:YGjSTTUsaIRBVKdkWxjDtR', 'spotify:track:CiUYCsQwZuoLutIfZXNZUu', 'spotify:track:mrBhOJbiSvYkGidzZBPTGm', 'spotify:track:SvzAsGnCWRsoEtASILsiLo', 'spotify:track:juYwDpUpvJqtWkxddbxYyq', 'spotify:track:VZuIbHycIOcAPcUbHmFPZu', 'spotify:track:irXhkStyJcgEchsseshJjH', 'spotify:track:cMFBAASpxYOjlxoAeDEHTi', 'spotify:track:yhsvRDuxkEnYMxXViLDLje', 'spotify:track:CmgEtYWdTaxcKmzGMIJPRb', 'spotify:track:eFRcOldasHAQYzfRZvoSeM', 'spotify:track:BtWggfmyUDOmlYfwPPHGEu', 'spotify:track:kSsGAvZxGcSXrROevsocEg', 'spotify:track:GMndkiqMAsxpDdYUmeLCsZ', 'spotify:track:hRCuhcDJrAOrvFXtwPsDro', 'spotify:track:cgXfDDAXuUHJhMCvxXBzbm', 'spotify:track:yZtvXOmOceLPAduAkIuEFh', 'spotify:track:ZhRSDvuZGMYWQBnRubOvas', 'spotify:track:QpqxDYQAJKpyiSrlOLgRQG', 'spotify:track:IHRpHvQgkkkOBhysexofLW', 'spotify:track:rVDxzVHlvYexbdXpQAXSNJ', 'spotify:track:lPQnGWomOlBdyXvtUYzFEg', 'spotify:track:azBWWzrnJwJTZHlRgqrYms', 'spotify:track:SplDaLOnmGNxblRBogyzsg', 'spotify:track:KgyGxVpPYgeraPUzlKndQw', 'spotify:track:DCGuNovTedaNKviMjAkmwK', 'spotify:track:UUsbNAKeCUAltNhsSwBtsR', 'spotify:track:iLRrSDUGbZDkUMqQPDdULl', 'spotify:track:SgmmGhVdvkuQtCjqQpVZPT', 'spotify:track:SoqRtrqYfMtaIKHYfRmTXG', 'spotify:track:hEdjJEIAFppqfesVcnMXay', 'spotify:track:jdACZmzbjtlwejdJNUcjko', 'spotify:track:ZqcTPMZbfkBGYHhJZpwBcV', 'spotify:track:PeiYeZpijHExlYPVMFvHyu', 'spotify:track:ckNdonncGIJXWkpaTSyEVl', 'spotify:track:ZNRSuXrZFlXHeuvqbtERKW', 'spotify:track:LXcGpDhgtHXlHOnQzkIBoP', 'spotify:track:wknBZvOfETLQmlOCTHmHWv', 'spotify:track:sRfEwLXmWIExnYqpWTtuwQ', 'spotify:track:qnrZltQBnhsyOLvIKUTQFT', 'spotify:track:uZBCpYRxmlLiYmfZDDnCiB', 'spotify:track:KfAHIREwRWGkzoiDMwssBr', 'spotify:track:JNLpkZRENgMswnBtkCkxUd', 'spotify:track:RahriXGPpcEHbLTXOVxqAt', 'spotify:track:SWbLNgpACKaOmcvzMXWMZg', 'spotify:track:brLgNEPlqupfXZIheNIlFe', 'spotify:track:dOYBepkQHawotMDRWLpKSu', 'spotify:track:dLqpPHfpCzLRyOsqdOACfX', 'spotify:track:kBhQOvyPugFNQHVMqpuQrv', 'spotify:track:iOemyzARbWINaMNdzDBwSn', 'spotify:track:kYSSKGGSBKBxxOKtqYJnKA', 'spotify:track:JibVhyLQGKhOacYxeNFWwA', 'spotify:track:OwvDmJYGnFjXMZJdRrqenN', 'spotify:track:nynJmpnwPihsjVFCuvdJQZ', 'spotify:track:vkajboIDlNRqeRQaqYqVWl', 'spotify:track:SkzxWJbhktwsqPJDwgBTvZ', 'spotify:track:APSSGAPPDJeXzAlhxfcJit', 'spotify:track:MPcgAIVKgEXHiMbcrQbZgt', 'spotify:track:njcMhVHtJNlztCbBvjPHWH', 'spotify:track:qKLpAslzTwbCVuFTMDkcJA', 'spotify:track:xlMIbYvsMeDxdJwTqydBYp', 'spotify:track:uSAXwzsAYZnaFHDLdSJaRS', 'spotify:track:HIKZTdUJwGsdBeFbYKmFZa', 'spotify:track:xgGBycFbdsIlzewIvubbyZ', 'spotify:track:iDOynNriQZhzSgkyrSMDMF', 'spotify:track:LypgQBnwxcPXlgATVzWNJQ', 'spotify:track:qdmwPOgtQAVdcLVEAvjbOc', 'spotify:track:YeDoPHJRBFRpAKoNIoIvzf', 'spotify:track:WQANLzmErBfBsYcqCHrPay', 'spotify:track:tEbfEddozxDevsYjOMLSaD', 'spotify:track:afxluksqsDHtNIfnSjLdcV', 'spotify:track:OsgxzlwzfDelTPJWgbqISP', 'spotify:track:YsRRDqEabsfxznrlkVFGdO', 'spotify:track:VWdFcFutfwFflLLbRrskyr', 'spotify:track:KnnyfoHHkJIdenrgMxdoCt', 'spotify:track:lFVlFwtxaslwdpHAecPPno')}
2024-01-17 22:51:46.315 | [   DEBUG] m.s.api.api.SpotifyAPI.add_to_playlist   [ 121] | DONE   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Added    199 items to playlist: https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks
2024-01-17 22:51:46.320 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi | Playlists:    1 | Params: None
2024-01-17 22:51:46.331 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=50&limit=50           |    100/199    tracks
2024-01-17 22:51:46.349 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=100&limit=50          |    150/199    tracks
2024-01-17 22:51:46.366 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=150&limit=50          |    199/199    tracks
2024-01-17 22:51:46.381 | [   DEBUG] m.spotify.api.api.SpotifyAPI.get_items   [ 271] | DONE   : https://api.spotify.com/v1/playlists                                    | Retrieved    199 tracks across     1 playlists
self = <test_spotify_playlist.TestSpotifyPlaylist object at 0x111225100>
sync_playlist = SpotifyPlaylist({'name': 'qpVQgMrDeaXbqotmTvfmQNmHnDtStoAOe', 'description': 'HKuRJwwEyNMPvFiDxVOltpBjOIjerIK', 'track...otify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi', 'url_ext': 'https://open.spotify.com/playlist/PUnXWkGgUTniEziVdftzPi'})
sync_items = []
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x1120c3410>

    def test_sync(self, sync_playlist: RemotePlaylist, sync_items: list[RemoteTrack], api_mock: RemoteMock):
        sync_items_extended = sync_items + sync_playlist[:10]
        result = sync_playlist.sync(kind="sync", items=sync_items_extended, reload=False, dry_run=False)
    
        sync_uri = {track.uri for track in sync_items_extended}
        assert result.start == len(sync_playlist)
        assert result.added == len(sync_items)
        assert result.removed == len([track.uri for track in sync_playlist if track.uri not in sync_uri])
        assert result.unchanged == len([track.uri for track in sync_playlist if track.uri in sync_uri])
        assert result.difference == len(sync_items) - result.removed
        assert result.final == result.start + result.difference
    
        uri_add, uri_clear = self.get_sync_uris(url=sync_playlist.url, api_mock=api_mock)
        assert uri_add == [track.uri for track in sync_items]
        assert uri_clear == [track.uri for track in sync_playlist if track.uri not in sync_uri]
    
        # 1 load when clearing
>       self.assert_playlist_loaded(sync_playlist=sync_playlist, api_mock=api_mock, count=1)

tests/shared/remote/object.py:205: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

sync_playlist = SpotifyPlaylist({'name': 'qpVQgMrDeaXbqotmTvfmQNmHnDtStoAOe', 'description': 'HKuRJwwEyNMPvFiDxVOltpBjOIjerIK', 'track...otify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi', 'url_ext': 'https://open.spotify.com/playlist/PUnXWkGgUTniEziVdftzPi'})
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x1120c3410>, count = 1

    @staticmethod
    def assert_playlist_loaded(sync_playlist: RemotePlaylist, api_mock: RemoteMock, count: int = 1) -> None:
        """Assert the given playlist was fully reloaded through GET requests ``count`` number of times"""
        pages = api_mock.calculate_pages_from_response(sync_playlist.response)
    
        requests = api_mock.get_requests(url=sync_playlist.url, method="GET")
        requests += api_mock.get_requests(url=sync_playlist.url + "/tracks", method="GET")
    
>       assert len(requests) == pages * count
E       AssertionError

tests/shared/remote/object.py:98: AssertionError
---------------------------- Captured stdout setup -----------------------------
2024-01-17 22:51:46.419 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=50&limit=50           |    100/199    tracks
2024-01-17 22:51:46.435 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=100&limit=50          |    150/199    tracks
2024-01-17 22:51:46.451 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks?offset=150&limit=50          |    199/199    tracks
2024-01-17 22:51:46.509 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | GET    : https://api.spotify.com/v1/me                                          
----------------------------- Captured stdout call -----------------------------
2024-01-17 22:51:46.522 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | DELETE : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Clearing 189 tracks | Json: {'tracks': [{'uri': 'spotify:track:GBeJlweLxTdNzACkOzLROm'}, {'uri': 'spotify:track:EpDmcDUtKELoRwfAkrEKJs'}, {'uri': 'spotify:track:fLZAROddxPOUiwnmsMimVE'}, {'uri': 'spotify:track:uALOGVsACYzvZHOvXzlOpv'}, {'uri': 'spotify:track:cUojVXCzQgRXuhEpABdhWl'}, {'uri': 'spotify:track:bwwChxDgxmzGRPSogTOtXc'}, {'uri': 'spotify:track:JERFYCiYZOILqvhzvXjsxR'}, {'uri': 'spotify:track:EqQWsgHFZdhKzsoFIKuQjt'}, {'uri': 'spotify:track:yRKXOfjpGMCiyZQkBNyBAb'}, {'uri': 'spotify:track:EYgxJFPSELHiZZYKdjXRsK'}, {'uri': 'spotify:track:dqGejHBhQJILIuLRcQybnP'}, {'uri': 'spotify:track:BFOUTASwRIlkIBQzyXZZej'}, {'uri': 'spotify:track:GICmflpYwGruqbjBAatGXH'}, {'uri': 'spotify:track:UFUKBPqbWNqqCFgaxGHwwj'}, {'uri': 'spotify:track:qSYVmhcjwJHCpSGMUxkPnx'}, {'uri': 'spotify:track:grwLAoxMhCgEhINCVcLWSc'}, {'uri': 'spotify:track:nVfWzJydpDMsrRGyuIQqVx'}, {'uri': 'spotify:track:VTvPHvPclaNkcLdNuOUGvD'}, {'uri': 'spotify:track:CBZxzArmmLPvvhMDVGTZmf'}, {'uri': 'spotify:track:FvdEHErZVhVMvMVzhsdJXv'}, {'uri': 'spotify:track:HOOxqDrVKMLqhKxbQWKUDq'}, {'uri': 'spotify:track:LaSJavvCrKffrhZJgUpQLJ'}, {'uri': 'spotify:track:euwDSQQyQImUHicWuOVkYh'}, {'uri': 'spotify:track:KWwBRaXRprtGMSNMHSveBm'}, {'uri': 'spotify:track:PUdehJWFmjfiaxMCyFMPpw'}, {'uri': 'spotify:track:YPkOXBsfGKBExyUSSmaShL'}, {'uri': 'spotify:track:WxcMRELjpEniEmXCHGGVqm'}, {'uri': 'spotify:track:vnDuNHdleQrKhjBlmjPQky'}, {'uri': 'spotify:track:exPVxuSmGiNIspaEbKnFvF'}, {'uri': 'spotify:track:nUPBJWJgGHvvbjYaenwnXJ'}, {'uri': 'spotify:track:iMfQnlISysusGEsfFCaUuV'}, {'uri': 'spotify:track:WtCBTpivjwvUNgtIoDFjqf'}, {'uri': 'spotify:track:qYvpvLKnHJBCUFmIyEIgpF'}, {'uri': 'spotify:track:NXCuBlAnsgIpWefrQKzeCW'}, {'uri': 'spotify:track:HpFdommNzmgiaKDMLSkTvs'}, {'uri': 'spotify:track:GVrfqtrdExgkLbUueDKwlB'}, {'uri': 'spotify:track:HgMgvtjBAYgILegTONzOVK'}, {'uri': 'spotify:track:gpEXPTHFBxjhytTDDJqBtk'}, {'uri': 'spotify:track:fORLBNIVbUKXykhLJtBBxU'}, {'uri': 'spotify:track:iHFTxzitovZxvMzCAvXvQO'}, {'uri': 'spotify:track:CGHngNnSZtpjXBodQxPlcM'}, {'uri': 'spotify:track:JpDWdJrddsDeixcWjfKfnE'}, {'uri': 'spotify:track:SQQstxXiXRgYxsxqqOtlQi'}, {'uri': 'spotify:track:WUmQKJqnYrpZTEZHRaqpky'}, {'uri': 'spotify:track:BzrMpqbFfeBflLvcIRQTUF'}, {'uri': 'spotify:track:OxbtSvJPhahWuOtBUfsZES'}, {'uri': 'spotify:track:BrbXDMacZPwNQYgnWLRmPr'}, {'uri': 'spotify:track:iBXztqNZWnujCvjHstyXJa'}, {'uri': 'spotify:track:EIuRzgaTNHAZDmdjBpOnLx'}, {'uri': 'spotify:track:fXlpVCGWoYQXAngdOUMLZF'}, {'uri': 'spotify:track:myYNgpvcFnEFatQVaZfBiL'}, {'uri': 'spotify:track:dKsoUmwOArXuctTsSjkDgJ'}, {'uri': 'spotify:track:ehOEMSUxzbjQKGmdVJmEqm'}, {'uri': 'spotify:track:HOyxmQtkbpptaUErtgYJha'}, {'uri': 'spotify:track:MlWuKWbHzfhIRtErPftEit'}, {'uri': 'spotify:track:IOxowKfSipemMFTsnXfFMb'}, {'uri': 'spotify:track:eiVJluBhDxihaueMHdHLSr'}, {'uri': 'spotify:track:YFmxrJIrcRVfwXMgozVdVK'}, {'uri': 'spotify:track:woVdocQabpQXMvWLAOTXdw'}, {'uri': 'spotify:track:GVBsqpBelkFqLUyjrVTigG'}, {'uri': 'spotify:track:uMYDlbWVXxrKxayOCfNwrW'}, {'uri': 'spotify:track:tTGXJxNPIvYdzRGFXrGpvX'}, {'uri': 'spotify:track:zioUXvXefZbNwtNYJgcAGm'}, {'uri': 'spotify:track:LxqPNsXJggpOOmZrkpYyxM'}, {'uri': 'spotify:track:veGAcghyUrcepryTGetNad'}, {'uri': 'spotify:track:ohdcsDSdTmolazftbiwKYj'}, {'uri': 'spotify:track:uTfgBcTHrmNduHsjLuLEpO'}, {'uri': 'spotify:track:ooNQcdLpzuwwQbhIRLhlZj'}, {'uri': 'spotify:track:eBRRTzDoEiSsUZrKenKKCj'}, {'uri': 'spotify:track:nmwHoxPgOYoxnAOjuiqBOL'}, {'uri': 'spotify:track:PCuLtTWDsZCYljXoqkIjXo'}, {'uri': 'spotify:track:cudtjWhxKxlfDheHdzxLDF'}, {'uri': 'spotify:track:pkSGirRzOaZRSqJVYAGrAz'}, {'uri': 'spotify:track:GbyFTHOHjZQbpvIWjcUVcZ'}, {'uri': 'spotify:track:HafhxHDnuLkceUFrYAfyWH'}, {'uri': 'spotify:track:yDsPnzdDHmFDETegQuxVSf'}, {'uri': 'spotify:track:qpGMoYUScHiYjVsmuoafsM'}, {'uri': 'spotify:track:MbTlldxGCAAZphkFZOFLWe'}, {'uri': 'spotify:track:dYruZvJzkBMhwyFdKOXHIA'}, {'uri': 'spotify:track:vAYkClNzbzSNSfEJWeEKnU'}, {'uri': 'spotify:track:ukWBqJuRDkNhrpAIKEyABB'}, {'uri': 'spotify:track:wJQWdtWIpTSCVEWDpYvhdm'}, {'uri': 'spotify:track:CZSrQdGDYaVKPsItCKZRoM'}, {'uri': 'spotify:track:AxKqMRoIBLxtvpOycRjARA'}, {'uri': 'spotify:track:ubBgMmQufKwGTQVamjALHG'}, {'uri': 'spotify:track:MVMRvgndvzySlvAPNOurak'}, {'uri': 'spotify:track:UqigKRpRSKUjUwhihddTPT'}, {'uri': 'spotify:track:hDiAIUofDlATrFQDbhYgdt'}, {'uri': 'spotify:track:yEarerxADqekbUMRqPlrmN'}, {'uri': 'spotify:track:ueXaZGYoMRLOIcTcHvYXTO'}, {'uri': 'spotify:track:PfjAidmtvgoMMcjrRQOadD'}, {'uri': 'spotify:track:MsdxdDSnpAMUIptuTmUNCg'}, {'uri': 'spotify:track:nADVKdgDDwKalQbRYGYITS'}, {'uri': 'spotify:track:KeQxqDUeQrpaTOvNEPmsQz'}, {'uri': 'spotify:track:ygJLKAeREmnZPRqrdKfuBp'}, {'uri': 'spotify:track:snBnfOyqUnaWXxpXOVgakv'}, {'uri': 'spotify:track:HBCRFZjicmaZfjzOViYdij'}, {'uri': 'spotify:track:nzVsSGCoQswPPXNBZqIipv'}, {'uri': 'spotify:track:tnwvRDMFPEglyPdhIKuqFK'}, {'uri': 'spotify:track:mDjNuipjgwOEEkOhfaZRdx'}]}
2024-01-17 22:51:46.527 | [   DEBUG] m.s.api.request.RequestHandler._request  [ 125] | DELETE : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Clearing 189 tracks | Json: {'tracks': [{'uri': 'spotify:track:VhIUYtCzKhbsxVrChuYzSg'}, {'uri': 'spotify:track:FLMgElZjeTKDqIQwduulxM'}, {'uri': 'spotify:track:YetCUBdLGHEAkVdeegAbSp'}, {'uri': 'spotify:track:oiJceBdOtTaDCEZAqfQXNN'}, {'uri': 'spotify:track:bGRvRhGtqulpphRENxUHRy'}, {'uri': 'spotify:track:OAIpZYpbMegMqgIiETvIeC'}, {'uri': 'spotify:track:cquylvLkYSWZFbbykeqgGx'}, {'uri': 'spotify:track:vMwQAGaGftOmoUfJZcFmWZ'}, {'uri': 'spotify:track:JpDAXdWoUWYyYSJUqdmonK'}, {'uri': 'spotify:track:rvRknSLDjBibLvkiVpeMnH'}, {'uri': 'spotify:track:LpcALPHwMcmtPxFjwDVPBa'}, {'uri': 'spotify:track:hFkxrjgrsOntuVPuCcHhUW'}, {'uri': 'spotify:track:kbYEUNVAanngPRxIiwPmwn'}, {'uri': 'spotify:track:YGjSTTUsaIRBVKdkWxjDtR'}, {'uri': 'spotify:track:CiUYCsQwZuoLutIfZXNZUu'}, {'uri': 'spotify:track:mrBhOJbiSvYkGidzZBPTGm'}, {'uri': 'spotify:track:SvzAsGnCWRsoEtASILsiLo'}, {'uri': 'spotify:track:juYwDpUpvJqtWkxddbxYyq'}, {'uri': 'spotify:track:VZuIbHycIOcAPcUbHmFPZu'}, {'uri': 'spotify:track:irXhkStyJcgEchsseshJjH'}, {'uri': 'spotify:track:cMFBAASpxYOjlxoAeDEHTi'}, {'uri': 'spotify:track:yhsvRDuxkEnYMxXViLDLje'}, {'uri': 'spotify:track:CmgEtYWdTaxcKmzGMIJPRb'}, {'uri': 'spotify:track:eFRcOldasHAQYzfRZvoSeM'}, {'uri': 'spotify:track:BtWggfmyUDOmlYfwPPHGEu'}, {'uri': 'spotify:track:kSsGAvZxGcSXrROevsocEg'}, {'uri': 'spotify:track:GMndkiqMAsxpDdYUmeLCsZ'}, {'uri': 'spotify:track:hRCuhcDJrAOrvFXtwPsDro'}, {'uri': 'spotify:track:cgXfDDAXuUHJhMCvxXBzbm'}, {'uri': 'spotify:track:yZtvXOmOceLPAduAkIuEFh'}, {'uri': 'spotify:track:ZhRSDvuZGMYWQBnRubOvas'}, {'uri': 'spotify:track:QpqxDYQAJKpyiSrlOLgRQG'}, {'uri': 'spotify:track:IHRpHvQgkkkOBhysexofLW'}, {'uri': 'spotify:track:rVDxzVHlvYexbdXpQAXSNJ'}, {'uri': 'spotify:track:lPQnGWomOlBdyXvtUYzFEg'}, {'uri': 'spotify:track:azBWWzrnJwJTZHlRgqrYms'}, {'uri': 'spotify:track:SplDaLOnmGNxblRBogyzsg'}, {'uri': 'spotify:track:KgyGxVpPYgeraPUzlKndQw'}, {'uri': 'spotify:track:DCGuNovTedaNKviMjAkmwK'}, {'uri': 'spotify:track:UUsbNAKeCUAltNhsSwBtsR'}, {'uri': 'spotify:track:iLRrSDUGbZDkUMqQPDdULl'}, {'uri': 'spotify:track:SgmmGhVdvkuQtCjqQpVZPT'}, {'uri': 'spotify:track:SoqRtrqYfMtaIKHYfRmTXG'}, {'uri': 'spotify:track:hEdjJEIAFppqfesVcnMXay'}, {'uri': 'spotify:track:jdACZmzbjtlwejdJNUcjko'}, {'uri': 'spotify:track:ZqcTPMZbfkBGYHhJZpwBcV'}, {'uri': 'spotify:track:PeiYeZpijHExlYPVMFvHyu'}, {'uri': 'spotify:track:ckNdonncGIJXWkpaTSyEVl'}, {'uri': 'spotify:track:ZNRSuXrZFlXHeuvqbtERKW'}, {'uri': 'spotify:track:LXcGpDhgtHXlHOnQzkIBoP'}, {'uri': 'spotify:track:wknBZvOfETLQmlOCTHmHWv'}, {'uri': 'spotify:track:sRfEwLXmWIExnYqpWTtuwQ'}, {'uri': 'spotify:track:qnrZltQBnhsyOLvIKUTQFT'}, {'uri': 'spotify:track:uZBCpYRxmlLiYmfZDDnCiB'}, {'uri': 'spotify:track:KfAHIREwRWGkzoiDMwssBr'}, {'uri': 'spotify:track:JNLpkZRENgMswnBtkCkxUd'}, {'uri': 'spotify:track:RahriXGPpcEHbLTXOVxqAt'}, {'uri': 'spotify:track:SWbLNgpACKaOmcvzMXWMZg'}, {'uri': 'spotify:track:brLgNEPlqupfXZIheNIlFe'}, {'uri': 'spotify:track:dOYBepkQHawotMDRWLpKSu'}, {'uri': 'spotify:track:dLqpPHfpCzLRyOsqdOACfX'}, {'uri': 'spotify:track:kBhQOvyPugFNQHVMqpuQrv'}, {'uri': 'spotify:track:iOemyzARbWINaMNdzDBwSn'}, {'uri': 'spotify:track:kYSSKGGSBKBxxOKtqYJnKA'}, {'uri': 'spotify:track:JibVhyLQGKhOacYxeNFWwA'}, {'uri': 'spotify:track:OwvDmJYGnFjXMZJdRrqenN'}, {'uri': 'spotify:track:nynJmpnwPihsjVFCuvdJQZ'}, {'uri': 'spotify:track:vkajboIDlNRqeRQaqYqVWl'}, {'uri': 'spotify:track:SkzxWJbhktwsqPJDwgBTvZ'}, {'uri': 'spotify:track:APSSGAPPDJeXzAlhxfcJit'}, {'uri': 'spotify:track:MPcgAIVKgEXHiMbcrQbZgt'}, {'uri': 'spotify:track:njcMhVHtJNlztCbBvjPHWH'}, {'uri': 'spotify:track:qKLpAslzTwbCVuFTMDkcJA'}, {'uri': 'spotify:track:xlMIbYvsMeDxdJwTqydBYp'}, {'uri': 'spotify:track:uSAXwzsAYZnaFHDLdSJaRS'}, {'uri': 'spotify:track:HIKZTdUJwGsdBeFbYKmFZa'}, {'uri': 'spotify:track:xgGBycFbdsIlzewIvubbyZ'}, {'uri': 'spotify:track:iDOynNriQZhzSgkyrSMDMF'}, {'uri': 'spotify:track:LypgQBnwxcPXlgATVzWNJQ'}, {'uri': 'spotify:track:qdmwPOgtQAVdcLVEAvjbOc'}, {'uri': 'spotify:track:YeDoPHJRBFRpAKoNIoIvzf'}, {'uri': 'spotify:track:WQANLzmErBfBsYcqCHrPay'}, {'uri': 'spotify:track:tEbfEddozxDevsYjOMLSaD'}, {'uri': 'spotify:track:afxluksqsDHtNIfnSjLdcV'}, {'uri': 'spotify:track:OsgxzlwzfDelTPJWgbqISP'}, {'uri': 'spotify:track:YsRRDqEabsfxznrlkVFGdO'}, {'uri': 'spotify:track:VWdFcFutfwFflLLbRrskyr'}, {'uri': 'spotify:track:KnnyfoHHkJIdenrgMxdoCt'}, {'uri': 'spotify:track:lFVlFwtxaslwdpHAecPPno'}]}
2024-01-17 22:51:46.532 | [   DEBUG] m.s.a.api.SpotifyAPI.clear_from_playlist [ 182] | DONE   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks      | Cleared 189 tracks
2024-01-17 22:51:46.534 | [   DEBUG] m.s.api.api.SpotifyAPI.add_to_playlist   [ 104] | SKIP   : https://api.spotify.com/v1/playlists/PUnXWkGgUTniEziVdftzPi/tracks | No data given

How to reproduce

Happens randomly

Operating System

MacOS

Python version

3.12

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Reduce cyclomatic complexity in all functions to 10

Description

Reduce cyclomatic complexity in all functions to 10 and reduce flake8 config for max-complexity to 10.
Currently only the following functions have complexity >10:

./musify/core/printer.py:87:5: C901 'PrettyPrinter._to_str' is too complex (15)
./musify/libraries/core/collection.py:287:5: C901 'MusifyCollection.__getitem__' is too complex (11)
./musify/libraries/remote/core/processors/check.py:382:5: C901 'RemoteItemChecker._match_to_input' is too complex (14)
./musify/libraries/remote/spotify/processors.py:67:5: C901 'SpotifyDataWrangler._get_item_type' is too complex (12)
./musify/libraries/remote/spotify/processors.py:103:5: C901 'SpotifyDataWrangler.convert' is too complex (16)
./musify/processors/limit.py:101:5: C901 'ItemLimiter.limit' is too complex (13)
./musify/report.py:82:1: C901 'report_missing_tags' is too complex (13)
./tests/libraries/local/track/test_track.py:314:5: C901 'TestLocalTrackWriter.assert_track_tags_equal' is too complex (17)
./tests/libraries/remote/core/utils.py:58:5: C901 'RemoteMock.get_requests' is too complex (13)
./tests/libraries/remote/spotify/api/test_item.py:618:5: C901 'TestSpotifyAPIItems.assert_extend_tracks_results' is too complex (11)

Use case/motivation

Reduce complexity in the program as measured by McCabe complexity algorithm to improve readability in the code.

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Implement `merge` functionality for playlists

Description

Users should be able to merge two playlists together, with similar operation to merging two tracks.

This should merge the tracks in the reference playlist to the source playlist (not to be confused with track tag merging, tracks should not be modified in the process).

Use case/motivation

This will enable users to sync playlists across library sources, including to other local library sources enabling local sync functionality

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Implement all `ShuffleMode`s in the `ItemSorter`

Description

Currently only NONE and RANDOM are supported. HIGHER_RATING, RECENT_ADDED, and DIFFERENT_ARTIST still need to be implemented

Use case/motivation

XAutoPF will not be fully supported until this is complete

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Version on documentation deployments always shows 0.0.0

Musify version

N/A

What happened?

Version on documentation deployments always shows 0.0.0

What you think should happen instead?

Version should reflect latest version or no version. Pull latest version from PyPI as with build workflow or remove 0.0.0

Please paste any logs that you see related to this issue here

No response

How to reproduce

https://geo-martino.github.io/musify/

Operating System

Windows

Python version

n/a

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Better alignment of stats logging when emojis present

Musify version

0.8.0

What happened?

When printing stats for playlists that have emojis in their name, the alignment of values for that row will be skewed

What you think should happen instead?

Text should be aligned properly

Please paste any logs that you see related to this issue here

BigVibes                       |   125 available |     0 missing |     0 unavailable |   125 total
boogiboogi ๐Ÿชฉ                   |   228 available |     0 missing |     0 unavailable |   228 total
breezin' ๐Ÿ›ถ                     |   124 available |     0 missing |     2 unavailable |   126 total
Chill                          |    68 available |     0 missing |     1 unavailable |    69 total

How to reproduce

Print stats for a LocalLibrary for all loaded playlists using log_playlists. If playlist names contain an emoji, the effect will be seen

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

`TagWriter` returning incorrect results when writing to file

Musify version

0.9.0

What happened?

TagWriter write function is returning incorrect results. It appears that it is return too many tags that have been updated when the associated files appear to be unchanged for the tags it reports it has changed.

What do you think should have happened instead?

It should only return the tags relating to those it has actually change

Please paste any logs that you see related to this issue here

No response

How to reproduce

Attempt to change tags in a file using the TagWriter

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Improve performance by implementing concurrency where useful

Description

Currently the entire package runs on one thread. Before release can be possible, package should be able to run certain highload tasks concurrenctly including:

  • Loading a local/remote library
  • Any API calls
  • Synchronising tasks
  • Saving tracks

Use case/motivation

Entire package currently has massive performance bottlenecks due to this, high priority

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Implement async framework for local libraries package

Description

All IO operations in the local package should be implemented asynchronously.

Use case/motivation

With the remote libraries package now running asynchronously, in order to fully unify the interface that links them, the local libraries package should also run in an asynchronous framework.

Related issues

Split from #13

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Implement async logic to `RequestHandler` and API cache backend

Description

Improve performance on API calls by implementing asynchronous logic on RequestHandler. It is most likely that the cache.backend package will require some modification to make it asynchronous too.

Use case/motivation

Improve response times for all API logic.

Related issues

Split from #13 and replaces #67.

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Implement full `XAutoPF` functionality

Description

Currently not supported but should be (in order of necessary changes):

  • Saving only supports Exceptions ExceptionsInclude keys. Should be able to update all XML fields include those relating to matcher, sorter, and limiter processors
  • Generating a new XAutoPF through Musify. Currently user needs to provide a currently existing and valid XAutoPF file to instantiate this object

Use case/motivation

This is moderately low priority. But it would be good to have full functionality for this file type

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Docs image for OpenGraph social media cards

Musify version

N/A

What happened?

Currently no image is shown for social media cards on the docs site

What you think should happen instead?

An image should show when the card is generated for all social media sites

Please paste any logs that you see related to this issue here

No response

How to reproduce

Operating System

N/A

Python version

N/A

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Sync remote playlist description and images

Description

Expand functionality of sync method on RemotePlaylist to sync playlist image and description too. Currently, a placeholder description is used and no image is set.

Use case/motivation

Give user more control over how their playlists are sync'd.

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

RemoteItemDownloader processor

Description

This processor should help users download tracks from a remote library by automatically opening store pages for tracks in a given remote playlist.
Unlikely that we can direct users to the exact page to buy the tracks themselves, so this should only need to direct users to the appropriate search page using say the track title and artist name as the search terms

Use case/motivation

Part of the motivation to ensure users can move easily between all library types

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

`RemoteAPI` methods to accept `RemoteObject`s as inputs

Description

RemoteObjects are just interfaces for the responses from RemoteAPI methods. Any parameter that uses the APIMethodInputType TypeVar should also be able to accept these RemoteObjects by processing the stored response in these objects. After the method processes these objects, it should then call refresh() to update the RemoteObject.

Use case/motivation

Cleaner experience for the end developer. It makes sense that the methods that use this type as input should also accept these objects as input too

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Sub-progress-bars on `extend_items` method do not progress

Musify version

1.0.0 dev

What happened?

The progress bars generated by the extend_items method in the SpotifyAPI do not progress past the first 1-2 ticks when being called as part of the get_artist_albums method in the same class. Possibly an issue with how tqdm is being set up in the get_iterator method on MusifyLogger.

What do you think should have happened instead?

Bar should progress smoothly until it is complete. Not get stuck after 5% and then suddenly finish

Please paste any logs that you see related to this issue here

No response

How to reproduce

Run any operation on get_artist_albums that involves an artist with many albums and therefore needs their response extending.

Operating System

Windows 11

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Improve async logic on local IO

Description

Currently, the program uses the mutagen package to handling loading audio files and their metadata/properties. However, this package appears to only be able to load files synchronously. As such, loading the metadata from audio files is currently not as optimised as it can be.

Ideally, the program should be able to utilise a package such as aiofiles to handle loading these files and their metadata. The handling of tag data is currently abstracted away to TagReader and TagWriter definitions which are in a hidden scope and the key load APIs exposed to the user are set up to run asynchronously despite the actual logic of these methods running synchronously still. As such, it should be possible to replace the mutagen package without introducing breaking changes to the code.

However, if it is possible to have mutagen load the files asynchronously, this would be preferred. Further research is needed.

Along with this, the loading of playlist files could also be made asynchronous with the aid of aiofiles or another similar package. Currently, while these load functions also operate in an asynchronous context, their logic is also synchronous.

It is a similar story too with LocalLibrary objects and their child classes. This should also be adapted as with playlists to fully complete this ticket.

Use case/motivation

Loading tracks from the system can be one of the slowest tasks to run at present, especially for an entire library. This would be a huge performance boost to one of the key areas of the program.

Related issues

Part of an ongoing drive to improve performance as per #13 and #86

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Fix failing `RemoteAPI` tests

Musify version

0.9.0

What happened?

The responses being received by some tests are not passing due to the input data being too small in size for the test to work. Need to implement some logic to ensure the input responses being tested are of the appropriate size.

What do you think should have happened instead?

Tests should pass

Please paste any logs that you see related to this issue here


=================================== FAILURES ===================================
_________ TestSpotifyAPIItems.test_get_items_single_response[PLAYLIST] _________

self = <tests.spotify.api.test_item.TestSpotifyAPIItems object at 0x7eed4aede030>
object_type = <RemoteObjectType.PLAYLIST: 1>
response = {'collaborative': False, 'description': 'qlKJwrxiqPDPcgIoSNVZbAvILnsPaYYqsbatVbDQMpdIiSXkvMkwI', 'external_urls': {'sp...': 'https://open.spotify.com/playlist/OQQDiujQHBdmBVcPGasMmc'}, 'followers': {'href': None, 'total': 46025070957}, ...}
key = 'tracks'
api = <musify.spotify.api.api.SpotifyAPI object at 0x7eed31d9e440>
api_mock = <tests.spotify.api.mock.SpotifyMock object at 0x7eed4bcfb0e0>
object_factory = SpotifyObjectFactory(playlist=<class 'musify.spotify.object.SpotifyPlaylist'>, track=<class 'musify.spotify.object.Spo...'>, album=<class 'musify.spotify.object.SpotifyAlbum'>, artist=<class 'musify.spotify.object.SpotifyArtist'>, api=None)

    @pytest.mark.parametrize("object_type", [
        RemoteObjectType.TRACK, RemoteObjectType.PLAYLIST, RemoteObjectType.ALBUM,
        # other RemoteResponse types not yet implemented/do not provide expected results
    ], ids=idfn)
    def test_get_items_single_response(
            self,
            object_type: RemoteObjectType,
            response: dict[str, Any],
            key: str,
            api: SpotifyAPI,
            api_mock: SpotifyMock,
            object_factory: SpotifyObjectFactory,
    ):
        if object_type in api.collection_item_map:
>           self.reduce_items(response=response, key=key, api=api, api_mock=api_mock)

tests/spotify/api/test_item.py:508: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
tests/spotify/api/test_item.py:47: in reduce_items
    limit = get_limit(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

values = 0, max_limit = 0, pages = 3

    def get_limit(values: Collection | int, max_limit: int, pages: int = 3) -> int:
        """
        Get a limit value for the expected number of ``pages`` when testing the given ``values``.
        Limit maximum value to ``max_limit``.
        """
        total = len(values) if isinstance(values, Collection) else values
        limit = max(min(total // pages, max_limit), 1)  # force pagination
    
>       assert total >= limit  # ensure ranges are valid for test to work
E       AssertionError

tests/spotify/api/utils.py:19: AssertionError
----- generated xml file: /home/runner/work/musify/musify/test-results.xml -----
=========================== short test summary info ============================
FAILED tests/spotify/api/test_item.py::TestSpotifyAPIItems::test_get_items_single_response[PLAYLIST] - AssertionError
============= 1 failed, 684 passed, 16 skipped in 61.83s (0:01:01) =============
Error: Process completed with exit code 1.

How to reproduce

Run the tests

Operating System

N/A

Python version

3.12.1

Anything else?

No response

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Add step before build to remove GitHub-specific notation from README on deployment

Description

PyPI shows GitHub-specific markdown code on the main page as it just uses the README in the repo. Would be nice to add a step before build to format the README to remove this code.

Use case/motivation

Just makes things look a bit nicer on PyPI :)

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Expand `RemoteItemSearcher`/`ItemMatcher` functionality to include all Item types

Description

Currently only specific items and albums are supported. This may require an overhaul of the process to make it more generalised.

Use case/motivation

This is quite a low priority feature. Only motiviation is for complete interoperability for all items through these processors

Related issues

No response

Are you willing to submit a PR?

  • Yes I am willing to submit a PR!

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.