diff --git a/HISTORY.rst b/HISTORY.rst
index 7ea13ab..ed42822 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -4,6 +4,22 @@ History
 =======
 v0.7.7
 ------
+* Tests: Added additional playlist, folder tests. - tehkillerbee_
+* Feature: Add support for playlist merging. - tehkillerbee_
+* Added trn to playlist object for convenience. - tehkillerbee_
+* Set limits from argument in all relevant methods. - tehkillerbee_
+* Feature: Use v2 endpoint for playlist creation. - tehkillerbee_
+* Feature: Add support for playlist folders (#181) - tehkillerbee_
+* Feature: Add track to user playlist, user tracks from ISRC (#96) - tehkillerbee_
+* Feature: Add optional fn_print to Session::login_session_file - GioF71_
+* Feature: Add support for moving playlist items (#116) - tehkillerbee_
+* Feature: Allow adding items multiple times to the same playlist - tehkillerbee_
+* Feature: Add support for adding items to a playlists at a specific position (#116) - tehkillerbee_
+* Feature: Set UserPlaylist public/private. Add method for getting public user playlists. - tehkillerbee_
+* Feature: Remove multiple items from UserPlaylist. (Fixes #259) - tehkillerbee_
+* Remove deprecated username/pass login method (Fixes #279) - tehkillerbee_
+* Populate the track/items.album attributes from the parent Album object. Updated tests (Fixes #281) - tehkillerbee_
+* Added clarifications to video_url method. Check video URLs for all available video qualities (Fixes #257) - tehkillerbee_
 * Tests: Fix all tests that previously failed. - tehkillerbee_
 * Use enum to specify default audio / video quality - tehkillerbee_
 * Bugfix: Recent TIDAL changes resulted in missing Mix not causing a ObjectNotFound exception. - tehkillerbee_
@@ -198,6 +214,7 @@ v0.6.2
 .. _Jimmyscene: https://github.com/Jimmyscene
 .. _quodrum-glas: https://github.com/quodrum-glas
 .. _M4TH1EU: https://github.com/M4TH1EU
+.. _GioF71: https://github.com/GioF71
 
 
 
diff --git a/tests/test_album.py b/tests/test_album.py
index ba8c8e0..b4302c0 100644
--- a/tests/test_album.py
+++ b/tests/test_album.py
@@ -23,8 +23,7 @@
 from dateutil import tz
 
 import tidalapi
-from tidalapi.album import Album
-from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound
+from tidalapi.exceptions import ObjectNotFound
 from tidalapi.media import AudioMode, Quality
 
 from .cover import verify_image_cover, verify_video_cover
@@ -72,11 +71,20 @@ def test_get_tracks(session):
     assert tracks[0].id == 17927864
     assert tracks[0].volume_num == 1
     assert tracks[0].track_num == 1
+    assert tracks[0].album == album
 
     assert tracks[-1].name == "Pray"
     assert tracks[-1].id == 17927885
     assert tracks[-1].volume_num == 2
     assert tracks[-1].track_num == 8
+    assert tracks[-1].album == album
+
+    # Getting album.tracks with sparse_album=True will result in a track.album containing only essential fields
+    tracks_sparse = album.tracks(sparse_album=True)
+    assert tracks_sparse[0].album.audio_quality is None
+    assert tracks_sparse[0].album.id == 17927863
+    assert tracks_sparse[-1].album.audio_quality is None
+    assert tracks_sparse[-1].album.id == 17927863
 
 
 def test_get_items(session):
@@ -87,11 +95,20 @@ def test_get_items(session):
     assert items[0].id == 108043415
     assert items[0].volume_num == 1
     assert items[0].track_num == 1
+    assert items[0].album == album
 
     assert items[-1].name == "Lemonade Film"
     assert items[-1].id == 108043437
     assert items[-1].volume_num == 1
     assert items[-1].track_num == 15
+    assert items[-1].album == album
+
+    # Getting album.items with sparse_album=True will result in a track.album containing only essential fields
+    items_sparse = album.items(sparse_album=True)
+    assert items_sparse[0].album.id == 108043414
+    assert items_sparse[0].album.audio_quality is None
+    assert items_sparse[-1].album.id == 108043414
+    assert items_sparse[-1].album.audio_quality is None
 
 
 def test_image_cover(session):
diff --git a/tests/test_media.py b/tests/test_media.py
index 2d73958..5e0a61e 100644
--- a/tests/test_media.py
+++ b/tests/test_media.py
@@ -23,6 +23,7 @@
 from dateutil import tz
 
 import tidalapi
+from tidalapi import VideoQuality
 from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound
 from tidalapi.media import (
     AudioExtensions,
@@ -341,8 +342,23 @@ def test_video_no_release_date(session):
     ]
 
 
+def test_video_not_found(session):
+    with pytest.raises(ObjectNotFound):
+        session.video(12345678)
+
+
 def test_video_url(session):
+    # Test video URLs at all available qualities
     video = session.video(125506698)
+    session.video_quality = VideoQuality.low
+    url = video.get_url()
+    assert "m3u8" in url
+    verify_video_resolution(url, 640, 360)
+    session.video_quality = VideoQuality.medium
+    url = video.get_url()
+    assert "m3u8" in url
+    verify_video_resolution(url, 1280, 720)
+    session.video_quality = VideoQuality.high
     url = video.get_url()
     assert "m3u8" in url
     verify_video_resolution(url, 1920, 1080)
diff --git a/tests/test_page.py b/tests/test_page.py
index 2521c24..98ec827 100644
--- a/tests/test_page.py
+++ b/tests/test_page.py
@@ -15,7 +15,6 @@
 #
 # You should have received a copy of the GNU Lesser General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
-import pytest
 
 import tidalapi
 
@@ -48,6 +47,7 @@ def test_get_explore_items(session):
     assert first_item.categories[1].title == "Milestone Year Albums"
     assert first_item.categories[2].title == "Albums Of The Decade"
     playlist = first_item.categories[0].items[0]
+    assert isinstance(playlist, tidalapi.Playlist)
     assert playlist.name  # == 'Remember...the 1950s'
     assert playlist.num_tracks > 1
     assert playlist.num_videos == 0
@@ -55,6 +55,7 @@ def test_get_explore_items(session):
     genre_genres = explore.categories[0].items[1]
     genre_genres_page_items = iter(genre_genres.get())
     playlist = next(genre_genres_page_items)  # Usually a playlist
+    assert isinstance(playlist, tidalapi.Playlist)
     assert playlist.name  # == 'Remember...the 1950s'
     assert playlist.num_tracks > 1
     assert playlist.num_videos == 0
@@ -66,9 +67,25 @@ def test_get_explore_items(session):
     assert next(iterator).title == "Country"
 
 
+def test_hires_page(session):
+    hires = session.hires_page()
+    first = next(iter(hires))
+    assert first.name == "Electronic: Headphone Classics"
+    assert isinstance(first, tidalapi.Playlist)
+
+
 def test_for_you(session):
     for_you = session.for_you()
-    assert for_you
+    first = next(iter(for_you))
+    assert first.title == "My Daily Discovery"
+    assert isinstance(first, tidalapi.Mix)
+
+
+def test_videos(session):
+    videos = session.videos()
+    first = next(iter(videos))
+    assert first.type == "VIDEO"
+    assert isinstance(first.get(), tidalapi.Video)
 
 
 def test_show_more(session):
diff --git a/tests/test_playlist.py b/tests/test_playlist.py
index ef1cb19..271a5c8 100644
--- a/tests/test_playlist.py
+++ b/tests/test_playlist.py
@@ -93,6 +93,193 @@ def test_playlist_not_found(session):
         session.playlist("12345678")
 
 
+def test_playlist_categories(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_id = playlist.id
+    assert playlist.add("125169484", allow_duplicates=True)
+    # Playlist should be found in (user) playlists
+    user_playlists = session.user.playlists()
+    playlist_ids = [playlist.id for playlist in user_playlists]
+    assert playlist_id in playlist_ids
+
+    # Playlist not found in user favourite playlists
+    # playlists_favs = session.user.favorites.playlists()
+    # playlist_ids = [playlist.id for playlist in playlists_favs]
+    # assert playlist_id in playlist_ids
+
+    # Playlist is found in user (created) playlists and favourite playlists
+    playlists_and_favs = session.user.playlist_and_favorite_playlists()
+    playlist_ids = [playlist.id for playlist in playlists_and_favs]
+    assert playlist_id in playlist_ids
+
+    # Check if playlist is found in list of public playlists
+    public_playlists = session.user.public_playlists()
+    playlist_ids = [playlist.id for playlist in public_playlists]
+    assert not playlist_id in playlist_ids
+    playlist.set_playlist_public()
+
+    # Check if playlist is found in list of public playlists
+    public_playlists = session.user.public_playlists()
+    playlist_ids = [playlist.id for playlist in public_playlists]
+    assert playlist_id in playlist_ids
+    playlist.delete()
+
+
+def test_playlist_add_duplicate(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # track id 125169484
+    assert 125169484 in playlist.add("125169484", allow_duplicates=True)
+    assert 125169484 in playlist.add("125169484", allow_duplicates=True)
+    assert 125169484 not in playlist.add("125169484", allow_duplicates=False)
+    playlist.add(["125169484", "125169484", "125169484"], allow_duplicates=False)
+    # Check if track has been added more than 2 times
+    item_ids = [item.id for item in playlist.items()]
+    assert item_ids.count(125169484) == 2
+    # Add again, this time allowing duplicates
+    assert playlist.add(["125169484", "125169484", "125169484"], allow_duplicates=True)
+    item_ids = [item.id for item in playlist.items()]
+    assert item_ids.count(125169484) == 5
+    playlist.delete()
+
+
+def test_playlist_add_at_position(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    # Add 10 tracks to the new playlist
+    track_ids = [track.id for track in playlist_a.tracks()]
+    playlist.add(track_ids[0:10])
+    # Add a track to the end of the playlist (default)
+    assert playlist.add("125169484")
+    item_ids = [item.id for item in playlist.items()]
+    assert str(item_ids[-1]) == "125169484"
+    # Add a new track to a specific position
+    assert playlist.add("77692131", position=2)
+    # Verify that track matches the expected position
+    item_ids = [item.id for item in playlist.items()]
+    assert str(item_ids[2]) == "77692131"
+    # Add last four tracks to position 2 in the playlist and verify they are stored at the expected location
+    playlist.add(track_ids[-4:], position=2)
+    tracks = [item.id for item in playlist.items()][2:6]
+    for idx, track_id in enumerate(track_ids[-4:]):
+        assert tracks[idx] == track_id
+    playlist.delete()
+
+
+def test_playlist_remove_by_indices(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    track_ids = [track.id for track in playlist_a.tracks()][0:9]
+    playlist.add(track_ids)
+    # Remove odd tracks
+    playlist.remove_by_indices([1, 3, 5, 7])
+    # Verify remaining tracks
+    tracks = [item.id for item in playlist.items()]
+    for idx, track_id in enumerate(tracks):
+        assert track_id == track_ids[idx * 2]
+    # Remove last track in playlist and check that track has been removed
+    last_track = tracks[-1]
+    playlist.remove_by_index(playlist.num_tracks - 1)
+    tracks = [item.id for item in playlist.items()]
+    assert last_track not in tracks
+    playlist.delete()
+
+
+def test_playlist_remove_by_id(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    track_ids = [track.id for track in playlist_a.tracks()][0:9]
+    playlist.add(track_ids)
+    # Remove track with specific ID
+    playlist.remove_by_id(str(track_ids[2]))
+    tracks = [item.id for item in playlist.items()]
+    assert track_ids[2] not in tracks
+    playlist.delete()
+
+
+def test_playlist_add_isrc(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # track id 125169484
+    assert playlist.add_by_isrc("NOG841907010", allow_duplicates=True)
+    assert playlist.add_by_isrc("NOG841907010", allow_duplicates=True)
+    assert not playlist.add_by_isrc("NOG841907010", allow_duplicates=False)
+    assert not playlist.add_by_isrc(
+        "NOG841907123", allow_duplicates=True, position=0
+    )  # Does not exist, returns false
+    # Check if track has been added more than 2 times
+    item_ids = [item.id for item in playlist.items()]
+    assert item_ids.count(125169484) == 2
+    playlist.delete()
+
+
+def test_playlist_move_by_id(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # Add tracks from existing playlist
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    track_ids = [track.id for track in playlist_a.tracks()]
+    playlist.add(track_ids[0:9])
+    # Move first track to the end
+    first_track_id = track_ids[0]
+    playlist.move_by_id(media_id=str(first_track_id), position=playlist.num_tracks - 2)
+    # Last track(-2) should now match the previous first track
+    tracks = playlist.tracks()
+    assert first_track_id == tracks[playlist.num_tracks - 2].id
+    playlist.delete()
+
+
+def test_playlist_move_by_index(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # Add tracks from existing playlist
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    track_ids = [track.id for track in playlist_a.tracks()]
+    playlist.add(track_ids[0:9])
+    # Move first track to the end
+    first_track_id = track_ids[0]
+    playlist.move_by_index(index=0, position=playlist.num_tracks - 2)
+    # Last track(-2) should now match the previous first track
+    tracks = playlist.tracks()
+    track_ids = [track.id for track in playlist.tracks()]
+    assert track_ids.index(first_track_id) == playlist.num_tracks - 2
+    playlist.delete()
+
+
+def test_playlist_move_by_indices(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # Add tracks from existing playlist
+    playlist_a = session.playlist("7eafb342-141a-4092-91eb-da0012da3a19")
+    track_ids = [track.id for track in playlist_a.tracks()]
+    playlist.add(track_ids[0:9])
+    # Move first 4 tracks to the end
+    playlist.move_by_indices(indices=[0, 1, 2, 3], position=playlist.num_tracks)
+    # First four tracks should now be moved to the end
+    last_tracks = [track.id for track in playlist.tracks()][-4:]
+    for idx, track_id in enumerate(last_tracks):
+        assert track_ids[idx] == track_id
+    playlist.delete()
+
+
+def test_playlist_merge(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # Add tracks from existing playlist
+    added_items = playlist.merge("7eafb342-141a-4092-91eb-da0012da3a19")
+    # Check if tracks were added
+    # Note: Certain tracks might be skipped for unknown reasons. (Why?)
+    # Therefore, we will compare the list of added items with the actual playlist content.
+    tracks = [track.id for track in playlist.tracks()]
+    for track in tracks:
+        assert track in added_items
+
+
+def test_playlist_public_private(session):
+    playlist = session.user.create_playlist("TestingA", "Testing1234")
+    # Default: UserPlaylist is not public
+    assert not playlist.public
+    playlist.set_playlist_public()
+    assert playlist.public
+    playlist.set_playlist_private()
+    assert not playlist.public
+    playlist.delete()
+
+
 def test_video_playlist(session):
     playlist = session.playlist("aa3611ff-5b25-4bbe-8ce4-36c678c3438f")
     assert playlist.id == "aa3611ff-5b25-4bbe-8ce4-36c678c3438f"
diff --git a/tests/test_session.py b/tests/test_session.py
index 990419d..b123af0 100644
--- a/tests/test_session.py
+++ b/tests/test_session.py
@@ -37,13 +37,6 @@ def test_load_oauth_session(session):
     assert isinstance(session.user, tidalapi.LoggedInUser)
 
 
-def test_failed_login():
-    session = tidalapi.Session()
-    with pytest.raises(requests.HTTPError):
-        session.login("", "")
-    assert session.check_login() is False
-
-
 @pytest.mark.interactive
 def test_oauth_login(capsys):
     config = tidalapi.Config(item_limit=20000)
diff --git a/tests/test_user.py b/tests/test_user.py
index 53ce25f..66950a2 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -23,6 +23,7 @@
 import pytest
 
 import tidalapi
+from tidalapi.exceptions import ObjectNotFound
 
 
 def test_user(session):
@@ -59,6 +60,15 @@ def test_get_user_playlists(session):
     assert playlist_ids | favourite_ids == both_ids
 
 
+def test_get_playlist_folders(session):
+    folder = session.user.create_folder(title="testfolder")
+    assert folder
+    folder_ids = [folder.id for folder in session.user.playlist_folders()]
+    assert folder.id in folder_ids
+    folder.remove()
+    assert folder.id not in folder_ids
+
+
 def test_get_user_playlist_creator(session):
     playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3")
     creator = playlist.creator
@@ -77,13 +87,13 @@ def test_get_editorial_playlist_creator(session):
 
 def test_create_playlist(session):
     playlist = session.user.create_playlist("Testing", "Testing1234")
-    playlist.add([125169484])
+    playlist.add(["125169484"])
     assert playlist.tracks()[0].name == "Alone, Pt. II"
     assert playlist.description == "Testing1234"
     assert playlist.name == "Testing"
-    playlist.remove_by_id(125169484)
+    playlist.remove_by_id("125169484")
     assert len(playlist.tracks()) == 0
-    playlist.add([64728757, 125169484])
+    playlist.add(["64728757", "125169484"])
     for index, item in enumerate(playlist.tracks()):
         if item.name == "Alone, Pt. II":
             playlist.remove_by_index(index)
@@ -114,16 +124,115 @@ def test_create_playlist(session):
     long_playlist = session.playlist("944dd087-f65c-4954-a9a3-042a574e86e3")
     playlist_tracks = long_playlist.tracks(limit=250)
 
-    playlist.add(playlist.id for playlist in playlist_tracks)
+    playlist.add([track.id for track in playlist_tracks])
     playlist._reparse()
-    playlist.remove_by_id(199477058)
+    playlist.remove_by_id("199477058")
     playlist._reparse()
 
-    assert all(playlist.id != 199477058 for playlist in playlist.tracks(limit=250))
+    track_ids = [track.id for track in playlist.tracks(limit=250)]
+    assert 199477058 not in track_ids
 
     playlist.delete()
 
 
+def test_create_folder(session):
+    folder = session.user.create_folder(title="testfolder")
+    assert folder.name == "testfolder"
+    assert folder.parent is None
+    assert folder.parent_folder_id == "root"
+    assert folder.listen_url == f"https://listen.tidal.com/folder/{folder.id}"
+    assert folder.total_number_of_items == 0
+    assert folder.trn == f"trn:folder:{folder.id}"
+    folder_id = folder.id
+
+    # update name
+    folder.rename(name="testfolder1")
+    assert folder.name == "testfolder1"
+
+    # cleanup
+    folder.remove()
+
+    # check if folder has been removed
+    with pytest.raises(ObjectNotFound):
+        session.folder(folder_id)
+
+
+def test_folder_add_items(session):
+    folder = session.user.create_folder(title="testfolder")
+    folder_a = session.folder(folder.id)
+    assert isinstance(folder_a, tidalapi.playlist.Folder)
+    assert folder_a.id == folder.id
+
+    # create a playlist and add it to the folder
+    playlist_a = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_a.add(["125169484"])
+    playlist_b = session.user.create_playlist("TestingB", "Testing1234")
+    playlist_b.add(["125169484"])
+    folder.add_items([playlist_a.id, playlist_b.id])
+
+    # verify items
+    assert folder.total_number_of_items == 2
+    items = folder.items()
+    assert len(items) == 2
+    item_ids = [item.id for item in items]
+    assert playlist_a.id in item_ids
+    assert playlist_b.id in item_ids
+
+    # cleanup (This will also delete playlists inside the folder!)
+    folder.remove()
+
+
+def test_folder_moves(session):
+    folder_a = session.user.create_folder(title="testfolderA")
+    folder_b = session.user.create_folder(title="testfolderB")
+
+    # create a playlist and add it to the folder
+    playlist_a = session.user.create_playlist("TestingA", "Testing1234")
+    playlist_a.add(["125169484"])
+    playlist_b = session.user.create_playlist("TestingB", "Testing1234")
+    playlist_b.add(["125169484"])
+    folder_a.add_items([playlist_a.id, playlist_b.id])
+
+    # verify items
+    assert folder_a.total_number_of_items == 2
+    assert folder_b.total_number_of_items == 0
+    items = folder_a.items()
+    item_ids = [item.id for item in items]
+
+    # move items to folder B
+    folder_a.move_items_to_folder(trns=item_ids, folder=folder_b.id)
+    folder_b._reparse()  # Manually refresh, as src folder contents will have changed
+    assert folder_a.total_number_of_items == 0
+    assert folder_b.total_number_of_items == 2
+    item_a_ids = [item.id for item in folder_a.items()]
+    item_b_ids = [item.id for item in folder_b.items()]
+    assert playlist_a.id not in item_a_ids
+    assert playlist_b.id not in item_a_ids
+    assert playlist_a.id in item_b_ids
+    assert playlist_b.id in item_b_ids
+
+    # move items to the root folder
+    folder_b.move_items_to_root(trns=item_ids)
+    assert folder_a.total_number_of_items == 0
+    assert folder_b.total_number_of_items == 0
+    folder_b.move_items_to_folder(trns=item_ids)
+    assert folder_b.total_number_of_items == 2
+
+    # cleanup (This will also delete playlists inside the folders, if they are still there
+    folder_a.remove()
+    folder_b.remove()
+
+
+def test_add_remove_folder(session):
+    folder = session.user.create_folder(title="testfolderA")
+    folder_id = folder.id
+    # remove folder from favourites
+    session.user.favorites.remove_folders_playlists([folder.id])
+    # check if folder has been removed
+    with pytest.raises(ObjectNotFound):
+        session.folder(folder_id)
+
+
 def test_add_remove_favorite_artist(session):
     favorites = session.user.favorites
     artist_id = 5247488
diff --git a/tidalapi/album.py b/tidalapi/album.py
index d25b41f..cfee372 100644
--- a/tidalapi/album.py
+++ b/tidalapi/album.py
@@ -18,6 +18,7 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import copy
+import functools
 from datetime import datetime
 from typing import TYPE_CHECKING, List, Optional, Union, cast
 
@@ -180,31 +181,59 @@ def available_release_date(self) -> Optional[datetime]:
             return self.tidal_release_date
         return None
 
-    def tracks(self, limit: Optional[int] = None, offset: int = 0) -> List["Track"]:
+    def tracks(
+        self,
+        limit: Optional[int] = None,
+        offset: int = 0,
+        sparse_album: bool = False,
+    ) -> List["Track"]:
         """Returns the tracks in classes album.
 
         :param limit: The amount of items you want returned.
         :param offset: The position of the first item you want to include.
+        :param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
+            False: Populate the track.album attributes from the parent Album object (self)
         :return: A list of the :class:`Tracks <.Track>` in the album.
         """
         params = {"limit": limit, "offset": offset}
 
+        if sparse_album:
+            parse_track_callable = self.session.parse_track
+        else:
+            # Parse tracks attributes but provide the Album object directly from self
+            parse_track_callable = functools.partial(
+                self.session.parse_track, album=self
+            )
+
         tracks = self.request.map_request(
-            "albums/%s/tracks" % self.id, params, parse=self.session.parse_track
+            "albums/%s/tracks" % self.id, params, parse=parse_track_callable
         )
         assert isinstance(tracks, list)
         return cast(List["Track"], tracks)
 
-    def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video"]]:
+    def items(
+        self, limit: int = 100, offset: int = 0, sparse_album: bool = False
+    ) -> List[Union["Track", "Video"]]:
         """Gets the first 'limit' tracks and videos in the album from TIDAL.
 
         :param limit: The number of items you want to retrieve
         :param offset: The index you want to start retrieving items from
+        :param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
+            False: Populate the track.album attributes from the parent Album object (self)
         :return: A list of :class:`Tracks<.Track>` and :class:`Videos`<.Video>`
         """
         params = {"offset": offset, "limit": limit}
+
+        if sparse_album:
+            parse_media_callable = self.session.parse_media
+        else:
+            # Parse tracks attributes but provide the Album object directly from self
+            parse_media_callable = functools.partial(
+                self.session.parse_media, album=self
+            )
+
         items = self.request.map_request(
-            "albums/%s/items" % self.id, params=params, parse=self.session.parse_media
+            "albums/%s/items" % self.id, params=params, parse=parse_media_callable
         )
         assert isinstance(items, list)
         return cast(List[Union["Track", "Video"]], items)
@@ -289,7 +318,6 @@ def review(self) -> str:
         :return: A :class:`str` containing the album review, with wimp links
         :raises: :class:`requests.HTTPError` if there isn't a review yet
         """
-        # morguldir: TODO: Add parsing of wimplinks?
         review = self.request.request("GET", "albums/%s/review" % self.id).json()[
             "text"
         ]
diff --git a/tidalapi/artist.py b/tidalapi/artist.py
index 57d69c0..cbde3f4 100644
--- a/tidalapi/artist.py
+++ b/tidalapi/artist.py
@@ -212,7 +212,6 @@ def get_bio(self) -> str:
         :return: A string containing the bio, as well as identifiers to other TIDAL
             objects inside the bio.
         """
-        # morguldir: TODO: Add parsing of wimplinks?
         return cast(
             str, self.request.request("GET", f"artists/{self.id}/bio").json()["text"]
         )
diff --git a/tidalapi/media.py b/tidalapi/media.py
index 46d3942..8b42c82 100644
--- a/tidalapi/media.py
+++ b/tidalapi/media.py
@@ -40,6 +40,7 @@
 from isodate import parse_duration
 from mpegdash.parser import MPEGDASHParser
 
+from tidalapi.album import Album
 from tidalapi.exceptions import (
     ManifestDecodeError,
     MetadataNotAvailable,
@@ -223,9 +224,11 @@ def _get(self, media_id: str) -> Media:
             "You are not supposed to use the media class directly."
         )
 
-    def parse(self, json_obj: JsonObj) -> None:
+    def parse(self, json_obj: JsonObj, album: Optional[Album] = None) -> None:
         """Assigns all :param json_obj:
 
+        :param json_obj: The JSON object to parse
+        :param album: The (optional) album to use, instead of parsing the JSON object
         :return:
         """
         artists = self.session.parse_artists(json_obj["artists"])
@@ -236,9 +239,9 @@ def parse(self, json_obj: JsonObj) -> None:
         else:
             artist = artists[0]
 
-        album = None
-        if json_obj["album"]:
+        if album is None and json_obj["album"]:
             album = self.session.album().parse(json_obj["album"], artist, artists)
+        self.album = album
 
         self.id = json_obj["id"]
         self.name = json_obj["title"]
@@ -265,21 +268,23 @@ def parse(self, json_obj: JsonObj) -> None:
         self.popularity = json_obj["popularity"]
         self.artist = artist
         self.artists = artists
-        self.album = album
         self.type = json_obj.get("type")
 
         self.artist_roles = json_obj.get("artistRoles")
 
-    def parse_media(self, json_obj: JsonObj) -> Union["Track", "Video"]:
+    def parse_media(
+        self, json_obj: JsonObj, album: Optional[Album] = None
+    ) -> Union["Track", "Video"]:
         """Selects the media type when checking lists that can contain both.
 
         :param json_obj: The json containing the media
+        :param album: The (optional) album to use, instead of parsing the JSON object
         :return: Returns a new Video or Track object.
         """
         if json_obj.get("type") is None or json_obj["type"] == "Track":
-            return Track(self.session).parse_track(json_obj)
-        # There are other types like Event, Live, and Video witch match the video class
-        return Video(self.session).parse_video(json_obj)
+            return Track(self.session).parse_track(json_obj, album)
+        # There are other types like Event, Live, and Video which match the video class
+        return Video(self.session).parse_video(json_obj, album)
 
 
 class Track(Media):
@@ -295,8 +300,8 @@ class Track(Media):
     copyright = None
     media_metadata_tags = None
 
-    def parse_track(self, json_obj: JsonObj) -> Track:
-        Media.parse(self, json_obj)
+    def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track:
+        Media.parse(self, json_obj, album)
         self.replay_gain = json_obj["replayGain"]
         # Tracks from the pages endpoints might not actually exist
         if "peak" in json_obj and "isrc" in json_obj:
@@ -846,8 +851,8 @@ class Video(Media):
     video_quality: Optional[str] = None
     cover: Optional[str] = None
 
-    def parse_video(self, json_obj: JsonObj) -> Video:
-        Media.parse(self, json_obj)
+    def parse_video(self, json_obj: JsonObj, album: Optional[Album] = None) -> Video:
+        Media.parse(self, json_obj, album)
         release_date = json_obj.get("releaseDate")
         self.release_date = (
             dateutil.parser.isoparse(release_date) if release_date else None
@@ -871,6 +876,7 @@ def _get(self, media_id: str) -> Video:
 
         :param media_id: TIDAL's identifier of the video
         :return: A :class:`Video` object containing all the information about the video.
+        :raises: A :class:`exceptions.ObjectNotFound` if video is not found or unavailable
         """
 
         try:
@@ -886,7 +892,7 @@ def _get(self, media_id: str) -> Video:
             return cast("Video", video)
 
     def get_url(self) -> str:
-        """Retrieves the URL for a video.
+        """Retrieves the URL to the m3u8 video playlist.
 
         :return: A `str` object containing the direct video URL
         :raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this video
diff --git a/tidalapi/mix.py b/tidalapi/mix.py
index 9605e4c..3b3125c 100644
--- a/tidalapi/mix.py
+++ b/tidalapi/mix.py
@@ -176,10 +176,7 @@ class TextInfo:
 
 
 class MixV2:
-    """A mix from TIDALs v2 api endpoint, weirdly, it is used in only one place
-    currently."""
-
-    # tehkillerbee: TODO Doesn't look like this is using the v2 endpoint anyways!?
+    """A mix from TIDALs v2 api endpoint."""
 
     date_added: Optional[datetime] = None
     title: Optional[str] = None
diff --git a/tidalapi/playlist.py b/tidalapi/playlist.py
index 84548c5..f026fae 100644
--- a/tidalapi/playlist.py
+++ b/tidalapi/playlist.py
@@ -37,11 +37,20 @@
 import dateutil.parser
 
 
+def list_validate(lst):
+    if isinstance(lst, str):
+        lst = [lst]
+    if len(lst) == 0:
+        raise ValueError("An empty list was provided.")
+    return lst
+
+
 class Playlist:
     """An object containing various data about a playlist and methods to work with
     them."""
 
     id: Optional[str] = None
+    trn: Optional[str] = None
     name: Optional[str] = None
     num_tracks: int = -1
     num_videos: int = -1
@@ -88,6 +97,7 @@ def parse(self, json_obj: JsonObj) -> "Playlist":
         :return: Returns a copy of the original :exc: 'Playlist': object
         """
         self.id = json_obj["uuid"]
+        self.trn = f"trn:playlist:{self.id}"
         self.name = json_obj["title"]
         self.num_tracks = int(json_obj["numberOfTracks"])
         self.num_videos = int(json_obj["numberOfVideos"])
@@ -136,7 +146,7 @@ def parse(self, json_obj: JsonObj) -> "Playlist":
 
         return copy.copy(self)
 
-    def factory(self) -> "Playlist":
+    def factory(self) -> Union["Playlist", "UserPlaylist"]:
         if (
             self.id
             and self.creator
@@ -231,35 +241,276 @@ def wide_image(self, width: int = 1080, height: int = 720) -> str:
         )
 
 
+class Folder:
+    """An object containing various data about a folder and methods to work with
+    them."""
+
+    trn: Optional[str] = None
+    id: Optional[str] = None
+    parent_folder_id: Optional[str] = None
+    name: Optional[str] = None
+    parent: Optional[str] = None  # TODO Determine the correct type of the parent
+    added: Optional[datetime] = None
+    created: Optional[datetime] = None
+    last_modified: Optional[datetime] = None
+    total_number_of_items: int = 0
+
+    # Direct URL to https://listen.tidal.com/folder/<folder_id>
+    listen_url: str = ""
+
+    def __init__(
+        self,
+        session: "Session",
+        folder_id: Optional[str],
+        parent_folder_id: str = "root",
+    ):
+        self.id = folder_id
+        self.parent_folder_id = parent_folder_id
+        self.session = session
+        self.request = session.request
+        self.playlist = session.playlist()
+        self._endpoint = "my-collection/playlists/folders"
+        if folder_id:
+            # Go through all available folders and see if the requested folder exists
+            try:
+                params = {
+                    "folderId": parent_folder_id,
+                    "offset": 0,
+                    "limit": 50,
+                    "order": "NAME",
+                    "includeOnly": "FOLDER",
+                }
+                request = self.request.request(
+                    "GET",
+                    self._endpoint,
+                    base_url=self.session.config.api_v2_location,
+                    params=params,
+                )
+                for item in request.json().get("items"):
+                    if item["data"].get("id") == folder_id:
+                        self.parse(item)
+                        return
+                raise ObjectNotFound
+            except ObjectNotFound:
+                raise ObjectNotFound(f"Folder not found")
+            except TooManyRequests:
+                raise TooManyRequests("Folder unavailable")
+
+    def _reparse(self) -> None:
+        params = {
+            "folderId": self.parent_folder_id,
+            "offset": 0,
+            "limit": 50,
+            "order": "NAME",
+            "includeOnly": "FOLDER",
+        }
+        request = self.request.request(
+            "GET",
+            self._endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        )
+        for item in request.json().get("items"):
+            if item["data"].get("id") == self.id:
+                self.parse(item)
+                return
+
+    def parse(self, json_obj: JsonObj) -> "Folder":
+        """Parses a folder from tidal, replaces the current folder object.
+
+        :param json_obj: Json data returned from api.tidal.com containing a folder
+        :return: Returns a copy of the original :class:`Folder` object
+        """
+        self.trn = json_obj.get("trn")
+        self.id = json_obj["data"].get("id")
+        self.name = json_obj.get("name")
+        self.parent = json_obj.get("parent")
+        added = json_obj.get("addedAt")
+        created = json_obj["data"].get("createdAt")
+        last_modified = json_obj["data"].get("lastModifiedAt")
+        self.added = dateutil.parser.isoparse(added) if added else None
+        self.created = dateutil.parser.isoparse(created) if added else None
+        self.last_modified = dateutil.parser.isoparse(last_modified) if added else None
+        self.total_number_of_items = json_obj["data"].get("totalNumberOfItems")
+
+        self.listen_url = f"{self.session.config.listen_base_url}/folder/{self.id}"
+
+        return copy.copy(self)
+
+    def rename(self, name: str) -> bool:
+        """Rename the selected folder.
+
+        :param name: The name to be used for the folder
+        :return: True, if operation was successful.
+        """
+        params = {"trn": self.trn, "name": name}
+        endpoint = "my-collection/playlists/folders/rename"
+        res = self.request.request(
+            "PUT",
+            endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        )
+        self._reparse()
+        return res.ok
+
+    def remove(self) -> bool:
+        """Remove the selected folder.
+
+        :return: True, if operation was successful.
+        """
+        params = {"trns": self.trn}
+        endpoint = "my-collection/playlists/folders/remove"
+        return self.request.request(
+            "PUT",
+            endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        ).ok
+
+    def items(
+        self, offset: int = 0, limit: int = 50
+    ) -> List[Union["Playlist", "UserPlaylist"]]:
+        """Return the items in the folder.
+
+        :param offset: Optional; The index of the first item to be returned. Default: 0
+        :param limit: Optional; The amount of items you want returned. Default: 50
+        :return: Returns a list of :class:`Playlist` or :class:`UserPlaylist` objects
+        """
+        params = {
+            "folderId": self.id,
+            "offset": offset,
+            "limit": limit,
+            "order": "NAME",
+            "includeOnly": "PLAYLIST",
+        }
+        endpoint = "my-collection/playlists/folders"
+        json_obj = self.request.request(
+            "GET",
+            endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        ).json()
+        # Generate a dict of Playlist items from the response data
+        if json_obj.get("items"):
+            playlists = {"items": [item["data"] for item in json_obj.get("items")]}
+            return cast(
+                List[Union["Playlist", "UserPlaylist"]],
+                self.request.map_json(playlists, parse=self.playlist.parse_factory),
+            )
+        else:
+            return []
+
+    def add_items(self, trns: [str]):
+        """Convenience method to add items to the current folder.
+
+        :param trns: List of playlist trns to be added to the current folder
+        :return: True, if operation was successful.
+        """
+        self.move_items_to_folder(trns, self.id)
+
+    def move_items_to_root(self, trns: [str]):
+        """Convenience method to move items from the current folder to the root folder.
+
+        :param trns: List of playlist trns to be moved from the current folder
+        :return: True, if operation was successful.
+        """
+        self.move_items_to_folder(trns, folder="root")
+
+    def move_items_to_folder(self, trns: [str], folder: str = None):
+        """Move item(s) in one folder to another folder.
+
+        :param trns: List of playlist trns to be moved.
+        :param folder: Destination folder. Default: Use the current folder
+        :return: True, if operation was successful.
+        """
+        trns = list_validate(trns)
+        # Make sure all trns has the correct type prepended to it
+        trns_full = []
+        for trn in trns:
+            if "trn:" in trn:
+                trns_full.append(trn)
+            else:
+                trns_full.append(f"trn:playlist:{trn}")
+        if not folder:
+            folder = self.id
+        params = {"folderId": folder, "trns": ",".join(trns_full)}
+        endpoint = "my-collection/playlists/folders/move"
+        res = self.request.request(
+            "PUT",
+            endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        )
+        self._reparse()
+        return res.ok
+
+
 class UserPlaylist(Playlist):
     def _reparse(self) -> None:
+        """Re-Read Playlist to get ETag."""
         request = self.request.request("GET", self._base_url % self.id)
         self._etag = request.headers["etag"]
         self.request.map_json(request.json(), parse=self.parse)
 
     def edit(
         self, title: Optional[str] = None, description: Optional[str] = None
-    ) -> None:
+    ) -> bool:
+        """Edit UserPlaylist title & description.
+
+        :param title: Playlist title
+        :param description: Playlist title.
+        :return: True, if successful.
+        """
         if not title:
             title = self.name
         if not description:
             description = self.description
 
         data = {"title": title, "description": description}
-        self.request.request("POST", self._base_url % self.id, data=data)
+        return self.request.request("POST", self._base_url % self.id, data=data).ok
 
-    def delete(self) -> None:
-        self.request.request("DELETE", self._base_url % self.id)
+    def delete_by_id(self, media_ids: List[str]) -> bool:
+        """Delete one or more items from the UserPlaylist.
 
-    def add(self, media_ids: List[str]) -> None:
+        :param media_ids: Lists of Media IDs to remove.
+        :return: True, if successful.
+        """
+        media_ids = list_validate(media_ids)
+        # Generate list of track indices of tracks found in the list of media_ids.
+        track_ids = [str(track.id) for track in self.tracks()]
+        matching_indices = [i for i, item in enumerate(track_ids) if item in media_ids]
+        return self.remove_by_indices(matching_indices)
+
+    def add(
+        self,
+        media_ids: List[str],
+        allow_duplicates: bool = False,
+        position: int = -1,
+        limit: int = 100,
+    ) -> List[int]:
+        """Add one or more items to the UserPlaylist.
+
+        :param media_ids: List of Media IDs to add.
+        :param allow_duplicates: Allow adding duplicate items
+        :param position: Insert items at a specific position.
+            Default: insert at the end of the playlist
+        :param limit: Maximum number of items to add
+        :return: List of media IDs that has been added
+        """
+        media_ids = list_validate(media_ids)
+        # Insert items at a specific index
+        if position < 0 or position > self.num_tracks:
+            position = self.num_tracks
         data = {
             "onArtifactNotFound": "SKIP",
-            "onDupes": "SKIP",
             "trackIds": ",".join(map(str, media_ids)),
+            "toIndex": position,
+            "onDupes": "ADD" if allow_duplicates else "SKIP",
         }
-        params = {"limit": 100}
+        params = {"limit": limit}
         headers = {"If-None-Match": self._etag} if self._etag else None
-        self.request.request(
+        res = self.request.request(
             "POST",
             self._base_url % self.id + "/items",
             params=params,
@@ -267,35 +518,215 @@ def add(self, media_ids: List[str]) -> None:
             headers=headers,
         )
         self._reparse()
+        # Respond with the added item IDs:
+        added_items = res.json().get("addedItemIds")
+        if added_items:
+            return added_items
+        else:
+            return []
+
+    def merge(
+        self, playlist: str, allow_duplicates: bool = False, allow_missing: bool = True
+    ) -> List[int]:
+        """Add (merge) items from a playlist with the current playlist.
+
+        :param playlist: Playlist UUID to be merged in the current playlist
+        :param allow_duplicates: If true, duplicate tracks are allowed. Otherwise,
+            tracks will be skipped.
+        :param allow_missing: If true, missing tracks are allowed. Otherwise, exception
+            will be thrown
+        :return: List of items that has been added from the playlist
+        """
+        data = {
+            "fromPlaylistUuid": str(playlist),
+            "onArtifactNotFound": "SKIP" if allow_missing else "FAIL",
+            "onDupes": "ADD" if allow_duplicates else "SKIP",
+        }
+        headers = {"If-None-Match": self._etag} if self._etag else None
+        res = self.request.request(
+            "POST",
+            self._base_url % self.id + "/items",
+            data=data,
+            headers=headers,
+        )
+        self._reparse()
+        # Respond with the added item IDs:
+        added_items = res.json().get("addedItemIds")
+        if added_items:
+            return added_items
+        else:
+            return []
+
+    def add_by_isrc(
+        self,
+        isrc: str,
+        allow_duplicates: bool = False,
+        position: int = -1,
+    ) -> bool:
+        """Add an item to a playlist, using the track ISRC.
+
+        :param isrc: The ISRC of the track to be added
+        :param allow_duplicates: Allow adding duplicate items
+        :param position: Insert items at a specific position.
+            Default: insert at the end of the playlist
+        :return: True, if successful.
+        """
+        if not isinstance(isrc, str):
+            isrc = str(isrc)
+        try:
+            track = self.session.get_tracks_by_isrc(isrc)
+            if track:
+                # Add the first track in the list
+                track_id = track[0].id
+                added = self.add(
+                    [str(track_id)],
+                    allow_duplicates=allow_duplicates,
+                    position=position,
+                )
+                if track_id in added:
+                    return True
+                else:
+                    return False
+            else:
+                return False
+        except ObjectNotFound:
+            return False
+
+    def move_by_id(self, media_id: str, position: int) -> bool:
+        """Move an item to a new position, by media ID.
 
-    def remove_by_index(self, index: int) -> None:
+        :param media_id: The index of the item to be moved
+        :param position: The new position of the item
+        :return: True, if successful.
+        """
+        if not isinstance(media_id, str):
+            media_id = str(media_id)
+        track_ids = [str(track.id) for track in self.tracks()]
+        try:
+            index = track_ids.index(media_id)
+            if index is not None and index < self.num_tracks:
+                return self.move_by_indices([index], position)
+        except ValueError:
+            return False
+
+    def move_by_index(self, index: int, position: int) -> bool:
+        """Move a single item to a new position.
+
+        :param index: The index of the item to be moved
+        :param position: The new position/offset of the item
+        :return: True, if successful.
+        """
+        if not isinstance(index, int):
+            raise ValueError
+        return self.move_by_indices([index], position)
+
+    def move_by_indices(self, indices: Sequence[int], position: int) -> bool:
+        """Move one or more items to a new position.
+
+        :param indices: List containing indices to move.
+        :param position: The new position/offset of the item(s)
+        :return: True, if successful.
+        """
+        # Move item to a new position
+        if position < 0 or position >= self.num_tracks:
+            position = self.num_tracks
+        data = {
+            "toIndex": position,
+        }
         headers = {"If-None-Match": self._etag} if self._etag else None
-        self.request.request(
-            "DELETE", (self._base_url + "/items/%i") % (self.id, index), headers=headers
+        track_index_string = ",".join([str(x) for x in indices])
+        res = self.request.request(
+            "POST",
+            (self._base_url + "/items/%s") % (self.id, track_index_string),
+            data=data,
+            headers=headers,
         )
+        self._reparse()
+        return res.ok
+
+    def remove_by_id(self, media_id: str) -> bool:
+        """Remove a single item from the playlist, using the media ID.
+
+        :param media_id: Media ID to remove.
+        :return: True, if successful.
+        """
+        if not isinstance(media_id, str):
+            media_id = str(media_id)
+        track_ids = [str(track.id) for track in self.tracks()]
+        try:
+            index = track_ids.index(media_id)
+            if index is not None and index < self.num_tracks:
+                return self.remove_by_index(index)
+        except ValueError:
+            return False
+
+    def remove_by_index(self, index: int) -> bool:
+        """Remove a single item from the UserPlaylist, using item index.
+
+        :param index: Media index to remove
+        :return: True, if successful.
+        """
+        return self.remove_by_indices([index])
 
-    def remove_by_indices(self, indices: Sequence[int]) -> None:
+    def remove_by_indices(self, indices: Sequence[int]) -> bool:
+        """Remove one or more items from the UserPlaylist, using list of indices.
+
+        :param indices: List containing indices to remove.
+        :return: True, if successful.
+        """
         headers = {"If-None-Match": self._etag} if self._etag else None
         track_index_string = ",".join([str(x) for x in indices])
-        self.request.request(
+        res = self.request.request(
             "DELETE",
-            (self._base_url + "/tracks/%s") % (self.id, track_index_string),
+            (self._base_url + "/items/%s") % (self.id, track_index_string),
             headers=headers,
         )
+        self._reparse()
+        return res.ok
+
+    def clear(self, chunk_size: int = 50) -> bool:
+        """Clear UserPlaylist.
 
-    def _calculate_id(self, media_id: str) -> Optional[int]:
-        i = 0
-        while i < self.num_tracks:
-            items = self.items(100, i)
-            for index, item in enumerate(items):
-                if item.id == media_id:
-                    # Return the amount of items we have gone through plus the index in the last list.
-                    return index + i
-
-            i += len(items)
-        return None
-
-    def remove_by_id(self, media_id: str) -> None:
-        index = self._calculate_id(media_id)
-        if index is not None:
-            self.remove_by_index(index)
+        :param chunk_size: Number of items to remove per request
+        :return: True, if successful.
+        """
+        while self.num_tracks:
+            indices = range(min(self.num_tracks, chunk_size))
+            if not self.remove_by_indices(indices):
+                return False
+        return True
+
+    def set_playlist_public(self):
+        """Set UserPlaylist as Public.
+
+        :return: True, if successful.
+        """
+        res = self.request.request(
+            "PUT",
+            base_url=self.session.config.api_v2_location,
+            path=(self._base_url + "/set-public") % self.id,
+        )
+        self.public = True
+        self._reparse()
+        return res.ok
+
+    def set_playlist_private(self):
+        """Set UserPlaylist as Private.
+
+        :return: True, if successful.
+        """
+        res = self.request.request(
+            "PUT",
+            base_url=self.session.config.api_v2_location,
+            path=(self._base_url + "/set-private") % self.id,
+        )
+        self.public = False
+        self._reparse()
+        return res.ok
+
+    def delete(self) -> bool:
+        """Delete UserPlaylist.
+
+        :return: True, if successful.
+        """
+        return self.request.request("DELETE", path="playlists/%s" % self.id).ok
diff --git a/tidalapi/session.py b/tidalapi/session.py
index 3b323a1..91e3cb2 100644
--- a/tidalapi/session.py
+++ b/tidalapi/session.py
@@ -24,7 +24,6 @@
 import datetime
 import hashlib
 import json
-import locale
 import logging
 import os
 import random
@@ -46,7 +45,7 @@
     cast,
     no_type_check,
 )
-from urllib.parse import parse_qs, urlencode, urljoin, urlsplit
+from urllib.parse import parse_qs, urlencode, urlsplit
 
 import requests
 from requests.exceptions import HTTPError
@@ -274,14 +273,14 @@ def __init__(self, config: Config = Config()):
         self.request = request.Requests(session=self)
         self.genre = genre.Genre(session=self)
 
-        self.parse_artists = self.artist().parse_artists
-        self.parse_playlist = self.playlist().parse
+        # self.parse_artists = self.artist().parse_artists
+        # self.parse_playlist = self.playlist().parse
 
-        self.parse_track = self.track().parse_track
-        self.parse_video = self.video().parse_video
-        self.parse_media = self.track().parse_media
-        self.parse_mix = self.mix().parse
-        self.parse_v2_mix = self.mixv2().parse
+        # self.parse_track = self.track().parse_track
+        # self.parse_video = self.video().parse_video
+        # self.parse_media = self.track().parse_media
+        # self.parse_mix = self.mix().parse
+        # self.parse_v2_mix = self.mixv2().parse
 
         self.parse_user = user.User(self, None).parse
         self.page = page.Page(self, "")
@@ -318,14 +317,46 @@ def parse_album(self, obj: JsonObj) -> album.Album:
         """Parse an album from the given response."""
         return self.album().parse(obj)
 
+    def parse_track(
+        self, obj: JsonObj, album: Optional[album.Album] = None
+    ) -> media.Track:
+        """Parse an album from the given response."""
+        return self.track().parse_track(obj, album)
+
+    def parse_video(self, obj: JsonObj) -> media.Video:
+        """Parse an album from the given response."""
+        return self.video().parse_video(obj)
+
+    def parse_media(
+        self, obj: JsonObj, album: Optional[album.Album] = None
+    ) -> Union[media.Track, media.Video]:
+        """Parse a media type (track, video) from the given response."""
+        return self.track().parse_media(obj, album)
+
     def parse_artist(self, obj: JsonObj) -> artist.Artist:
         """Parse an artist from the given response."""
         return self.artist().parse_artist(obj)
 
+    def parse_artists(self, obj: List[JsonObj]) -> List[artist.Artist]:
+        """Parse an artist from the given response."""
+        return self.artist().parse_artists(obj)
+
     def parse_mix(self, obj: JsonObj) -> mix.Mix:
         """Parse a mix from the given response."""
         return self.mix().parse(obj)
 
+    def parse_v2_mix(self, obj: JsonObj) -> mix.Mix:
+        """Parse a mixV2 from the given response."""
+        return self.mixv2().parse(obj)
+
+    def parse_playlist(self, obj: JsonObj) -> playlist.Playlist:
+        """Parse a playlist from the given response."""
+        return self.playlist().parse(obj)
+
+    def parse_folder(self, obj: JsonObj) -> playlist.Folder:
+        """Parse an album from the given response."""
+        return self.folder().parse(obj)
+
     def convert_type(
         self,
         search: Any,
@@ -416,32 +447,6 @@ def load_oauth_session(
 
         return True
 
-    def login(self, username: str, password: str) -> bool:
-        """Logs in to the TIDAL api.
-
-        :param username: The TIDAL username
-        :param password: The password to your TIDAL account
-        :return: Returns true if we think the login was successful.
-        """
-        url = urljoin(self.config.api_v1_location, "login/username")
-        headers: dict[str, str] = {"X-Tidal-Token": self.config.api_token}
-        payload = {
-            "username": username,
-            "password": password,
-            "clientUniqueKey": format(random.getrandbits(64), "02x"),
-        }
-        request = self.request_session.post(url, data=payload, headers=headers)
-
-        if not request.ok:
-            log.error("Login failed: %s", request.text)
-            request.raise_for_status()
-
-        body = request.json()
-        self.session_id = str(body["sessionId"])
-        self.country_code = str(body["countryCode"])
-        self.user = user.User(self, user_id=body["userId"]).factory()
-        return True
-
     def login_session_file(
         self,
         session_file: Path,
@@ -843,6 +848,20 @@ def playlist(
             log.warning("Playlist '%s' is unavailable", playlist_id)
             raise
 
+    def folder(self, folder_id: Optional[str] = None) -> playlist.Folder:
+        """Function to create a Folder object with access to the session instance in a
+        smoother way. Calls :class:`tidalapi.Folder(session=session, folder_id=track_id)
+        <.Folder>` internally.
+
+        :param folder_id:
+        :return: Returns a :class:`.Folder` object that has access to the session instance used.
+        """
+        try:
+            return playlist.Folder(session=self, folder_id=folder_id)
+        except ObjectNotFound:
+            log.warning("Folder '%s' is unavailable", folder_id)
+            raise
+
     def track(
         self, track_id: Optional[str] = None, with_album: bool = False
     ) -> media.Track:
diff --git a/tidalapi/user.py b/tidalapi/user.py
index 8512c24..eea2f29 100644
--- a/tidalapi/user.py
+++ b/tidalapi/user.py
@@ -25,9 +25,10 @@
 from __future__ import annotations
 
 from copy import copy
-from typing import TYPE_CHECKING, Dict, List, Optional, Union, cast
+from typing import TYPE_CHECKING, List, Optional, Union, cast
 from urllib.parse import urljoin
 
+from tidalapi.exceptions import ObjectNotFound
 from tidalapi.types import JsonObj
 
 if TYPE_CHECKING:
@@ -35,10 +36,18 @@
     from tidalapi.artist import Artist
     from tidalapi.media import Track, Video
     from tidalapi.mix import MixV2
-    from tidalapi.playlist import Playlist, UserPlaylist
+    from tidalapi.playlist import Folder, Playlist, UserPlaylist
     from tidalapi.session import Session
 
 
+def list_validate(lst):
+    if isinstance(lst, str):
+        lst = [lst]
+    if len(lst) == 0:
+        raise ValueError("An empty list was provided.")
+    return lst
+
+
 class User:
     """A class containing various information about a TIDAL user.
 
@@ -57,6 +66,7 @@ def __init__(self, session: "Session", user_id: Optional[int]):
         self.session = session
         self.request = session.request
         self.playlist = session.playlist()
+        self.folder = session.folder()
 
     def factory(self) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]:
         return cast(
@@ -131,7 +141,7 @@ def parse(self, json_obj: JsonObj) -> "LoggedInUser":
         return copy(self)
 
     def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]:
-        """Get the playlists created by the user.
+        """Get the (personal) playlists created by the user.
 
         :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
         """
@@ -142,15 +152,71 @@ def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]:
             ),
         )
 
+    def playlist_folders(
+        self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root"
+    ) -> List["Folder"]:
+        """Get a list of folders created by the user.
+
+        :param offset: The amount of items you want returned.
+        :param limit: The index of the first item you want included.
+        :param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
+        :return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
+        """
+        params = {
+            "folderId": parent_folder_id,
+            "offset": offset,
+            "limit": limit,
+            "order": "NAME",
+            "includeOnly": "FOLDER",
+        }
+        endpoint = "my-collection/playlists/folders"
+        return cast(
+            List["Folder"],
+            self.session.request.map_request(
+                url=urljoin(
+                    self.session.config.api_v2_location,
+                    endpoint,
+                ),
+                params=params,
+                parse=self.session.parse_folder,
+            ),
+        )
+
+    def public_playlists(
+        self, offset: int = 0, limit: int = 50
+    ) -> List[Union["Playlist", "UserPlaylist"]]:
+        """Get the (public) playlists created by the user.
+
+        :param offset: The amount of items you want returned.
+        :param limit: The index of the first item you want included.
+        :return: List of public playlists.
+        """
+        params = {"limit": limit, "offset": offset}
+        endpoint = "user-playlists/%s/public" % self.id
+        json_obj = self.request.request(
+            "GET", endpoint, base_url=self.session.config.api_v2_location, params=params
+        ).json()
+
+        # The response contains both playlists and user details (followInfo, profile) but we will discard the latter.
+        playlists = {"items": []}
+        for index, item in enumerate(json_obj["items"]):
+            if item["playlist"]:
+                playlists["items"].append(item["playlist"])
+
+        return cast(
+            List[Union["Playlist", "UserPlaylist"]],
+            self.request.map_json(playlists, parse=self.playlist.parse_factory),
+        )
+
     def playlist_and_favorite_playlists(
-        self, offset: int = 0
+        self, limit: Optional[int] = None, offset: int = 0
     ) -> List[Union["Playlist", "UserPlaylist"]]:
         """Get the playlists created by the user, and the playlists favorited by the
         user. This function is limited to 50 by TIDAL, requiring pagination.
 
         :return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
         """
-        params = {"limit": 50, "offset": offset}
+        params = {"limit": limit, "offset": offset}
         endpoint = "users/%s/playlistsAndFavoritePlaylists" % self.id
         json_obj = self.request.request("GET", endpoint, params=params).json()
 
@@ -164,13 +230,52 @@ def playlist_and_favorite_playlists(
             self.request.map_json(json_obj, parse=self.playlist.parse_factory),
         )
 
-    def create_playlist(self, title: str, description: str) -> "Playlist":
-        data = {"title": title, "description": description}
-        json = self.request.request(
-            "POST", "users/%s/playlists" % self.id, data=data
+    def create_playlist(
+        self, title: str, description: str, parent_id: str = "root"
+    ) -> "UserPlaylist":
+        """Create a playlist in the specified parent folder.
+
+        :param title: Playlist title
+        :param description: Playlist description
+        :param parent_id: Parent folder ID. Default: 'root' playlist folder
+        :return: Returns an object of :class:`~tidalapi.playlist.UserPlaylist` containing the newly created playlist
+        """
+        params = {"name": title, "description": description, "folderId": parent_id}
+        endpoint = "my-collection/playlists/folders/create-playlist"
+
+        json_obj = self.request.request(
+            method="PUT",
+            path=endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
         ).json()
-        playlist = self.session.playlist().parse(json)
-        return playlist.factory()
+        json = json_obj.get("data")
+        if json and json.get("uuid"):
+            playlist = self.session.playlist().parse(json)
+            return playlist.factory()
+        else:
+            raise ObjectNotFound("Playlist not found after creation")
+
+    def create_folder(self, title: str, parent_id: str = "root") -> "Folder":
+        """Create folder in the specified parent folder.
+
+        :param title: Folder title
+        :param parent_id: Folder parent ID. Default: 'root' playlist folder
+        :return: Returns an object of :class:`~tidalapi.playlist.Folder` containing the newly created object
+        """
+        params = {"name": title, "folderId": parent_id}
+        endpoint = "my-collection/playlists/folders/create-folder"
+
+        json_obj = self.request.request(
+            method="PUT",
+            path=endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        ).json()
+        if json_obj and json_obj.get("data"):
+            return self.request.map_json(json_obj, parse=self.folder.parse)
+        else:
+            raise ObjectNotFound("Folder not found after creation")
 
 
 class PlaylistCreator(User):
@@ -241,6 +346,25 @@ def add_track(self, track_id: str) -> bool:
             "POST", f"{self.base_url}/tracks", data={"trackId": track_id}
         ).ok
 
+    def add_track_by_isrc(self, isrc: str) -> bool:
+        """Adds a track to the users favorites, using isrc.
+
+        :param isrc: The ISRC of the track to be added
+        :return: True, if successful.
+        """
+        try:
+            track = self.session.get_tracks_by_isrc(isrc)
+            if track:
+                # Add the first track in the list
+                track_id = str(track[0].id)
+                return self.requests.request(
+                    "POST", f"{self.base_url}/tracks", data={"trackId": track_id}
+                ).ok
+            else:
+                return False
+        except ObjectNotFound:
+            return False
+
     def add_video(self, video_id: str) -> bool:
         """Adds a video to the users favorites.
 
@@ -299,6 +423,33 @@ def remove_video(self, video_id: str) -> bool:
         """
         return self.requests.request("DELETE", f"{self.base_url}/videos/{video_id}").ok
 
+    def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool:
+        """Removes one or more folders or playlists from the users favourites, using the
+        v2 endpoint.
+
+        :param trns: List of folder (or playlist) trns to be deleted
+        :param type: Type of trn: as string, either `folder` or `playlist`. Default `folder`
+        :return: A boolean indicating whether theÅ› request was successful or not.
+        """
+        if type not in ("playlist", "folder"):
+            raise ValueError("Invalid trn value used for playlist/folder endpoint")
+        trns = list_validate(trns)
+        # Make sure all trns has the correct type prepended to it
+        trns_full = []
+        for trn in trns:
+            if "trn:" in trn:
+                trns_full.append(trn)
+            else:
+                trns_full.append(f"trn:{type}:{trn}")
+        params = {"trns": ",".join(trns_full)}
+        endpoint = "my-collection/playlists/folders/remove"
+        return self.requests.request(
+            method="PUT",
+            path=endpoint,
+            base_url=self.session.config.api_v2_location,
+            params=params,
+        ).ok
+
     def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]:
         """Get the users favorite artists.