aome510 / spotify-player

A Spotify player in the terminal with full feature parity
MIT License
3.65k stars 166 forks source link

add playlist folders support #518

Closed aNNiMON closed 3 months ago

aNNiMON commented 4 months ago

Resolves #453

[!NOTE]
Spotify doesn't have an API to retrieve or modify playlist folders. In order to retrieve a folder structure a third party utility is required mikez/spotify-folders. Using this utility you need to create a compatible json file and put it in ~/.cache/spotify-player/PlaylistFolders_cache.json. You'll need to update this file every time you change your folder structure.

Demo

https://github.com/user-attachments/assets/7cbf8029-f9a8-43f7-b2c8-37b169bab459

Configuration

Follow the mikez/spotify-folders installation instructions. Then run

spotifyfolders > ~/.cache/spotify-player/PlaylistFolders_cache.json

It generates a full playlist folders structure and stores to a json file, like this:

{
  "type": "folder",
  "children": [
    {
      "type": "playlist",
      "uri": "spotify:playlist:00000000000000000000"
    },
    {
      "name": "Folder name",
      "type": "folder",
      "uri": "spotify:user:aaaaaaaaaaa:folder:bbbbbbbbb",
      "children": [
        {
          "type": "playlist",
          "uri": "spotify:playlist:111111111111111111111"
        },
        {
          "type": "playlist",
          "uri": "spotify:playlist:222222222222222222222"
        }
      ]
    }
  ]
}
aNNiMON commented 4 months ago

How it works

  1. spotifyfolders generates a json file with folder structure:
{
  "type": "folder", // root node is always a folder
  "children": [
    {
      "type": "playlist", // playlist type (contains uri)
      "uri": "spotify:playlist:00000000000000000000"
    },
    {
      "name": "Folder",
      "type": "folder", // folder type (contains name, uri and 0..N children)
      "uri": "spotify:user:aaaaaaaaaaa:folder:bbbbbbbbb",
      "children": [ /*...*/ ]
    }
  ]
}
  1. Imagine we have a playlist folder structure like this:
playlist: Playlist 1
folder: Folder 1
    playlist: Playlist 1.1
    folder: Folder 1A
        playlist: Playlist 1A1
    playlist: Playlist 1.2
    folder: Folder 1B
        playlist: Playlist 1B1
        playlist: Playlist 1B2
folder: Folder 2
    playlist: Playlist 2.1
    playlist: Playlist 2.2
  1. To represent it in a flat view like we have now, we need to introduce the following fields:

    • is_folder: bool — false for playlist, true for folder
    • current_level: usize — what level the playlist or the folder belongs to
    • target_level: usize — for folders: what level the folder points to. Unique for each folder.

    3.1. Why must the target level be unique? To avoid ambiguity when navigating to a folder:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       playlist: Playlist 1.1 // (1, 1)
    folder: Folder 2 // (0, 1)
       playlist: Playlist 2.1 // (1, 1)
       playlist: Playlist 2.2 // (1, 1)

    If we want to render playlists in Folder 1 on a level 1, we'll render:

    playlist: Playlist 1.1 // (1, 1)
    playlist: Playlist 2.1 // (1, 1)
    playlist: Playlist 2.2 // (1, 1)

    The last two playlists aren't what we want to see.

    3.2 Correct representation using unique target levels:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       playlist: Playlist 1.1 // (1, 1)
       folder: Folder 1A // (1, 2)
           playlist: Playlist 1A1 // (2, 2)
       playlist: Playlist 1.2 // (1, 1)
       folder: Folder 1B // (1, 3)
           playlist: Playlist 1B1 // (3, 3)
           playlist: Playlist 1B2 // (3, 3)
    folder: Folder 2 // (0, 4)
       playlist: Playlist 2.1 // (4, 4)
       playlist: Playlist 2.2 // (4, 4)

    3.3 To jump back to a previous folder, an additional playlist folder with the swapped target and source levels is needed:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       folder: ← Folder 1 // (1, 0) here
       playlist: Playlist 1.1 // (1, 1)
       folder: Folder 1A // (1, 2)
           folder: ← Folder 1A // (2, 1) here
           playlist: Playlist 1A1 // (2, 2)
       playlist: Playlist 1.2 // (1, 1)
       folder: Folder 1B // (1, 3)
           folder: ← Folder 1B // (3, 1) here
           playlist: Playlist 1B1 // (3, 3)
           playlist: Playlist 1B2 // (3, 3)
    folder: Folder 2 // (0, 4)
       folder: ← Folder 2 // (4, 0) here
       playlist: Playlist 2.1 // (4, 4)
       playlist: Playlist 2.2 // (4, 4)
  2. By reading the PlaylistFolders_cache.json and passing it together with the flat Playlists retrieved from the API to the structurize function, we can get a playlist folder representation as a regular flat vector.

  3. If the PlaylistFolders_cache doesn't contain some playlists, they'll go to a root folder level: (0, 0). https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L12-L21 Same for newly created playlists.

  4. A playlist folder uri is not supported by the application at this time, but it's needed for proper playlist folder structuring. Moreover, I prepend the id with f for folders and u for up node, so folder's uri shouldn't be used in the API calls:

https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L54-L58

https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L65-L70

aome510 commented 3 months ago

How it works

  1. spotifyfolders generates a json file with folder structure:
{
  "type": "folder", // root node is always a folder
  "children": [
    {
      "type": "playlist", // playlist type (contains uri)
      "uri": "spotify:playlist:00000000000000000000"
    },
    {
      "name": "Folder",
      "type": "folder", // folder type (contains name, uri and 0..N children)
      "uri": "spotify:user:aaaaaaaaaaa:folder:bbbbbbbbb",
      "children": [ /*...*/ ]
    }
  ]
}
  1. Imagine we have a playlist folder structure like this:
playlist: Playlist 1
folder: Folder 1
    playlist: Playlist 1.1
    folder: Folder 1A
        playlist: Playlist 1A1
    playlist: Playlist 1.2
    folder: Folder 1B
        playlist: Playlist 1B1
        playlist: Playlist 1B2
folder: Folder 2
    playlist: Playlist 2.1
    playlist: Playlist 2.2
  1. To represent it in a flat view like we have now, we need to introduce the following fields:

    • is_folder: bool — false for playlist, true for folder
    • current_level: usize — what level the playlist or the folder belongs to
    • target_level: usize — for folders: what level the folder points to. Unique for each folder.

    3.1. Why must the target level be unique? To avoid ambiguity when navigating to a folder:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       playlist: Playlist 1.1 // (1, 1)
    folder: Folder 2 // (0, 1)
       playlist: Playlist 2.1 // (1, 1)
       playlist: Playlist 2.2 // (1, 1)

    If we want to render playlists in Folder 1 on a level 1, we'll render:

    playlist: Playlist 1.1 // (1, 1)
    playlist: Playlist 2.1 // (1, 1)
    playlist: Playlist 2.2 // (1, 1)

    The last two playlists aren't what we want to see. 3.2 Correct representation using unique target levels:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       playlist: Playlist 1.1 // (1, 1)
       folder: Folder 1A // (1, 2)
           playlist: Playlist 1A1 // (2, 2)
       playlist: Playlist 1.2 // (1, 1)
       folder: Folder 1B // (1, 3)
           playlist: Playlist 1B1 // (3, 3)
           playlist: Playlist 1B2 // (3, 3)
    folder: Folder 2 // (0, 4)
       playlist: Playlist 2.1 // (4, 4)
       playlist: Playlist 2.2 // (4, 4)

    3.3 To jump back to a previous folder, an additional playlist folder with the swapped target and source levels is needed:

    playlist: Playlist 1 // (0, 0)
    folder: Folder 1 // (0, 1)
       folder: ← Folder 1 // (1, 0) here
       playlist: Playlist 1.1 // (1, 1)
       folder: Folder 1A // (1, 2)
           folder: ← Folder 1A // (2, 1) here
           playlist: Playlist 1A1 // (2, 2)
       playlist: Playlist 1.2 // (1, 1)
       folder: Folder 1B // (1, 3)
           folder: ← Folder 1B // (3, 1) here
           playlist: Playlist 1B1 // (3, 3)
           playlist: Playlist 1B2 // (3, 3)
    folder: Folder 2 // (0, 4)
       folder: ← Folder 2 // (4, 0) here
       playlist: Playlist 2.1 // (4, 4)
       playlist: Playlist 2.2 // (4, 4)
    1. By reading the PlaylistFolders_cache.json and passing it together with the flat Playlists retrieved from the API to the structurize function, we can get a playlist folder representation as a regular flat vector.
    2. If the PlaylistFolders_cache doesn't contain some playlists, they'll go to a root folder level: (0, 0). https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L12-L21

    Same for newly created playlists.

    1. A playlist folder uri is not supported by the application at this time, but it's needed for proper playlist folder structuring. Moreover, I prepend the id with f for folders and u for up node, so folder's uri shouldn't be used in the API calls:

https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L54-L58

https://github.com/aome510/spotify-player/blob/e61b6cfbd17bc9179d91110efb3d46c8c8bb78b6/spotify_player/src/playlist_folders.rs#L65-L70

Thanks for the detailed explanation. I get the idea now.

Using level is confusing and level is not meant to be unique. (current_level, target_level) should be replaced (current_id, target_id) and should include a comment that a folder points to another folder represented by target_id.

In addition, I don't think it's a good idea to modify playlist to support representing folder because playlist and folder are separate identities. My suggestion is to define a separate struct for folder and another enum PlaylistFolderItem to represent either a folder or a playlist for use in action/UI state. IMHO, separating the data representation of playlist and folder also improves the code clarity and simplifies the implementation.

aNNiMON commented 3 months ago

@aome510 for me current_id is misleading because we also have a Spotify id "spotify:user:xxx:folder:yyy". What about current_folder_id / target_folder_id and comment that it's a local id, not just a global Spotify id? Or current_folder_index / target_folder_index?

Separate struct is okay, will do so.

aome510 commented 3 months ago

@aome510 for me current_id is misleading because we also have a Spotify id "spotify:user:xxx:folder:yyy". What about current_folder_id / target_folder_id and comment that it's a local id, not just a global Spotify id? Or current_folder_index / target_folder_index?

That also works. I mean Spotify ID doesn't really apply to folder, so we don't really need to differentiate here. Comments/documentations will definitely make it more clear though.

aNNiMON commented 3 months ago

@aome510 done

aome510 commented 3 months ago

@aNNiMON sorry for the delay (I've been kinda busy lately). I've reviewed the PR again and pushed a commit to simplify the codes and processing logic further. Can you review the commit and test it to ensure that nothing breaks in terms of functionalities? I don't have folders so couldn't really test the feature myself.

aNNiMON commented 3 months ago

@aome510, everything works fine. 👍 Thanks