Repository: platers/unitunes (language: python)

Issue:
GUI
A GUI is necessary for making unitunes more accessible. Currently working on a GUI with dearpygui.


Discussion / hints:

Fix patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6f79b37
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,19 @@
+# Contributing
+
+## Development Setup
+
+1. Fork and clone the repository.
+2. Install [poetry](https://python-poetry.org/).
+3. In the root directory, run `poetry shell`.
+4. Run `poetry install`.
+5. `unitunes` should now be runnable.
+
+## Testing
+
+Run `pytest` to run tests. With no arguments, it will skip tests that require service configs.
+
+Add a service config to run more tests.
+
+```bash
+pytest -s --spotify spotify_config.json --ytm ytm_config.json # may need to run with -s to paste spotify redirect URL the first time
+```
diff --git a/README.md b/README.md
index 023ebd6..05d340f 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,9 @@
 
 ![unituneslogo](https://github.com/platers/unitunes/blob/master/unitunes.png?raw=true)
 
-A command-line interface tool to manage playlists across music streaming services.
+A python GUI and library to sync playlists across music streaming services.
 
-![demo](demo.gif)
+![playlist_tab](assets/playlist_tab.png)
 
 ## Introduction
 
@@ -19,114 +19,26 @@ unitunes stores your playlists in plain text, allowing you to version control yo
 | MusicBrainz   |          |          |     ✅     |
 | Spotify       |    ✅    |    ✅    |     ✅     |
 | Youtube Music |    ✅    |    ✅    |     ✅     |
+| Beatsaber     |    ✅    |    ✅    |     ✅     |
 
 Want to add support for another service? See [contributing](#contributing).
 
-## Documentation
-
-[Documentation](https://github.com/platers/unitunes/blob/master/docs.md)
-
-## Quickstart
-
-### Installation
+## Usage
 
 ```bash
 pip install unitunes
+unitunes
 ```
 
-### Initialize
-
-```bash
-unitunes init
-```
-
-This creates a `index.json` file in the current directory.
+In settings, set the directory to store your playlists. You can version control this directory with git.
 
-### Add Services
+Connect services in the service tab. Enter a service name, and click the button to add the corresponding service. Each service type requires some configuration, Spotify requires a client id and secret, and Youtube Music requires request headers.
+![service_tab](assets/service_tab.png)
 
-#### Spotify
+Playlists can then be added to the playlist tab.
 
-Follow the instructions at https://spotipy.readthedocs.io/en/2.19.0/#getting-started to obtain client credentials.
-
-Put the credentials in a file like so:
-
-```json
-{
-  "client_id": "...",
-  "client_secret": "...",
-  "redirect_uri": "http://example.com"
-}
-```
-
-Register the service in unitunes:
-
-```bash
-unitunes service add spotify spotify_config.json
-```
-
-#### Youtube Music
-
-Follow the instructions at https://ytmusicapi.readthedocs.io/en/latest/setup.html#manual-file-creation to create a `ytm_config.json` file.
-
-Register the service in unitunes:
-
-```bash
-unitunes service add ytm ytm_config.json
-```
-
-### Add Playlists
-
-Initialize UP's from your existing playlists:
-
-```bash
-unitunes fetch spotify # use -f to skip confirmation
-unitunes fetch ytm
-```
-
-### Pull Playlists
-
-Pull all tracks from all playlists.
-
-```bash
-unitunes pull
-```
-
-### Search Playlists
-
-Search for tracks on another service:
-
-```bash
-unitunes search SERVICE_TYPE PLAYLIST_NAME
-```
-
-### Push Playlists
-
-Push all changes to streaming services:
-
-```bash
-unitunes push
-```
+After adding playlists, you can sync them. You likely just want to press the `Sync All` button, which will pull, search, and push all playlists.
 
 ## Contributing
 
-unitunes is in alpha. Contributions are very welcome. I am looking for collaborators to grow unitunes into a foundation for user controlled music software.
-
-Take a look at the open issues!
-
-### Development Setup
-
-1. Fork and clone the repository.
-2. Install [poetry](https://python-poetry.org/).
-3. In the root directory, run `poetry shell`.
-4. Run `poetry install`.
-5. `unitunes` should now be runnable.
-
-#### Testing
-
-Run `pytest` to run tests. With no arguments, it will skip tests that require service configs.
-
-Add a service config to run more tests.
-
-```bash
-pytest --spotify spotify_config.json --ytm ytm_config.json # may need to run with -s to paste spotify redirect URL the first time
-```
+unitunes is rapidly evolving. Take a look at the [contributing guide](CONTRIBUTING.md).
diff --git a/assets/playlist_tab.png b/assets/playlist_tab.png
new file mode 100644
index 0000000..1ed685f
Binary files /dev/null and b/assets/playlist_tab.png differ
diff --git a/assets/service_tab.png b/assets/service_tab.png
new file mode 100644
index 0000000..df652ce
Binary files /dev/null and b/assets/service_tab.png differ
diff --git a/unitunes.png b/assets/unitunes.png
similarity index 100%
rename from unitunes.png
rename to assets/unitunes.png
diff --git a/demo.gif b/demo.gif
deleted file mode 100644
index bcaa277..0000000
Binary files a/demo.gif and /dev/null differ
diff --git a/docs.md b/docs.md
deleted file mode 100644
index b95f08f..0000000
--- a/docs.md
+++ /dev/null
@@ -1,291 +0,0 @@
-# `unitunes`
-
-Unitunes playlist manager. https://github.com/platers/unitunes
-
-**Usage**:
-
-```console
-$ unitunes [OPTIONS] COMMAND [ARGS]...
-```
-
-**Options**:
-
-* `--install-completion`: Install completion for the current shell.
-* `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
-* `--help`: Show this message and exit.
-
-**Commands**:
-
-* `add`: Add a playlist to the config file.
-* `fetch`: Quickly add playlists from a service.
-* `init`: Create a new playlist manager.
-* `list`: List all playlists.
-* `pull`: Pull playlist tracks from services.
-* `push`: Push playlist tracks to services.
-* `search`: Search for tracks on a service.
-* `service`: Manage services.
-* `view`: Show a playlists metadata and tracks.
-
-## `unitunes add`
-
-Add a playlist to the config file.
-
-If a playlist with the same name already exists, the url will be added to the playlist.
-Otherwise, a new playlist will be created.
-
-**Usage**:
-
-```console
-$ unitunes add [OPTIONS] PLAYLIST_NAME SERVICE_NAME [URL]
-```
-
-**Arguments**:
-
-* `PLAYLIST_NAME`: [required]
-* `SERVICE_NAME`: [required]
-* `[URL]`
-
-**Options**:
-
-* `--help`: Show this message and exit.
-
-## `unitunes fetch`
-
-Quickly add playlists from a service.
-
-Asks for confirmation before adding each playlist unless the force flag is set.
-
-**Usage**:
-
-```console
-$ unitunes fetch [OPTIONS] [SERVICE_NAME]
-```
-
-**Arguments**:
-
-* `[SERVICE_NAME]`: Service to fetch from.
-
-**Options**:
-
-* `-f, --force`: Auto accept prompts.  [default: False]
-* `--help`: Show this message and exit.
-
-## `unitunes init`
-
-Create a new playlist manager.
-
-Creates a new playlist manager in the given directory.
-
-**Usage**:
-
-```console
-$ unitunes init [OPTIONS] [DIRECTORY]
-```
-
-**Arguments**:
-
-* `[DIRECTORY]`: Directory to store playlist files in.  [default: .]
-
-**Options**:
-
-* `--help`: Show this message and exit.
-
-## `unitunes list`
-
-List all playlists.
-
-**Usage**:
-
-```console
-$ unitunes list [OPTIONS]
-```
-
-**Options**:
-
-* `--plain / --no-plain`: [default: False]
-* `--help`: Show this message and exit.
-
-## `unitunes pull`
-
-Pull playlist tracks from services.
-
-If no playlist is specified, all playlists are pulled.
-If no service is specified, all services are used.
-
-**Usage**:
-
-```console
-$ unitunes pull [OPTIONS] [PLAYLIST_NAMES]...
-```
-
-**Arguments**:
-
-* `[PLAYLIST_NAMES]...`: Playlists to pull from services. If not specified, all playlists are pulled.
-
-**Options**:
-
-* `-s, --service TEXT`: Services to pull from. If not specified, all services are used.
-* `-v, --verbose`: [default: False]
-* `--help`: Show this message and exit.
-
-## `unitunes push`
-
-Push playlist tracks to services.
-
-Asks for confirmation before pushing.
-
-If no playlist is specified, all playlists are pushed.
-If no service is specified, all services are used.
-
-**Usage**:
-
-```console
-$ unitunes push [OPTIONS] [PLAYLIST_NAMES]...
-```
-
-**Arguments**:
-
-* `[PLAYLIST_NAMES]...`: Playlists to push to services. If not specified, all playlists are pushed.
-
-**Options**:
-
-* `-s, --service TEXT`: Services to push to. If not specified, all services are used.
-* `--help`: Show this message and exit.
-
-## `unitunes search`
-
-Search for tracks on a service. Adds found URI's to tracks.
-
-If the preview flag is set, URI's will not be added.
-
-**Usage**:
-
-```console
-$ unitunes search [OPTIONS] SERVICE:[spotify|ytm|mb] PLAYLIST_NAME
-```
-
-**Arguments**:
-
-* `SERVICE:[spotify|ytm|mb]`: [required]
-* `PLAYLIST_NAME`: [required]
-
-**Options**:
-
-* `-p, --preview`: Preview tracks to add without adding them.  [default: False]
-* `-d, --debug`: Show debug information  [default: False]
-* `--onlyfailed`: Only show failed tracks in debug information.  [default: False]
-* `--showall`: Show all tracks in debug information.  [default: False]
-* `--help`: Show this message and exit.
-
-## `unitunes service`
-
-Manage services.
-
-**Usage**:
-
-```console
-$ unitunes service [OPTIONS] COMMAND [ARGS]...
-```
-
-**Options**:
-
-* `--help`: Show this message and exit.
-
-**Commands**:
-
-* `add`: Add a service.
-* `list`: List all services.
-* `playlists`: List all user playlists on a service.
-* `remove`: Remove a service.
-
-### `unitunes service add`
-
-Add a service.
-
-If no namm is provided, the service type will be used as the name.
-
-**Usage**:
-
-```console
-$ unitunes service add [OPTIONS] [SERVICE_TYPE]:[spotify|ytm|mb] [SERVICE_CONFIG_PATH] [SERVICE_NAME]
-```
-
-**Arguments**:
-
-* `[SERVICE_TYPE]:[spotify|ytm|mb]`: Service to add.
-* `[SERVICE_CONFIG_PATH]`: Path to service config.
-* `[SERVICE_NAME]`: Name of service. Defaults to service type.
-
-**Options**:
-
-* `--help`: Show this message and exit.
-
-### `unitunes service list`
-
-List all services.
-
-**Usage**:
-
-```console
-$ unitunes service list [OPTIONS]
-```
-
-**Options**:
-
-* `--plain / --no-plain`: [default: False]
-* `--help`: Show this message and exit.
-
-### `unitunes service playlists`
-
-List all user playlists on a service.
-
-**Usage**:
-
-```console
-$ unitunes service playlists [OPTIONS] SERVICE_NAME
-```
-
-**Arguments**:
-
-* `SERVICE_NAME`: [required]
-
-**Options**:
-
-* `--help`: Show this message and exit.
-
-### `unitunes service remove`
-
-Remove a service.
-
-**Usage**:
-
-```console
-$ unitunes service remove [OPTIONS] SERVICE_NAME
-```
-
-**Arguments**:
-
-* `SERVICE_NAME`: [required]
-
-**Options**:
-
-* `-f, --force`: [default: False]
-* `--help`: Show this message and exit.
-
-## `unitunes view`
-
-Show a playlists metadata and tracks.
-
-**Usage**:
-
-```console
-$ unitunes view [OPTIONS] PLAYLIST
-```
-
-**Arguments**:
-
-* `PLAYLIST`: [required]
-
-**Options**:
-
-* `--help`: Show this message and exit.
diff --git a/gui/engine.py b/gui/engine.py
new file mode 100644
index 0000000..db04f51
--- /dev/null
+++ b/gui/engine.py
@@ -0,0 +1,134 @@
+from enum import Enum
+from queue import Queue
+from threading import Thread
+import traceback
+from typing import Callable
+from unitunes import PlaylistManager
+
+GuiCallback = Callable[[], None]
+
+
+class JobStatus(Enum):
+    PENDING = 0
+    RUNNING = 1
+    SUCCESS = 2
+    FAILED = 3
+    CANCELLED = 4
+
+
+class JobType(Enum):
+    PULL = 0
+    PUSH = 1
+    SEARCH = 2
+
+
+class Job:
+    type: JobType
+    description: str
+    playlist_id: str  # playlist the job operates on
+    size: int = 0
+    progress: int = 0
+    gui_callback: GuiCallback
+    status: JobStatus = JobStatus.PENDING
+    pm: PlaylistManager
+
+    def __init__(
+        self,
+        type: JobType,
+        playlist_id: str,
+        gui_callback: GuiCallback,
+        pm: PlaylistManager,
+    ):
+        self.playlist_id = playlist_id
+        self.gui_callback = gui_callback
+        self.pm = pm
+        self.type = type
+        name = self.pm.playlists[playlist_id].name
+
+        if type == JobType.PULL:
+            self.description = f"Pull {name}"
+        elif type == JobType.PUSH:
+            self.description = f"Push {name}"
+        elif type == JobType.SEARCH:
+            self.description = f"Search {name}"
+
+    def execute(self):
+        def progress_callback(progress: int, size: int):
+            self.progress = progress
+            self.size = size
+            assert self.progress <= self.size
+            self.gui_callback()
+
+        self.status = JobStatus.RUNNING
+        self.gui_callback()
+
+        if self.type == JobType.PULL:
+            self.pm.pull_playlist(
+                self.playlist_id,
+                progress_callback=progress_callback,
+            )
+        elif self.type == JobType.PUSH:
+            self.pm.push_playlist(
+                self.playlist_id,
+                progress_callback=progress_callback,
+            )
+        elif self.type == JobType.SEARCH:
+            self.pm.search_playlist(
+                self.playlist_id,
+                progress_callback=progress_callback,
+            )
+
+        self.status = JobStatus.SUCCESS
+        self.gui_callback()
+
+
+class Engine:
+    _pm: PlaylistManager
+    _queue: Queue[int] = Queue()
+    _jobs: dict[int, Job] = {}
+    thread: Thread
+
+    def __init__(self, pm: PlaylistManager) -> None:
+        self.set_pm(pm)
+        self.thread = Thread(target=self._process_queue, daemon=True)
+        self.thread.start()
+
+    def set_pm(self, pm: PlaylistManager) -> None:
+        self._pm = pm
+        self._queue.queue.clear()
+        self._jobs.clear()
+
+    def _process_queue(self) -> None:
+        while True:
+            job_id = self._queue.get()
+            job = self._jobs[job_id]
+            print(f"Executing job {job_id}: {job.description}")
+            job.status = JobStatus.RUNNING
+
+            try:
+                job.execute()
+            except Exception as e:
+                print(f"Job {job_id} failed: {e}")
+                traceback.print_exc()
+                job.status = JobStatus.FAILED
+
+            assert job.status != JobStatus.RUNNING
+            print(f"Finished job {job_id}: {job.description}")
+
+            job.gui_callback()
+
+    def _generate_id(self) -> int:
+        """Generate a unique job id."""
+        return len(self._jobs)  # assumes jobs are not removed from _jobs
+
+    def push_job(self, job: Job) -> int:
+        job_id = self._generate_id()
+        self._jobs[job_id] = job
+        self._queue.put(job_id)
+        return job_id
+
+    def get_job(self, job_id: int) -> Job:
+        return self._jobs[job_id]
+
+    def jobs(self) -> list[Job]:
+        return list(self._jobs.values())
diff --git a/gui/main.py b/gui/main.py
new file mode 100644
index 0000000..b284bfe
--- /dev/null
+++ b/gui/main.py
@@ -0,0 +1,841 @@
+from datetime import datetime
+from pathlib import Path
+import webbrowser
+import dearpygui.dearpygui as dpg
+from appdirs import user_data_dir
+from pydantic import BaseModel
+from gui.engine import Engine, Job, JobStatus, JobType
+from unitunes import PlaylistManager, FileManager, Index
+from unitunes.index import IndexServiceEntry
+from unitunes.services.beatsaber import BeatsaberConfig, BeatsaberSearchConfig
+from unitunes.services.spotify import SpotifyConfig
+from unitunes.services.ytm import YtmConfig
+from unitunes.types import ServiceType
+from unitunes.uri import PlaylistURIs, playlistURI_from_url
+
+dpg.create_context()
+dpg.create_viewport(title="Unitunes", width=600, height=600)
+
+
+class AppConfig(BaseModel):
+    unitunes_dir: Path
+
+
+def hyperlink(url: str) -> None:
+    b = dpg.add_button(label=url, callback=lambda: webbrowser.open(url))
+    dpg.bind_item_theme(b, "hyperlinkTheme")
+
+
+class GUI:
+    app_config: AppConfig
+    pm: PlaylistManager
+    engine: Engine
+    touched_playlists: set[str] = set()
+
+    def __init__(self):
+        self.load_app_config()
+        self.load_playlist_manager()
+        self.engine = Engine(self.pm)
+        self.main_window_setup()
+        self.init()
+
+    def init(self):
+        self.load_app_config()
+        self.load_playlist_manager()
+        self.engine.set_pm(self.pm)
+        self.sync_playlist_list()
+        self.sync_service_tabs()
+
+    def load_playlist_manager(self):
+        fm = FileManager(self.app_config.unitunes_dir)
+        try:
+            fm.load_index()
+        except FileNotFoundError:
+            # Create a new index if it doesn't exist
+            fm.save_index(Index())
+            print(f"Created new index at {fm.index_path.absolute()}")
+
+        self.pm = PlaylistManager(fm.load_index(), fm)
+
+    def load_app_config(self):
+        # If the config file doesn't exist, create it
+        config_dir = Path(user_data_dir("unitunes"))
+        config_dir.mkdir(exist_ok=True)
+        config_path = config_dir / "config.json"
+        if not config_path.exists():
+            config_path.touch()
+            self.app_config = AppConfig(unitunes_dir=config_dir)
+        # Load the config file
+        try:
+            self.app_config = AppConfig.parse_file(config_path)
+        except Exception as e:
+            print(e)
+            print("Could not load config file. Using default config.")
+            self.app_config = AppConfig(unitunes_dir=config_dir)
+            self.save_app_config()
+
+    def save_app_config(self):
+        config_path = Path(user_data_dir("unitunes")) / "config.json"
+        with open(config_path, "w") as f:
+            f.write(self.app_config.json())
+
+    def init_themes(self):
+        with dpg.theme(tag="hyperlinkTheme"):
+            with dpg.theme_component(dpg.mvButton):
+                dpg.add_theme_color(dpg.mvThemeCol_Button, [0, 0, 0, 0])
+                dpg.add_theme_color(dpg.mvThemeCol_ButtonActive, [0, 0, 0, 0])
+                dpg.add_theme_color(dpg.mvThemeCol_ButtonHovered, [29, 151, 236, 25])
+                dpg.add_theme_color(dpg.mvThemeCol_Text, [29, 151, 236])
+
+    def main_window_setup(self):
+        with dpg.window(label="Example Window", tag="primary_window"):
+            self.init_themes()
+            with dpg.tab_bar(tag="main_tab_bar"):
+                dpg.add_tab(label="Playlists", tag="playlists_tab")
+                dpg.add_tab(label="Services", tag="services_tab")
+                dpg.add_tab(label="Jobs", tag="jobs_tab")
+                dpg.add_tab(label="Settings", tag="settings_tab")
+                self.playlists_tab_setup()
+                self.services_tab_setup()
+                self.jobs_tab_setup()
+                self.settings_tab_setup()
+
+    ########################################
+    # Jobs tab
+    ########################################
+
+    def jobs_tab_setup(self):
+        if dpg.does_item_exist("jobs_window"):
+            dpg.delete_item("jobs_window")
+        with dpg.child_window(tag="jobs_window", parent="jobs_tab"):
+
+            def clear_completed_jobs():
+                # loop children of jobs_window and remove those that are complete
+                children: list[int] = dpg.get_item_children("jobs_window", 1)  # type: ignore
+                for child in children:
+                    tag = dpg.get_item_alias(child)
+                    if tag.startswith("job_row_"):
+                        job_id = int(tag.split("_")[-1])
+                        if self.engine.get_job(job_id).status == JobStatus.SUCCESS:
+                            dpg.delete_item(child)
+
+            dpg.add_button(
+                label="Clear Completed",
+                tag="clear_completed_button",
+                callback=clear_completed_jobs,
+            )
+
+    def add_job_row_placeholder(self, job_id: int):
+        with dpg.child_window(tag=f"job_row_{job_id}", height=60, parent="jobs_window"):
+            with dpg.group(horizontal=True):
+                dpg.add_text("placeholder", tag=f"job_description_{job_id}")
+                dpg.add_progress_bar(tag=f"job_progress_{job_id}")
+                dpg.add_text("placeholder", tag=f"job_progress_text_{job_id}")
+            with dpg.group(horizontal=True):
+                # dpg.add_button(label="Cancel", tag=f"cancel_button_{job_id}")
+                dpg.add_text("placeholder", tag=f"job_status_text_{job_id}")
+
+    def touch_playlist(self, playlist: str):
+        self.touched_playlists.add(playlist)
+        self.pm.save_playlist(playlist)
+
+    def sync_job_row(self, job_id: int):
+        job = self.engine.get_job(job_id)
+        dpg.set_value(f"job_description_{job_id}", job.description)
+        dpg.set_value(f"job_status_text_{job_id}", job.status.name)
+        if job.status == JobStatus.SUCCESS:
+            status_color = (0, 255, 0)  # green
+        elif job.status == JobStatus.FAILED:
+            status_color = (255, 0, 0)  # red
+        elif job.status == JobStatus.RUNNING:
+            status_color = (255, 255, 0)  # yellow
+        else:
+            status_color = (255, 255, 255)  # white
+
+        dpg.configure_item(f"job_status_text_{job_id}", color=status_color)
+
+        if job.size > 0:
+            dpg.set_value(f"job_progress_{job_id}", job.progress / job.size)
+            dpg.set_value(f"job_progress_text_{job_id}", f"{job.progress}/{job.size}")
+        else:
+            dpg.set_value(f"job_progress_{job_id}", 0)
+            dpg.set_value(f"job_progress_text_{job_id}", "")
+
+        self.touch_playlist(job.playlist_id)
+
+        # update job count in tab label
+        active_jobs = [
+            j
+            for j in self.engine.jobs()
+            if j.status == JobStatus.PENDING or j.status == JobStatus.RUNNING
+        ]
+        dpg.set_item_label("jobs_tab", f"Jobs ({len(active_jobs)})")
+
+        self.sync_playlist_row(job.playlist_id)
+
+    def add_job(self, job_type: JobType, playlist_id: str):
+        job_id = self.engine.push_job(
+            Job(
+                job_type,
+                playlist_id,
+                lambda: self.sync_job_row(job_id),
+                self.pm,
+            )
+        )
+        self.add_job_row_placeholder(job_id)
+        self.sync_job_row(job_id)
+
+    ########################################
+    # Settings tab
+    ########################################
+    def settings_tab_setup(self):
+        with dpg.child_window(tag="settings_window", parent="settings_tab"):
+            with dpg.group(horizontal=True):
+                # File Dialog
+                def change_unitunes_dir(sender, app_data):
+                    self.app_config.unitunes_dir = Path(app_data["current_path"])
+                    self.save_app_config()
+                    self.init()
+
+                file_dialog = dpg.add_file_dialog(
+                    label="Unitunes Directory",
+                    tag="unitunes_dir_dialog",
+                    directory_selector=True,
+                    show=False,
+                    height=300,
+                    width=600,
+                    callback=change_unitunes_dir,
+                )
+
+                dpg.add_text(
+                    "Unitunes Directory:",
+                )
+
+                # Button
+                def sync_unitunes_dir_text():
+                    dpg.set_item_label(
+                        "unitunes_dir_button",
+                        str(self.app_config.unitunes_dir),
+                    )
+
+                dpg.add_button(
+                    tag="unitunes_dir_button",
+                    label="placeholder",
+                    callback=lambda: dpg.show_item(file_dialog),
+                )
+                sync_unitunes_dir_text()
+
+    ########################################
+    # Playlists tab
+    ########################################
+
+    def playlists_tab_setup(self):
+        if dpg.does_item_exist("playlist_window"):
+            dpg.delete_item("playlist_window")
+        with dpg.child_window(tag="playlist_window", parent="playlists_tab"):
+
+            def add_playlist_callback():
+                playlist_id = (
+                    f"New Playlist {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
+                )
+                self.pm.add_playlist(playlist_id)
+                self.touch_playlist(playlist_id)
+                self.sync_playlist_list()
+                # Open playlist edit window
+                self.edit_playlist_row(playlist_id)
+
+            dpg.add_button(
+                label="Add Playlist",
+                tag="add_playlist_button",
+                callback=add_playlist_callback,
+            )
+
+            with dpg.group(horizontal=True):
+
+                def pull_all_callback():
+                    for playlist in self.pm.playlists:
+                        self.add_job(JobType.PULL, playlist)
+
+                dpg.add_button(
+                    label="Pull All",
+                    tag="pull_all_button",
+                    callback=pull_all_callback,
+                )
+
+                def search_all_callback():
+                    for playlist in self.pm.playlists:
+                        self.add_job(JobType.SEARCH, playlist)
+
+                dpg.add_button(
+                    label="Search All",
+                    tag="search_all_button",
+                    callback=search_all_callback,
+                )
+
+                def push_all_callback():
+                    for playlist in self.pm.playlists:
+                        self.add_job(JobType.PUSH, playlist)
+
+                dpg.add_button(
+                    label="Push All",
+                    tag="push_all_button",
+                    callback=push_all_callback,
+                )
+
+                def sync_all_callback():
+                    pull_all_callback()
+                    search_all_callback()
+                    push_all_callback()
+
+                dpg.add_button(
+                    label="Sync All",
+                    tag="sync_all_button",
+                    callback=sync_all_callback,
+                )
+
+            if dpg.does_item_exist("edit_playlist_window"):
+                dpg.delete_item("edit_playlist_window")
+            with dpg.window(
+                tag="edit_playlist_window",
+                modal=True,
+                show=False,
+                label="Edit Playlist",
+                width=500,
+                height=600,
+            ):
+                dpg.add_input_text(
+                    tag="playlist_name_input",
+                    label="Playlist Name",
+                )
+                dpg.add_input_text(
+                    tag="playlist_description_input",
+                    label="Playlist Description",
+                    multiline=True,
+                    height=50,
+                )
+                with dpg.child_window(
+                    tag="add_playlist_url_window",
+                    label="Add Playlist URL",
+                    height=100,
+                ):
+                    with dpg.group(horizontal=True):
+                        dpg.add_combo(tag="service_combo", label="Service", width=100)
+                        dpg.add_input_text(
+                            tag="playlist_url_input",
+                            hint="Playlist URL",
+                            width=200,
+                        )
+                        dpg.add_button(
+                            label="Add URL",
+                            tag="add_playlist_url_button_2",
+                        )
+
+                    with dpg.table(
+                        tag="uri_table",
+                        resizable=True,
+                        policy=dpg.mvTable_SizingStretchProp,
+                    ):
+                        dpg.add_table_column(
+                            label="Service",
+                            width_stretch=True,
+                            init_width_or_weight=0.5,
+                        )
+                        dpg.add_table_column(label="URL")
+                        dpg.add_table_column()  # Delete button
+
+            if dpg.does_item_exist("delete_playlist_window"):
+                dpg.delete_item("delete_playlist_window")
+            with dpg.window(
+                tag="delete_playlist_window",
+                modal=True,
+                label="Delete Playlist",
+                width=400,
+                height=100,
+                show=False,
+                pos=(100, 200),
+            ):
+                dpg.add_text(
+                    f"placeholder",
+                    tag="delete_playlist_text",
+                )
+                with dpg.group(horizontal=True):
+                    dpg.add_button(
+                        label="Yes",
+                        tag="delete_playlist_yes_button",
+                    ),
+                    dpg.add_button(
+                        label="No",
+                        tag="delete_playlist_no_button",
+                        callback=lambda: dpg.hide_item("delete_playlist_window"),
+                    )
+
+    def edit_playlist_row(self, playlist_id: str):
+        dpg.show_item("edit_playlist_window")
+        dpg.set_value("playlist_name_input", self.pm.playlists[playlist_id].name)
+        dpg.set_value(
+            "playlist_description_input",
+            self.pm.playlists[playlist_id].description,
+        )
+
+        def name_input_callback(sender, app_data):
+            self.pm.playlists[playlist_id].name = app_data
+            print(f"Renamed {playlist_id} to {app_data}")
+            self.touch_playlist(playlist_id)
+            self.sync_playlist_row(playlist_id)
+
+        dpg.set_item_callback(
+            "playlist_name_input",
+            name_input_callback,
+        )
+
+        def description_input_callback(sender, app_data):
+            self.pm.playlists[playlist_id].description = app_data
+            print(f"Changed {playlist_id} description to {app_data}")
+            self.touch_playlist(playlist_id)
+            self.sync_playlist_row(playlist_id)
+
+        dpg.set_item_callback(
+            "playlist_description_input",
+            description_input_callback,
+        )
+
+        # Set up URI table
+        def delete_uri_callback(sender, app_data, user_data):
+            (service_name, uri) = user_data
+            print(f"Deleted {service_name} {uri}")
+            self.pm.playlists[playlist_id].remove_uri(service_name, uri)
+            self.touch_playlist(playlist_id)
+            self.edit_playlist_row(playlist_id)
+
+        # Delete current rows
+        rows: list[int] = dpg.get_item_children("uri_table", 1)  # type: ignore
+        for row in rows:
+            dpg.delete_item(row)
+
+        for service_name, uris in self.pm.playlists[playlist_id].uris.items():
+            for uri in uris:
+                with dpg.table_row(parent="uri_table"):
+                    dpg.add_text(service_name)
+                    hyperlink(uri.url)
+                    dpg.add_button(
+                        label="Delete",
+                        callback=delete_uri_callback,
+                        tag=f"delete_uri_button_{service_name}_{uri.url}",
+                        user_data=(service_name, uri),
+                    )
+
+        dpg.set_value("playlist_url_input", "")
+        # Set up service combo
+        dpg.set_value("service_combo", "")
+        dpg.configure_item("service_combo", items=list(self.pm.services.keys()))
+
+        # Set up add playlist URL button
+        def add_playlist_url_callback(sender, app_data):
+            service_name = dpg.get_value("service_combo")
+            url = dpg.get_value("playlist_url_input")
+            if service_name and url:
+                self.pm.playlists[playlist_id].add_uri(
+                    service_name, playlistURI_from_url(url)
+                )
+                self.touch_playlist(playlist_id)
+                self.edit_playlist_row(playlist_id)
+
+        dpg.set_item_callback(
+            "add_playlist_url_button_2",
+            add_playlist_url_callback,
+        )
+
+    def add_placeholder_playlist_row(self, playlist_id: str):
+        pl = self.pm.playlists[playlist_id]
+        with dpg.child_window(
+            tag=f"playlist_row_{playlist_id}", height=60, parent="playlist_window"
+        ):
+            with dpg.group(horizontal=True):
+                dpg.add_text(pl.name, tag=f"playlist_row_name_{playlist_id}")
+            with dpg.group(horizontal=True):
+                dpg.add_text(
+                    "placeholder",
+                    tag=f"playlist_track_count_{playlist_id}",
+                )
+                dpg.add_button(
+                    label="Pull",
+                    tag=f"pull_button_{playlist_id}",
+                    callback=lambda: self.add_job(JobType.PULL, playlist_id),
+                )
+                dpg.add_button(
+                    label="Search",
+                    tag=f"search_button_{playlist_id}",
+                    callback=lambda: self.add_job(JobType.SEARCH, playlist_id),
+                )
+                dpg.add_button(
+                    label="Push",
+                    tag=f"push_button_{playlist_id}",
+                    callback=lambda: self.add_job(JobType.PUSH, playlist_id),
+                )
+
+                dpg.add_button(
+                    label="Edit",
+                    tag=f"edit_button_{playlist_id}",
+                    callback=lambda: self.edit_playlist_row(playlist_id),
+                )
+
+                dpg.add_button(
+                    label="Delete",
+                    tag=f"delete_button_{playlist_id}",
+                    callback=lambda: self.delete_playlist(playlist_id),
+                )
+
+    def sync_playlist_list(self):
+        """Remove all playlist rows and add them again."""
+        self.playlists_tab_setup()
+
+        playlists = list(self.pm.playlists.keys())
+        # sort by name
+        playlists.sort(key=lambda x: self.pm.playlists[x].name)
+        for playlist in playlists:
+            self.add_placeholder_playlist_row(playlist)
+            self.sync_playlist_row(playlist)
+
+    def sync_playlist_row(self, playlist_id: str):
+        pl = self.pm.playlists[playlist_id]
+        dpg.set_value(f"playlist_track_count_{playlist_id}", f"{len(pl.tracks)} tracks")
+        dpg.set_value(f"playlist_row_name_{playlist_id}", pl.name)
+
+    def delete_playlist(self, playlist_id: str):
+        dpg.show_item("delete_playlist_window")
+        dpg.set_value(
+            "delete_playlist_text",
+            f"Are you sure you want to delete {self.pm.playlists[playlist_id].name}?",
+        )
+
+        def delete_playlist_yes_callback():
+            self.pm.remove_playlist(playlist_id)
+            self.pm.save_index()
+            self.sync_playlist_list()
+            dpg.hide_item("delete_playlist_window")
+
+        dpg.set_item_callback(
+            "delete_playlist_yes_button",
+            delete_playlist_yes_callback,
+        )
+
+        def delete_playlist_no_callback():
+            dpg.hide_item("delete_playlist_window")
+
+        dpg.set_item_callback(
+            "delete_playlist_no_button",
+            delete_playlist_no_callback,
+        )
+
+    ########################################
+    # Services tab
+    ########################################
+
+    def services_tab_setup(self):
+        if dpg.does_item_exist("services_window"):
+            dpg.delete_item("services_window")
+        with dpg.child_window(tag="services_window", parent="services_tab"):
+            with dpg.group(horizontal=True):
+
+                def create_service_callback(type: ServiceType):
+                    if type == ServiceType.SPOTIFY:
+                        config = SpotifyConfig()
+                    elif type == ServiceType.YTM:
+                        config = YtmConfig()
+                    elif type == ServiceType.BEATSABER:
+                        config = BeatsaberConfig()
+                    else:
+                        raise ValueError(f"Unknown service type {type}")
+
+                    name = dpg.get_value("service_name_input")
+                    if not name:
+                        return
+                    fm = self.pm.file_manager
+                    fm.save_service_config(name, config)
+                    self.pm.add_service(type, fm.service_config_path(name), name)
+                    self.pm.save_index()
+                    self.sync_service_tabs()
+
+                dpg.add_input_text(
+                    tag="service_name_input", width=200, hint="New Service Name"
+                )
+                dpg.add_button(
+                    label="Add Spotify",
+                    tag="add_spotify_button",
+                    callback=lambda: create_service_callback(ServiceType.SPOTIFY),
+                )
+                dpg.add_button(
+                    label="Add YouTube",
+                    tag="add_youtube_button",
+                    callback=lambda: create_service_callback(ServiceType.YTM),
+                )
+                dpg.add_button(
+                    label="Add Beatsaber",
+                    tag="add_beatsaber_button",
+                    callback=lambda: create_service_callback(ServiceType.BEATSABER),
+                )
+                with dpg.window(
+                    tag=f"delete_service_popup",
+                    popup=True,
+                    show=False,
+                ):
+                    dpg.add_text("Are you sure you want to delete this service?")
+                    with dpg.group(horizontal=True):
+                        dpg.add_button(
+                            label="Yes",
+                            tag=f"delete_service_yes_button",
+                        )
+                        dpg.add_button(
+                            label="No",
+                            tag=f"delete_service_no_button",
+                        )
+            with dpg.tab_bar(tag="services_tab_bar"):
+                self.sync_service_tabs()
+
+    def sync_service_tabs(self):
+        # Delete current tabs
+        tabs: list[str] = dpg.get_item_children("services_tab_bar", 1)  # type: ignore
+        for tab in tabs:
+            dpg.delete_item(tab)
+        # Add tabs
+        for service_entry in self.pm.index.services.values():
+            self.add_service_tab(service_entry)
+            self.sync_service_tab(service_entry)
+
+    def sync_service_tab(self, service_entry: IndexServiceEntry):
+        # Check if service is properly initialized
+        print(f"Syncing service tab {service_entry.name}")
+        self.pm.load_services()
+        if service_entry.name in self.pm.services:
+            dpg.hide_item(f"service_failed_text_{service_entry.name}")
+        else:
+            dpg.show_item(f"service_failed_text_{service_entry.name}")
+
+        if service_entry.service == ServiceType.SPOTIFY:
+            config = SpotifyConfig.parse_file(service_entry.config_path)
+            print(service_entry.config_path)
+            dpg.set_value(
+                f"spotify_client_id_input_{service_entry.name}",
+                config.client_id,
+            )
+            dpg.set_value(
+                f"spotify_client_secret_input_{service_entry.name}",
+                config.client_secret,
+            )
+            dpg.set_value(
+                f"spotify_redirect_uri_input_{service_entry.name}",
+                config.redirect_uri,
+            )
+        elif service_entry.service == ServiceType.YTM:
+            config = YtmConfig.parse_file(service_entry.config_path)
+            dpg.set_value(
+                f"ytm_headers_input_{service_entry.name}",
+                config.headers,
+            )
+        elif service_entry.service == ServiceType.BEATSABER:
+            config = BeatsaberConfig.parse_file(service_entry.config_path)
+            dpg.set_item_label(
+                f"beatsaber_dir_button_{service_entry.name}",
+                str(config.dir),
+            )
+            dpg.set_value(
+                f"beatsaber_min_nps_input_{service_entry.name}",
+                config.search_config.minNps,
+            )
+            dpg.set_value(
+                f"beatsaber_max_nps_input_{service_entry.name}",
+                config.search_config.maxNps,
+            )
+            dpg.set_value(
+                f"beatsaber_min_rating_input_{service_entry.name}",
+                config.search_config.minRating,
+            )
+        else:
+            raise Exception(f"Unknown service type {service_entry.service}")
+
+        self.sync_playlist_list()
+
+    def add_service_tab(self, service_entry: IndexServiceEntry):
+        service_name = service_entry.name
+        print(f"Adding service tab {service_name}")
+        with dpg.tab(
+            label=service_name,
+            tag=f"service_tab_{service_name}",
+            parent="services_tab_bar",
+        ):
+            with dpg.child_window(tag=f"service_window_{service_name}"):
+                dpg.add_text(
+                    "Failed to initialize service. Please fix the configuration.",
+                    tag=f"service_failed_text_{service_name}",
+                )
+
+                if service_entry.service == ServiceType.SPOTIFY:
+
+                    def sync_spotify_service_callback():
+                        self.pm.file_manager.save_service_config(
+                            service_name,
+                            SpotifyConfig(
+                                client_id=dpg.get_value(
+                                    f"spotify_client_id_input_{service_name}",
+                                ),
+                                client_secret=dpg.get_value(
+                                    f"spotify_client_secret_input_{service_name}",
+                                ),
+                                redirect_uri=dpg.get_value(
+                                    f"spotify_redirect_uri_input_{service_name}",
+                                ),
+                            ),
+                        )
+                        self.sync_service_tab(service_entry)
+
+                    hyperlink(
+                        "https://spotipy.readthedocs.io/en/2.19.0/#getting-started"
+                    )
+                    dpg.add_input_text(
+                        label="SPOTIPY_CLIENT_ID",
+                        tag=f"spotify_client_id_input_{service_name}",
+                    )
+                    dpg.add_input_text(
+                        label="SPOTIPY_CLIENT_SECRET",
+                        tag=f"spotify_client_secret_input_{service_name}",
+                    )
+                    dpg.add_input_text(
+                        label="SPOTIPY_REDIRECT_URI",
+                        tag=f"spotify_redirect_uri_input_{service_name}",
+                    )
+                    dpg.add_button(
+                        label="Save",
+                        tag=f"spotify_save_button_{service_name}",
+                        callback=sync_spotify_service_callback,
+                    )
+                elif service_entry.service == ServiceType.YTM:
+
+                    def sync_ytm_service_callback():
+                        self.pm.file_manager.save_service_config(
+                            service_name,
+                            YtmConfig(
+                                headers=dpg.get_value(
+                                    f"ytm_headers_input_{service_name}"
+                                )
+                            ),
+                        )
+                        self.sync_service_tab(service_entry)
+
+                    hyperlink(
+                        "https://ytmusicapi.readthedocs.io/en/latest/setup.html#copy-authentication-headers"
+                    )
+
+                    dpg.add_input_text(
+                        label="Headers",
+                        tag=f"ytm_headers_input_{service_name}",
+                        multiline=True,
+                    )
+                    dpg.add_button(
+                        label="Save",
+                        tag=f"ytm_save_button_{service_name}",
+                        callback=sync_ytm_service_callback,
+                    )
+                elif service_entry.service == ServiceType.BEATSABER:
+
+                    def sync_beatsaber_service_callback():
+                        self.pm.file_manager.save_service_config(
+                            service_name,
+                            BeatsaberConfig(
+                                dir=Path(
+                                    dpg.get_item_label(
+                                        f"beatsaber_dir_button_{service_name}",
+                                    )  # type: ignore
+                                ),
+                                search_config=BeatsaberSearchConfig(
+                                    minNps=dpg.get_value(
+                                        f"beatsaber_min_nps_input_{service_name}",
+                                    ),
+                                    maxNps=dpg.get_value(
+                                        f"beatsaber_max_nps_input_{service_name}",
+                                    ),
+                                    minRating=dpg.get_value(
+                                        f"beatsaber_min_rating_input_{service_name}",
+                                    ),
+                                ),
+                            ),
+                        )
+                        self.sync_service_tab(service_entry)
+
+                    def add_beatsaber_dir_callback(sender, app_data):
+                        dpg.set_item_label(
+                            f"beatsaber_dir_button_{service_name}",
+                            app_data["current_path"],
+                        )
+                        self.pm.load_services()
+
+                    # delete file dialog if it exists
+                    if dpg.does_item_exist(f"beatsaber_dir_input_{service_name}"):
+                        dpg.delete_item(f"beatsaber_dir_input_{service_name}")
+                    dpg.add_file_dialog(
+                        label="Beat Saber Playlist Directory",
+                        tag=f"beatsaber_dir_input_{service_name}",
+                        width=400,
+                        height=400,
+                        show=False,
+                        directory_selector=True,
+                        callback=add_beatsaber_dir_callback,
+                    )
+                    with dpg.group(horizontal=True):
+                        dpg.add_text(
+                            "Beatsaber Playlist Directory: ",
+                        )
+                        dpg.add_button(
+                            label="placeholder",
+                            tag=f"beatsaber_dir_button_{service_name}",
+                            callback=lambda: dpg.show_item(
+                                f"beatsaber_dir_input_{service_name}"
+                            ),
+                        )
+                    dpg.add_input_int(
+                        label="Min Notes per Second",
+                        tag=f"beatsaber_min_nps_input_{service_name}",
+                    )
+                    dpg.add_input_int(
+                        label="Max Notes per Second",
+                        tag=f"beatsaber_max_nps_input_{service_name}",
+                    )
+                    dpg.add_input_float(
+                        label="Min Rating",
+                        tag=f"beatsaber_min_rating_input_{service_name}",
+                    )
+
+                    dpg.add_button(
+                        label="Save",
+                        tag=f"beatsaber_save_button_{service_name}",
+                        callback=sync_beatsaber_service_callback,
+                    )
+
+                def delete_service_callback():
+                    self.pm.remove_service(service_name)
+                    self.pm.save_index()
+                    self.sync_service_tabs()
+                    dpg.hide_item(f"delete_service_popup")
+
+                dpg.add_button(
+                    label="Delete",
+                    tag=f"delete_service_button_{service_name}",
+                    callback=lambda: dpg.show_item("delete_service_popup"),
+                )
+                dpg.set_item_callback(
+                    "delete_service_yes_button",
+                    delete_service_callback,
+                )
+                dpg.set_item_callback(
+                    "delete_service_no_button",
+                    lambda: dpg.hide_item("delete_service_popup"),
+                )
+
+
+def main():
+    gui = GUI()
+
+    dpg.setup_dearpygui()
+    dpg.show_viewport()
+    dpg.set_primary_window("primary_window", True)
+    dpg.start_dearpygui()
+    dpg.destroy_context()
diff --git a/poetry.lock b/poetry.lock
index 720daf6..ac329b0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,3 +1,11 @@
+[[package]]
+name = "appdirs"
+version = "1.4.4"
+description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
+category = "main"
+optional = false
+python-versions = "*"
+
 [[package]]
 name = "atomicwrites"
 version = "1.4.0"
@@ -91,6 +99,14 @@ python-versions = "*"
 [package.extras]
 test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
 
+[[package]]
+name = "dearpygui"
+version = "1.6.2"
+description = "DearPyGui: A simple Python GUI Toolkit"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
 [[package]]
 name = "idna"
 version = "3.3"
@@ -338,7 +354,7 @@ telegram = ["requests"]
 
 [[package]]
 name = "typer"
-version = "0.4.1"
+version = "0.4.2"
 description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
 category = "main"
 optional = false
@@ -350,10 +366,10 @@ colorama = {version = ">=0.4.3,<0.5.0", optional = true, markers = "extra == \"a
 shellingham = {version = ">=1.3.0,<2.0.0", optional = true, markers = "extra == \"all\""}
 
 [package.extras]
-all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"]
-dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"]
-doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)"]
-test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=22.3.0,<23.0.0)", "isort (>=5.0.6,<6.0.0)"]
+test = ["isort (>=5.0.6,<6.0.0)", "black (>=22.3.0,<23.0.0)", "mypy (==0.910)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "coverage (>=5.2,<6.0)", "pytest-cov (>=2.10.0,<3.0.0)", "pytest (>=4.4.0,<5.4.0)", "shellingham (>=1.3.0,<2.0.0)"]
+doc = ["mdx-include (>=1.4.1,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mkdocs (>=1.1.2,<2.0.0)"]
+dev = ["pre-commit (>=2.17.0,<3.0.0)", "flake8 (>=3.8.3,<4.0.0)", "autoflake (>=1.3.1,<2.0.0)"]
+all = ["shellingham (>=1.3.0,<2.0.0)", "colorama (>=0.4.3,<0.5.0)"]
 
 [[package]]
 name = "typing-extensions"
@@ -409,9 +425,13 @@ dev = ["pre-commit", "flake8", "yapf", "coverage", "sphinx", "sphinx-rtd-theme"]
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.9"
-content-hash = "b9d03adc1b84a87baa5e81531b3f21d020065f5c4a5bf1b87125277caccd9457"
+content-hash = "10bf554ffe0b83191ca427e2b8aa732a16efa1e28ee6baf2dedf00a275e1014d"
 
 [metadata.files]
+appdirs = [
+    {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
+    {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
+]
 atomicwrites = [
     {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
@@ -465,6 +485,7 @@ commonmark = [
     {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
     {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
 ]
+dearpygui = []
 idna = [
     {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
     {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
@@ -586,10 +607,7 @@ tqdm = [
     {file = "tqdm-4.64.0-py2.py3-none-any.whl", hash = "sha256:74a2cdefe14d11442cedf3ba4e21a3b84ff9a2dbdc6cfae2c34addb2a14a5ea6"},
     {file = "tqdm-4.64.0.tar.gz", hash = "sha256:40be55d30e200777a307a7585aee69e4eabb46b4ec6a4b4a5f2d9f11e7d5408d"},
 ]
-typer = [
-    {file = "typer-0.4.1-py3-none-any.whl", hash = "sha256:e8467f0ebac0c81366c2168d6ad9f888efdfb6d4e1d3d5b4a004f46fa444b5c3"},
-    {file = "typer-0.4.1.tar.gz", hash = "sha256:5646aef0d936b2c761a10393f0384ee6b5c7fe0bb3e5cd710b17134ca1d99cff"},
-]
+typer = []
 typing-extensions = [
     {file = "typing_extensions-4.2.0-py3-none-any.whl", hash = "sha256:6657594ee297170d19f67d55c05852a874e7eb634f4f753dbd667855e07c1708"},
     {file = "typing_extensions-4.2.0.tar.gz", hash = "sha256:f1c24655a0da0d1b67f07e17a5e6b2a105894e6824b92096378bb3668ef02376"},
diff --git a/pyproject.toml b/pyproject.toml
index 7c25293..2ab3008 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,15 +1,14 @@
 [tool.poetry]
 name = "unitunes"
-version = "1.0.1"
-description = "A CLI tool to manage playlists across music streaming services."
+version = "2.0.0"
+description = "A GUI and library to manage playlists across music streaming services."
 authors = ["platers <platers81@gmail.com>"]
 readme = "README.md"
 license = "GPLv3"
 homepage = "https://github.com/platers/unitunes"
-keywords = ["cli", "music", "playlist", "playlists", "spotify", "ytmusic", "musicbrainz"]
+keywords = ["gui", "music", "playlist", "playlists", "spotify", "ytmusic", "musicbrainz"]
 classifiers = [
     "Development Status :: 3 - Alpha",
-    "Environment :: Console",
     "Intended Audience :: End Users/Desktop",
     "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
     "Natural Language :: English",
@@ -17,22 +16,20 @@ classifiers = [
 ]
 
 [tool.poetry.scripts]
-unitunes = "unitunes.cli.cli:app"
-# up-data= "unitunes.eval.data.create_dataset:main"
-# up-eval = "unitunes.eval.eval:eval_app"
+unitunes = "gui.main:main"
 
 [tool.poetry.dependencies]
 python = "^3.9"
-typer = {extras = ["all"], version = "^0.4.1"}
 spotipy = "^2.19.0"
 ytmusicapi = "^0.21.0"
 musicbrainzngs = "^0.7.1"
 pydantic = "^1.9.0"
 strsimpy = "^0.2.1"
-rich = "^12.2.0"
 ratelimit = "^2.2.1"
 youtube-title-parse = "^1.0.0"
 tqdm = "^4.64.0"
+dearpygui = "^1.6.2"
+appdirs = "^1.4.4"
 
 [tool.poetry.dev-dependencies]
 pytest = "^5.2"
diff --git a/unitunes/__init__.py b/unitunes/__init__.py
index b794fd4..d156bb5 100644
--- a/unitunes/__init__.py
+++ b/unitunes/__init__.py
@@ -1,1 +1,4 @@
-__version__ = '0.1.0'
+from .main import PlaylistManager
+from .playlist import Playlist
+from .file_manager import FileManager
+from .index import Index
diff --git a/unitunes/cli/cli.py b/unitunes/cli/cli.py
deleted file mode 100644
index 17af137..0000000
--- a/unitunes/cli/cli.py
+++ /dev/null
@@ -1,488 +0,0 @@
-from tqdm import tqdm
-import typer
-from unitunes.cli.pull import pull_playlist
-from unitunes.cli.utils import (
-    expand_playlists,
-    expand_services,
-    get_playlist_manager,
-    print_grid,
-    print_playlist,
-    print_tracks,
-    toggleable_confirm,
-    tracks_to_add,
-    tracks_to_remove,
-)
-from unitunes.main import (
-    FileManager,
-    PlaylistManager,
-    get_predicted_tracks,
-    get_prediction_track,
-)
-from unitunes.index import Index
-from typing import List, Optional
-from pathlib import Path
-from rich.console import Console
-from rich.table import Table
-from unitunes.cli.service_cli import service_app
-from unitunes.matcher import DefaultMatcherStrategy, MatcherStrategy
-from unitunes.playlist import Playlist
-from unitunes.searcher import DefaultSearcherStrategy
-from unitunes.services.services import (
-    Checkable,
-    PlaylistPullable,
-    Pushable,
-    Searchable,
-    StreamingService,
-    UserPlaylistPullable,
-)
-from unitunes.track import Track
-
-from unitunes.types import ServiceType
-from unitunes.uri import PlaylistURIs, TrackURIs, playlistURI_from_url
-
-
-console = Console()
-app = typer.Typer(
-    no_args_is_help=True,
-    help="Unitunes playlist manager. https://github.com/platers/unitunes",
-)
-app.add_typer(service_app, name="service", help="Manage services.")
-
-
-@app.command()
-def init(
-    directory: Optional[Path] = typer.Argument(
-        Path("."),
-        help="Directory to store playlist files in.",
-    )
-) -> None:
-    """
-    Create a new playlist manager.
-
-    Creates a new playlist manager in the given directory.
-    """
-    if not directory:
-        directory = Path(".")
-    fm = FileManager(directory)
-
-    try:
-        fm.save_index(Index())
-    except FileExistsError:
-        console.print(
-            f"A playlist manager is already initialized in {directory}",
-            style="red",
-        )
-        raise typer.Exit()
-
-    fm.make_playlist_dir()
-
-
-@app.command()
-def view(playlist: str) -> None:
-    """Show a playlists metadata and tracks."""
-    pm = get_playlist_manager(Path.cwd())
-    if playlist not in pm.playlists:
-        console.print(f"{playlist} is not a playlist", style="red")
-        raise typer.Exit()
-    pl = pm.playlists[playlist]
-
-    print_playlist(pl)
-
-
-@app.command()
-def pull(
-    playlist_names: Optional[List[str]] = typer.Argument(
-        None,
-        help="Playlists to pull from services. If not specified, all playlists are pulled.",
-    ),
-    service_names: Optional[List[str]] = typer.Option(
-        None,
-        "--service",
-        "-s",
-        help="Services to pull from. If not specified, all services are used.",
-    ),
-    verbose: bool = typer.Option(
-        False,
-        "--verbose",
-        "-v",
-    ),
-    noninteractive: bool = typer.Option(
-        False,
-        "--no-input",
-        "-i",
-        help="If flag set, assumes default values for all prompts. Accepts pushes to existing playlists, does not create new ones.",
-    ),
-):
-    """
-    Pull playlist tracks from services.
-
-    If no playlist is specified, all playlists are pulled.
-    If no service is specified, all services are used.
-    """
-
-    pm = get_playlist_manager(Path.cwd())
-
-    playlists = expand_playlists(pm, playlist_names)
-    services = [
-        service
-        for service in expand_services(pm, service_names)
-        if isinstance(service, PlaylistPullable)
-    ]
-
-    for pl in playlists:
-        console.print(f"Pulling {pl.name}...")
-        pull_playlist(pl, services, verbose=verbose, noninteractive=noninteractive)  # type: ignore
-        pm.save_playlist(pl.name)
-        console.print()
-
-
-@app.command()
-def push(
-    playlist_names: Optional[List[str]] = typer.Argument(
-        None,
-        help="Playlists to push to services. If not specified, all playlists are pushed.",
-    ),
-    service_names: Optional[List[str]] = typer.Option(
-        None,
-        "--service",
-        "-s",
-        help="Services to push to. If not specified, all services are used.",
-    ),
-    noninteractive: bool = typer.Option(
-        False,
-        "--no-input",
-        "-i",
-        help="If flag set, assumes default values for all prompts. Accepts pushes to existing playlists, does not create new ones.",
-    ),
-):
-    """
-    Push playlist tracks to services.
-
-    Asks for confirmation before pushing.
-
-    If no playlist is specified, all playlists are pushed.
-    If no service is specified, all services are used.
-    """
-    pm = get_playlist_manager(Path.cwd())
-
-    playlists = expand_playlists(pm, playlist_names)
-    services = [
-        service
-        for service in expand_services(pm, service_names)
-        if isinstance(service, Pushable)
-    ]
-
-    for pl in playlists:
-        console.print(f"Calculating changes to {pl.name}")
-        for service in services:
-            if not any([t.find_uri(service.type) for t in pl.tracks]):
-                continue
-
-            if not service.name in pl.uris:
-                console.print(f"{pl.name} does not have a uri for {service.type}")
-                create_new = toggleable_confirm(
-                    "Create new playlist?", noninteractive, False
-                )
-                if not create_new:
-                    continue
-                playlist_uri = service.create_playlist(pl.name)
-                console.print(f"Created {playlist_uri.url}")
-                pl.add_uri(service.name, playlist_uri)
-                pm.save_playlist(pl.name)
-
-            for playlist_uri in pl.uris[service.name]:
-                console.print(f"Pushing to {playlist_uri.url}")
-                current_tracks = service.pull_tracks(playlist_uri)
-                added = tracks_to_add(service.type, current_tracks, pl.tracks)
-                removed = tracks_to_remove(service.type, current_tracks, pl.tracks)
-
-                if added:
-                    console.print(f"{len(added)} new tracks")
-                    console.print("Added tracks:")
-                    print_tracks(added)
-                if removed:
-                    console.print("Removed tracks:")
-                    console.print(f"{len(removed)} removed tracks")
-                    print_tracks(removed)
-
-                if not added and not removed:
-                    continue
-
-                if not toggleable_confirm(
-                    f"Push to {playlist_uri.url}?",
-                    noninteractive,
-                    True,
-                ):
-                    continue
-
-                if added:
-                    service.add_tracks(playlist_uri, added)
-                if removed:
-                    service.remove_tracks(playlist_uri, removed)
-
-                console.print(f"Pushed {pl.name} to {playlist_uri.url}", end="\n\n")
-        console.print()
-
-
-@app.command()
-def search(
-    playlist_names: Optional[List[str]] = typer.Argument(
-        None,
-        help="Playlists to push to services. If not specified, all playlists are pushed.",
-    ),
-    service_names: Optional[List[str]] = typer.Option(
-        None,
-        "--service",
-        "-s",
-        help="Services to push to. If not specified, all services are used.",
-    ),
-    preview: bool = typer.Option(
-        False,
-        "--preview",
-        "-p",
-        help="Preview tracks to add without adding them.",
-    ),
-    debug: bool = typer.Option(
-        False,
-        "--debug",
-        "-d",
-        help="Show debug information",
-    ),
-    onlyfailed: bool = typer.Option(
-        False,
-        "--onlyfailed",
-        help="Only show failed tracks in debug information.",
-    ),
-    showall: bool = typer.Option(
-        False,
-        "--showall",
-        help="Show all tracks in debug information.",
-    ),
-) -> None:
-    """
-    Search for tracks on a service. Adds found URI's to tracks.
-
-    If the preview flag is set, URI's will not be added.
-    """
-    pm = get_playlist_manager(Path.cwd())
-    playlists = expand_playlists(pm, playlist_names)
-    services = [
-        service
-        for service in expand_services(pm, service_names)
-        if isinstance(service, Searchable)
-    ]
-
-    for pl in playlists:
-        for service in services:
-
-            typer.echo(f"Searching {service.name} for {pl.name}")
-
-            original_tracks = [
-                track for track in pl.tracks if not track.find_uri(service.type)
-            ]
-            matcher = DefaultMatcherStrategy()
-            searcher = DefaultSearcherStrategy(matcher)
-
-            predicted_tracks = [
-                get_prediction_track(service, track, matcher, searcher, threshold=0.7)
-                for track in tqdm(original_tracks)
-            ]
-            num_not_found = len([t for t in predicted_tracks if t is None])
-            console.print(f"{len(predicted_tracks) - num_not_found} tracks found")
-            console.print(f"{num_not_found} tracks not found")
-
-            for (original, predicted) in zip(original_tracks, predicted_tracks):
-                if predicted is None:
-                    console.print(f"{original.name.value} ->", style="red")
-                    continue
-                print(
-                    f"{original.name.value} -> {predicted.name.value}"
-                )  # no highlight for clarity
-                original.merge(predicted)
-
-            if not preview:
-                pm.save_playlist(pl.name)
-
-            if debug:
-                all_predicted_tracks = [
-                    get_predicted_tracks(service, track, searcher)
-                    for track in original_tracks
-                ]
-
-                table = Table(
-                    title=f"Uncertain {service.name} search results for {pl.name}"
-                )
-                table.add_column("Original Track")
-                table.add_column("Predicted Track")
-                table.add_column("Confidence")
-                table.show_lines = True
-
-                for i, (original, predicted) in enumerate(
-                    zip(original_tracks, predicted_tracks)
-                ):
-                    if predicted is None:
-                        if showall or onlyfailed:
-                            table.add_row(original, "", "")
-                            for track in all_predicted_tracks[i]:
-                                table.add_row(
-                                    "",
-                                    track,
-                                    f"{matcher.similarity(original, track):.2f}",
-                                )
-                        continue
-                    elif onlyfailed:
-                        continue
-
-                    similarity = matcher.similarity(original, predicted)
-                    if not showall and similarity >= 0.7:
-                        continue
-
-                    table.add_row(original, predicted, f"{similarity:.2f}")
-                    if debug:
-                        for track in all_predicted_tracks[i][1:]:
-                            table.add_row(
-                                "", track, f"{matcher.similarity(original, track):.2f}"
-                            )
-
-                console.print(table)
-
-
-@app.command()
-def add(
-    playlist_name: str, service_name: str, url: Optional[str] = typer.Argument(None)
-):
-    """
-    Add a playlist to the index file.
-
-    If a playlist with the same name already exists, the url will be added to the playlist.
-    Otherwise, a new playlist will be created.
-    """
-    pm = get_playlist_manager(Path.cwd())
-
-    if service_name not in pm.services:
-        console.print(f"Service {service_name} not found", style="red")
-        return
-
-    if playlist_name not in pm.playlists:
-        pm.add_playlist(playlist_name)
-        console.print(f"Created playlist {playlist_name}")
-
-    if url is not None:
-        uri = playlistURI_from_url(url)
-        pm.add_uri_to_playlist(playlist_name, service_name, uri)
-        console.print(f"Added {uri.url} to {playlist_name}")
-
-
-@app.command(name="list")
-def list_cmd(plain: bool = False) -> None:
-    """List all playlists."""
-    pm = get_playlist_manager(Path.cwd())
-    grid = [
-        [
-            pl.name,
-            len(pl.uris),
-            len(pl.tracks),
-        ]
-        for pl in pm.playlists.values()
-    ]
-
-    print_grid(
-        "Playlists", headers=["Name", "URIs", "# Tracks"], rows=grid, plain=plain
-    )
-
-
-@app.command()
-def fetch(
-    service_name: str = typer.Argument(None, help="Service to fetch from."),
-    noninteractive: bool = typer.Option(
-        False, "--no-input", "-i", help="Auto accept prompts."
-    ),
-) -> None:
-    """
-    Quickly add playlists from a service.
-
-    Asks for confirmation before adding each playlist unless the force flag is set.
-    """
-
-    if service_name is None:
-        console.print("Please specify a service.")
-        raise typer.Exit(1)
-
-    pm = get_playlist_manager(Path.cwd())
-    service = pm.services[service_name]
-    if not isinstance(service, UserPlaylistPullable):
-        console.print(f"Cannot fetch user playlists from {service.type}", style="red")
-        raise typer.Exit(1)
-
-    assert isinstance(service, UserPlaylistPullable)
-    playlists = service.get_playlist_metadatas()
-    console.print(f"Found {len(playlists)} playlists")
-
-    for pl in playlists:
-        if pm.is_tracking_playlist(pl.uri):
-            console.print(f"Already tracking {pl.name}")
-            continue
-
-        track_pl = toggleable_confirm(
-            f"Add {pl.name} ({pl.uri.url})?", noninteractive, True
-        )
-        if not track_pl:
-            continue
-
-        up_name = (
-            pl.name
-            if noninteractive
-            else typer.prompt(f"UP name for {pl.name}", default=pl.name)
-        )
-        if up_name in pm.playlists:
-            pm.add_uri_to_playlist(up_name, service_name, pl.uri)
-            console.print(f"Added {pl.uri.url} to {up_name}")
-        else:
-            pm.add_playlist(up_name)
-            pm.add_uri_to_playlist(up_name, service_name, pl.uri)
-            console.print(f"Created playlist {up_name}")
-
-        pm.save_playlist(up_name)
-
-
-@app.command()
-def merge(
-    source_playlist_name: str,
-    target_playlist_name: str,
-) -> None:
-    """
-    Merge a playlist into another playlist.
-    """
-
-    pm = get_playlist_manager(Path.cwd())
-
-    source_playlist = pm.playlists[source_playlist_name]
-    target_playlist = pm.playlists[target_playlist_name]
-    original_length = len(target_playlist.tracks)
-
-    matcher = DefaultMatcherStrategy()
-
-    target_playlist.merge_playlist(source_playlist, matcher)
-    pm.save_playlist(target_playlist_name)
-
-    new_length = len(target_playlist.tracks)
-    console.print(f"Merged {source_playlist_name} into {target_playlist_name}")
-    console.print(f"{original_length} -> {new_length} tracks")
-
-
-@app.command()
-def remove(
-    playlist_name: str,
-):
-    """
-    Remove a playlist.
-    """
-
-    pm = get_playlist_manager(Path.cwd())
-
-    if playlist_name not in pm.playlists:
-        console.print(f"Playlist {playlist_name} not found", style="red")
-        raise typer.Exit(1)
-
-    pm.remove_playlist(playlist_name)
-    console.print(f"Removed playlist {playlist_name}")
diff --git a/unitunes/cli/pull.py b/unitunes/cli/pull.py
deleted file mode 100644
index a7e506f..0000000
--- a/unitunes/cli/pull.py
+++ /dev/null
@@ -1,176 +0,0 @@
-from typing import List
-
-from rich.console import Console
-import typer
-from unitunes.playlist import Playlist
-from unitunes.services.services import StreamingService
-
-from unitunes.track import Track
-from unitunes.uri import TrackURIs
-
-from unitunes.cli.utils import (
-    print_tracks,
-    toggleable_prompt,
-    tracks_to_add,
-)
-from unitunes.matcher import DefaultMatcherStrategy, MatcherStrategy
-from unitunes.services.services import (
-    Checkable,
-    PlaylistPullable,
-    StreamingService,
-)
-from unitunes.track import Track
-
-from unitunes.types import ServiceType
-from unitunes.uri import PlaylistURIs, TrackURIs, playlistURI_from_url
-
-console = Console()
-
-
-def get_missing_uris(
-    service: ServiceType, current: List[Track], new: List[Track]
-) -> List[TrackURIs]:
-    def tracks_to_uris(tracks: List[Track]) -> List[TrackURIs]:
-        uris = [track.uris for track in tracks]
-        flat_uris = [uri for uri_list in uris for uri in uri_list]
-        return flat_uris
-
-    uris_on_service = [uri for uri in tracks_to_uris(current) if uri.service == service]
-    remote = tracks_to_uris(new)
-    missing = [uri for uri in uris_on_service if uri not in remote]
-    return missing
-
-
-def get_invalid_uris(
-    service: StreamingService, uris: List[TrackURIs]
-) -> List[TrackURIs]:
-    return [
-        uri
-        for uri in uris
-        if isinstance(service, Checkable) and not service.is_uri_alive(uri)
-    ]
-
-
-def merge_new_tracks(
-    tracks: List[Track], new_tracks: List[Track], matcher: MatcherStrategy
-) -> None:
-    for track in new_tracks:
-        matches = [t for t in tracks if matcher.are_same(t, track)]
-        if matches:
-            for match in matches:
-                match.merge(track)
-        else:
-            tracks.append(track)
-
-
-def remove_tracks(
-    current_tracks: List[Track], missing: List[TrackURIs], noninteractive: bool
-) -> None:
-    for missing_uri in missing:
-        matches = [t for t in current_tracks if missing_uri in t.uris]
-        for t in matches:
-            console.print(f"Track {t.name.value} not found in playlist")
-            prompt: str = toggleable_prompt(
-                f"Mark {missing_uri.url} as bad (b), remove track from playlist (r), or skip (s)?",
-                noninteractive,
-                default="b",
-            )
-
-            if prompt.startswith("b"):
-                # remove uri
-                t.uris.remove(missing_uri)
-                t.bad_uris.append(missing_uri)
-                console.print(
-                    f"Removed {missing_uri.url} from {t.name.value} and marked it as bad"
-                )
-            elif prompt.startswith("r"):
-                # remove track
-                current_tracks.remove(t)
-                console.print(f"Removed {t.name.value}")
-            elif prompt.startswith("s"):
-                console.print("Skipping")
-
-
-def get_added(
-    service: StreamingService,
-    playlist_uri: PlaylistURIs,
-    pl: Playlist,
-    remote_tracks: List[Track],
-) -> List[Track]:
-    added = tracks_to_add(service.type, pl.tracks, remote_tracks)
-
-    if added:
-        console.print(
-            f"{playlist_uri.url} added {len(added)} tracks from {service.name}"
-        )
-        print_tracks(added)
-
-    return added
-
-
-def add_changed_uris(current_tracks: List[Track], remote_tracks: List[Track]) -> None:
-    """Finds matching tracks with different uris and adds them"""
-    matcher = DefaultMatcherStrategy()
-
-    def fix_track_uri(track: Track) -> None:
-        matches = [t for t in remote_tracks if matcher.are_same(t, track)]
-        if not matches:
-            return
-        new_uri = matches[0].uris[0]
-        if new_uri not in track.uris:
-            # remove other uris on this service
-            track.uris = [u for u in track.uris if u.service != new_uri.service]
-
-            track.uris.append(new_uri)
-            print(f"{track.name.value} updated uri to {new_uri.url}")
-
-    for track in current_tracks:
-        fix_track_uri(track)
-
-
-def remove_uris(current_tracks: List[Track], uris: List[TrackURIs]) -> None:
-    for track in current_tracks:
-        for uri in uris:
-            if uri in track.uris:
-                track.uris.remove(uri)
-                track.bad_uris.append(uri)
-                console.print(f"Removed invalid URL {uri.url} from {track.name.value}")
-
-
-def pull_playlist(
-    pl: Playlist,
-    services: List[StreamingService],
-    verbose: bool = False,
-    noninteractive: bool = False,
-) -> None:
-    new_tracks: List[Track] = []
-    missing_uris: List[TrackURIs] = []
-    invalid_uris: List[TrackURIs] = []
-
-    new_tracks = []
-    services = [service for service in services if service.name in pl.uris]
-    for service in services:
-        for playlist_uri in pl.uris[service.name]:
-            assert isinstance(service, PlaylistPullable)
-            remote_tracks = service.pull_tracks(playlist_uri)
-
-            new_tracks.extend(get_added(service, playlist_uri, pl, remote_tracks))
-
-            add_changed_uris(pl.tracks, remote_tracks)
-            new_missing = get_missing_uris(service.type, pl.tracks, remote_tracks)
-            invalid_uris.extend(get_invalid_uris(service, new_missing))
-            new_missing = [uri for uri in new_missing if uri not in invalid_uris]
-            missing_uris.extend(new_missing)
-
-    remove_uris(pl.tracks, invalid_uris)
-
-    if verbose:
-        print_tracks(new_tracks)
-    console.print(f"{len(new_tracks)} new tracks")
-    console.print(f"{len(missing_uris)} missing tracks")
-    if verbose:
-        for playlist_uri in missing_uris:
-            console.print(f"{playlist_uri.url}")
-
-    merge_new_tracks(pl.tracks, new_tracks, DefaultMatcherStrategy())
-    remove_tracks(pl.tracks, missing_uris, noninteractive=noninteractive)
diff --git a/unitunes/cli/service_cli.py b/unitunes/cli/service_cli.py
deleted file mode 100644
index ffae10d..0000000
--- a/unitunes/cli/service_cli.py
+++ /dev/null
@@ -1,96 +0,0 @@
-from pathlib import Path
-from typing import Optional
-import typer
-
-from unitunes.cli.utils import get_playlist_manager, print_grid
-from unitunes.services.services import UserPlaylistPullable
-from unitunes.types import ServiceType
-
-from rich.console import Console
-from rich.table import Table
-
-service_app = typer.Typer(no_args_is_help=True)
-
-console = Console()
-
-
-@service_app.command()
-def add(
-    service_type: ServiceType = typer.Argument(None, help="Service to add."),
-    service_config_path: str = typer.Argument("", help="Path to service config."),
-    service_name: Optional[str] = typer.Argument(
-        None, help="Name of service. Defaults to service type."
-    ),
-) -> None:
-    """
-    Add a service.
-
-    If no name is provided, the service type will be used as the name.
-    """
-
-    pm = get_playlist_manager(Path.cwd())
-    if not service_name:
-        service_name = service_type.value
-    assert service_name
-    try:
-        pm.add_service(service_type, Path(service_config_path), service_name)
-    except ValueError as e:
-        console.print(f"Service with name {service_name} already exists")
-        raise typer.Exit(1)
-    typer.echo(f"Added {service_type.value, service_config_path}")
-
-
-@service_app.command()
-def list(plain: bool = False) -> None:
-    """List all services."""
-
-    pm = get_playlist_manager(Path.cwd())
-    grid = [[s.name, s.service, s.config_path] for s in pm.index.services.values()]
-    print_grid(
-        "Services", headers=["Name", "Service", "Config Path"], rows=grid, plain=plain
-    )
-
-
-@service_app.command()
-def remove(
-    service_name: str, force: Optional[bool] = typer.Option(False, "--force", "-f")
-) -> None:
-    """Remove a service."""
-
-    pm = get_playlist_manager(Path.cwd())
-    if service_name not in pm.index.services:
-        console.print(f"Service with name {service_name} does not exist")
-        raise typer.Exit(1)
-
-    if not force:
-        typer.confirm(
-            f"Are you sure you want to remove service{service_name}? This will remove {service_name} from all playlists.",
-            abort=True,
-        )
-    pm.remove_service(service_name)
-    typer.echo(f"Removed {service_name}")
-
-
-@service_app.command()
-def playlists(service_name: str) -> None:
-    """List all user playlists on a service."""
-
-    pm = get_playlist_manager(Path.cwd())
-    service = pm.services[service_name]
-    if not isinstance(service, UserPlaylistPullable):
-        console.print(f"Cannot fetch user playlists from {service.type}", style="red")
-        raise typer.Exit()
-
-    playlists = service.get_playlist_metadatas()
-
-    table = Table(title="Playlists")
-    table.add_column("Name", justify="left")
-    table.add_column(
-        "Description",
-        justify="left",
-    )
-    table.add_column("URL", justify="left", no_wrap=True)
-
-    for pl in playlists:
-        table.add_row(pl.name, pl.description, pl.uri.url)
-    console.print(table)
diff --git a/unitunes/cli/utils.py b/unitunes/cli/utils.py
deleted file mode 100644
index fc6fbae..0000000
--- a/unitunes/cli/utils.py
+++ /dev/null
@@ -1,148 +0,0 @@
-from pathlib import Path
-from typing import Any, List, Optional
-
-import typer
-from unitunes.main import FileManager, PlaylistManager
-from rich import print
-from rich.table import Table
-from rich.console import Console
-from unitunes.matcher import DefaultMatcherStrategy, MatcherStrategy
-from unitunes.playlist import Playlist
-from unitunes.services.services import StreamingService
-
-from unitunes.track import Track
-from unitunes.types import ServiceType
-
-console = Console()
-
-
-def toggleable_confirm(prompt: str, noninteractive: bool, default: bool) -> bool:
-    if noninteractive:
-        console.print(prompt, "YES" if default else "NO")
-        return default
-
-    return typer.confirm(prompt, default=default)
-
-
-def toggleable_prompt(prompt: str, noninteractive: bool, default: str) -> str:
-    console.print(prompt, default)
-    if noninteractive:
-        return default
-
-    return typer.prompt(prompt, default=default)
-
-
-def get_playlist_manager(dir: Path) -> PlaylistManager:
-    fm = FileManager(dir)
-    try:
-        index = fm.load_index()
-    except FileNotFoundError:
-        typer.echo(
-            "Index file not found. Please run `unitunes init` to create a config file."
-        )
-        raise typer.Exit()
-
-    return PlaylistManager(index, fm)
-
-
-def print_grid(
-    title: str, headers: List[str], rows: List[List[Any]], plain: bool
-) -> None:
-    if plain:
-        grid = Table.grid()
-    else:
-        grid = Table(title=title)
-
-    for header in headers:
-        grid.add_column(header, justify="left")
-    for row in rows:
-        r = map(str, row)
-        if plain:
-            # add padding after each column
-            r = map(lambda x: x + "  ", r)
-        grid.add_row(*r)
-    print(grid)
-
-
-def print_tracks(tracks: List[Track], plain: bool = False) -> None:
-    grid = []
-    for track in tracks:
-        artists = ", ".join([a.__rich__() for a in track.artists])
-        albumms = ", ".join([a.__rich__() for a in track.albums])
-        uris = ", ".join([u.__rich__() for u in track.uris])
-        grid.append([track.name.__rich__(), artists, albumms, track.length, uris])
-
-    print_grid(
-        "Tracks",
-        headers=["Name", "Artists", "Albums", "Length", "URIs"],
-        rows=grid,
-        plain=plain,
-    )
-
-
-def print_playlist(playlist: Playlist, plain: bool = False) -> None:
-    console.print(playlist)
-    print_tracks(playlist.tracks, plain=plain)
-
-
-def expand_playlists(
-    pm: PlaylistManager, playlist_names: Optional[List[str]]
-) -> List[Playlist]:
-    if not playlist_names:
-        playlist_names = pm.index.playlists
-
-    for playlist_name in playlist_names:
-        if playlist_name not in pm.playlists:
-            console.print(f"{playlist_name} is not a playlist", style="red")
-            raise typer.Exit()
-
-    return [pm.playlists[playlist_name] for playlist_name in playlist_names]
-
-
-def expand_services(
-    pm: PlaylistManager, service_names: Optional[List[str]]
-) -> List[StreamingService]:
-    if not service_names:
-        service_names = list(pm.index.services.keys())
-
-    for service_name in service_names:
-        if service_name not in pm.services:
-            console.print(f"{service_name} is not a service", style="red")
-            raise typer.Exit()
-
-    return [pm.services[service_name] for service_name in service_names]
-
-
-def tracks_match_and_on_service(
-    service: ServiceType,
-    t1: Track,
-    t2: Track,
-    matcher: MatcherStrategy = DefaultMatcherStrategy(),
-) -> bool:
-    return (
-        matcher.are_same(t1, t2)
-        and t1.is_on_service(service)
-        and t2.is_on_service(service)
-    )
-
-
-def tracks_to_add(
-    service: ServiceType, current: List[Track], new: List[Track]
-) -> List[Track]:
-    new_on_service = [track for track in new if track.is_on_service(service)]
-    return [
-        track
-        for track in new_on_service
-        if not any(tracks_match_and_on_service(service, track, t) for t in current)
-    ]
-
-
-def tracks_to_remove(
-    service: ServiceType, current: List[Track], new: List[Track]
-) -> List[Track]:
-    current_on_service = [track for track in current if track.is_on_service(service)]
-    return [
-        track
-        for track in current_on_service
-        if not any(tracks_match_and_on_service(service, t, track) for t in new)
-    ]
diff --git a/unitunes/eval/data/cases.json b/unitunes/eval/data/cases.json
deleted file mode 100644
index 6d3ccd5..0000000
--- a/unitunes/eval/data/cases.json
+++ /dev/null
@@ -1,56 +0,0 @@
-[
-    {
-        "description": "Translated title",
-        "matches": [
-            "https://open.spotify.com/track/7gp6jnNbMqpxSP5vvQ7Fin",
-            "https://musicbrainz.org/recording/13359de9-4e03-45be-8d9c-8858b01750e1"
-        ],
-        "non_matches": [
-            "https://musicbrainz.org/recording/9c50b21d-8d3b-44d3-9ce7-d3f7f3c6c301",
-            "https://musicbrainz.org/recording/6402b7d3-c90f-453c-a6da-b0970afc7f95",
-            "https://open.spotify.com/track/1iOQmZULzzkDN1hQZNoDii",
-            "https://open.spotify.com/track/66a0Ib85gRjtZmac4HFsvw",
-            "https://open.spotify.com/track/6MKOd8KDFzwpu8Vz6ccZU5",
-            "https://open.spotify.com/track/5AX80QDx8islQsWJEU4FNP",
-            "https://musicbrainz.org/recording/03ba160e-f8a0-46a0-a879-29cba3f19106",
-            "https://musicbrainz.org/recording/05c9def7-d736-4816-9be2-0a47efb5377c",
-            "https://open.spotify.com/track/0l1PJN9Cxx6ArCv3Owato7",
-            "https://open.spotify.com/track/3DiBAQdZtWKYmVNQYtkIKB"
-        ]
-    },
-    {
-        "description": "Translated title, uploader is not artist, messy title",
-        "matches": [
-            "https://music.youtube.com/watch?v=WAoPeG1LU1g"
-        ],
-        "non_matches": [
-            "https://musicbrainz.org/recording/0199a889-2ab5-4dd4-bfcb-1cbe81c837c7",
-            "https://musicbrainz.org/recording/4b029910-f8fd-443d-9c6c-9529897a1194",
-            "https://musicbrainz.org/recording/4585391e-aa64-4d64-8b50-0cdd5b8de6f8",
-            "https://musicbrainz.org/recording/ac8fa48d-c640-4f3a-96ee-e9e4ea9b0231"
-        ]
-    },
-    {
-        "description": "Uploader is not artist",
-        "matches": [
-            "https://music.youtube.com/watch?v=_1sNRmguTIE",
-            "https://musicbrainz.org/recording/c580c2a8-67e7-4a59-ae8a-888c643d2802",
-            "https://musicbrainz.org/recording/28934aea-377b-417c-8b80-f5ce61f236f0"
-        ],
-        "non_matches": [
-            "https://music.youtube.com/watch?v=ZKk8BxAi_yM",
-            "https://music.youtube.com/watch?v=_gRsZqSNvKU",
-            "https://music.youtube.com/watch?v=BAdfpfC7Cm8",
-            "https://musicbrainz.org/recording/6c028099-6af2-455d-9f88-ed9abe62d1bb",
-            "https://musicbrainz.org/recording/3492bdc9-55b5-451b-bbfb-02c0c096a2c3",
-            "https://musicbrainz.org/recording/58e4c3c7-008e-4494-8023-2528c01df56b",
-            "https://music.youtube.com/watch?v=fZjcQd83zrk",
-            "https://music.youtube.com/watch?v=y8zsVGm6Dpk",
-            "https://music.youtube.com/watch?v=6QM4WIS_bmI",
-            "https://musicbrainz.org/recording/a6379411-40a0-442d-ad75-f3790d9d03d3",
-            "https://music.youtube.com/watch?v=5yiusyi_CoM",
-            "https://music.youtube.com/watch?v=FyWdgUqTJcE",
-            "https://music.youtube.com/watch?v=9sMWXTSnwoA"
-        ]
-    }
-]
\ No newline at end of file
diff --git a/unitunes/eval/data/create_dataset.py b/unitunes/eval/data/create_dataset.py
deleted file mode 100644
index 79dcbc1..0000000
--- a/unitunes/eval/data/create_dataset.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import json
-from pathlib import Path
-from unitunes.services.services import *
-import os
-import pandas as pd
-from tqdm import tqdm
-
-from unitunes.services.spotify import SpotifyService
-from unitunes.uri import SpotifyTrackURI
-
-
-def main():
-    os.chdir(Path(__file__).parent)
-
-    sp = SpotifyService("spotify", Path("service_configs/spotify_config.json"))
-
-    sp_uris = []
-    ytm_uris = []
-    mb_uris = []
-
-    filepath = "ytm-spotify-raw"
-    with tqdm(total=os.path.getsize(filepath)) as pbar:
-        with open(filepath, "r") as f:
-            # loop through lines
-            for line in tqdm(f):
-                pbar.update(len(line))
-                raw = json.loads(line)
-                relations = raw["relations"]
-
-                spotify_album_id = None
-                ytm_track_id = None
-                mb_recording_id = None
-                if raw["media"] and "tracks" in raw["media"][0]:
-                    mb_recording_id = raw["media"][0]["tracks"][0]["recording"]["id"]
-                else:
-                    continue
-
-                for relation in relations:
-                    if "url" in relation:
-                        url = relation["url"]
-                        if "spotify" in url["resource"]:
-                            spotify_album_id = url["resource"]
-                        if "music.youtube" in url["resource"]:
-                            ytm_track_id = url["resource"]
-
-                if spotify_album_id and ytm_track_id:
-                    if spotify_album_id.startswith(
-                        "https://open.spotify"
-                    ) and ytm_track_id.startswith("https://music.youtube"):
-                        tracks = sp.get_tracks_in_album(
-                            SpotifyTrackURI.from_uri(spotify_album_id)
-                        )
-                        sp_track = tracks[0]
-
-                        sp_uris.append(sp_track.uris[0].uri.split("/")[-1])
-                        ytm_track_id = ytm_track_id.split("&")[0]
-                        ytm_uris.append(ytm_track_id.split("=")[-1])
-
-                        mb_uris.append(mb_recording_id)
-
-    df = pd.DataFrame({"spotify": sp_uris, "ytm": ytm_uris, "mb": mb_uris})
-    # shuffle
-    df = df.sample(frac=1).reset_index(drop=True)
-    df.to_csv("dataset.csv", index=False)
diff --git a/unitunes/eval/data/dataset.csv b/unitunes/eval/data/dataset.csv
deleted file mode 100644
index 9fb8ad2..0000000
--- a/unitunes/eval/data/dataset.csv
+++ /dev/null
@@ -1,375 +0,0 @@
-spotify,ytm,mb
-5EbtodsuLbxrFDH6j5avVS,puXvCRUjHXM,a4ee6206-47a4-49f0-86b0-8f8f4109842b
-6CodyRtMuUIe8F7OvV7US9,8OjpGvSbv1U,80973bc6-832e-4817-bcf0-d859137fe4da
-3aFiljyrkuwi1jPkgR1gTr,pjyPeu49ZXs,2b59a628-c1fe-4788-a90c-faed7a154c91
-4Vejcun4ipAmiY4O7I1j5Y,WE8AOwVhULs,bcc4a532-68b5-422a-af79-fa32935fefca
-6Ca9szNTJLsjCrz0IrGSh7,zQ71C2cQjdU,cc077051-524f-49f0-b5cd-d6f6269752c3
-4fqlG9zSvMf4KqQMfVjx6x,svAnhRyPtZI,6e0a2ea8-33af-42c9-ae9b-d3bd760947ec
-3SULXU4iQK2Nvl78o7Q6us,pARsZlzYFSs,e177b781-e5af-42a5-b42e-67e2d8ea76a6
-5ElCCFD0fzwYCTLvnxDrFn,poIhlVoeLes,d3860e57-b31b-4d38-8dac-d97132544799
-1Y0k7l2XBpMJbXQ41MiYhk,fmNwTuPVP48,2b67c0ae-e7f9-4709-affc-aaf99376a5e0
-6tED4XSPIFvpBvBmuDuTue,bR42Ee16fZk,e9b76541-0bd3-4480-bee2-198cf55f9a89
-7sYXudDYZ0RPA3V8UPTNek,E8lOTqG6miQ,c8db464e-956d-4dff-ae73-e9b80a7ddce9
-5pmbUB6zamnoP30Ds0lxHh,ZZiNQfhDPLM,0d993631-2789-418c-bb6f-954d7991582c
-1PyRDQnBoDvH5tr0H0gqRK,vwPvJIT9CIg,caa05360-9939-4182-b2b4-9cc01ebd6d57
-0U7ISRTjDy6tBIVJ1PQNTx,RdiMcTDSx_U,080c6c7c-b568-4209-8f66-0dba09508a07
-2FXGnRnSuatPXgyac5oUyR,u2uI8Mb9wMA,2068c828-d90b-4909-8004-024aeb4117e6
-4dyloVwDmEorwmIJlyxQGf,pv4Kfz8AaIA,d432dc9d-8343-4c82-b6b7-bdf42661d9e0
-38lwahbeNI39w0RC7keUEt,UfI7Ir_4Lug,0bede6d8-7c3b-4378-aac3-bbf4e2dbacb8
-6PfR0ag7p6LGs3QfPgZmAp,9h_3mYewbTA,6fa6a2c5-be8f-4289-a93a-eff384a51bb4
-6264cROY7K8qobF3KkwUrw,M8dwmTdfkxM,cfc7403c-59fa-4c4b-8c53-1c3d243371c2
-3uPixcdhbOAxGKHhbCJ9FF,Ks4uUNhbGhg,53112d1f-3046-4aa9-bc69-4c0ce7547aba
-277AdyT7oSDskpOXQSVmw9,IeD5_0su4GU,eb2b0144-e8bf-4362-99e2-30474e2c7166
-2wnQUs3jFe1zc2xlrAOOrn,o6HAEXXtUe4,3d660ff4-d9b9-4ca3-bda3-af7df086d982
-5l5Jc4USmoD8O8FCidNHGr,0ANZwoMwUcg,a2da6c5e-3645-4c9a-bfdf-d86e3d5e1ac5
-0XsaisLwwHIcV3RME9lxYV,6YvAkNPATls,bd3bf69e-f180-4264-b330-fb053561bd71
-0v12Wjn0XfdyHZSWpOoh9F,eoxfeaCqB7Q,0db949e5-20cc-46da-b9af-9ddec4208c65
-2H6HYGrSdnEyeuUxsOo1VW,zstSc3ZvGLo,c491874c-efa6-4c3d-aa50-7953908c1117
-3frXVggQsKJ6a5zjh3eBK7,vyp7CTvHWaY,df68deab-2930-4c67-98bd-560fd4efe196
-0ftSIngedU67Igb3cfKffa,eoxfeaCqB7Q,0db949e5-20cc-46da-b9af-9ddec4208c65
-5YOlr46EwaH34hfN6r4vXR,Cg2M-7dWhoI,40905d83-0549-4a08-a68a-66343aaddd3d
-1PROp2KRSQDR1t4KtEHyCI,IMmtpTUkrNY,2a46ed0b-a0b0-40a6-aa8d-83974c22a451
-7nMGseSHfrPIZlhBOIhnMC,9x_Uo1-Nr5I,68b392a3-7df8-4f0a-b811-4fee07f8d1fc
-1xdj6DvJ5hr3wiNxO6wQMc,EMOqVYw8Y8k,d9ecfc21-0776-4f4c-a125-43affca312ee
-24A0B5zWpd4tg00HPfFuiH,T_QCKEDffBs,ab3421c2-5fbe-4f6e-8ffe-cfb45f65a3e3
-1o7CQtp3Bz9RnWJ73NyUYY,DoX1UqsN4zU,228fcbd0-89d2-4bfa-8c10-f33e715639d5
-5QXvcJJGTEH4g0srBqPDN7,DmcB_NdoL40,7e84e0f0-25e6-49ba-bd32-7cc4efb02a54
-3xRKDcP311MfE0z2yWMv6n,LwOIqPLjQnI,75e319ce-9df0-4dc2-ac4b-ca80e20e34a3
-739bUKxuAzSf2Q2eQJOtGr,1AD_1R83OoU,f3d01713-3eab-4bca-b0e0-9f926cb47094
-4sBLPwV10z8LhYVLVAjsVZ,fuLxtQh5_cs,0e0e6dfd-b546-43b6-912c-a44b5dca7530
-0WFi8O9Vf77EBz6DWJ1c8d,0_e4H3tSzzo,a96e750a-3e54-4dcf-988f-a886d437a4de
-0hdKQtIUMIz3HQYZG221NH,vDson0AwH1s,3c0aef47-7b75-4e96-a762-0cc7cb01beac
-5N9VLUpHANtOkzR3iB67Xk,DpKsuSoBA5s,20839534-0ef2-4397-b132-9699654d7a2a
-2BCToThjRmAWPtBPXCVrMw,ilNaVSTfJ3Y,6493d4ef-d506-4ab4-ad10-2e01b9c89116
-56Ta1O7Jxxpjnc6cWdDKUo,tp1IlFyNLNo,38e380af-25cd-4a06-95d8-dfd63e6262e5
-5aaFQu2Qks9tjUr3xpGtYi,pbtm1FczlDY,7c3f95b1-8c92-4953-8a63-055bd131b589
-6oV0fxyzI10jkoHR4dT0dg,sr-YGDLnCKg,c8e6bcf0-75b9-496d-8767-1fdc4971945f
-1WEWlnPbqOZle0WyBwB2Ot,0PKMLylex4s,e36b9c17-409d-456d-80e3-78dc4cb9ef1d
-3fjlnwMNAFMG70na5w5BAV,ScNOhV7aDMw,bc7efac2-f63c-4bb4-ad60-0c32df09758f
-1z0Zl8KMcx9crTqlVcxZQd,Z-6j6YIAf7o,a27b5a99-889f-4b7a-98e3-2fabf06bb4a8
-6J65wrLSvnvHzA19tXwIfj,1XDIh9QkUVI,a746041f-db13-4e0c-a930-050d8b25793e
-5SOQovkftI8OGbxKp0noKN,XujMT5-rz7o,453e8214-ff49-4c2b-a8cd-fd548286c81a
-4cTL5Fb8VwlpPwgRZMZS3g,0Tuu0UzPXPI,89685eaa-9aa9-4093-a656-641bd2cfad4d
-39YycfxBsjaGqumFDyBri6,rVMm9mZZ9QE,adcd7468-5b81-414b-9c53-1881144eb64a
-3uwObx6v9ANMms2WsgbZvF,TKfo37I1Ca8,c1f74b46-1f6f-4094-ac1e-49706bd0a2f9
-1IpbkW9ZhvIx4X8JkkoiUG,2OI4giLgeTs,0c23b707-ba6a-4beb-8d49-d490e69dbebc
-59cdmBFsdsJLN3Cut2fnvY,28Yem1sjr5c,52e35daf-2c4d-4721-a7bd-25854be5ea87
-40qHVajSvM3WxeWGFOtBdN,mfWsU40ABao,02018482-273b-473c-b47c-34ca8c35d03e
-3R2BO2sbqCYx0o0eJfYvPW,Hh_qB1KSTuM,867430f6-7813-4a48-98c7-4a2b0686cd32
-7l4jcn5P2auBDgpduWZE9C,tCXmUGOW12U,a853460e-0834-4a41-922f-829c3a82d7e8
-7ftnn8FCNEym1kFW0qDpJw,AOLsjO1u2GE,093f1ac5-784a-4fdb-bc3f-14641fc4426e
-6yB2y7HnieNpjr6Ogzhwtm,kDJlKiXumd4,ec07a36e-128c-4fea-acae-7a41178263f5
-1E0s0rmgODXAl27xVqWYqC,ZHmfWqUBg0M,583c81a5-7e0c-452b-9d7e-aa287f5fc6cc
-3SlZs4PaSOhcDycNdcLX4G,GrRUawggiI4,97cde1b7-487f-451c-90ff-65168be6704f
-5heLJ6jsEFF3pBhbb5AycX,ocWIwQHCxYg,725ed85f-9fa0-4890-b005-0d9b74d9f3c2
-5HEEqyvsd0Mn3CvA242yab,C0SIiXzb9e8,12a1460f-175e-43f8-a2f6-397ff8c3bb31
-1O4qOKH26XOw87zb5HI6hw,Tdl0FzHXNkg,8b6c5192-cfaf-4ebd-9cac-fd652b273799
-0QrTw5kOAhOcnidIqOonUw,iARbdTxKaTM,d7af2845-8a3e-445e-b841-935f1f150f01
-0XcsMkqJfIxP5NmNLx7bzI,0sgupQxoYLg,8b69f35a-8025-4d28-be37-0818ed58bfb4
-4X4xI1TNF2W5H0nwA9ccJS,J7Kj58RO7iY,e5945a3f-a7de-401e-bde3-e59ea4760240
-3xEc6YZ5a5AYdD2HdPzb6k,zvbi6XyPILk,faf2bc7c-a056-41ab-a24f-e2adeaef339f
-6JOpQ2IBvYY6ZPYZLN0e9r,ZUQx9CMtyrs,66b880ca-77be-4a07-ade0-69c966bd4f3d
-1Er3f94mUlxTMv1pry7eNY,dYOUCcpOhiI,9bf31965-0bc9-4569-8bd6-6fb97b918080
-1oGBpAapQy6aQYNJUTVkxa,5P1vSclYmWo,46205d14-579c-4913-8d78-55c4ad1535f6
-0t5YT2Rg0Sfu58czHRY8De,smPHkvFFSLI,dbf0fd1a-6b57-4dab-990a-da0312ca4a75
-5TQ300ZmmJKCTlKLtoTOz1,qcE9hXs2Ykg,b3ff1ed3-75de-4fdb-9e5d-85ec461a3a53
-4t5YvYrrYAkGI6yA3hN1Ry,aZzfupag-Bg,03b75d60-3c77-4be9-a4c8-e42805e72236
-0SkF8i7ijlwtBXVVfdmX1g,ud4WNqQODLE,8aa10833-ed84-427d-8424-d936a89af26d
-7D9U0QVZTrQuxPCyay4aWB,b9cYQVoaZkc,861f55a6-6635-4c00-ba32-ef6ca77402c0
-6CMh2HCj41Se0ausL17eNJ,Q_IQ_xRUzqs,d451049c-2e41-41d5-9c9f-b1db04a6b9e0
-74LN7dlSSUNUyA63Supco8,GkrjVSJawPY,da128c7a-c5e7-4e24-a2c4-ea3a26f1c43e
-4IrKludoXFQbnBEbAkU2Z6,ToC1j4P0aZ0,d3ca8e57-eedf-4c20-9253-54760cad8fca
-2YInBkGnCLiLCScwkjM2AW,c5p24YLJLRI,319576cc-b7ec-4c25-9e33-d3211ae471b0
-1Sa1ilDbeevAqiVZtvsPcH,MagGVvowf6s,9921d28e-b750-4a84-9a5a-347a1bc76fa4
-5BDr9eogckm9DDO8EFlsxI,s_AKnb41P4M,7112e0df-05de-4d47-a185-85b2ab9276e4
-09Kj72ekjPvHyCUJ5kUCfw,IzKn-wkl-K0,1d171533-330c-4bc6-a19d-2e9a104d6f1b
-0AOLWObbWO8vIJMCofBSkr,BL0uReyRMrc,8242b5c1-3a22-44cb-9023-ea5479364dcf
-0RG4VnipPu8LX2LiAAvc0d,0yfNyt1oi5Q,6770330f-8b98-4ff8-adbe-847fdcc561ba
-27xzkLoxM2i50B41uWrfxG,jSYLIUauYxk,a5b6c5b3-c4eb-4b84-83c3-8116bdf9af73
-7ySmUNlzMlBZzFhqUrEeBt,APEkR8ZbUQA,1edc5d46-dfc0-4608-ac05-91824c08030b
-6K5uIXknkivJ2eVlMcafa0,9js8c5mXdY0,dcc6a47a-a0e0-453a-9bf3-cf47cbf238fe
-3iqYC2gMPND9ChNmmK0mLD,PX10Ef3yVhY,d73fe5e5-1a3b-4670-b913-56d47eb44eee
-1kjzoSbXfiqMIE7AwmQ63e,LyS7mpuQOUg,3571d4f2-78c0-4403-a4a8-853b0bc51cf4
-1eLldJMsSIe9kSVNkq3kVF,AX8nC5dkeOg,fd992f01-4562-4daa-b480-ff24dda06183
-27kQusgwMVJoFtV71NaqKc,fIxQBNexQS8,8cd631a2-825a-4dcf-9357-3480a6629e76
-7azjQaN53EDezJFLtMKKkn,GZq6_ziK1GE,e9ba73e4-9897-4e95-8048-ff6a65255a94
-2xVyWv5f31wI37XeYbXjWJ,mjpxSFsffGo,9edb7841-3112-49c6-80b5-7528696f1d28
-0XSkFv1j0397UvXKo4GAUW,e3f5RmLrhz0,ed490674-0a01-4a41-8dab-a0e6c23d111a
-4Z55llsKV24VTRpncDWVu8,auHnaLTeZj8,49e49ecf-83db-412c-a488-0d25eaeaf16b
-4IVKGLZkSQ3EUH4NqnlzkM,QD15u0DhC4U,06fecb50-3aee-4914-8b31-3bd958eddb0a
-7q9NUxTI5vhWNBq94PYURo,vZJ0n4uY-8I,d8fde02c-b803-4d9b-8e57-0ddf1c8e2784
-7wApP2fRALWxfhu4kniK8p,EAe8ULSPi-Q,2420fae1-e7d6-459d-b24f-88d8668cbe46
-5kqdGjJy0FsyIAklo5GUAU,9a6b-DeuubI,559cbb73-6fb6-4290-97c5-37dbfc21333d
-5cQTXfQWRvSqeiv73Sl4YV,_LyZA9xbPS4,32741bcd-5aec-4e0e-8b31-ba6f36db8268
-65e7qMabWkX1Y0GgLWmuB4,U0_VT7ud93w,38544924-a49f-42ec-b8e5-6e22172a2338
-4MRVygkPxJBjX1sR9cUogg,TWw8ggV65go,4b86a608-30bf-40ac-ab08-f742f46fc8ac
-4drtVDKM01zVhHDB9HMpFt,GT3QSZQ-Nyo,325e14ef-6d44-4721-b15b-dbb1080d13e5
-0JNdL7tmG99rXSYMHk0WqH,p58tQK-sidw,61fa60ea-9786-4ec4-8961-32f41968d277
-7J0x2wH3UfkGJ3RulDIoy1,mbUHIyjBStI,4cb4d2c7-3543-4013-95eb-91e87f61b6fe
-5uKuF3mQkhAoXyVLepeGLW,_M8JCiCuAXY,001b187f-3284-4e67-aa25-8f9e0d67200f
-0IVcqStkVn5qzgabebew3A,WVmy8FWKfwE,6ae389a9-e7c5-4c71-829f-8ab489cd9e4f
-1Tqfh6FoDTnbY1bHDyyhV8,iBiRWFEyTb8,a9d5d54a-3ff7-4c1f-af7d-77700a09286b
-514uF9aZ946mltuHEi4f04,ABfm9ixh4aM,4cb9c95b-0e12-4541-9d6c-2f40e37310cb
-2b36T1CBd8wXBKXNYGZ8hI,yoUYGRN5dbM,d3e9f538-7fb7-4501-84f4-b758e2adbc9b
-1yiMtDjdJwr486omxVnxuk,6S401Gq1HEc,d75e4c30-3f75-44cc-ae3f-a5d8bb864c64
-1MihptVMRoD4RwXuIs9mOz,HokMonAPHdE,6c7b18ec-65a7-4a5f-a791-b608d219d947
-2mZYonwoHzrTd251BHVz2T,OEGdYygJ1Gg,c373c027-772f-4e35-889e-56abbf748f0c
-0qQQeWCfgR1AIb5MeJXtyS,2-IuSqVGM74,63a28e3b-461c-448f-bb82-6a3a870d2c2d
-0xB5U12ejSbADybwvC8DWS,mPGARc-0SVQ,0d29154d-0d70-4222-bd48-9f300b313f09
-0lU2cXsgPdySZ9kHV7r05Y,nV7Ct7wd6A4,9c9d56ae-87a7-4d6a-9395-ca8f246d085f
-2BdsrgQCLlZdGsNMJDW2Rg,IiIFtCIu62Y,17fa965b-8eba-4d5f-a0d6-2ab3f919c770
-6sXCpG5BcLsw4ZeRAXvOMh,PMHzazZO0XQ,e54c7925-a21c-40fa-941a-8d04794bafa5
-3WhWCvLRUfV2CD4mLe0grW,GgX9Vr5UHs0,eacdb4c2-94f9-4f5a-a78f-584774d8f026
-1hKHwJ8A1NJOg30VtuaUzk,5jLusX7edIw,7207206b-f056-4b5f-b2f8-621a50669e01
-7na7Bk98usp84FaOJFPv3d,kqz6Zc8Aplw,d653c32b-a526-4b0d-bfa0-de6692c3bfe0
-6XXeYekfoKFZyia7gStz0n,XlLbsbIb2UI,c047f1b7-80ba-4ad8-bb9b-9b08dddb472c
-4kk5sCCzZJeh8bUp9c26uX,tswYnWqnfes,b48d90e9-8a5d-4f77-8f62-a57d7b9d34a0
-7w3czUAKctwpWXlxZj1048,MfRLBaHeqAU,e11ec282-7f65-4c36-a57a-0917757b224e
-5YnJjLNeJ51VwX6B5J5RF1,5K4AuF4JpnE,8afbe1f9-41ca-4f70-95fd-08f6358e801d
-0Qkc2TLHNtAsqdkQFFVEK1,BXYMEv_HjMI,c0651040-70fb-4ffa-94e7-2bb5d5d5fb15
-5oEb8IVRT71hV4jCHzgAkm,dJslztaruP4,251da177-bfb8-4606-8fd7-559f622668ef
-6IzueUNcl6OfLpnasDxu0P,k_VMO6YKMc4,c7bdd772-818b-4b17-9d94-bd9d7e7bcfcc
-0L3s9CHb9m6WIWpLGuJggG,G5m1geVwU6I,f8b42316-e0d0-42b7-9112-d742b8f9bc2f
-2z6wxnKa1Jy9YE7hKBZoyu,WU6v084dAL0,efe18d46-5214-4fb2-b91e-8c5e19179395
-2XAhVUpLVnFvDnZ9vM15au,LEUtc6GPgoU,7538db19-8ec1-4357-b864-78513d0ad832
-7kh3kV6U5UPQ52h4LQXM0P,gDzW4w2ziJc,f24508b6-ac00-423d-9a75-df61d91049b2
-3fhOpwaKobrELFjKLcXHgI,U2R5RwT55RE,75d77d1d-bc9e-4f40-a413-3b605a713cd6
-7asFSf2pkWNEG3E5EuN1QR,vOwVTtoR-9E,2eb80089-53a3-41da-a96c-1f783b45c209
-3sdGyD9VZGUHHHnqRzO5Di,nrwIxg6mM8A,f4adbe5c-61ad-49e8-9896-b382844e81f3
-3uOVHdlNjlrJceB3XDy0QC,enNrXr4L8gY,495f155f-fa19-4145-85e8-cd305043c1a7
-4gKJSIDhmQbG4GUFXUXa2u,JDPa7YL-sQg,2de9f65f-caa1-4f0c-8460-9b55baf5930b
-56TjFsoFVJ5AV3u5LABPLF,X2i7v0b1FPY,a948ea4e-6dae-479d-a762-b6d5c79869e4
-63SF57mF3TddURmiC1YaLH,I8ZqIv43hcQ,4c706937-efeb-4ffd-858f-b3dc4c148cd1
-5pT9gBsj7odK8pPc2J2C1T,Qmel8CiTMpg,6fb69ead-de4a-4e3b-ac42-0810aaba22e6
-16m4LLN0FXRmFhhCBrYPv3,llXJ_RWyVZE,e168790b-cdf3-42fc-bde4-1fdbdb1ee1fc
-2XUoJVBavffDvISbuFazeZ,f7XbqIPqhWo,c00e93ef-5d3b-4b98-96a0-897a595b86f1
-40fFwq2447OQ90SwoXPgGz,XCktpVHXubc,a1406a3b-a65a-46e9-b970-997f49bc8b58
-0P1NTAHAh2s7QAXCFN5oVX,CDowLteedC8,7a700d7d-a388-4f33-93f5-c3a05f871909
-4M4yDWxulVBJG2DeBm58JN,Gms4G8opsqI,45254921-2e3b-454f-a8af-f6e7e2601ba2
-29LH5tKy9NA4Hr1zucAPnl,LA4Vypx8Vvo,f88a6387-8235-4cbc-bbcc-d6f1d1e7840a
-1XqmXPgP7VlnwjwpaWIf2Z,yg7_jcw_l_Y,85a13386-42e1-433c-991b-5fd3b6371300
-5xmLf4QMju9SV9ieu2gwLm,XCPZbfX28u0,459a89dc-61b3-4d8f-826d-eaabbb63c8a2
-1M2HaGlu9y8Losb85FsnIL,LujKdCuZ6GQ,39074a20-c434-4ed8-8cbe-3908bec78da3
-1UI1CaEEX6MWSoPk2s9paA,h-wFVK_QXDQ,008bbdaa-6fa6-4780-bb5a-da82cfbcea22
-6Za2orBlzDGo3vK2bcuH8h,wg2VQ0KR5s8,493747d5-dd69-49ed-85a6-de5b461a1a2c
-3kjkraC812MO23RGPaQa8B,IwmTYALC6rY,2764f179-2267-4de4-8880-1c72653faed7
-1QnH4Aw84QLlNqRPRrtE4b,p2tNJkp9z_o,985b59c8-70cb-423b-ac55-865dc4062f8f
-3XGYr5z7xEiaSfSEI7Mdj3,hEdOtodAIiI,24851d97-b6cd-49e7-9076-9eb198bc11bc
-1GkLVxltghsiPXZL11iHbT,zfzPaR9kTUo,4d7b94fc-ca03-4eb7-9b38-8ea1bed97e53
-35VoAdsl1ed3Jkr2xNd5Gz,d2VohaE6IQY,8670f53a-0d29-4737-a7ca-9187fa688f83
-19vDh3058V1tKMYs9pmYPV,ImwpQGzkP0Q,1b939aaa-4542-478b-8ba5-207ada34a25a
-2Nt7pzuvp8ZUyXl62mJnDP,T_xJnsPC3cs,6ea7714d-bf40-4d0f-909e-a014e34dd214
-2QcSg9H3B6l6Xn5B8bZj5S,eLuYKqmJg9I,f0dfec52-9642-463d-a19f-3cc35b690c78
-66eVoSWX8wuU2PdWPT5UV4,zBqJYh9zxM8,3ebed838-de48-409d-834e-10768e97477e
-2S0olO5pM9dcOMgCS2ZGFy,oSwklDJFgPQ,1af3653f-6299-4677-aeb4-2e175569914a
-4cNBwQxA8s0SLKgIEj372G,LNCqpC3Npbg,1b6694c5-8a47-4629-9e28-4e8e677cc714
-0RrKpjfx8Ccax11b3h3s1P,2_v7Y-R-lP0,f7e4ade7-6a85-458f-8dba-76c83f175d58
-3f9eR0IZrktbCSqPfxM9jr,t88lUWdpmKs,ea678ed8-f3d7-44ab-8f05-425467fbf3b0
-55pcIf6SxLKganxo1vNRKW,rNpFohOgaP0,6b83b3ea-33df-4196-87f1-6ebedb3572fb
-6j0VCrWvnYFMLQA20QCSG9,kzbIb1NQXEc,9eb3bfcd-5fd8-4392-8fff-5507ae87cc68
-3y9pU3vApshiVOJ5ngTZAB,9ZB_TmWAfyo,13d3d355-c6ed-4bc3-80d3-07a1afcafa7d
-2lu8Nr4hC6ADvmTcjsFkjd,HyaGSEYT0pU,15414677-f811-4c33-868a-6820475952f3
-0oKS0Upg2G3eB9N9r316Hq,14a-lY0LH3k,21a10c79-7aaf-49ee-84f3-584c0fe05f2a
-7yvQtxsmEUj5mk3lLAR0ma,0MDr3wgCV7k,495d707a-6429-4efe-a7b6-2bddcaf4062e
-1dFcFRrvcjuJUtkPbWkWV7,lSxwYFXNpGc,c055ec81-652a-4140-8b26-430a7bb9245c
-5qoRwhCH2WlrRjaju9xakM,E0tVXkQrHtk,f208b88f-05fc-44fa-866e-f354a39458ab
-2CWIKyX5KDABK3gyCrFCyg,Iwr78PH2OC0,d14d67d9-26e4-4c0f-92fb-f876f486bb2f
-377egUjeD08wvB5V0enH01,IWPUzKJoXaI,28666cba-8621-4dbc-8dc3-6b08ecae5bd5
-1vkwiOySFbGeIqsjSFJ5z8,zPb0XriPnYA,09b1127a-16d2-4565-babc-de35cfff289f
-5LSUcUKDrpYZhcePDzb62F,dTuD7BmO_Io,4a3d0149-0a8b-459c-8159-20575c21e297
-6gXQSJLJLFBjCKpZC8iyGM,Qg5yJnyKZGA,7e8a513d-577f-4979-bad4-14e4452036af
-6aN5OahryHJWhuZnHrw1uC,JDtdqiIhbEM,8b2e24fd-4ea4-4e63-ad23-04b7a4179b01
-14b0DKppivv89I7aA5gEN6,74crzuGEuPQ,f3fe8f69-18a7-443e-9daf-bc718b79f33e
-304jOwTNascGGQnZygwham,WP1ycsh21nc,41abf964-0b67-4de9-a6c1-94e4de9e0e2c
-5GFp89EG8Gm9lrxBJFIzNX,_XtASO2jM-M,c1a5ad5c-47c5-44e0-960d-12c5a019551e
-3ynxkq3137GLUxVAvzuJrJ,xrga9kZfRu0,abdb7b44-ddd4-4947-8540-779133a2f9ff
-6clJESBAgfBLLd1nfvM5sr,26xEWFcwhtg,6015815d-a860-4725-89a1-954392d1558a
-5Z28MtxS4SCGdS9sCYGbok,mlYmT-42K0o,8d477d98-8b44-49fd-a895-062e52fe29f7
-0mOoPsb9xZTzFqKLLqvkjL,MD_ABVX3jVo,215ad680-fad0-4622-9037-c40aa411e92e
-7wITWtaoV6uiFj5XJp7gT6,bqdVrBwNE84,c0a1ccd4-b2a0-41e6-8cff-1a9601e629a5
-6cppQycxTjgCT3RSA476SN,suMft_17gwQ,ebdfbe30-d3b2-408a-a974-0d49f656cdab
-3rKz0OCTTmMYtOyWXTu877,hsSP2zodqvc,7f6ef2a0-537f-4b4f-bc34-2b8d19df9a3a
-25RG6JousclGcOKwbqh8Gy,tY4F46kn15U,41e8b794-a1cc-4e19-a039-3c406cf4d8b6
-7G9aeSGTA3kZzLX5FcamRN,HBGSiqt9nGo,0e38d646-72d5-44ff-b452-27956cf6408a
-1Nch0OvPcHoDtnG9WfoFCD,0fld0p1OFnw,0136baaf-64a1-4dd9-b62b-d3139b2171da
-1sWlklRBde2Qf4HQSCwXnO,XEsiZM-XPVA,650400d7-2ce7-4463-b109-9b7bb9387aaa
-75ovs7BBsJTDYTJAbONx8i,UQYN6W2qmlw,a943e941-e29b-455b-a404-c518de7c2875
-2xVAeLO2RxxKrIyQyxGb6t,tw6MBFgPtEg,c01409ea-de44-44dd-aae1-1c6055aa5d82
-4dkvpsbKHB88I6ujIiTiXn,A_gaZp9G2_o,13946f16-ab19-4fd3-b7a9-197fc6e34449
-3wwJWKinvEyQg7BBdgZ1S7,OkNm-66EBkM,648a5bda-2773-44ad-bdc0-64fa337056c8
-5bmjRUdGcIURytJMvulQjj,ykeeE5pdR6Y,636103b6-c337-4f28-a6ff-36f13195855e
-2JYE7mskqtdXQElWQ8CkcV,X9T9Lvtgtiw,261c1ed8-b039-4ca9-a5fc-51a196e4d615
-1efTacyWLr3E4j0nul2j1b,uh8w787YEW8,6ed8759b-c236-4f11-844c-12d7991b5b43
-7iSWczCQzT4X4hN1w3KgxB,lYjQD0opNa4,359024c7-c372-4a9a-bdf1-a0bd8ded8ec5
-4N8sFTTjINCUyQL6hDMJCC,jwnJb1_Hqmw,dedc1ec0-796c-46a7-a4ae-21555e66a3c1
-728JhI2Z1GDz1ThXbMIBvl,tXucwKCpdmE,ff0d4c99-d4f5-4b29-b852-ac86a1a272a3
-1g4PXkf1TOUx28EXkCaK1L,KPdY1xpqve0,eeaf3942-d90b-4b6d-a614-a91000868b8f
-4cX45yXXROROJlWEVLbqaO,bspbA7aP-Go,07bd1680-786f-4b4a-acf9-34bb82fed9b6
-6zhZrPitKYrBTTrdyG8TYD,qMoz1KoLaBU,5ce2f30f-5b74-445f-a062-c09a7bd8c167
-0HJM7sScyH2MPcl4HHE1xF,-9h-_Rqk-nE,ff9bf350-afeb-4b0f-864e-3160a9dbef65
-5i2FHB6baaUDNids0bzkm4,11mjdsFFes0,18217b81-0ed8-43e3-a7cf-a78934416581
-5TCyp6n14NjGnQuRMsWL4h,MWKw-heslkQ,d0509c75-ac7a-45cc-aacd-58a4a575b982
-331J5Aq55seqTMz3DpQBnG,9t_JCdrOOq8,54b472f8-15e8-4d56-a6c1-87c05f76d8f0
-1EwjhVKbfwsKGQRCrhZEFM,eJsHKgYL2qE,f5772f1f-f9b3-4931-959e-e5ab269a577a
-40RP7ugs8cX9ycrrcVPUeo,hYvS3Tkq84I,90bf3640-e093-463c-98e6-f58a44e30930
-2UuJE2hbPmcqQUEKeVAE8b,1yxusPDfRf8,68e17899-dc0a-44e6-8919-31a1b64cc2de
-0Snbzbd74RLfL0i4nn1vU5,eedq_gArSeE,8df1e201-7252-4649-9100-62bab24ae92e
-1bfbuIFas2OflBilwcAuO8,DgqSRPsfZi0,26a9f9de-3d98-4327-ac0d-e00565c9fccd
-3HHvRL6xnMyCpobHHQLkk7,lqRz_j5wZjw,a2a0ae70-5f74-4870-8980-ebda5487b22c
-7hbSbCz49tj5XnBF43W7Fd,3Gvq_aHmMt4,9dbb538b-b82e-400b-a7d2-dcc838c84170
-5Sj23j08x4NQlhvJUMj5ZN,mvKvOITg868,0e1a5958-1584-4720-9459-b5cd626b4955
-3UyqBs5XB58GJ1H98wruKN,fJSdWOS1NJM,cac7f3a7-e8f5-4c44-bb70-f9d4f133216a
-5b6aNI4ecZpFBjrCVd7uXr,KFnjrJ1QuoA,3775cbee-49e8-41a3-a10b-ad51bbfe558e
-5DBWSzLMGydMd0nyG7HVnY,J81DH5YPXxU,367eed35-6290-41c5-8b5a-53facc97d993
-0BZQFHV8gXS4R4U3n1zjJl,ocolO3KrHTs,d5cec552-52ad-425e-987b-c033c4557475
-4fUOFWOUzQJf90JNpPOcQN,lyyHb6vcv1M,ccc7412f-e368-46e6-aa4f-d5348fb5ee7b
-5GA5ZARZ6KySsNNqCB7tx6,W2xrLNwFZ0A,2ff8c3b2-3d2b-4ae9-8b47-8410adb3c397
-1fYVwpPhcpxlh4CdG1it9X,R-p8URpzgaw,7f3f3a3d-a916-47cc-a3bc-0bcc62f40046
-6gJRl4Esgg3wI5KRqv9hRY,E6dm_a-GSaY,b6b4f4da-463b-4bd1-b1e8-d08c60cd31fb
-0UQwlF2pRkr1lMN6kNNUj3,wh4RNI9n2LA,03f0fc42-c8b6-44ba-b253-67095c93dc24
-3C4z0X3iASURoNIY39YXLG,TeWLjsRBd0Q,cbb70c04-22ed-4325-885c-221bcd72bc73
-7hRzEvN8ou5B5RpmvOm7HP,-pBP16azD5I,14bc6e3a-1c42-43f0-8623-c0d018abcbc3
-28N5LIY0hnF9mEoNhpHKR7,mAm_BMJqYyQ,7a32e0ba-47f4-41b0-8808-ee560f83b1c5
-3gyC5xtHkLnTFQw7iHYXNn,tagRbkKslpg,9c16a1da-6921-4eb6-a174-d8ac48eeb530
-1A7NVLblYZctBdOEpv9f6x,Lt6DsFL-5rA,f50d43c3-5b8e-40b9-9c31-80b1fff7079a
-1KgDF0aQPn1sb1DuY4ckUF,imd_CbK2LrM,4648d3a6-a102-4deb-9cfc-0e57802e6456
-7LWG0HDah0M6vUfc1eGyIo,M_-PwH9CZHc,e446d51f-9472-45f2-a705-b5f71c8b7adf
-218xFvWUE1llzG5e9oaTyy,YDOUl4VRwMk,5d7479dc-da1d-4d76-930d-c283af4ab057
-4161xaeGpZ12moAzWyTjXR,BrxNOiQFKbc,27480682-cc3e-48ee-bdad-ca231571f528
-5Jga1UVnRUScbHqTbi7lNR,T5m5yNvxTZs,69a73727-a8d1-4fb3-b7b2-51d0d07db30f
-0JYq5vnnHWCwTLjw9ckBzV,G9tZqKL-IOQ,25fc5aec-f8bb-4b82-beb1-aea1ed4bbde7
-108MBT88L1kfV5knWRkmb2,HLdHmfwU46g,d3275040-6376-4645-93eb-5b01787fa39b
-7K9Z3yFNNLv5kwTjQYGjnu,6dPezw1HxSU,e1686287-57f6-4e78-b4b9-115bf5ce35cf
-7jA6AqFb0h95bne8OAFQyj,c-nTxhKEvN0,46366edf-3527-4540-a7e4-5292360e6e6c
-21UR9VB2bBsLeRKZBTVxMQ,Tk6-6wRcN24,5bd31586-c293-42c9-b81d-32bec8ada51d
-0eClsMn1L4U1d1MIy9mMZn,7xtPu-0MiK4,372fb516-bb31-47d1-b0bd-c2c98f5e33c5
-5Z22onxh6PltqGaIfi30Yq,RsIrTv9fQmM,665ac945-950b-481e-85b3-b5fc0906e491
-7GzDPRzxJccgsJW1ZI8yWQ,bsZNm7XwR84,2b1bc2be-3ff0-445e-b24d-1653bbe3a490
-0tSsK3F1V6zfJvXms8Q7bY,RiD0YV5ocL4,23beadf4-f213-4f9d-a3e8-2bcc713c09e1
-6c0WjRtUGOrKpf4myUNGEY,qOsJrqPxYsE,5d3ed8bc-ac3c-4fa5-922d-af97fd359eda
-6282M6VbRos8sYOvuiWKVk,AABL4RWnnYg,b066dc22-962d-4bb4-9266-74f12a16be01
-7eIbsNf1YmLg7EtOKlLILb,2NMw54sbf6c,cde88976-1b58-4083-a60b-ac34370a8bba
-4l0SepR2c0iyQkVLSlhQVX,Mcz8eQNG7xU,4c3d6a5d-8a20-4653-822d-e9cbcc67e2aa
-00OE0xYT6wuwHX32yPRkF0,bskJ8pHVyrI,4a7a0ada-58e3-453d-af78-056f28d4569a
-7wvwdLG3Y548vAtDc3LrzP,sekG1u8J-hg,8737f653-3ec4-469a-b64a-9a33a7750480
-3BLPcwue9dpGS8UskIezqT,QyMM3IfjzW0,739e382b-c0bf-4730-90ed-de09d116d2b5
-7tK6fwAvWcKxVHqwqce6NM,BpUAH0uSE2k,4f92aec3-d195-4146-954f-c1ee0b246a4c
-3JR0ducCexKbeK47g6AHm5,m_2dtd838NM,e006668a-841c-46c3-ab31-071b1584b73d
-3q1NG5LZOix1bCg19uR1OU,lE9litiZRFs,8b28a9ee-845e-4ba1-ab03-c25f76a401c1
-4qluwGB0E43AJFDcuQwKKV,n_UgMgCOPFc,8118f9ee-a2d5-4504-9ffc-81dfb2fc9e66
-1yUiP7Lr4JIJY05fiRbHVT,6WUzHUSqZ2E,eebc864b-6b92-4e06-815f-0f3fa2ffeac0
-70DT0YMia52dlndbJcTWUF,gQoxsyLbM8M,759f5fe1-3b38-4898-8d40-fb7afbcc979f
-1yVr8pfZckIEVbVMrVZh0e,TC-0fRnH22g,5214694c-b5e5-439b-9391-097bdcb568e2
-4WgZBU1frx6WxgMya67waM,YRdrFVMIciU,0fac1a7c-52b2-4518-8d68-1739665d5bd2
-4iSIsxbfK83pg0YATCXY4a,AE97rTVFnUE,f721eb8c-d786-4f72-b593-fde8f9395e13
-73PgaeSmh2JDv5Ut59SRso,1pVH61djMhU,6128dc1b-2c36-4aef-822d-0bda66420163
-2O8CIlOWBQutAvnqcZWgPu,vxHDH3BbUwI,a563cfc8-267b-4e13-9829-9e16980fb044
-3PKlVskd2ttaStxBhn84HP,kqv_7DX95K0,c2dd9495-3bb9-49fd-a17c-ec9366956908
-0uZs8ywjmhL4Om8zp8tKWh,Yhw0bFWu9x4,970e2e49-0fde-4a0a-a7de-a01f7bf7c6cd
-4QBRU3qrSUVFohaWTh0IDd,PI7NoRjt5Q8,27aac9e3-9ac8-479e-9477-f66ab09e9b4c
-6sSGBcLObCvzyc0ZMySdd5,8IKfncZ9tmc,062a5e34-3aa3-4958-9d53-9c7469542861
-341mMMgX1KIH1v29DE2gmc,9I-Fw7M9nfg,a7ac2757-2ed4-4d89-878e-ce4d61977791
-1DJT9Sw9xxJembz51WF3kQ,NvAHauvZxQY,0cec9f8d-cce6-4459-a6bf-53fce87819e3
-541VIo6kd4JzpU9DZiMMtf,nyM4seo2BgA,4a740b51-1435-4b4b-992a-2ff11fb96758
-3KznevWMhtotO7UO0TPKrE,fX6kEiOeFBk,b61f57d5-33c6-4f22-aa91-52c3dcab3a16
-48foOyMTj9FhvH4cM7CSh2,SucO-cqI0hY,0749325c-d1b5-4eb4-9390-aa1cc2368026
-7hyaUnRVyycXOsje4Rgec1,xG1VHIa0qfE,c731fa1c-d6c1-4115-b064-08a540fd4ee5
-70UbxfwWbeiglv4hbKcDz7,0qwJr_veC90,018797a2-d7ea-44f1-93b4-cc37f57250b1
-1IVjsxjhxiMnkX6Nlfguwo,-Ut6ZkEYfVI,909e857e-a872-41bb-8da6-ef9b1ca5e9f2
-2yYIn5gLbUzjJnsAk0xIZk,UB-ZEnHkFK8,8fefb699-39ca-46d7-ae36-efd0637824b2
-2CFPBHvzWybYZ5mL6ByLTb,B8Nfr7bvXm8,c51abf41-f334-4a86-b8bb-e7e905121150
-3M3i9BPQf0miM7nr72FUXk,nqGA3GLN6TU,bd30e6b0-f390-468d-80c1-a9aa10fdfbae
-57VAaQr7ZjS8D1Qt5vkdap,sFR72u6yOPY,7a69d30f-4df2-4967-ba0b-a0e1625495db
-68npVdVwnOQD6SirzrTwxK,5gcWPJBZXnk,52721400-62bf-41f1-b958-8ef2c16ca56d
-4BmA7PhItiUwkgsjbOEM9o,cdrhPt7kyzg,2d2a9520-e407-4bac-80fd-584bce6b37f4
-0kiUO0cZrel00kjM3JXz9w,MKtFvWL4XoI,f18f4bdf-f526-4e4c-9e2b-b865712ea69a
-5VWYRIrkgXIlCSQjyCb0L7,ZmN02jVhxcw,c4054b00-d18a-4cdd-a66c-9ad99f0d527a
-2OPz8DIuIK8rTHNU5GGF9R,cUiKT-Zz5Rs,3bc46ba2-989f-416c-80fc-7fdddf78cfd3
-1Im8nf2ezzMc5wYnjsISJz,OZFKhqepQaE,ed9902cf-3452-43a3-882a-5f973a02e68e
-6Ys9QZvy7uE45RTDe8dGf5,IHW6aTaBAdw,e4817db5-ebd5-406f-b541-f8566fea0320
-3p1AzvHP4DaBdaFjlFAlBM,UAVQhQCdiiE,ca2191fe-ad2c-4cfe-82ab-14828d9d0208
-7lt8ATIgajYaQh99SttxOk,epSYeNdeakY,914a37dc-0653-4a7b-b3f6-4d345f8e6ff6
-2EoWaApqRF6fdFtaOTTBbp,OJftkeU4Tug,c4054b00-d18a-4cdd-a66c-9ad99f0d527a
-6c0PZ7u6bPxlpCTePFQqKq,iCQmTQdRiFY,89a1e3fd-9c50-4f52-b152-9592d714f485
-7If8YekaPQVPQch6fllDaY,b8BcjshqS74,a4c00e52-2321-465b-ade5-178fd764e28c
-2bewzMBN7Tu5fWiPedGVzT,tgXyjkibyNM,6e532f8c-4429-4e28-a9e0-9827ebd6b999
-3kaEX81vzEk5qFws05HL6Q,jWhw_1ut-BY,b50bc237-11ec-402e-a428-3e0a9744cb6d
-2LpcJZ6cx9nXYhJzJ63jMK,tjE4mp4bSyk,5142ed3a-c624-4915-bb03-6da6581fdebe
-3zTBxaggnMzTGkEVEYOuuG,SWCKFTcpFqU,3d380c35-907f-4aaf-b62b-b7e13bd31520
-1MyYzXQ2LC1L9Jox2GDeRk,Od_mDQJ2Xeo,36233c45-f43a-42c6-8056-ea0aca7a5c85
-0PNAXQ1tjSqqxyFz0Ahcns,nobHd0xBdCY,f6c21858-8644-4b38-8f54-1d2003d9f602
-1ysas1ZOlXQxGGA1BJzr80,_rtnvx4-WoY,6d602433-9373-4803-bfad-84ec6daab582
-2RrJRSIdGweb6clmo5NRxp,OVj0hF5jTEs,5b1fa34b-5a32-444f-966f-c79d4823c815
-7tNTFqq74B4xiFj0Rgo56r,AeqCl6xcIRo,6ea22d35-6965-46fd-96be-b7d20950424a
-484Z4mMoc1C8LCWchYfosB,PaAAu1xI0f4,9b8d407f-b329-4cf3-89e6-724583d93f6e
-7K4iHHNZPyCQ8vEyzejlQP,mg1uEPzqY04,50ac2815-76c2-40f5-86d4-1cd16cf60982
-68yDqLqxTEJQwxaE32qXUi,NWFOrti6YRA,1ae542d8-a1bb-4b7b-9da0-4bd28738ada2
-2vjNJT3mkjDOPEnRlUwACn,eklyQmlUisc,c02f27e6-df41-4e82-9790-e8cc8dc0b5b4
-2O6BRHCngjHwi1SdG1aDgt,3vReCmIPDII,c77079d2-faea-496c-a938-4f84b18e79f5
-2vdw3wlLolDUwwFFPAn7D8,74Q-CR7AQG4,06cbd608-1486-4d27-9ac0-b1757ffdd3c2
-1gXCeawhQWUzngKxEuRmg7,6aTpqnq_gHc,0cd48785-c207-4709-adb5-e148a7b43b33
-7xFTB1Me6bEcOpKx0Gil5P,IiBC3mooT2s,1a43cd9c-553b-4a86-a039-1691c68e062a
-5ZyWa7KpFE48dXNupshdhz,ysfNXB1eOmU,97716d95-c549-48bc-8de1-640eb8db4b9e
-6mHKwu1UbrZShABB6CTe5s,jEr2ecTqwXg,7e52a0bf-1fc1-4fc7-b9d8-1253ef84d60a
-7gkw0d8USQ6ocYGRf4eaAp,2S8PoT1D4yc,b4a3cf11-2cb2-4557-b12a-fd255ec06cb3
-61GkOMQArIyYAJi0qmvaf1,csdNY6g_ojg,89cd1d08-50e9-453f-bfc8-db8821528fd8
-1rIWOe3GkLFVDweZVbS9Ou,OJihixaIFJ8,bae8b4ae-7fc0-4366-9c1e-4a7c30a532be
-7wAwXFM0sNwiNMS9nKf1JX,1ERWyMR3hcQ,c4da2301-db6e-45b1-b2eb-0dfea74a074d
-36TaY3GEtXYK8vAV5b4wmo,UHRnTsbjuXI,4950e7cf-75ea-403a-9993-c494cb89fb59
-684lxG2mzcMrYwlj34spY1,v-RWoKfBeQQ,b42792ee-43e1-4056-a9cd-a8fc43acdc0f
-289djqpngVtS97QND0ywDR,i9BXKZFqJyQ,a668bf7b-a51d-4ef9-a6bd-d0acb0f7e366
-4yUblweQl1cQ5pfxOcfmtB,JPlwPMyvbMo,fdfac07e-a853-4299-a55e-890e4efc69bc
-7EpoBLJF1EflCM5ybTTD7o,RfqM7fQjeuw,acb8f9a6-5119-4b8f-8b3f-f4a8e7583320
-4UylnlkacXGJ4XZWktsVkS,re9hQSbBi-I,5dc00896-c162-41ca-be99-911f80628046
-3VRLTSXGCogtSHc5XkDHrA,4QYB1neLVVE,653c26ad-5c55-4b96-a026-07c19b050ecd
-5sACV2jKnaNBgcT7h6O5uA,bGJS1iFpelE,ef5e7d64-9c89-45e4-bd81-fa4d434b670b
-3GgnbhQeCiPpo8Hcf0Ge4I,rP2DZeoBmC4,e4634c95-531a-44b0-8bc3-c037967d3157
-1bJKDRiXRrjZgQiZMNFhBI,dn47MQZci7g,2da2ed3e-4c51-4af3-9dd2-7299ab97fabe
-7hJtrZSvKx45GT7nWHTXpr,K5A32kYhN9U,4a0d4e08-fbf8-464b-bc33-27517f25ace9
-65WyHRQuV09aRYGEjJgQQP,W8QdCYXH1Qs,4c795b89-ceeb-4fe7-be15-43ff67ff4d0f
-70eyrfe4ru3LONov5ZSwUX,YmlhEsK8Itk,fcec983b-5f41-4af9-aa8d-81f361c414b5
-1JQ1fOuuVFoLNyb6V4ngzs,TYhOTJcmMu0,3517c910-191a-4436-b8a6-7f8b649f22cd
-32PBeRozbJyDokYnifml7v,iEEQvLnY9NA,76b3e91a-fc44-4134-9438-c1ed31677b4e
-38EFRzyrAwnNSiGynW3G56,GbSQcvwGoXU,f6eb895d-c24a-4644-a37b-a9b5631fc156
-3Wheol8dN41BYcLEKQ9oEd,RRyi5AUBDjw,4333b072-7f30-412b-8bcd-a03885d94053
-32NcWTa3P9D1590FhtHqvD,fujYOvZqPSI,40f77921-5ec0-4368-848d-1fddf4645e9f
-1HZWbndTQSt8nlsUAOItDE,XV_blVXNH0o,dc3224b2-53a9-4b94-b6d2-5342afe834ab
-2yfJ8Rdo8yqzIzdL8F7v9g,KzWK4TX1A40,db639cc5-da0b-4a07-a031-95648f4a2b85
-5rYkBePHNBTB9CmHP8Bsek,F6P-GusGK0w,d334f718-3944-49ba-9f06-bbb65ba19df3
-4yTtg3Eld2gRZFFJ2XnvYO,ZvP6vjCjDB8,ea03d0c6-c235-405d-83d6-3a1a2cf7b6cf
-132KtBgnk5ulNdJYiM4etl,dZ8xq8P7_Oo,2d0f5317-abe4-4bb0-9428-a0112fca1b16
-0djNuMqEv8KCVE0tUQyyrJ,Fkb8nOa6G0s,88b11d71-ead2-4b2d-a325-508a13b0ad1e
-4VeVxpijD6skvgMi96dpgg,01LruD8-lGg,4bd8a562-4aab-4032-a856-efc8b5f5d1fb
-3AEF6ihMejttEBxaugxpxg,U34KNf-Txac,ba7320e4-db34-4b94-996a-c65f84a5454b
-5RpWm3Uhp5eGfBP7KXkFOD,N_ccp-WGdXk,c8cbc0c9-efa7-405a-8dd0-48ba42af47eb
-1bsl63S7JEMW1C386Nh5LQ,q4BCD7-wCFs,01681f10-c3b5-4f10-a82c-9d3920a5cfd1
-0naqsq1cR4dUD37HYoOnkB,vY4u2dyDDas,6d1e50ea-6bbf-43de-9eff-c731d1ac3df1
-5Vevp2nzHwPsu425BBI78U,9UAAnM1wmVY,7232b4b3-ce16-4514-a2f8-afa3211e6af1
-39kR7T32uJwl8lOR8ZIF2D,otWLhArQKUk,c76d649a-94ed-45fe-b3f5-ed245e38e9d3
-0uLUbyIXv7wJWjMkNSqVRX,kczEYdLG8r0,b9b3c96b-fe48-41d1-a793-650b30f289fb
-2bv8iDYnF74KBvBaqCMrgz,yDuvi2YBQm4,fa6aa49a-0e9a-4805-b15b-6ad13daeddf8
-6Cv955a33uxv2PYsKfHg5G,96xVtmCSExA,03233a42-1ddf-45f4-afad-0749e278b97b
-2cLOSF54zDP52z6ma8mivK,L3_qQZvpt4c,d2c211cb-5f14-4a40-af2e-13912e57cd41
-61phXrM269314ckafmkgDs,QAKQ4Lhyfw0,c4b559b3-eead-46a9-887d-a3c360059451
-6Q4SBaYAu8buQStcwnpfuc,K6El_R4dVWM,f074fb4f-b739-4a01-8cdc-73fe95fa235f
-7BIy3EGQhg98CsRdKYHnJC,7fxcBauT-h0,22db3993-7d22-4e48-84b6-1d8bf1fad086
-3uINt9rq3ivOmGViGKveUj,PCMUnjpdryg,ca9dfcde-14c4-49e9-9b47-54c03b637421
-115FQXhFmEZNNzXLvr76G8,5rctjApahR4,bf3d437e-94ac-4c1c-b8b7-cf2a930942b7
-2f6JiYWgQh2i2hpA8jxD8b,Y575T6F94-s,c41106be-d8bc-49ef-810f-efd1f87d061e
-7k2t5SM7IsgEtKtRK8V2sm,uub5Vttr7ac,e5a50379-2aa2-47da-ba77-1a099111170f
-7g6z9yDduZIqzEsoXiNpFo,2BhwE6Lt_Y4,c28c344b-88c5-42a7-90d6-6986772dc0e0
-5l4HSmdUcS86DxZ7xDdFe3,gcDSkPl--TQ,ec1b5973-c782-44e0-bb13-6dd0cf392252
-3SAn4J7Rqz73ZkgGIlddUZ,PvMJYtUt0DU,02695af1-4baf-4b21-87a6-5ee87274f1d9
-0ZHuHx7qgTdVNnGpEvoqk4,odxlLq88dPM,bee5cc6e-8555-43fe-8b7a-88a1c7cde418
-1HNhRpWLoWHHVvy3cFU55D,cWDRKNLFBmc,41724ff1-b88b-495f-981e-123847414e8f
-0uhZ1Fese96ytWzQp5Y75r,joZy-OMKCD4,ad954a31-d5f0-4b6e-968e-98ea77344a80
-4KAOXsvysiO5Xwc9IuBsZy,8tC4vwOj4KM,218336e7-778b-4b61-ae88-3edb64312907
-2NmN63dWVT0qxkSLh7cGFy,VgWx3Zd578Y,b9c7bb6d-e0c6-4b1b-ab5a-9572f6f2fe17
-6ZyDNdg77ocFb8lHM2mcYJ,16JhgYYMn7k,0475961f-bb9f-4b1f-b823-7df1806ab046
-1W5W3zTbD9yJZJJSzybZlG,PIm4vznImn4,a8c08610-4916-4178-b96c-ab2dad6b5032
-2FDUc0ClW6xxOSsnLkpaFM,zIW-7QUFdKM,92274c52-350b-41a4-8c6b-d4e292abef9a
-3cfcUzkJJT0lxHTlEWAfYd,FKqj5yprls0,d7f2cced-1d31-4841-b00f-5e7b3ecec531
-3SlZs4PaSOhcDycNdcLX4G,HLpw-E9aStw,a1b7e817-1c17-499b-8f26-0fcebc3a170e
-6ymLZVapH97agq1bZGdjkK,EJROsOyHBgE,7902c338-91bd-42ea-ba23-f46205946ced
-1x0TiZaTcuslkBREnvCMBt,hfpj7Re5ZNw,cb4d57a4-fdd2-4dd9-bed3-65e379241865
-1shnTJ3amV0dKcJp2bYyVY,ba_1dkO5rIo,a80b3b2c-0ac3-4e65-8fea-794b9d71f4f0
diff --git a/unitunes/eval/eval.py b/unitunes/eval/eval.py
deleted file mode 100644
index fdb6c27..0000000
--- a/unitunes/eval/eval.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import json
-from pathlib import Path
-from typing import List, Optional
-from pydantic import BaseModel
-import typer
-from rich.console import Console
-from unitunes.main import service_factory
-from unitunes.matcher import DefaultMatcherStrategy
-from unitunes.searcher import DefaultSearcherStrategy
-from unitunes.services.services import (
-    Searchable,
-    StreamingService,
-    TrackPullable,
-)
-
-from unitunes.types import ServiceType
-from unitunes.uri import TrackURI, URI_from_url, trackURI_from_url
-
-"""
-This is a script to help you test your matcher and searcher.
-I use it to debug tricky cases.
-"""
-
-
-class Case(BaseModel):
-    description: str = ""
-    matches: List[str] = []
-    non_matches: List[str] = []
-
-
-eval_app = typer.Typer()
-console = Console()
-
-path = Path(__file__).absolute().parent / "data" / "cases.json"
-
-
-def load_cases() -> List[Case]:
-    with open(path) as f:
-        raw = json.load(f)
-    return [Case.parse_obj(case) for case in raw]
-
-
-def save_cases(cases: List[Case]) -> None:
-    with open(path, "w") as f:
-        json.dump([case.dict() for case in cases], f, indent=4)
-
-
-@eval_app.command()
-def add(description: str, uri: str) -> None:
-    """Add a new case"""
-    cases = load_cases()
-    cases.append(Case(description=description, matches=[uri]))
-    save_cases(cases)
-
-
-def build_service(service_type: ServiceType) -> StreamingService:
-    path = (
-        Path(__file__).parent
-        / "data"
-        / "service_configs"
-        / f"{service_type}_config.json"
-    )
-    cache_path = Path(__file__).absolute().parent / "data" / "cache"
-    service = service_factory(
-        service_type, service_type, cache_path=cache_path, config_path=path
-    )
-    return service
-
-
-@eval_app.command()
-def search(
-    guess_service: Optional[ServiceType] = typer.Option(None, "--guess", "-g"),
-    description: Optional[str] = typer.Option(None, "--description", "-d"),
-) -> None:
-    """
-    Evaluate search performance.
-    """
-    cases = load_cases()
-
-    if description:
-        cases = [case for case in cases if description in case.description]
-
-    for case in cases:
-        console.print(f"Case: {case.description}", style="bold")
-        matches = [trackURI_from_url(url) for url in case.matches]
-        non_matches = [trackURI_from_url(url) for url in case.non_matches]
-        matched_services = [uri.service for uri in matches]
-        if (guess_service is not None) and (guess_service not in matched_services):
-            matched_services.append(guess_service)
-
-        matcher = DefaultMatcherStrategy()
-        searcher = DefaultSearcherStrategy(matcher)
-
-        for source_uri in matches:
-            for target_type in matched_services:
-                target_service = build_service(target_type)
-                source_service = build_service(source_uri.service)
-                if not isinstance(source_service, TrackPullable):
-                    continue
-                if not isinstance(target_service, Searchable):
-                    continue
-                source_track = source_service.pull_track(source_uri)
-                guesses = searcher.search(target_service, source_track)
-                cur_matches = [trackURI_from_url(url) for url in case.matches]
-                cur_non_matches = [trackURI_from_url(url) for url in case.non_matches]
-
-                if guesses and guesses[0].uris[0] in cur_matches:
-                    console.print(
-                        f"{source_uri.service} -> {target_type}",
-                        style="green",
-                    )
-                    continue
-                console.print(
-                    f"{source_uri.service} -> {target_type}",
-                    style="red",
-                )
-
-                console.print("Original:", end="\n\n")
-                console.print(source_track, end="\n\n")
-                console.print("Guesses:")
-
-                for g in guesses:
-                    uris_on_service = [
-                        uri for uri in g.uris if uri.service == target_type
-                    ]
-                    assert len(uris_on_service) == 1
-                    guess_uri = uris_on_service[0]
-
-                    if guess_uri in cur_matches:
-                        console.print(g, style="green")
-                    elif guess_uri in cur_non_matches:
-                        console.print(g, style="red")
-                    else:
-                        # ask user if this is the correct match
-                        console.print(g)
-                        correct = typer.confirm(f"Is {g.name.value} a correct match?")
-
-                        if correct:
-                            case.matches.append(guess_uri.url)
-                            print("Added to matches")
-                        else:
-                            case.non_matches.append(guess_uri.url)
-                            print("Added to non-matches")
-
-                        save_cases(cases)
diff --git a/unitunes/file_manager.py b/unitunes/file_manager.py
index d337d3e..658c076 100644
--- a/unitunes/file_manager.py
+++ b/unitunes/file_manager.py
@@ -1,8 +1,10 @@
+import json
 from pathlib import Path
 import string
 
 from unitunes.index import Index
 from unitunes.playlist import Playlist
+from unitunes.services.services import ServiceConfig
 
 
 def format_filename(s):
@@ -19,44 +21,60 @@ def format_filename(s):
 
 class FileManager:
     dir: Path
-    config_path: Path
+    index_path: Path
     playlist_folder: Path
     cache_path: Path
+    service_configs_path: Path
 
     def __init__(self, dir: Path) -> None:
         self.dir = dir
-        self.config_path = dir / "index.json"
+        self.index_path = dir / "index.json"
         self.playlist_folder = dir / "playlists"
         self.cache_path = dir / "cache"
+        self.service_configs_path = dir / "service_configs"
 
-    def get_playlist_path(self, name: str) -> Path:
-
-        return self.playlist_folder / f"{format_filename(name)}.json"
-
-    def make_playlist_dir(self) -> None:
-        self.playlist_folder.mkdir(exist_ok=True)
+    def get_playlist_path(self, playlist_id: str) -> Path:
+        return self.playlist_folder / f"{format_filename(playlist_id)}.json"
 
     def save_index(self, index: Index) -> None:
-        with open(self.config_path, "w") as f:
+        with open(self.index_path, "w") as f:
             f.write(index.json(indent=4))
 
     def load_index(self) -> Index:
-        if not self.config_path.exists():
-            raise FileNotFoundError(f"index file not found: {self.config_path}")
-        return Index.parse_file(self.config_path)
+        if not self.index_path.exists():
+            raise FileNotFoundError(f"index file not found: {self.index_path}")
+        return Index.parse_file(self.index_path)
 
-    def save_playlist(self, playlist: Playlist) -> None:
-        with open(self.get_playlist_path(playlist.name), "w") as f:
+    def save_playlist(self, playlist: Playlist, playlist_id: str) -> None:
+        self.playlist_folder.mkdir(exist_ok=True)
+        with open(self.get_playlist_path(playlist_id), "w") as f:
             f.write(playlist.json(indent=4))
 
-    def load_playlist(self, name: str) -> Playlist:
-        path = self.get_playlist_path(name)
+    def load_playlist(self, playlist_id: str) -> Playlist:
+        path = self.get_playlist_path(playlist_id)
         if not path.exists():
             raise FileNotFoundError(f"Playlist file not found: {path}")
         return Playlist.parse_file(path)
 
-    def delete_playlist(self, name: str) -> None:
-        path = self.get_playlist_path(name)
+    def delete_playlist(self, playlist_id: str) -> None:
+        path = self.get_playlist_path(playlist_id)
         if not path.exists():
             raise FileNotFoundError(f"Playlist file not found: {path}")
         path.unlink()
+
+    def service_config_path(self, service_name: str) -> Path:
+        return (
+            self.service_configs_path / f"{format_filename(service_name)}_config.json"
+        )
+
+    def save_service_config(self, service_name: str, config: ServiceConfig) -> None:
+        self.service_configs_path.mkdir(exist_ok=True)
+        path = self.service_config_path(service_name)
+        with open(path, "w") as f:
+            f.write(config.json(indent=4))
+
+    def delete_service_config(self, service_name: str) -> None:
+        path = self.service_config_path(service_name)
+        if not path.exists():
+            raise FileNotFoundError(f"Service config file not found: {path}")
+        path.unlink()
diff --git a/unitunes/index.py b/unitunes/index.py
index f87d231..fdbfb6c 100644
--- a/unitunes/index.py
+++ b/unitunes/index.py
@@ -1,10 +1,12 @@
 from pydantic import BaseModel
 from typing import Dict, List
 
+from unitunes.types import ServiceType
+
 
 class IndexServiceEntry(BaseModel):
     name: str
-    service: str
+    service: ServiceType
     config_path: str
 
 
@@ -16,12 +18,12 @@ class Index(BaseModel):
     services: Dict[str, IndexServiceEntry] = {}
     playlists: List[str] = []
 
-    def add_playlist(self, name: str):
-        if name in self.playlists:
-            raise ValueError(f"Playlist {name} already exists")
-        self.playlists.append(name)
+    def add_playlist(self, playlist_id: str):
+        if playlist_id in self.playlists:
+            raise ValueError(f"Playlist {playlist_id} already exists")
+        self.playlists.append(playlist_id)
 
-    def add_service(self, name: str, service: str, config_path: str):
+    def add_service(self, name: str, service: ServiceType, config_path: str):
         if name in self.services:
             raise ValueError(f"Service {name} already exists")
         self.services[name] = IndexServiceEntry(
diff --git a/unitunes/main.py b/unitunes/main.py
index effff95..4d92976 100644
--- a/unitunes/main.py
+++ b/unitunes/main.py
@@ -1,17 +1,28 @@
-import json
-from typing import Dict, List, Optional
+from typing import Callable, Dict, List, Optional, Tuple
 from pathlib import Path
 from unitunes.file_manager import FileManager
 from unitunes.index import Index
-from unitunes.matcher import MatcherStrategy
+from unitunes.matcher import DefaultMatcherStrategy, MatcherStrategy
 from unitunes.playlist import Playlist
-from unitunes.searcher import SearcherStrategy
+from unitunes.pull_playlist import (
+    add_changed_uris,
+    get_invalid_uris,
+    get_missing_uris,
+    merge_new_tracks,
+    remove_tracks,
+    remove_uris,
+    tracks_to_add,
+    tracks_to_remove,
+)
+from unitunes.searcher import DefaultSearcherStrategy, SearcherStrategy
 from unitunes.services.beatsaber import (
+    BeatsaberConfig,
     BeatsaberService,
-    BeatsaverAPIWrapper,
 )
-from unitunes.services.musicbrainz import MusicBrainz, MusicBrainzWrapper
+from unitunes.services.musicbrainz import MusicBrainz
 from unitunes.services.services import (
+    PlaylistPullable,
+    Pushable,
     Searchable,
     StreamingService,
     TrackPullable,
@@ -19,13 +30,13 @@
 
 
 from unitunes.services.spotify import (
-    SpotifyAPIWrapper,
+    SpotifyConfig,
     SpotifyService,
 )
-from unitunes.services.ytm import YTM, YtmAPIWrapper
+from unitunes.services.ytm import YTM, YtmConfig
 from unitunes.track import Track
 from unitunes.types import ServiceType
-from unitunes.uri import PlaylistURIs, TrackURI
+from unitunes.uri import PlaylistURIs, TrackURI, TrackURIs
 
 
 def service_factory(
@@ -37,17 +48,18 @@ def service_factory(
 
     if service_type == ServiceType.SPOTIFY:
         assert config_path is not None
-        index = json.load(config_path.open())
-        return SpotifyService(name, SpotifyAPIWrapper(index, cache_path))
+        config = SpotifyConfig.parse_file(config_path)
+        return SpotifyService(name, config, cache_path)
     elif service_type == ServiceType.YTM:
         assert config_path is not None
-        return YTM(name, YtmAPIWrapper(config_path, cache_path))
+        config = YtmConfig.parse_file(config_path)
+        return YTM(name, config, cache_path)
     elif service_type == ServiceType.MB:
-        return MusicBrainz(MusicBrainzWrapper(cache_path))
+        return MusicBrainz(cache_path)
     elif service_type == ServiceType.BEATSABER:
         assert config_path is not None
-        config = json.load(config_path.open())
-        return BeatsaberService(name, BeatsaverAPIWrapper(cache_path), config)
+        config = BeatsaberConfig.parse_file(config_path)
+        return BeatsaberService(name, config, cache_path)
     else:
         raise ValueError(f"Unknown service type: {service_type}")
 
@@ -76,41 +88,42 @@ def load_services(self) -> None:
             "MusicBrainz",
             cache_path=self.file_manager.cache_path,
         )
+        self.services = {}
         for s in self.index.services.values():
             service_config_path = Path(s.config_path)
-            self.services[s.name] = service_factory(
-                ServiceType(s.service),
-                s.name,
-                config_path=service_config_path,
-                cache_path=self.file_manager.cache_path,
-            )
+            try:
+                self.services[s.name] = service_factory(
+                    s.service,
+                    s.name,
+                    config_path=service_config_path,
+                    cache_path=self.file_manager.cache_path,
+                )
+            except Exception as e:
+                print(f"Failed to load service {s.name}: {e}")
 
     def add_service(
         self, service: ServiceType, service_config_path: Path, name: str
     ) -> None:
-        self.index.add_service(
-            name, service.value, service_config_path.absolute().as_posix()
-        )
-        self.load_services()
+        self.index.add_service(name, service, service_config_path.absolute().as_posix())
         self.file_manager.save_index(self.index)
 
     def remove_service(self, name: str) -> None:
         if name not in self.index.services:
             raise ValueError(f"Service {name} not found")
         self.index.remove_service(name)
+        self.file_manager.delete_service_config(name)
 
         for playlist in self.playlists.values():
             playlist.remove_service(name)
-            self.file_manager.save_playlist(playlist)
 
-        self.file_manager.save_index(self.index)
+        self.save_index()
 
-    def add_playlist(self, name: str) -> None:
+    def add_playlist(self, playlist_id: str) -> None:
         """Initialize a UP. Raise ValueError if the playlist already exists."""
-        self.index.add_playlist(name)
-        self.playlists[name] = Playlist(name=name)
-        self.file_manager.save_index(self.index)
-        self.file_manager.save_playlist(self.playlists[name])
+        self.index.add_playlist(playlist_id)
+        self.playlists[playlist_id] = Playlist(name=playlist_id)
+        self.save_index()
+        self.save_playlist(playlist_id)
 
     def remove_playlist(self, name: str) -> None:
         """Remove a playlist from the index and filesystem."""
@@ -119,20 +132,37 @@ def remove_playlist(self, name: str) -> None:
         self.file_manager.delete_playlist(name)
         del self.playlists[name]
         self.index.remove_playlist(name)
-        self.file_manager.save_index(self.index)
+        self.save_index()
 
     def add_uri_to_playlist(
-        self, playlist_name: str, service_name: str, uri: PlaylistURIs
+        self, playlist_id: str, service_name: str, uri: PlaylistURIs
     ) -> None:
         """Link a playlist URI to a UP. UP must exist."""
-        pl = self.playlists[playlist_name]
+        pl = self.playlists[playlist_id]
         pl.add_uri(service_name, uri)
+        self.save_playlist(playlist_id)
+
+    def save_playlist(self, playlist_id: str) -> None:
+        self.file_manager.save_playlist(self.playlists[playlist_id], playlist_id)
 
+    def save_index(self) -> None:
         self.file_manager.save_index(self.index)
-        self.file_manager.save_playlist(pl)
 
-    def save_playlist(self, playlist_name: str) -> None:
-        self.file_manager.save_playlist(self.playlists[playlist_name])
+    def update_playlist_id(self, old_id: str) -> str:
+        """Update the playlist id to match the playlist name in the index and filesystem.
+        Return the new id."""
+        pl = self.playlists[old_id]
+        new_id = pl.name
+        while new_id in self.index.playlists:
+            new_id += "_"
+
+        self.remove_playlist(old_id)
+        self.index.add_playlist(new_id)
+        self.playlists[new_id] = pl
+
+        self.save_playlist(new_id)
+        self.save_index()
+        return new_id
 
     def is_tracking_playlist(self, uri: PlaylistURIs) -> bool:
         for playlist in self.playlists.values():
@@ -141,6 +171,170 @@ def is_tracking_playlist(self, uri: PlaylistURIs) -> bool:
                     return True
         return False
 
+    ###########################################################################
+    # Pulling and pushing
+    ###########################################################################
+
+    def pull_playlist(
+        self,
+        playlist_id: str,
+        progress_callback: Callable[[int, int], None] = lambda x, y: None,
+    ) -> None:
+        """Pull all tracks from a playlist from its services."""
+        playlist = self.playlists[playlist_id]
+
+        new_tracks: List[Track] = []
+        missing_uris: List[TrackURIs] = []
+        invalid_uris: List[TrackURIs] = []
+
+        pullable_services = [
+            service_name
+            for service_name in playlist.uris
+            if isinstance(self.services[service_name], PlaylistPullable)
+        ]
+
+        progress = 0
+        progress_callback(progress, len(pullable_services))
+
+        for service_name in pullable_services:
+            service = self.services[service_name]
+            assert isinstance(service, PlaylistPullable)
+
+            for uri in playlist.uris[service_name]:
+                # Pull metadata
+                remote_metadata = service.pull_metadata(uri)
+                playlist.merge_metadata(remote_metadata)
+
+                # Get remote tracks
+                remote_tracks = service.pull_tracks(uri)
+
+                # Record new tracks not already in the playlist
+                new_tracks.extend(
+                    tracks_to_add(service.type, playlist.tracks, remote_tracks)
+                )
+
+                # Update URIs if they do not match the remote URIs (YTM URIs are not stable)
+                add_changed_uris(playlist.tracks, remote_tracks)
+
+                # Record URIs that are no longer in the remote
+                new_missing = get_missing_uris(
+                    service.type, playlist.tracks, remote_tracks
+                )
+
+                # Record URIs that are invalid (e.g. not found on the service. Usually YTM)
+                invalid_uris.extend(get_invalid_uris(service, new_missing))
+
+                # Remove invalid URIs from the missing list
+                new_missing = [uri for uri in new_missing if uri not in invalid_uris]
+
+                missing_uris.extend(new_missing)
+
+            # Update progress
+            progress += 1
+            progress_callback(progress, len(pullable_services))
+
+        remove_uris(playlist.tracks, invalid_uris)
+
+        print(f"{len(new_tracks)} new tracks")
+        print(f"{len(missing_uris)} missing tracks")
+
+        merge_new_tracks(playlist.tracks, new_tracks, DefaultMatcherStrategy())
+        remove_tracks(playlist.tracks, missing_uris)
+
+    def push_playlist(
+        self,
+        playlist_id: str,
+        progress_callback: Callable[[int, int], None] = lambda x, y: None,
+    ) -> None:
+        """Push all tracks from a playlist to its services."""
+        playlist = self.playlists[playlist_id]
+
+        pushable_services = [
+            service_name
+            for service_name in playlist.uris
+            if isinstance(self.services[service_name], Pushable)
+        ]
+
+        progress = 0
+        progress_callback(progress, len(pushable_services))
+
+        for service_name in pushable_services:
+            service = self.services[service_name]
+            assert isinstance(service, Pushable)
+
+            for uri in playlist.uris[service_name]:
+                # Update remote metadata
+                service.update_metadata(uri, playlist.metadata())
+
+                # Update remote tracks
+                current_tracks = service.pull_tracks(uri)
+                new_tracks = tracks_to_add(
+                    service.type, current_tracks, playlist.tracks
+                )
+                removed_tracks = tracks_to_remove(
+                    service.type, current_tracks, playlist.tracks
+                )
+
+                if new_tracks:
+                    service.add_tracks(uri, new_tracks)
+                if removed_tracks:
+                    service.remove_tracks(uri, removed_tracks)
+
+            # Update progress
+            progress += 1
+            progress_callback(progress, len(pushable_services))
+
+    def search_playlist(
+        self,
+        playlist_name: str,
+        progress_callback: Callable[[int, int], None] = lambda x, y: None,
+    ) -> None:
+
+        """
+        Search for tracks on a service. Adds found URI's to tracks.
+        """
+
+        playlist = self.playlists[playlist_name]
+
+        searchable_services = [
+            service_name
+            for service_name in playlist.uris
+            if isinstance(self.services[service_name], Searchable)
+        ]
+
+        tracks_to_search: List[Tuple[str, Track]] = []
+
+        for service_name in searchable_services:
+            service = self.services[service_name]
+            assert isinstance(service, Searchable)
+
+            tracks_to_search.extend(
+                [
+                    (service_name, track)
+                    for track in playlist.tracks
+                    if not track.find_uri(service.type)
+                ]
+            )
+        print(f"{len(tracks_to_search)} tracks to search")
+
+        matcher = DefaultMatcherStrategy()
+        searcher = DefaultSearcherStrategy(matcher)
+
+        progress = 0
+        progress_callback(progress, len(tracks_to_search))
+
+        for service_name, track in tracks_to_search:
+            service = self.services[service_name]
+            predicted = get_prediction_track(
+                service, track, matcher, searcher, threshold=0.7
+            )
+            if predicted:
+                track.merge(predicted)
+
+            # Update progress
+            progress += 1
+            progress_callback(progress, len(tracks_to_search))
+
 
 def get_predicted_tracks(
     target_service: StreamingService,
diff --git a/unitunes/playlist.py b/unitunes/playlist.py
index 617fde2..eadc9af 100644
--- a/unitunes/playlist.py
+++ b/unitunes/playlist.py
@@ -16,6 +16,11 @@ class PlaylistMetadata(BaseModel):
     uri: PlaylistURIs
 
 
+class PlaylistDetails(BaseModel):
+    name: str
+    description: str
+
+
 class Playlist(BaseModel):
     name: str
     description: str = ""
@@ -47,7 +52,8 @@ def remove_uri(self, service_name: str, uri: PlaylistURIs) -> None:
             del self.uris[service_name]
 
     def remove_service(self, service_name: str) -> None:
-        del self.uris[service_name]
+        if service_name in self.uris:
+            del self.uris[service_name]
 
     def contains_uri(self, uri: PlaylistURIs) -> bool:
         for service_name, uris in self.uris.items():
@@ -78,3 +84,13 @@ def merge_uris():
 
         for track in playlist.tracks:
             self.merge_track(track, matcher)
+
+    def metadata(self) -> PlaylistDetails:
+        return PlaylistDetails(
+            name=self.name,
+            description=self.description,
+        )
+
+    def merge_metadata(self, metadata: PlaylistDetails) -> None:
+        self.name = metadata.name
+        self.description = metadata.description
diff --git a/unitunes/pull_playlist.py b/unitunes/pull_playlist.py
new file mode 100644
index 0000000..4528463
--- /dev/null
+++ b/unitunes/pull_playlist.py
@@ -0,0 +1,127 @@
+from typing import List
+
+from unitunes.services.services import StreamingService
+
+from unitunes.track import Track
+from unitunes.uri import TrackURIs
+
+from unitunes.matcher import DefaultMatcherStrategy, MatcherStrategy
+from unitunes.services.services import (
+    Checkable,
+    StreamingService,
+)
+from unitunes.track import Track
+
+from unitunes.types import ServiceType
+from unitunes.uri import TrackURIs
+
+
+def get_missing_uris(
+    service: ServiceType, current: List[Track], new: List[Track]
+) -> List[TrackURIs]:
+    def tracks_to_uris(tracks: List[Track]) -> List[TrackURIs]:
+        uris = [track.uris for track in tracks]
+        flat_uris = [uri for uri_list in uris for uri in uri_list]
+        return flat_uris
+
+    uris_on_service = [uri for uri in tracks_to_uris(current) if uri.service == service]
+    remote = tracks_to_uris(new)
+    missing = [uri for uri in uris_on_service if uri not in remote]
+    return missing
+
+
+def get_invalid_uris(
+    service: StreamingService, uris: List[TrackURIs]
+) -> List[TrackURIs]:
+    return [
+        uri
+        for uri in uris
+        if isinstance(service, Checkable) and not service.is_uri_alive(uri)
+    ]
+
+
+def merge_new_tracks(
+    tracks: List[Track], new_tracks: List[Track], matcher: MatcherStrategy
+) -> None:
+    for track in new_tracks:
+        matches = [t for t in tracks if matcher.are_same(t, track)]
+        if matches:
+            for match in matches:
+                match.merge(track)
+        else:
+            tracks.append(track)
+
+
+def remove_tracks(current_tracks: List[Track], missing: List[TrackURIs]) -> None:
+    for missing_uri in missing:
+        matches = [t for t in current_tracks if missing_uri in t.uris]
+        for t in matches:
+            print(f"Track {t.name.value} not found in playlist")
+            # remove uri
+            t.uris.remove(missing_uri)
+            t.bad_uris.append(missing_uri)
+            print(f"Removed {missing_uri.url} from {t.name.value} and marked it as bad")
+
+
+def add_changed_uris(current_tracks: List[Track], remote_tracks: List[Track]) -> None:
+    """Finds matching tracks with different uris and adds them"""
+    matcher = DefaultMatcherStrategy()
+
+    def fix_track_uri(track: Track) -> None:
+        matches = [t for t in remote_tracks if matcher.are_same(t, track)]
+        if not matches:
+            return
+        new_uri = matches[0].uris[0]
+        if new_uri not in track.uris:
+            # remove other uris on this service
+            track.uris = [u for u in track.uris if u.service != new_uri.service]
+
+            track.uris.append(new_uri)
+            print(f"{track.name.value} updated uri to {new_uri.url}")
+
+    for track in current_tracks:
+        fix_track_uri(track)
+
+
+def remove_uris(current_tracks: List[Track], uris: List[TrackURIs]) -> None:
+    for track in current_tracks:
+        for uri in uris:
+            if uri in track.uris:
+                track.uris.remove(uri)
+                track.bad_uris.append(uri)
+                print(f"Removed invalid URL {uri.url} from {track.name.value}")
+
+
+def tracks_match_and_on_service(
+    service: ServiceType,
+    t1: Track,
+    t2: Track,
+    matcher: MatcherStrategy = DefaultMatcherStrategy(),
+) -> bool:
+    return (
+        matcher.are_same(t1, t2)
+        and t1.is_on_service(service)
+        and t2.is_on_service(service)
+    )
+
+
+def tracks_to_add(
+    service: ServiceType, current: List[Track], new: List[Track]
+) -> List[Track]:
+    new_on_service = [track for track in new if track.is_on_service(service)]
+    return [
+        track
+        for track in new_on_service
+        if not any(tracks_match_and_on_service(service, track, t) for t in current)
+    ]
+
+
+def tracks_to_remove(
+    service: ServiceType, current: List[Track], new: List[Track]
+) -> List[Track]:
+    current_on_service = [track for track in current if track.is_on_service(service)]
+    return [
+        track
+        for track in current_on_service
+        if not any(tracks_match_and_on_service(service, t, track) for t in new)
+    ]
diff --git a/unitunes/services/beatsaber.py b/unitunes/services/beatsaber.py
index 3346962..4e083fa 100644
--- a/unitunes/services/beatsaber.py
+++ b/unitunes/services/beatsaber.py
@@ -1,33 +1,39 @@
-from abc import ABC, abstractmethod
 from pathlib import Path
 from typing import Any, List
+from platformdirs import user_documents_dir
 
 from pydantic import BaseModel
 from unitunes.file_manager import format_filename
-from unitunes.playlist import Playlist, PlaylistMetadata
+from unitunes.playlist import PlaylistDetails, PlaylistMetadata
 import requests
 
 from unitunes.services.services import (
-    Pushable,
-    Searchable,
+    ServiceConfig,
     ServiceWrapper,
     StreamingService,
-    TrackPullable,
-    UserPlaylistPullable,
     cache,
 )
 from unitunes.track import AliasedString, Track
 from unitunes.types import ServiceType
 from unitunes.uri import (
-    URI,
     BeatsaberPlaylistURI,
     BeatsaberTrackURI,
     PlaylistURI,
-    PlaylistURIs,
-    TrackURI,
 )
 
 
+class BeatsaberSearchConfig(BaseModel):
+    minNps: int = 0
+    maxNps: int = 1000
+    minRating: float = 0.51
+    sortOrder: str = "Relevance"
+
+
+class BeatsaberConfig(ServiceConfig):
+    dir: Path = Path(user_documents_dir()) / "Beatsaber"
+    search_config: BeatsaberSearchConfig = BeatsaberSearchConfig()
+
+
 class BeatsaverAPIWrapper(ServiceWrapper):
     def __init__(self, cache_root) -> None:
         super().__init__("beatsaver", cache_root=cache_root)
@@ -55,29 +61,25 @@ class BPListSong(BaseModel):
 
 
 class BPList(BaseModel):
-    playlistTitle: str
-    playlistAuthor: str
-    playlistDescription: str
-    image: str
-    songs: List[BPListSong]
+    playlistTitle: str = ""
+    playlistAuthor: str = ""
+    playlistDescription: str = ""
+    image: str = ""
+    songs: List[BPListSong] = []
 
 
 class BeatsaberService(StreamingService):
     # Tracks are online at beatsaver.com, playlists are local .bplist files
     wrapper: BeatsaverAPIWrapper
-    search_config = {}
-    dir: Path
+    config: BeatsaberConfig
 
-    def __init__(self, name: str, wrapper: BeatsaverAPIWrapper, config) -> None:
-        super().__init__(name, ServiceType.BEATSABER)
-        self.wrapper = wrapper
+    def __init__(self, name: str, config: BeatsaberConfig, cache_root: Path) -> None:
+        super().__init__(name, ServiceType.BEATSABER, cache_root)
+        self.wrapper = BeatsaverAPIWrapper(cache_root)
+        self.load_config(config)
 
-        if "dir" not in config:
-            raise ValueError("No beatsaber directory specified")
-        self.dir = Path(config["dir"])
-
-        if "search_config" in config:
-            self.search_config = config["search_config"]
+    def load_config(self, config: BeatsaberConfig) -> None:
+        self.config = config
 
     def pull_track(self, uri: BeatsaberTrackURI) -> Track:
         res = self.wrapper.map(uri.uri)
@@ -93,7 +95,7 @@ def search_query(self, query: str) -> List[Track]:
         results = self.wrapper.search(
             query,
             0,
-            search_config=self.search_config,
+            search_config=self.config.search_config.dict(),
         )
         return [
             self.pull_track(BeatsaberTrackURI.from_uri(res["id"])) for res in results
@@ -108,7 +110,7 @@ def query_generator(self, track: Track) -> List[str]:
     def get_playlist_metadatas(self) -> list[PlaylistMetadata]:
         # find .bplist files in the beatsaber directory
         playlists = []
-        for file in self.dir.iterdir():
+        for file in self.config.dir.iterdir():
             if file.suffix == ".bplist":
                 bp = BPList.parse_file(file)
                 playlists.append(
@@ -122,18 +124,20 @@ def get_playlist_metadatas(self) -> list[PlaylistMetadata]:
         return playlists
 
     def pull_tracks(self, uri: PlaylistURI) -> List[Track]:
-        bp = BPList.parse_file(self.dir / (uri.uri))
+        # create a new playlist if it doesn't exist
+        path = self.config.dir / uri.uri
+        if not path.exists():
+            raise FileNotFoundError(f"{path} does not exist. Try pushing first.")
+
+        bp = BPList.parse_file(path)
         return [
             self.pull_track(BeatsaberTrackURI.from_uri(song.key)) for song in bp.songs
         ]
 
-    def write_bplist(self, playlist_uri: PlaylistURI, bp: BPList) -> None:
-        with (self.dir / (playlist_uri.uri)).open("w") as f:
+    def write_bplist(self, playlist_uri: BeatsaberPlaylistURI, bp: BPList) -> None:
+        with (self.config.dir / playlist_uri.uri).open("w") as f:
             f.write(bp.json(indent=4))
 
-    def read_playlist(self, playlist_uri: PlaylistURI) -> BPList:
-        return BPList.parse_file(self.dir / (playlist_uri.uri))
-
     def create_playlist(
         self, title: str, description: str = ""
     ) -> BeatsaberPlaylistURI:
@@ -159,6 +163,12 @@ def get_song(self, track: Track) -> BPListSong:
             songName=results["name"],
         )
 
+    def read_playlist(self, playlist_uri: BeatsaberPlaylistURI) -> BPList:
+        path = self.config.dir / playlist_uri.uri
+        if not path.exists():
+            return BPList()
+        return BPList.parse_file(self.config.dir / (playlist_uri.uri))
+
     def add_tracks(
         self, playlist_uri: BeatsaberPlaylistURI, tracks: List[Track]
     ) -> None:
@@ -167,10 +177,27 @@ def add_tracks(
         bp.songs.extend(new_songs)
         self.write_bplist(playlist_uri, bp)
 
-    def remove_tracks(self, playlist_uri: PlaylistURI, tracks: List[Track]) -> None:
+    def remove_tracks(
+        self, playlist_uri: BeatsaberPlaylistURI, tracks: List[Track]
+    ) -> None:
         bp = self.read_playlist(playlist_uri)
         removed_songs = [self.get_song(track) for track in tracks]
         bp.songs = [
             song for song in bp.songs if song.key not in [s.key for s in removed_songs]
         ]
         self.write_bplist(playlist_uri, bp)
+
+    def pull_metadata(self, uri: BeatsaberPlaylistURI) -> PlaylistDetails:
+        bp = self.read_playlist(uri)
+        return PlaylistDetails(
+            name=bp.playlistTitle,
+            description=bp.playlistDescription,
+        )
+
+    def update_metadata(
+        self, uri: BeatsaberPlaylistURI, metadata: PlaylistDetails
+    ) -> None:
+        bp = self.read_playlist(uri)
+        bp.playlistTitle = metadata.name
+        bp.playlistDescription = metadata.description
+        self.write_bplist(uri, bp)
diff --git a/unitunes/services/musicbrainz.py b/unitunes/services/musicbrainz.py
index 22561b5..8b76785 100644
--- a/unitunes/services/musicbrainz.py
+++ b/unitunes/services/musicbrainz.py
@@ -6,10 +6,8 @@
 
 import requests
 from unitunes.services.services import (
-    Searchable,
     ServiceWrapper,
     StreamingService,
-    TrackPullable,
     cache,
 )
 from unitunes.track import AliasedString, Track
@@ -56,9 +54,12 @@ def get_release_by_id(self, *args, use_cache=True, **kwargs):
 class MusicBrainz(StreamingService):
     wrapper: MusicBrainzWrapper
 
-    def __init__(self, wrapper: MusicBrainzWrapper) -> None:
-        super().__init__("MusicBrainz", ServiceType.MB)
-        self.wrapper = wrapper
+    def __init__(self, cache_root: Path) -> None:
+        super().__init__("MusicBrainz", ServiceType.MB, cache_root)
+        self.wrapper = MusicBrainzWrapper(cache_root)
+
+    def load_config(self, config) -> None:
+        pass
 
     @staticmethod
     def parse_track(recording):
diff --git a/unitunes/services/services.py b/unitunes/services/services.py
index cfc141c..d578f4e 100644
--- a/unitunes/services/services.py
+++ b/unitunes/services/services.py
@@ -8,13 +8,19 @@
     Protocol,
     runtime_checkable,
 )
-from unitunes.playlist import PlaylistMetadata
+
+from pydantic import BaseModel
+from unitunes.playlist import PlaylistDetails, PlaylistMetadata
 from unitunes.track import Track
 
 from unitunes.types import ServiceType
 from unitunes.uri import PlaylistURI, PlaylistURIs, TrackURI, TrackURIs
 
 
+class ServiceConfig(ABC, BaseModel):
+    pass
+
+
 def cache(method):
     def wrapper(self, *args, use_cache=True, **kwargs):
         file_path = self.cache_path / f"{method.__name__}.json"
@@ -67,6 +73,10 @@ class PlaylistPullable(Protocol):
     def pull_tracks(self, uri: PlaylistURI) -> List[Track]:
         """Gets tracks from a playlist"""
 
+    @abstractmethod
+    def pull_metadata(self, uri: PlaylistURI) -> PlaylistDetails:
+        """Gets metadata from a playlist"""
+
 
 @runtime_checkable
 class TrackPullable(Protocol):
@@ -101,6 +111,12 @@ def add_tracks(self, playlist_uri: PlaylistURI, tracks: List[Track]) -> None:
     def remove_tracks(self, playlist_uri: PlaylistURI, tracks: List[Track]) -> None:
         """Removes tracks from a playlist"""
 
+    @abstractmethod
+    def update_metadata(
+        self, playlist_uri: PlaylistURI, metadata: PlaylistDetails
+    ) -> None:
+        """Updates the metadata of a playlist"""
+
 
 @runtime_checkable
 class Checkable(Protocol):
@@ -113,7 +129,14 @@ class StreamingService(ABC):
     name: str
     type: ServiceType
     wrapper: ServiceWrapper
+    config: ServiceConfig
+    cache_root: Path
 
-    def __init__(self, name: str, type: ServiceType) -> None:
+    def __init__(self, name: str, type: ServiceType, cache_root: Path) -> None:
         self.name = name
         self.type = type
+        self.cache_root = cache_root
+
+    @abstractmethod
+    def load_config(self, config: ServiceConfig) -> None:
+        """Loads the config for the service"""
diff --git a/unitunes/services/spotify.py b/unitunes/services/spotify.py
index 691fa2c..8e4fcfe 100644
--- a/unitunes/services/spotify.py
+++ b/unitunes/services/spotify.py
@@ -1,10 +1,11 @@
-from abc import ABC, abstractmethod
-from typing import Any, List
+from pathlib import Path
+from typing import List
 import spotipy
 from spotipy import SpotifyOAuth
-from unitunes.playlist import PlaylistMetadata
+from unitunes.playlist import PlaylistDetails, PlaylistMetadata
 
 from unitunes.services.services import (
+    ServiceConfig,
     ServiceWrapper,
     StreamingService,
     cache,
@@ -12,80 +13,29 @@
 from unitunes.track import AliasedString, Track
 from unitunes.types import ServiceType
 from unitunes.uri import (
-    URI,
-    PlaylistURIs,
+    AlbumURI,
     SpotifyPlaylistURI,
     SpotifyTrackURI,
 )
 
 
-class SpotifyWrapper(ServiceWrapper, ABC):
-    @abstractmethod
-    def track(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
+class SpotifyConfig(ServiceConfig):
+    client_id: str = ""
+    client_secret: str = ""
+    redirect_uri: str = ""
 
-    @abstractmethod
-    def album_tracks(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
 
-    @cache
-    @abstractmethod
-    def search(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def create_playlist(self, title: str, description: str = "") -> str:
-        pass
-
-    @abstractmethod
-    def add_tracks(self, playlist_id: str, tracks: List[str]) -> None:
-        pass
-
-    @abstractmethod
-    def remove_tracks(self, playlist_id: str, tracks: List[str]) -> None:
-        pass
-
-    @abstractmethod
-    def current_user_playlists(self, *args, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def user_playlist_replace_tracks(self, *args, **kwargs):
-        pass
-
-    @abstractmethod
-    def playlist_tracks(self, *args, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def current_user(self, *args, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def user_playlist_create(self, *args, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def current_user_saved_tracks(self, limit: int = 20, offset: int = 0) -> Any:
-        pass
-
-    @abstractmethod
-    def current_user_saved_tracks_add(self, tracks: List[str]) -> None:
-        pass
-
-    @abstractmethod
-    def current_user_saved_tracks_delete(self, tracks: List[str]) -> None:
-        pass
-
-
-class SpotifyAPIWrapper(SpotifyWrapper):
-    def __init__(self, config, cache_root) -> None:
+class SpotifyAPIWrapper(ServiceWrapper):
+    def __init__(self, config: SpotifyConfig, cache_root) -> None:
         super().__init__("spotify", cache_root=cache_root)
+        self.init_config(config)
+
+    def init_config(self, config: SpotifyConfig) -> None:
         self.sp = spotipy.Spotify(
             auth_manager=SpotifyOAuth(
-                client_id=config["client_id"],
-                client_secret=config["client_secret"],
-                redirect_uri=config["redirect_uri"],
+                client_id=config.client_id,
+                client_secret=config.client_secret,
+                redirect_uri=config.redirect_uri,
                 scope="user-library-read playlist-modify-private, playlist-modify-public, user-library-modify playlist-read-private",
             )
         )
@@ -100,11 +50,8 @@ def album_tracks(self, *args, use_cache=True, **kwargs):
 
     @cache
     def search(self, *args, use_cache=True, **kwargs):
-        try:
-            return self.sp.search(*args, **kwargs)
-        except Exception as e:
-            print(e)
-            return {"tracks": {"items": []}}
+        # Query length must be less than or equal to 100 characters, otherwise 404 is returned
+        return self.sp.search(*args, **kwargs)
 
     def create_playlist(self, title: str, description: str = "") -> str:
         id = self.sp.user_playlist_create(self.sp.me()["id"], title, public=False)["id"]
@@ -131,6 +78,14 @@ def user_playlist_replace_tracks(self, *args, **kwargs):
     def playlist_tracks(self, *args, **kwargs):
         return self.sp.playlist_items(*args, **kwargs)
 
+    def playlist_metadata(self, playlist_id: str) -> PlaylistDetails:
+        if playlist_id == "Liked Songs":
+            # Liked songs has no metadata
+            return PlaylistDetails(name="Liked Songs", description="")
+
+        res = self.sp.playlist(playlist_id, fields="name,description")
+        return PlaylistDetails(name=res["name"], description=res["description"])
+
     def current_user(self, *args, **kwargs):
         return self.sp.current_user(*args, **kwargs)
 
@@ -154,13 +109,32 @@ def current_user_saved_tracks_delete(self, tracks: List[str]):
         for chunk in chunks:
             self.sp.current_user_saved_tracks_delete(chunk)
 
+    def change_details(self, playlist_id: str, title: str, description: str):
+        if playlist_id == "Liked Songs":
+            # Liked songs has no metadata
+            return
+
+        if not description:
+            self.sp.user_playlist_change_details(
+                self.sp.me()["id"], playlist_id, name=title
+            )
+        else:
+            self.sp.user_playlist_change_details(
+                self.sp.me()["id"], playlist_id, name=title, description=description
+            )
+
 
 class SpotifyService(StreamingService):
-    wrapper: SpotifyWrapper
+    wrapper: SpotifyAPIWrapper
+    config: SpotifyConfig
 
-    def __init__(self, name: str, wrapper: SpotifyWrapper) -> None:
-        super().__init__(name, ServiceType.SPOTIFY)
-        self.wrapper = wrapper
+    def __init__(self, name: str, config: SpotifyConfig, cache_root: Path) -> None:
+        super().__init__(name, ServiceType.SPOTIFY, cache_root)
+        self.load_config(config)
+
+    def load_config(self, config: SpotifyConfig) -> None:
+        self.config = config
+        self.wrapper = SpotifyAPIWrapper(config, self.cache_root)
 
     def get_playlist_metadatas(self) -> list[PlaylistMetadata]:
         results = self.wrapper.current_user_playlists()
@@ -212,18 +186,21 @@ def get_liked_tracks(offset: int) -> list[Track]:
         tracks = [track for track in tracks if track.uris]
         return tracks
 
-    def get_tracks_in_album(self, album_uri: URI) -> List[Track]:
+    def get_tracks_in_album(self, album_uri: AlbumURI) -> List[Track]:
         album_id = album_uri.uri.split("/")[-1]
         results = self.wrapper.album_tracks(album_id)
         return [self.raw_to_track(track) for track in results["items"]]
 
-    def pull_track(self, uri: URI) -> Track:
+    def pull_track(self, uri: SpotifyTrackURI) -> Track:
         track_id = uri.uri.split("/")[-1]
         results = self.wrapper.track(track_id)
         if not results:
             raise ValueError(f"Track {uri} not found")
         return self.raw_to_track(results)
 
+    def pull_metadata(self, uri: SpotifyPlaylistURI) -> PlaylistDetails:
+        return self.wrapper.playlist_metadata(uri.uri)
+
     def raw_to_track(self, raw: dict) -> Track:
         return Track(
             name=AliasedString(value=raw["name"]),
@@ -236,6 +213,8 @@ def raw_to_track(self, raw: dict) -> Track:
         )
 
     def search_query(self, query: str) -> List[Track]:
+        query = query[:100]
+
         results = self.wrapper.search(query, limit=5, type="track")
         return list(
             map(
@@ -255,7 +234,7 @@ def query_generator(self, track: Track) -> List[str]:
 
         return [query]
 
-    def create_playlist(self, name: str, description: str = "") -> PlaylistURIs:
+    def create_playlist(self, name: str, description: str = "") -> SpotifyPlaylistURI:
         playlist = self.wrapper.user_playlist_create(
             self.wrapper.current_user()["id"],
             name,
@@ -290,3 +269,10 @@ def remove_tracks(
             self.wrapper.current_user_saved_tracks_delete(track_ids)
         else:
             self.wrapper.remove_tracks(playlist_uri.uri, track_ids)
+
+    def update_metadata(
+        self, playlist_uri: SpotifyPlaylistURI, metadata: PlaylistDetails
+    ) -> None:
+        self.wrapper.change_details(
+            playlist_uri.uri, metadata.name, metadata.description
+        )
diff --git a/unitunes/services/ytm.py b/unitunes/services/ytm.py
index 0020287..6e2a09b 100644
--- a/unitunes/services/ytm.py
+++ b/unitunes/services/ytm.py
@@ -1,20 +1,16 @@
-from abc import ABC, abstractmethod
 from pathlib import Path
 import time
-from typing import Any, List, Optional
+from typing import List, Optional
 from tqdm import tqdm
 from ytmusicapi import YTMusic
-from unitunes.playlist import PlaylistMetadata
+from unitunes.playlist import PlaylistDetails, PlaylistMetadata
 from youtube_title_parse import get_artist_title
 
 
 from unitunes.services.services import (
-    Pushable,
-    Searchable,
+    ServiceConfig,
     ServiceWrapper,
     StreamingService,
-    TrackPullable,
-    UserPlaylistPullable,
     cache,
 )
 from unitunes.track import (
@@ -31,48 +27,18 @@
 )
 
 
-class YtmWrapper(ServiceWrapper, ABC):
-    @abstractmethod
-    def get_playlist(self, *args, **kwargs) -> Any:
-        pass
+class YtmConfig(ServiceConfig):
+    headers: str = "accept: */*\naccept-encoding: ... Paste your headers here ..."
 
-    @abstractmethod
-    def get_song(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
 
-    @abstractmethod
-    def search(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
-
-    @abstractmethod
-    def create_playlist(self, title: str, description: str = "") -> str:
-        pass
-
-    @abstractmethod
-    def edit_title(self, playlist_id: str, title: str) -> None:
-        pass
-
-    @abstractmethod
-    def edit_description(self, playlist_id: str, description: str) -> None:
-        pass
-
-    @abstractmethod
-    def add_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
-        """Add tracks to a playlist."""
-
-    @abstractmethod
-    def remove_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
-        """Remove tracks from a playlist."""
-
-    @abstractmethod
-    def get_library_playlists(self, *args, **kwargs) -> Any:
-        pass
-
-
-class YtmAPIWrapper(YtmWrapper):
-    def __init__(self, config_path: Path, cache_root: Path) -> None:
+class YtmAPIWrapper(ServiceWrapper):
+    def __init__(self, config: YtmConfig, cache_root: Path) -> None:
         super().__init__("ytm", cache_root=cache_root)
-        self.ytm = YTMusic(config_path.__str__())
+        headers_path = (
+            cache_root / "ytm_headers.json"
+        )  # not the best place but convenient to code
+        YTMusic.setup(headers_raw=config.headers, filepath=str(headers_path))
+        self.ytm = YTMusic(str(headers_path))
 
     def get_playlist(self, *args, **kwargs):
         kwargs["limit"] = 100000  # probably no playlist this big
@@ -91,12 +57,6 @@ def create_playlist(self, title: str, description: str = "") -> str:
         assert isinstance(id, str)
         return id
 
-    def edit_title(self, playlist_id: str, title: str) -> None:
-        self.ytm.edit_playlist(playlist_id, title=title)
-
-    def edit_description(self, playlist_id: str, description: str) -> None:
-        self.ytm.edit_playlist(playlist_id, description=description)
-
     def add_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
         """Add tracks to a playlist."""
         if playlist_id == "LM":
@@ -128,13 +88,26 @@ def remove_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
     def get_library_playlists(self, *args, **kwargs):
         return self.ytm.get_library_playlists(*args, **kwargs)
 
+    def edit_playlist(self, playlist_id: str, title: str, description: str) -> None:
+        if playlist_id == "LM":
+            # Your Likes playlist cannot be edited
+            return
+        try:
+            self.ytm.edit_playlist(playlist_id, title=title, description=description)
+        except Exception as e:
+            print(e)
+            print("Failed to edit playlist")
+
 
 class YTM(StreamingService):
-    wrapper: YtmWrapper
+    wrapper: YtmAPIWrapper
 
-    def __init__(self, name: str, wrapper: YtmWrapper) -> None:
-        super().__init__(name, ServiceType.YTM)
-        self.wrapper = wrapper
+    def __init__(self, name: str, config: YtmConfig, cache_root: Path) -> None:
+        super().__init__(name, ServiceType.YTM, cache_root)
+        self.load_config(config, cache_root)
+
+    def load_config(self, config: YtmConfig, cache_root: Path) -> None:
+        self.wrapper = YtmAPIWrapper(config, cache_root)
 
     def get_playlist_metadatas(self) -> list[PlaylistMetadata]:
         results = self.wrapper.get_library_playlists()
@@ -246,3 +219,17 @@ def remove_tracks(self, playlist_uri: PlaylistURI, tracks: List[Track]) -> None:
     def is_uri_alive(self, uri: TrackURIs) -> bool:
         raw = self.wrapper.get_song(uri.uri)
         return "playabilityStatus" in raw and raw["playabilityStatus"]["status"] == "OK"
+
+    def pull_metadata(self, uri: YtmPlaylistURI) -> PlaylistDetails:
+        res = self.wrapper.get_playlist(uri.uri)
+        return PlaylistDetails(
+            name=res["title"],
+            description=res["description"] or "",
+        )
+
+    def update_metadata(
+        self, playlist_uri: YtmPlaylistURI, metadata: PlaylistDetails
+    ) -> None:
+        self.wrapper.edit_playlist(
+            playlist_uri.uri, metadata.name, metadata.description
+        )
diff --git a/unitunes/uri.py b/unitunes/uri.py
index f7ad339..769cdad 100644
--- a/unitunes/uri.py
+++ b/unitunes/uri.py
@@ -100,6 +100,8 @@ def uri_to_url(uri: str) -> str:
     def url_to_uri(url: str) -> str:
         if url == "spotify:liked_songs":
             return "Liked Songs"
+        # remove params
+        url = url.split("?")[0]
         return url.split("/")[-1]
 
     @staticmethod
@@ -267,14 +269,8 @@ def from_url(url: str) -> "BeatsaberPlaylistURI":
 album_uri_types = [MB_RELEASE_URI]
 all_uri_types = [..., *playlist_uri_types, *track_uri_types, *album_uri_types]
 
-URI = Union[
-    TrackURIs,
-    PlaylistURIs,
-    AlbumURIs,
-]
 
-
-def URI_Builder(service: ServiceType, type: EntityType, uri: str) -> URI:
+def URI_Builder(service: ServiceType, type: EntityType, uri: str) -> URIBase:
     if service == ServiceType.SPOTIFY:
         if type == EntityType.TRACK:
             return SpotifyTrackURI.from_uri(uri)
@@ -320,7 +316,7 @@ def albumURI_from_url(url: str) -> AlbumURIs:
     raise ValueError(f"Unknown URL format {url}")
 
 
-def URI_from_url(url: str) -> URI:
+def URI_from_url(url: str) -> URIBase:
     for cls in all_uri_types:
         if cls.valid_url(url):
             return cls.from_url(url)


Test patch:
diff --git a/gui/test.py b/gui/test.py
new file mode 100644
index 0000000..454ff3d
--- /dev/null
+++ b/gui/test.py
@@ -0,0 +1,12 @@
+import dearpygui.dearpygui as dpg
+import dearpygui.demo as demo
+
+dpg.create_context()
+dpg.create_viewport(title="Custom Title", width=600, height=600)
+
+demo.show_demo()
+
+dpg.setup_dearpygui()
+dpg.show_viewport()
+dpg.start_dearpygui()
+dpg.destroy_context()
diff --git a/tests/cache/beatsaver/search.json b/tests/cache/beatsaver/search.json
index eb84f23..d55af3a 100644
--- a/tests/cache/beatsaver/search.json
+++ b/tests/cache/beatsaver/search.json
@@ -3269,5 +3269,953 @@
             "updatedAt": "2020-11-25T18:35:01.968Z",
             "lastPublishedAt": "2020-11-25T18:35:01.968Z"
         }
+    ],
+    "('My Hero MAN WITH A MISSION', 0)_{'search_config': {'minNps': 0, 'maxNps': 1000, 'minRating': 0.51, 'sortOrder': 'Relevance'}}": [
+        {
+            "id": "3687",
+            "name": "My Hero - MAN WITH A MISSION ( Inuyashiki OP )",
+            "description": "Inuyashiki Opening Song TV Size! \r\n\r\nOnly Ex for now. Rocks hard tho.\r\nCustom Lightmapping",
+            "uploader": {
+                "id": 4285645,
+                "name": "drmarc",
+                "hash": "5cff0b7398cc5a672c84ed14",
+                "avatar": "https://cdn.beatsaver.com/avatar/86157568925270016.png",
+                "type": "DISCORD",
+                "curator": false
+            },
+            "metadata": {
+                "bpm": 180.0,
+                "duration": 93,
+                "songName": "My Hero / Inuyashiki OP",
+                "songSubName": "MAN WITH A MISSION",
+                "songAuthorName": "Dr_Marc",
+                "levelAuthorName": "drmarc"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 145,
+                "downvotes": 17,
+                "score": 0.8098
+            },
+            "uploaded": "2019-02-15T21:51:02Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "9773076fd6dc5eee717af0f3461f14065502f516",
+                    "key": "3687",
+                    "state": "Published",
+                    "createdAt": "2019-02-15T21:51:02Z",
+                    "sageScore": 5,
+                    "diffs": [
+                        {
+                            "njs": 17.5,
+                            "offset": 1.0,
+                            "notes": 511,
+                            "bombs": 0,
+                            "obstacles": 15,
+                            "nps": 5.818,
+                            "length": 263.5,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 996,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 87.833,
+                            "paritySummary": {
+                                "errors": 26,
+                                "warns": 34,
+                                "resets": 0
+                            },
+                            "maxScore": 462875
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/9773076fd6dc5eee717af0f3461f14065502f516.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/9773076fd6dc5eee717af0f3461f14065502f516.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/9773076fd6dc5eee717af0f3461f14065502f516.mp3"
+                }
+            ],
+            "createdAt": "2019-02-15T21:51:02Z",
+            "updatedAt": "2019-02-15T21:51:02Z",
+            "lastPublishedAt": "2019-02-15T21:51:02Z",
+            "tags": [
+                "rock",
+                "alternative",
+                "j-rock"
+            ]
+        },
+        {
+            "id": "1bacd",
+            "name": "Get off of my way | by Man with a mission",
+            "description": "https://youtu.be/0lh1jDNxi0o ",
+            "uploader": {
+                "id": 4285412,
+                "name": "thelitlewiseowl",
+                "hash": "5cff0b7698cc5a672c854bca",
+                "avatar": "https://cdn.beatsaver.com/avatar/200231809387069440.png",
+                "type": "DISCORD",
+                "curator": false,
+                "verifiedMapper": true
+            },
+            "metadata": {
+                "bpm": 135.0,
+                "duration": 221,
+                "songName": "Get off of my way ",
+                "songSubName": "",
+                "songAuthorName": "Man with a mission",
+                "levelAuthorName": "thelitlewiseowl"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 21,
+                "downvotes": 3,
+                "score": 0.7327
+            },
+            "uploaded": "2021-08-28T15:12:07.596054Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "bc563f15cff420c3502882221e2141eed821a605",
+                    "state": "Published",
+                    "createdAt": "2021-08-27T20:06:32.481219Z",
+                    "sageScore": 5,
+                    "diffs": [
+                        {
+                            "njs": 19.0,
+                            "offset": -0.69,
+                            "notes": 1322,
+                            "bombs": 7,
+                            "obstacles": 3,
+                            "nps": 6.21,
+                            "length": 479.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 5052,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 212.889,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 1
+                            },
+                            "maxScore": 1208995
+                        },
+                        {
+                            "njs": 21.0,
+                            "offset": 0.135,
+                            "notes": 1326,
+                            "bombs": 7,
+                            "obstacles": 3,
+                            "nps": 6.229,
+                            "length": 479.0,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 5052,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 212.889,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 6,
+                                "resets": 1
+                            },
+                            "maxScore": 1212675
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/bc563f15cff420c3502882221e2141eed821a605.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/bc563f15cff420c3502882221e2141eed821a605.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/bc563f15cff420c3502882221e2141eed821a605.mp3"
+                }
+            ],
+            "createdAt": "2021-08-27T20:06:32.481219Z",
+            "updatedAt": "2021-09-01T17:40:26.247739Z",
+            "lastPublishedAt": "2021-08-28T15:12:07.596054Z"
+        },
+        {
+            "id": "1956",
+            "name": "Database feat. TAKUMA (10-FEET) - MAN WITH A MISSION",
+            "description": "DA-TA-BASE! DA-TA-BASE! Log Horizon's Opening\r\nThanks to Saut for the massive hand with timing and Kolezan for the audio edit\r\nFull combo expert play-through video: https://streamable.com/elr4s",
+            "uploader": {
+                "id": 11373,
+                "name": "redmagi",
+                "hash": "5cff0b7298cc5a672c84e9d2",
+                "avatar": "https://www.gravatar.com/avatar/5cff0b7298cc5a672c84e9d2?d=retro",
+                "type": "SIMPLE",
+                "curator": false,
+                "verifiedMapper": true
+            },
+            "metadata": {
+                "bpm": 177.0,
+                "duration": 144,
+                "songName": "Database feat. TAKUMA(10-FEET)",
+                "songSubName": "MAN WITH A MISSION",
+                "songAuthorName": "Redmagi",
+                "levelAuthorName": "redmagi"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 1251,
+                "downvotes": 58,
+                "score": 0.9032
+            },
+            "uploaded": "2018-10-08T17:22:57Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "c0832e4d89b736e07e00f06e547048c513018d74",
+                    "key": "1956",
+                    "state": "Published",
+                    "createdAt": "2018-10-08T17:22:57Z",
+                    "diffs": [
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 373,
+                            "bombs": 0,
+                            "obstacles": 2,
+                            "nps": 2.726,
+                            "length": 401.389,
+                            "characteristic": "Standard",
+                            "difficulty": "Easy",
+                            "events": 735,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 136.064,
+                            "paritySummary": {
+                                "errors": 57,
+                                "warns": 46,
+                                "resets": 0
+                            },
+                            "maxScore": 335915
+                        },
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 439,
+                            "bombs": 6,
+                            "obstacles": 3,
+                            "nps": 3.209,
+                            "length": 401.271,
+                            "characteristic": "Standard",
+                            "difficulty": "Normal",
+                            "events": 735,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 136.024,
+                            "paritySummary": {
+                                "errors": 44,
+                                "warns": 51,
+                                "resets": 3
+                            },
+                            "maxScore": 396635
+                        },
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 488,
+                            "bombs": 6,
+                            "obstacles": 3,
+                            "nps": 3.567,
+                            "length": 401.271,
+                            "characteristic": "Standard",
+                            "difficulty": "Hard",
+                            "events": 735,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 136.024,
+                            "paritySummary": {
+                                "errors": 42,
+                                "warns": 46,
+                                "resets": 1
+                            },
+                            "maxScore": 441715
+                        },
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 566,
+                            "bombs": 24,
+                            "obstacles": 3,
+                            "nps": 4.138,
+                            "length": 401.271,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 735,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 136.024,
+                            "paritySummary": {
+                                "errors": 41,
+                                "warns": 73,
+                                "resets": 7
+                            },
+                            "maxScore": 513475
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/c0832e4d89b736e07e00f06e547048c513018d74.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/c0832e4d89b736e07e00f06e547048c513018d74.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/c0832e4d89b736e07e00f06e547048c513018d74.mp3"
+                }
+            ],
+            "createdAt": "2018-10-08T17:22:57Z",
+            "updatedAt": "2018-10-08T17:22:57Z",
+            "lastPublishedAt": "2018-10-08T17:22:57Z"
+        },
+        {
+            "id": "25734",
+            "name": "Database [Log Horizon Opening] - Man with a Mission ft. Takuma",
+            "description": "---------Map uses the new 1.20 Note mechanics (eg: arc and chains) and won't work on any versions of Beat Saber below 1.20---------\n\nGameplay preview:\nhttps://youtu.be/qlczvcdrb_I\n\nThanks to \uff33\uff49\uff52\uff3f\uff33\uff4c\uff41\uff59\uff45\uff52#3753 for the special request!\n\nDatabase is the opening theme song for the first two seasons of the Log Horizon anime. It is performed by Man with a Mission, featuring TAKUMA from the band 10-Feet.\n\nKeep up to date on my maps: https://twitter.com/Joetastic_\nMy mapper's profile: https://bsaber.com/members/joetastic/\nContact me on discord: @Joetastic#2501",
+            "uploader": {
+                "id": 58338,
+                "name": "Joetastic",
+                "hash": "5cff0b7498cc5a672c85050e",
+                "avatar": "https://cdn.beatsaver.com/avatar/227767566402191360.png",
+                "type": "DISCORD",
+                "curator": true,
+                "verifiedMapper": true
+            },
+            "metadata": {
+                "bpm": 177.0,
+                "duration": 237,
+                "songName": "Database",
+                "songSubName": "ft. Takuma",
+                "songAuthorName": "Man with a Mission",
+                "levelAuthorName": "Joetastic"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 132,
+                "downvotes": 5,
+                "score": 0.8583
+            },
+            "uploaded": "2022-05-10T09:06:11.483073Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "67c6517ec10190acaa06d799e80e0dea1a7ad77f",
+                    "state": "Published",
+                    "createdAt": "2022-05-19T18:22:27.983222Z",
+                    "sageScore": 3,
+                    "diffs": [
+                        {
+                            "njs": 16.0,
+                            "offset": 0.1,
+                            "notes": 1174,
+                            "bombs": 104,
+                            "obstacles": 63,
+                            "nps": 5.259,
+                            "length": 658.5,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 12950,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 223.22,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 0
+                            },
+                            "maxScore": 1087135
+                        },
+                        {
+                            "njs": 20.0,
+                            "offset": -0.35,
+                            "notes": 1592,
+                            "bombs": 104,
+                            "obstacles": 64,
+                            "nps": 7.132,
+                            "length": 658.5,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 12950,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 223.22,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 0
+                            },
+                            "maxScore": 1497875
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/67c6517ec10190acaa06d799e80e0dea1a7ad77f.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/67c6517ec10190acaa06d799e80e0dea1a7ad77f.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/67c6517ec10190acaa06d799e80e0dea1a7ad77f.mp3"
+                }
+            ],
+            "curator": {
+                "id": 4280701,
+                "name": "rategyro",
+                "hash": "605e50f0cd73ba00060a45e3",
+                "avatar": "https://cdn.beatsaver.com/avatar/138351191028400128.png",
+                "type": "DISCORD",
+                "curator": true
+            },
+            "curatedAt": "2022-07-04T06:03:56.759402Z",
+            "createdAt": "2022-05-10T09:05:39.407357Z",
+            "updatedAt": "2022-07-04T06:03:56.759402Z",
+            "lastPublishedAt": "2022-05-19T18:22:31.368742Z",
+            "tags": [
+                "balanced",
+                "anime",
+                "j-rock"
+            ]
+        },
+        {
+            "id": "18976",
+            "name": "MAN WITH A MISSION - Database ft. TAKUMA",
+            "description": "Counteraction rising, Yeh we are ready for the, punch, line!\nThere's no use with all your gimmicks, so CHECK THIS OUT\n\nI really like this song, so it's been a treat to map it although I have struggled.\nCool bomb stuff and lots of movement, Expert+ is big swings and very stamina\ndraining. It is quite a decent map set so give it a shot!\n\nEnjoy!\n\nSpecial thanks goes to the following:\nGeneral Feedback and Testing:\n- Ghilo, for playtesting the map!\n- Rocker, for feedback and playtesting the map!\n\nUpdate #1:\nThis map has been updated as of: 19/02/2022\nExpert+ has been heavily fixed and partially remapped,\nExpert has been partially remapped\nHard is gone, The old Normal dif is the new Hard dif\nMade a 2.6sps true normal difficulty.\nYou will need to redownload the map to see these changes!",
+            "uploader": {
+                "id": 4254180,
+                "name": "joshabi",
+                "hash": "5cff0b7398cc5a672c84ee93",
+                "avatar": "https://cdn.beatsaver.com/avatar/169894107517419520.png",
+                "type": "DISCORD",
+                "curator": true,
+                "verifiedMapper": true
+            },
+            "metadata": {
+                "bpm": 177.0,
+                "duration": 237,
+                "songName": "Database",
+                "songSubName": "ft. TAKUMA",
+                "songAuthorName": "MAN WITH A MISSION",
+                "levelAuthorName": "Joshabi"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 459,
+                "downvotes": 56,
+                "score": 0.8316
+            },
+            "uploaded": "2021-05-31T20:42:46.972Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "125e40e1acc4a3b6ac4c8b857bd1004f0b33bf52",
+                    "state": "Published",
+                    "createdAt": "2022-02-19T22:50:28.043734Z",
+                    "sageScore": 3,
+                    "diffs": [
+                        {
+                            "njs": 13.0,
+                            "offset": -1.35,
+                            "notes": 583,
+                            "bombs": 78,
+                            "obstacles": 285,
+                            "nps": 2.626,
+                            "length": 655.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Normal",
+                            "events": 8239,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 222.034,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 7
+                            },
+                            "maxScore": 529115
+                        },
+                        {
+                            "njs": 14.0,
+                            "offset": 0.345,
+                            "notes": 1003,
+                            "bombs": 86,
+                            "obstacles": 216,
+                            "nps": 4.517,
+                            "length": 655.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Hard",
+                            "events": 8239,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 222.034,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 8
+                            },
+                            "maxScore": 915515
+                        },
+                        {
+                            "njs": 17.0,
+                            "offset": -0.1,
+                            "notes": 1393,
+                            "bombs": 94,
+                            "obstacles": 197,
+                            "nps": 6.274,
+                            "length": 655.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 8239,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 222.034,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 9
+                            },
+                            "maxScore": 1274315
+                        },
+                        {
+                            "njs": 20.0,
+                            "offset": -0.425,
+                            "notes": 1782,
+                            "bombs": 186,
+                            "obstacles": 167,
+                            "nps": 8.026,
+                            "length": 655.0,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 8239,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 222.034,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 18
+                            },
+                            "maxScore": 1632195
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/125e40e1acc4a3b6ac4c8b857bd1004f0b33bf52.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/125e40e1acc4a3b6ac4c8b857bd1004f0b33bf52.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/125e40e1acc4a3b6ac4c8b857bd1004f0b33bf52.mp3"
+                }
+            ],
+            "curator": {
+                "id": 58338,
+                "name": "Joetastic",
+                "hash": "5cff0b7498cc5a672c85050e",
+                "avatar": "https://cdn.beatsaver.com/avatar/227767566402191360.png",
+                "type": "DISCORD",
+                "curator": true,
+                "verifiedMapper": true
+            },
+            "curatedAt": "2021-05-31T20:42:46.972Z",
+            "createdAt": "2021-05-31T20:42:46.972Z",
+            "updatedAt": "2022-02-19T22:50:36.308663Z",
+            "lastPublishedAt": "2022-02-19T22:50:36.308663Z",
+            "tags": [
+                "anime",
+                "j-rock",
+                "balanced"
+            ]
+        },
+        {
+            "id": "27b65",
+            "name": "My Hero (TV Size) [Inuyashiki Opening] - MAN WITH A MISSION",
+            "description": "Thanks to https://twitter.com/kitsunesoba1 for the special request!\n\nGameplay preview:\nhttps://youtu.be/uLcGgJEOGuQ\n\nMy Hero is the opening theme song of the Inuyashiki anime series, performed by the Japanese rock band Man with a Mission.\n\nKeep up to date on my maps: https://twitter.com/Joetastic_\nMy mapper's profile: https://bsaber.com/members/joetastic/\nContact me on discord: @Joetastic#2501\n\nEDIT:  Fixed a small inconsistency on E+",
+            "uploader": {
+                "id": 58338,
+                "name": "Joetastic",
+                "hash": "5cff0b7498cc5a672c85050e",
+                "avatar": "https://cdn.beatsaver.com/avatar/227767566402191360.png",
+                "type": "DISCORD",
+                "curator": true,
+                "verifiedMapper": true
+            },
+            "metadata": {
+                "bpm": 180.0,
+                "duration": 92,
+                "songName": "My Hero (TV Size)",
+                "songSubName": "",
+                "songAuthorName": "MAN WITH A MISSION",
+                "levelAuthorName": "Joetastic"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 40,
+                "downvotes": 1,
+                "score": 0.8212
+            },
+            "uploaded": "2022-07-12T18:40:15.270804Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "0f1ccd14075ce937bc152cb88069d496301bc215",
+                    "state": "Published",
+                    "createdAt": "2022-07-12T19:13:49.403285Z",
+                    "sageScore": 3,
+                    "diffs": [
+                        {
+                            "njs": 14.0,
+                            "offset": 0.2,
+                            "notes": 303,
+                            "bombs": 48,
+                            "obstacles": 28,
+                            "nps": 3.45,
+                            "length": 263.5,
+                            "characteristic": "Standard",
+                            "difficulty": "Hard",
+                            "events": 2482,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 87.833,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 0
+                            },
+                            "maxScore": 271515
+                        },
+                        {
+                            "njs": 17.0,
+                            "offset": -0.1,
+                            "notes": 428,
+                            "bombs": 48,
+                            "obstacles": 38,
+                            "nps": 4.873,
+                            "length": 263.5,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 2482,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 87.833,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 1,
+                                "resets": 0
+                            },
+                            "maxScore": 386515
+                        },
+                        {
+                            "njs": 20.0,
+                            "offset": -0.3,
+                            "notes": 518,
+                            "bombs": 48,
+                            "obstacles": 27,
+                            "nps": 5.898,
+                            "length": 263.5,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 2867,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 87.833,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 5,
+                                "resets": 0
+                            },
+                            "maxScore": 469315
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/0f1ccd14075ce937bc152cb88069d496301bc215.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/0f1ccd14075ce937bc152cb88069d496301bc215.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/0f1ccd14075ce937bc152cb88069d496301bc215.mp3"
+                }
+            ],
+            "createdAt": "2022-07-12T18:40:04.134607Z",
+            "updatedAt": "2022-07-12T19:14:09.477843Z",
+            "lastPublishedAt": "2022-07-12T19:14:09.477843Z",
+            "tags": [
+                "j-rock",
+                "anime",
+                "balanced"
+            ]
+        },
+        {
+            "id": "6a0d",
+            "name": "Seven Deadly Sins - Man with a Mission Opening",
+            "description": "Seven Deadly Sins by Man with a Mission, from The Seven Deadly Sins (Nanatsu no taizai), hard, expert and expert+ levels, some parts of expert and expert+ are fast.\nVideo: https://www.youtube.com/watch?v=-zimSTWu474",
+            "uploader": {
+                "id": 27262,
+                "name": "a_krouson",
+                "hash": "5d4906baadb1a200062a0e26",
+                "avatar": "https://www.gravatar.com/avatar/5d4906baadb1a200062a0e26?d=retro",
+                "type": "SIMPLE",
+                "curator": false
+            },
+            "metadata": {
+                "bpm": 143.0,
+                "duration": 89,
+                "songName": "Seven Deadly Sins",
+                "songSubName": "",
+                "songAuthorName": "Man With a Mission",
+                "levelAuthorName": "Krouton_06"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 248,
+                "downvotes": 51,
+                "score": 0.7703
+            },
+            "uploaded": "2019-10-16T01:42:33.301Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "1089fbb71d45055f02bbf88924e3eeadba308413",
+                    "key": "6a0d",
+                    "state": "Published",
+                    "createdAt": "2019-10-16T01:42:33.301Z",
+                    "sageScore": 4,
+                    "diffs": [
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 345,
+                            "bombs": 0,
+                            "obstacles": 4,
+                            "nps": 4.238,
+                            "length": 194.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Hard",
+                            "events": 566,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 81.399,
+                            "paritySummary": {
+                                "errors": 27,
+                                "warns": 34,
+                                "resets": 0
+                            },
+                            "maxScore": 310155
+                        },
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 381,
+                            "bombs": 32,
+                            "obstacles": 4,
+                            "nps": 4.681,
+                            "length": 194.0,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 566,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 81.399,
+                            "paritySummary": {
+                                "errors": 31,
+                                "warns": 41,
+                                "resets": 0
+                            },
+                            "maxScore": 343275
+                        },
+                        {
+                            "njs": 12.0,
+                            "offset": 0.0,
+                            "notes": 381,
+                            "bombs": 32,
+                            "obstacles": 14,
+                            "nps": 4.681,
+                            "length": 194.0,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 566,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 81.399,
+                            "paritySummary": {
+                                "errors": 31,
+                                "warns": 21,
+                                "resets": 0
+                            },
+                            "maxScore": 343275
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/1089fbb71d45055f02bbf88924e3eeadba308413.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/1089fbb71d45055f02bbf88924e3eeadba308413.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/1089fbb71d45055f02bbf88924e3eeadba308413.mp3"
+                }
+            ],
+            "createdAt": "2019-10-16T01:42:33.301Z",
+            "updatedAt": "2019-10-16T01:42:33.301Z",
+            "lastPublishedAt": "2019-10-16T01:42:33.301Z",
+            "tags": [
+                "pop",
+                "anime",
+                "rock",
+                "j-rock"
+            ]
+        },
+        {
+            "id": "18f20",
+            "name": "MAN WITH A MISSION - When My Devil Rises",
+            "description": "N\u304bEx\u3067\u904a\u3093\u3067\u3002\nEx+\u306fpepega\u306b\u7247\u8db3\u7a81\u3063\u8fbc\u3093\u3067\u307e\u3059\u3002",
+            "uploader": {
+                "id": 4285118,
+                "name": "okazu",
+                "hash": "601ab3ab5ab13d000607a85a",
+                "avatar": "https://cdn.beatsaver.com/avatar/643349183394480128.png",
+                "type": "DISCORD",
+                "curator": false
+            },
+            "metadata": {
+                "bpm": 107.0,
+                "duration": 256,
+                "songName": "When My Devil Rises",
+                "songSubName": "",
+                "songAuthorName": "MAN WITH A MISSION",
+                "levelAuthorName": "okazu"
+            },
+            "stats": {
+                "plays": 0,
+                "downloads": 0,
+                "upvotes": 47,
+                "downvotes": 7,
+                "score": 0.7595
+            },
+            "uploaded": "2021-06-08T13:31:07.760Z",
+            "automapper": false,
+            "ranked": false,
+            "qualified": false,
+            "versions": [
+                {
+                    "hash": "016fa82af6b72f29ccc854c4501302c802d46d34",
+                    "key": "18f20",
+                    "state": "Published",
+                    "createdAt": "2021-06-08T13:31:07.760Z",
+                    "sageScore": 6,
+                    "diffs": [
+                        {
+                            "njs": 12.0,
+                            "offset": -0.5,
+                            "notes": 681,
+                            "bombs": 0,
+                            "obstacles": 112,
+                            "nps": 2.697,
+                            "length": 450.25,
+                            "characteristic": "Standard",
+                            "difficulty": "Normal",
+                            "events": 5604,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 252.477,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 0,
+                                "resets": 0
+                            },
+                            "maxScore": 619275
+                        },
+                        {
+                            "njs": 16.0,
+                            "offset": -0.8,
+                            "notes": 1263,
+                            "bombs": 17,
+                            "obstacles": 344,
+                            "nps": 5.002,
+                            "length": 450.25,
+                            "characteristic": "Standard",
+                            "difficulty": "Expert",
+                            "events": 5604,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 252.477,
+                            "paritySummary": {
+                                "errors": 0,
+                                "warns": 26,
+                                "resets": 1
+                            },
+                            "maxScore": 1154715
+                        },
+                        {
+                            "njs": 21.0,
+                            "offset": -0.6,
+                            "notes": 1544,
+                            "bombs": 88,
+                            "obstacles": 77,
+                            "nps": 6.115,
+                            "length": 450.25,
+                            "characteristic": "Standard",
+                            "difficulty": "ExpertPlus",
+                            "events": 5603,
+                            "chroma": false,
+                            "me": false,
+                            "ne": false,
+                            "cinema": false,
+                            "seconds": 252.477,
+                            "paritySummary": {
+                                "errors": 10,
+                                "warns": 84,
+                                "resets": 13
+                            },
+                            "maxScore": 1413235
+                        }
+                    ],
+                    "downloadURL": "https://r2cdn.beatsaver.com/016fa82af6b72f29ccc854c4501302c802d46d34.zip",
+                    "coverURL": "https://na.cdn.beatsaver.com/016fa82af6b72f29ccc854c4501302c802d46d34.jpg",
+                    "previewURL": "https://na.cdn.beatsaver.com/016fa82af6b72f29ccc854c4501302c802d46d34.mp3"
+                }
+            ],
+            "createdAt": "2021-06-08T13:31:07.760Z",
+            "updatedAt": "2022-06-28T06:53:03.489191Z",
+            "lastPublishedAt": "2021-06-08T13:31:07.760Z",
+            "tags": [
+                "tech"
+            ]
+        }
     ]
 }
\ No newline at end of file
diff --git a/tests/cache/spotify/search.json b/tests/cache/spotify/search.json
index 7d55bfc..fd3fe2e 100644
--- a/tests/cache/spotify/search.json
+++ b/tests/cache/spotify/search.json
@@ -1,7 +1,13 @@
 {
-    "('track:\"value=\\'OBENTO MAGNUM\\' aliases=[]\" artist:\"DOKONJOFINGER(CV:\u4f0a\u6771\u5065\u4eba\u3001\u5c0f\u677e\u660c\u5e73\u3001\u5c0f\u91ce\u53cb\u6a39\u3001\u767d\u4e95\u60a0\u4ecb)\" album:\"TV\u30a2\u30cb\u30e1\u300cSHOW BY ROCK!!STARS!!\u300d\u633f\u5165\u6b4c\u30df\u30cb\u30a2\u30eb\u30d0\u30e0 Vol.1\"',)_{'limit': 5, 'type': 'track'}": {
+    "('track:\"tfarotnfarotnferiatfartnfarotnferiatfarotnfarotnferiatfarotnfarotnfeatfarotnfarotnferiatfarot',)_{'limit': 5, 'type': 'track'}": {
         "tracks": {
-            "items": []
+            "href": "https://api.spotify.com/v1/search?query=track%3A%22tfarotnfarotnferiatfartnfarotnferiatfarotnfarotnferiatfarotnfarotnfeatfarotnfarotnferiatfarot&type=track&offset=0&limit=5",
+            "items": [],
+            "limit": 5,
+            "next": null,
+            "offset": 0,
+            "previous": null,
+            "total": 0
         }
     }
 }
\ No newline at end of file
diff --git a/tests/cache/ytm_headers.json b/tests/cache/ytm_headers.json
new file mode 100644
index 0000000..3719686
--- /dev/null
+++ b/tests/cache/ytm_headers.json
@@ -0,0 +1,31 @@
+{
+    "accept": "*/*",
+    "accept-encoding": "gzip, deflate",
+    "accept-language": "en-US,en;q=0.9",
+    "authorization": "SAPISIDHASH 1659923037_5aeb75f9768fb1f98cec045398ad4477112c7da4",
+    "cache-control": "no-cache",
+    "content-encoding": "gzip",
+    "content-type": "application/json",
+    "cookie": "VISITOR_INFO1_LIVE=bVVZmmjuWlk; LOGIN_INFO=AFmmF2swRgIhAL0xqwHnz_wD6-iDiQPHeYhTGpahHilW27FyT-6rP0FnAiEA1Fung7tcaFOB6LMXyqZACQGfXhBs94n4GKb8yA2nREE:QUQ3MjNmd2RaYjh0WHY0QUE2YjRfdUVreXkwQzlSZEdlUE56ckFFcVBiQTNCVVJpTDlaUFFzemNTSWh0RlBCa2l1Y0ZQOU15TzVyaDQ5QURzUFFWM21Yem4xV2dUQnJJLWhkTTR6T2x1SGVHNVNka2lySW5XbUN2azVNVW1MOHdMaXVfWjZVSnJXR055ZjNzdDFhcTVVbV8wV05SamRJR3p3; HSID=AFX0r3UJ4GbKza-zB; SSID=AwxCEldE4IbqJ1f9O; APISID=OkrQO-fl1UaH0TLv/AHx0F-CDWz3v4h0hu; SAPISID=eWFtKj0slsno_is2/A-hiICRovwfThRAII; __Secure-1PAPISID=eWFtKj0slsno_is2/A-hiICRovwfThRAII; __Secure-3PAPISID=eWFtKj0slsno_is2/A-hiICRovwfThRAII; PREF=volume=40&f6=40000000&has_user_changed_default_autoplay_mode=true&tz=America.Los_Angeles&library_tab_browse_id=FEmusic_liked_playlists&autoplay=true&f4=4000000&f5=30000; SID=MAgc0l600Gpf8gAZN6N0YI3oJaQtUu6ZlGB1rF45qBzbOp4hlLqztbxliIVTx85xBlyuDQ.; __Secure-1PSID=MAgc0l600Gpf8gAZN6N0YI3oJaQtUu6ZlGB1rF45qBzbOp4has7q9S-EKtsw77R1xBgUFg.; __Secure-3PSID=MAgc0l600Gpf8gAZN6N0YI3oJaQtUu6ZlGB1rF45qBzbOp4hNCMbCNnKQ-a7KqowfOOUCA.; YSC=6rs3JCnfNE0; wide=1; SIDCC=AEf-XMQ_jZbBdHelsdanghRFGMZMAeH8rYHWcfh604Kj40_DB1pwsQiJrI1S5fw1G9DiCEj9nNZw; __Secure-1PSIDCC=AEf-XMRdu9dOHVMnkuB589FHZWwdBM3A2KE1RK9Ne-SvJQJahpwRPQWFX77myJrelIgfXAHyEgQ; __Secure-3PSIDCC=AEf-XMRib7EY-_MH_6HUDSlaJmwIGuGVr2sF4id3LggUQ_AnV-lpl5O_VbuRgn5iGqMwmsW3cCk",
+    "origin": "https://music.youtube.com",
+    "pragma": "no-cache",
+    "referer": "https://music.youtube.com/",
+    "sec-ch-ua": "\"Chromium\";v=\"104\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"104\"",
+    "sec-ch-ua-arch": "\"arm\"",
+    "sec-ch-ua-bitness": "\"64\"",
+    "sec-ch-ua-full-version": "\"104.0.5112.79\"",
+    "sec-ch-ua-full-version-list": "\"Chromium\";v=\"104.0.5112.79\", \" Not A;Brand\";v=\"99.0.0.0\", \"Google Chrome\";v=\"104.0.5112.79\"",
+    "sec-ch-ua-mobile": "?0",
+    "sec-ch-ua-platform": "\"macOS\"",
+    "sec-ch-ua-platform-version": "\"12.5.0\"",
+    "sec-fetch-dest": "empty",
+    "sec-fetch-mode": "same-origin",
+    "sec-fetch-site": "same-origin",
+    "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
+    "x-client-data": "CIi2yQEIpbbJAQjEtskBCKmdygEI/fvKAQiVocsBCIm7zAEI9sDMAQizwcwBCMTBzAEI1sHMAQjexMwBCJzJzAE=",
+    "x-goog-authuser": "1",
+    "x-goog-visitor-id": "CgtiVlZabW1qdVdsayjc1MGXBg%3D%3D",
+    "x-origin": "https://music.youtube.com",
+    "x-youtube-client-name": "67",
+    "x-youtube-client-version": "1.20220801.01.00"
+}
\ No newline at end of file
diff --git a/tests/test_beatsaber.py b/tests/test_beatsaber.py
index 5207d78..2da495d 100644
--- a/tests/test_beatsaber.py
+++ b/tests/test_beatsaber.py
@@ -6,8 +6,12 @@
 import pytest
 from unitunes.matcher import DefaultMatcherStrategy
 from unitunes.searcher import DefaultSearcherStrategy
-from unitunes.services.services import cache
-from unitunes.services.beatsaber import BeatsaberService, BeatsaverAPIWrapper
+from unitunes.services.services import Pushable, cache
+from unitunes.services.beatsaber import (
+    BeatsaberConfig,
+    BeatsaberService,
+    BeatsaverAPIWrapper,
+)
 
 from tests.conftest import cache_path
 from unitunes.track import AliasedString, Track
@@ -119,17 +123,9 @@ def populated_dir(empty_dir: Path):
 
 
 @pytest.fixture
-def Beatsaber(beatsaver_api_wrapper, populated_dir: Path):
-    config = {
-        "dir": populated_dir.absolute().__str__(),
-        "search_config": {
-            "minNps": 0.0,
-            "maxNps": 1000,
-            "minRating": 0,
-            "sortOrder": "Relevance",
-        },
-    }
-    return BeatsaberService("beatsaber", beatsaver_api_wrapper, config)
+def Beatsaber(populated_dir: Path):
+    config = BeatsaberConfig(dir=populated_dir.absolute())
+    return BeatsaberService("beatsaber", config, cache_path)
 
 
 def test_pull_track(Beatsaber: BeatsaberService):
@@ -178,3 +174,7 @@ def test_add_remove_tracks(Beatsaber: BeatsaberService):
 
     Beatsaber.remove_tracks(playlist, [track])
     assert len(Beatsaber.pull_tracks(playlist)) == 2
+
+
+def test_protocols(Beatsaber: BeatsaberService):
+    assert isinstance(Beatsaber, Pushable)
diff --git a/tests/test_cli.py b/tests/test_cli.py
deleted file mode 100644
index 4b19ece..0000000
--- a/tests/test_cli.py
+++ /dev/null
@@ -1,231 +0,0 @@
-import json
-import os
-from pathlib import Path
-from typing import List
-import pytest
-from typer.testing import CliRunner
-import shutil
-
-from unitunes.cli.cli import app
-from unitunes.cli.utils import get_playlist_manager
-from unitunes.main import PlaylistManager
-from unitunes.services.spotify import SpotifyAPIWrapper, SpotifyService
-from unitunes.types import ServiceType
-from unitunes.uri import SpotifyPlaylistURI
-
-
-runner = CliRunner()
-test_dir = Path("tests") / "test_lib"
-
-
-@pytest.fixture(scope="module")
-def ytm_config_path(pytestconfig):
-    config_path = pytestconfig.getoption("ytm", skip=True)
-    return Path(config_path).absolute()
-
-
-@pytest.fixture(scope="module")
-def spotify_config_path(pytestconfig):
-    config_path = pytestconfig.getoption("spotify", skip=True)
-    return Path(config_path).absolute()
-
-
-@pytest.fixture
-def playlist_manager():
-    test_dir.mkdir(exist_ok=True)
-    result = runner.invoke(app, ["init", "tests/test_lib"])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-    # delete all files except .cache and cache
-    for f in test_dir.glob("*"):
-        if f.name == ".cache" or f.name == "cache":
-            continue
-        if f.is_dir():
-            shutil.rmtree(f)
-        else:
-            f.unlink()
-
-
-def test_init(playlist_manager):
-    assert isinstance(playlist_manager, PlaylistManager)
-    assert "mb" in playlist_manager.services
-    assert (test_dir / "index.json").exists()
-    assert playlist_manager.file_manager.dir == test_dir
-
-
-def invoke_cli(args: List[str]):
-    cwd = os.getcwd()
-    os.chdir(test_dir)
-    result = runner.invoke(app, args)
-    os.chdir(cwd)
-    return result
-
-
-def test_add_ytm_service(playlist_manager, ytm_config_path):
-    result = invoke_cli(["service", "add", "ytm", ytm_config_path.as_posix()])
-    assert result.exit_code == 0
-    assert "Added" in result.stdout
-    pm = get_playlist_manager(test_dir)
-
-    assert "ytm" in pm.services
-
-
-def test_add_spotify_service(playlist_manager, spotify_config_path):
-    result = invoke_cli(["service", "add", "spotify", spotify_config_path.as_posix()])
-    assert result.exit_code == 0
-    assert "Added" in result.stdout
-    pm = get_playlist_manager(test_dir)
-    assert "spotify" in pm.services
-
-
-@pytest.fixture
-def pm_with_spotify_service(playlist_manager, spotify_config_path):
-    result = invoke_cli(["service", "add", "spotify", spotify_config_path.as_posix()])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_add_same_service(pm_with_spotify_service, spotify_config_path):
-    result = invoke_cli(["service", "add", "spotify", spotify_config_path.as_posix()])
-    assert result.exit_code == 1
-    assert "already exists" in result.stdout.lower()
-
-
-@pytest.fixture
-def pm_added_playlist(pm_with_spotify_service):
-    result = invoke_cli(
-        [
-            "add",
-            "headphones",
-            "spotify",
-            "https://open.spotify.com/playlist/19TGUNYKnJ8N1bFe0oA5lv",
-        ],
-    )
-
-    assert result.exit_code == 0
-
-    result = invoke_cli(
-        [
-            "add",
-            "other",
-            "spotify",
-            "https://open.spotify.com/playlist/2LeJcP06AuR1V6zg85Dpzy",
-        ],
-    )
-
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_add_playlist(pm_added_playlist):
-    assert "spotify" in pm_added_playlist.services
-    assert len(pm_added_playlist.playlists) == 2
-    assert "headphones" in pm_added_playlist.playlists
-    pl = pm_added_playlist.playlists["headphones"]
-    assert "spotify" in pl.uris
-
-
-def test_remove_service(pm_added_playlist):
-    result = invoke_cli(["service", "remove", "spotify", "-f"])
-    assert result.exit_code == 0
-    assert "Removed" in result.stdout
-    pm = get_playlist_manager(test_dir)
-    assert "spotify" not in pm.index.services
-    assert "spotify" not in pm.services
-    assert "spotify" not in pm.playlists["headphones"].uris
-
-
-@pytest.fixture
-def pm_pulled_playlist(pm_added_playlist, spotify_config_path):
-    # Pull a playlist with spotify service first to ensure .cache is created
-    # typer.invoke cant take input
-
-    with open(spotify_config_path, "r") as f:
-        index = json.load(f)
-    wrapper = SpotifyAPIWrapper(index, test_dir / "cache")
-    service = SpotifyService("temp", wrapper)
-
-    owd = os.getcwd()
-    os.chdir(test_dir)
-    service.pull_tracks(
-        SpotifyPlaylistURI.from_url(
-            "https://open.spotify.com/playlist/19TGUNYKnJ8N1bFe0oA5lv"
-        )
-    )
-    os.chdir(owd)
-
-    result = invoke_cli(["pull", "headphones"])
-    assert result.exit_code == 0
-    result = invoke_cli(["pull"])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_pull_playlist(pm_pulled_playlist):
-    pl = pm_pulled_playlist.playlists["headphones"]
-    assert len(pl.tracks) > 5
-    assert pl.tracks[0].name.value == "Wilderness"
-
-    pl = pm_pulled_playlist.playlists["other"]
-    assert len(pl.tracks) > 5
-
-
-@pytest.fixture
-def pm_searched_playlist(pm_pulled_playlist):
-    result = invoke_cli(["search", "-s", "mb", "headphones"])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_search_playlist(pm_searched_playlist):
-    assert "mb" in pm_searched_playlist.services
-    pl = pm_searched_playlist.playlists["headphones"]
-    assert len(pl.tracks) > 5
-    assert pl.tracks[0].name.value == "Wilderness"
-    print(pl.tracks[0])
-    track1 = pl.tracks[0]
-    assert track1.find_uri(ServiceType.MB)
-    assert track1.find_uri(ServiceType.SPOTIFY)
-    # assert (
-    #     track1.find_uri(ServiceType.MB).url
-    #     == "https://musicbrainz.org/recording/8c7959e9-7487-485e-be9d-f00cd7e1d2be"
-    # )
-
-
-def test_fetch(pm_with_spotify_service):
-    result = invoke_cli(["fetch", "spotify"])
-    assert result.exit_code == 0
-    # cant test much more, user specific
-
-
-@pytest.fixture
-def merged_playlists(pm_pulled_playlist):
-    result = invoke_cli(["merge", "other", "headphones"])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_merge_playlists(merged_playlists):
-    pl = merged_playlists.playlists["headphones"]
-    assert len(pl.tracks) > len(merged_playlists.playlists["other"].tracks)
-    assert len(pl.uris["spotify"]) == 2
-
-
-@pytest.fixture
-def removed_playlist(merged_playlists):
-    result = invoke_cli(["remove", "other"])
-    assert result.exit_code == 0
-
-    yield get_playlist_manager(test_dir)
-
-
-def test_remove_playlist(removed_playlist):
-    assert "other" not in removed_playlist.playlists
-    assert "other" not in removed_playlist.index.playlists
diff --git a/tests/test_manager.py b/tests/test_manager.py
new file mode 100644
index 0000000..5871ed8
--- /dev/null
+++ b/tests/test_manager.py
@@ -0,0 +1,1 @@
+import pytest
diff --git a/tests/test_spotify.py b/tests/test_spotify.py
index b3d76bc..805d75a 100644
--- a/tests/test_spotify.py
+++ b/tests/test_spotify.py
@@ -1,92 +1,21 @@
-import json
-from pathlib import Path
-from typing import Any, List
-
 import pytest
-from unitunes.services.services import cache
+from unitunes.services.services import Pushable
 from unitunes.services.spotify import (
-    SpotifyAPIWrapper,
+    SpotifyConfig,
     SpotifyService,
-    SpotifyWrapper,
 )
 from unitunes.uri import SpotifyPlaylistURI, SpotifyTrackURI
 
 from tests.conftest import cache_path
 
 
-class LocalSpotifyWrapper(SpotifyWrapper):
-    def __init__(self, cache_root) -> None:
-        super().__init__("spotify", cache_root=cache_root)
-
-    @cache
-    def track(self, *args, use_cache=True, **kwargs):
-        raise NotImplementedError
-
-    @cache
-    def album_tracks(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
-
-    @cache
-    def search(self, *args, use_cache=True, **kwargs) -> Any:
-        pass
-
-    def create_playlist(self, title: str, description: str = "") -> str:
-        raise NotImplementedError
-
-    def add_tracks(self, playlist_id: str, tracks: List[str]) -> None:
-        raise NotImplementedError
-
-    def remove_tracks(self, playlist_id: str, tracks: List[str]) -> None:
-        raise NotImplementedError
-
-    def current_user_playlists(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-    def user_playlist_replace_tracks(self, *args, **kwargs):
-        raise NotImplementedError
-
-    def playlist_tracks(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-    def current_user(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-    def user_playlist_create(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-    def current_user_saved_tracks(self, limit: int = 20, offset: int = 0) -> Any:
-        raise NotImplementedError
-
-    def current_user_saved_tracks_add(self, tracks: List[str]) -> None:
-        raise NotImplementedError
-
-    def current_user_saved_tracks_delete(self, tracks: List[str]) -> None:
-        raise NotImplementedError
-
-
-@pytest.fixture(scope="module")
-def spotify_wrapper(pytestconfig):
-    spotify_config_path = pytestconfig.getoption("spotify")
-    if not spotify_config_path:
-        return LocalSpotifyWrapper(cache_path)
-    else:
-        with open(spotify_config_path) as f:
-            return SpotifyAPIWrapper(json.load(f), cache_path)
-
-
 @pytest.fixture(scope="module")
-def spotify_service(spotify_wrapper):
+def spotify_service(pytestconfig):
     """Returns a SpotifyService object. Abstract if no config is provided."""
-    return SpotifyService("spotifytest", spotify_wrapper)
-
-
-@pytest.fixture(scope="module")
-def spotify_api_service(pytestconfig):
-    """Returns a real SpotifyService object. Config is required."""
     spotify_config_path = pytestconfig.getoption("spotify", skip=True)
-    with open(spotify_config_path) as f:
-        wrapper = SpotifyAPIWrapper(json.load(f), cache_path)
-    return SpotifyService("spotifytest", wrapper)
+    return SpotifyService(
+        "spotifytest", SpotifyConfig.parse_file(spotify_config_path), cache_path
+    )
 
 
 def test_spotify_can_pull_track(spotify_service):
@@ -100,8 +29,8 @@ def test_spotify_can_pull_track(spotify_service):
     assert track.artists[0].value == "Owl City"
 
 
-def test_spotify_can_pull_playlist(spotify_api_service):
-    tracks = spotify_api_service.pull_tracks(
+def test_spotify_can_pull_playlist(spotify_service):
+    tracks = spotify_service.pull_tracks(
         SpotifyPlaylistURI.from_url(
             "https://open.spotify.com/playlist/19TGUNYKnJ8N1bFe0oA5lv"
         )
@@ -116,8 +45,8 @@ def test_liked_songs_uri():
     assert uri.is_liked_songs()
 
 
-def test_pull_liked_tracks(spotify_api_service):
-    tracks = spotify_api_service.pull_tracks(
+def test_pull_liked_tracks(spotify_service):
+    tracks = spotify_service.pull_tracks(
         SpotifyPlaylistURI.from_url("spotify:liked_songs")
     )
     assert len(tracks) > 5
@@ -125,6 +54,10 @@ def test_pull_liked_tracks(spotify_api_service):
 
 def test_spotify_can_search(spotify_service: SpotifyService):
     results = spotify_service.search_query(
-        'track:"value=\'OBENTO MAGNUM\' aliases=[]" artist:"DOKONJOFINGER(CV:伊東健人、小松昌平、小野友樹、白井悠介)" album:"TVアニメ「SHOW BY ROCK!!STARS!!」挿入歌ミニアルバム Vol.1"'
-    )  # this is a tricky URL due to the album. Spotipy returns 404, make sure we handle gracefully.
+        'track:"tfarotnfarotnferiatfartnfarotnferiatfarotnfarotnferiatfarotnfarotnfeatfarotnfarotnferiatfarotnfarotnferia"'
+    )  # Long queries are unsupported.
     assert results is not None
+
+
+def test_spotify_is_pushable(spotify_service: SpotifyService):
+    assert isinstance(spotify_service, Pushable)
diff --git a/tests/test_universal_playlists.py b/tests/test_universal_playlists.py
deleted file mode 100644
index ade2373..0000000
--- a/tests/test_universal_playlists.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from unitunes import __version__
-
-
-def test_version():
-    assert __version__ == "0.1.0"
diff --git a/tests/test_ytm.py b/tests/test_ytm.py
index e3ff444..b735e92 100644
--- a/tests/test_ytm.py
+++ b/tests/test_ytm.py
@@ -1,61 +1,15 @@
-from pathlib import Path
-from typing import Any, List
-
 import pytest
-from unitunes.services.services import cache
-from unitunes.services.ytm import YTM, YtmAPIWrapper, YtmWrapper
-from unitunes.uri import YtmTrackURI
+from unitunes.services.services import Pushable
+from unitunes.services.ytm import YTM, YtmConfig
+from unitunes.uri import YtmPlaylistURI, YtmTrackURI
 
 from tests.conftest import cache_path
 
 
-class YtmLocalWrapper(YtmWrapper):
-    def __init__(self, cache_root):
-        super().__init__("ytm", cache_root=cache_root)
-
-    @cache
-    def get_playlist(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-    @cache
-    def get_song(self, *args, use_cache=True, **kwargs) -> Any:
-        raise NotImplementedError
-
-    @cache
-    def search(self, *args, use_cache=True, **kwargs) -> Any:
-        raise NotImplementedError
-
-    def create_playlist(self, title: str, description: str = "") -> str:
-        raise NotImplementedError
-
-    def edit_title(self, playlist_id: str, title: str) -> None:
-        raise NotImplementedError
-
-    def edit_description(self, playlist_id: str, description: str) -> None:
-        raise NotImplementedError
-
-    def add_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
-        raise NotImplementedError
-
-    def remove_tracks(self, playlist_id: str, track_ids: List[str]) -> None:
-        raise NotImplementedError
-
-    def get_library_playlists(self, *args, **kwargs) -> Any:
-        raise NotImplementedError
-
-
-@pytest.fixture(scope="module")
-def ytm_wrapper(pytestconfig):
-    ytm_config_path = pytestconfig.getoption("ytm")
-    if not ytm_config_path:
-        return YtmLocalWrapper(cache_path)
-    else:
-        return YtmAPIWrapper(ytm_config_path, cache_path)
-
-
 @pytest.fixture(scope="module")
-def ytm_service(ytm_wrapper):
-    return YTM("ytmtest", ytm_wrapper)
+def ytm_service(pytestconfig):
+    config_path = pytestconfig.getoption("ytm", skip=True)
+    return YTM("ytmtest", YtmConfig.parse_file(config_path), cache_path)
 
 
 def test_ytm_can_pull_track(ytm_service):
@@ -80,3 +34,16 @@ def test_invalid_uri(ytm_service: YTM):
     assert ytm_service.is_uri_alive(
         YtmTrackURI.from_url("https://music.youtube.com/watch?v=AKXNtLwP294")
     )
+
+
+def test_protocols(ytm_service: YTM):
+    assert isinstance(ytm_service, Pushable)
+
+
+def test_pull_metadata(ytm_service: YTM):
+    imagine_dragons_playlist_uri = YtmPlaylistURI.from_url(
+        "https://music.youtube.com/playlist?list=RDCLAK5uy_mzpBFnAPcGS-4FYm4BzAY-Q3VmvNCQwxY"
+    )
+    metadata = ytm_service.pull_metadata(imagine_dragons_playlist_uri)
+    assert metadata.name == "Presenting Imagine Dragons"
+    assert metadata.description == "The most played hits and essential tracks."