From 133fed344f0d7d5db4cdb560c93e353366171761 Mon Sep 17 00:00:00 2001
From: Deluan <deluan@navidrome.org>
Date: Fri, 29 Oct 2021 22:55:28 -0400
Subject: [PATCH] Add `owner_id` to `playlist`

---
 core/playlists.go                             |  8 +--
 .../20211029213200_add_userid_to_playlist.go  | 60 +++++++++++++++++++
 model/playlist.go                             |  3 +-
 persistence/persistence_suite_test.go         | 14 ++++-
 persistence/playlist_repository.go            | 26 ++++----
 persistence/playlist_repository_test.go       |  2 +-
 resources/i18n/cs.json                        |  4 +-
 resources/i18n/da.json                        |  4 +-
 resources/i18n/de.json                        |  4 +-
 resources/i18n/eo.json                        |  4 +-
 resources/i18n/es.json                        |  4 +-
 resources/i18n/fa.json                        |  2 +-
 resources/i18n/fi.json                        |  4 +-
 resources/i18n/fr.json                        |  4 +-
 resources/i18n/it.json                        |  4 +-
 resources/i18n/ja.json                        |  4 +-
 resources/i18n/nl.json                        |  4 +-
 resources/i18n/pl.json                        |  4 +-
 resources/i18n/pt.json                        |  2 +-
 resources/i18n/ru.json                        |  4 +-
 resources/i18n/sl.json                        |  4 +-
 resources/i18n/sv.json                        |  2 +-
 resources/i18n/th.json                        |  4 +-
 resources/i18n/tr.json                        |  4 +-
 resources/i18n/uk.json                        |  4 +-
 resources/i18n/zh-Hans.json                   |  4 +-
 resources/i18n/zh-Hant.json                   |  4 +-
 server/subsonic/helpers.go                    |  6 +-
 server/subsonic/playlists.go                  | 10 ++--
 ui/src/common/Writable.js                     | 12 ++--
 ui/src/common/useSelectedFields.js            |  3 +-
 ui/src/dialogs/AddToPlaylistDialog.test.js    |  9 +--
 ui/src/dialogs/SelectPlaylistInput.js         |  2 +-
 ui/src/dialogs/SelectPlaylistInput.test.js    | 23 +++----
 ui/src/i18n/en.json                           |  2 +-
 ui/src/layout/PlaylistsSubMenu.js             |  4 +-
 ui/src/playlist/PlaylistEdit.js               |  2 +-
 ui/src/playlist/PlaylistList.js               |  9 +--
 38 files changed, 173 insertions(+), 100 deletions(-)
 create mode 100644 db/migration/20211029213200_add_userid_to_playlist.go

diff --git a/core/playlists.go b/core/playlists.go
index c31ea32cc..744139371 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -136,7 +136,7 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, baseDir s
 }
 
 func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
-	owner, _ := request.UsernameFrom(ctx)
+	owner, _ := request.UserFrom(ctx)
 
 	pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path)
 	if err != nil && err != model.ErrNotFound {
@@ -152,12 +152,12 @@ func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist)
 		newPls.ID = pls.ID
 		newPls.Name = pls.Name
 		newPls.Comment = pls.Comment
-		newPls.Owner = pls.Owner
+		newPls.OwnerID = pls.OwnerID
 		newPls.Public = pls.Public
 		newPls.EvaluatedAt = time.Time{}
 	} else {
-		log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner)
-		newPls.Owner = owner
+		log.Info(ctx, "Adding synced playlist", "playlist", newPls.Name, "path", newPls.Path, "owner", owner.UserName)
+		newPls.OwnerID = owner.ID
 	}
 	return s.ds.Playlist(ctx).Put(newPls)
 }
diff --git a/db/migration/20211029213200_add_userid_to_playlist.go b/db/migration/20211029213200_add_userid_to_playlist.go
new file mode 100644
index 000000000..2c7c86cbb
--- /dev/null
+++ b/db/migration/20211029213200_add_userid_to_playlist.go
@@ -0,0 +1,60 @@
+package migrations
+
+import (
+	"database/sql"
+
+	"github.com/pressly/goose"
+)
+
+func init() {
+	goose.AddMigration(upAddUseridToPlaylist, downAddUseridToPlaylist)
+}
+
+func upAddUseridToPlaylist(tx *sql.Tx) error {
+	_, err := tx.Exec(`
+create table playlist_dg_tmp
+(
+	id varchar(255) not null
+		primary key,
+	name varchar(255) default '' not null,
+	comment varchar(255) default '' not null,
+	duration real default 0 not null,
+	song_count integer default 0 not null,
+	public bool default FALSE not null,
+	created_at datetime,
+	updated_at datetime,
+	path string default '' not null,
+	sync bool default false not null,
+	size integer default 0 not null,
+	rules varchar,
+	evaluated_at datetime,
+	owner_id varchar(255) not null
+		constraint playlist_user_user_id_fk
+			references user
+				on update cascade on delete cascade
+);
+
+insert into playlist_dg_tmp(id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, owner_id) 
+select id, name, comment, duration, song_count, public, created_at, updated_at, path, sync, size, rules, evaluated_at, 
+       (select id from user where user_name = owner) as user_id from playlist;
+
+drop table playlist;
+alter table playlist_dg_tmp rename to playlist;
+create index playlist_created_at
+	on playlist (created_at);
+create index playlist_evaluated_at
+	on playlist (evaluated_at);
+create index playlist_name
+	on playlist (name);
+create index playlist_size
+	on playlist (size);
+create index playlist_updated_at
+	on playlist (updated_at);
+
+`)
+	return err
+}
+
+func downAddUseridToPlaylist(tx *sql.Tx) error {
+	return nil
+}
diff --git a/model/playlist.go b/model/playlist.go
index e05556d5c..262d724d9 100644
--- a/model/playlist.go
+++ b/model/playlist.go
@@ -15,7 +15,8 @@ type Playlist struct {
 	Duration  float32        `structs:"duration" json:"duration"`
 	Size      int64          `structs:"size" json:"size"`
 	SongCount int            `structs:"song_count" json:"songCount"`
-	Owner     string         `structs:"owner" json:"owner"`
+	OwnerName string         `structs:"-" json:"ownerName"`
+	OwnerID   string         `structs:"owner_id" json:"ownerId"  orm:"column(owner_id)"`
 	Public    bool           `structs:"public" json:"public"`
 	Tracks    PlaylistTracks `structs:"-" json:"tracks,omitempty"`
 	Path      string         `structs:"path" json:"path"`
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index 44ebfcaea..69f836e22 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -85,7 +85,14 @@ var _ = Describe("Initialize test DB", func() {
 	BeforeSuite(func() {
 		o := orm.NewOrm()
 		ctx := log.NewContext(context.TODO())
-		ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid"})
+		user := model.User{ID: "userid", UserName: "userid"}
+		ctx = request.WithUser(ctx, user)
+
+		ur := NewUserRepository(ctx, o)
+		err := ur.Put(&user)
+		if err != nil {
+			panic(err)
+		}
 
 		gr := NewGenreRepository(ctx, o)
 		for i := range testGenres {
@@ -126,12 +133,13 @@ var _ = Describe("Initialize test DB", func() {
 		plsBest = model.Playlist{
 			Name:      "Best",
 			Comment:   "No Comments",
-			Owner:     "userid",
+			OwnerID:   "userid",
+			OwnerName: "userid",
 			Public:    true,
 			SongCount: 2,
 		}
 		plsBest.AddTracks([]string{"1001", "1003"})
-		plsCool = model.Playlist{Name: "Cool", Owner: "userid"}
+		plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
 		plsCool.AddTracks([]string{"1004"})
 		testPlaylists = []*model.Playlist{&plsBest, &plsCool}
 
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 5f780206e..aa832f5d2 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -50,7 +50,7 @@ func (r *playlistRepository) userFilter() Sqlizer {
 	}
 	return Or{
 		Eq{"public": true},
-		Eq{"owner": user.UserName},
+		Eq{"owner_id": user.ID},
 	}
 }
 
@@ -70,7 +70,7 @@ func (r *playlistRepository) Delete(id string) error {
 		if err != nil {
 			return err
 		}
-		if pls.Owner != usr.UserName {
+		if pls.OwnerID != usr.ID {
 			return rest.ErrPermissionDenied
 		}
 	}
@@ -117,11 +117,11 @@ func (r *playlistRepository) Put(p *model.Playlist) error {
 }
 
 func (r *playlistRepository) Get(id string) (*model.Playlist, error) {
-	return r.findBy(And{Eq{"id": id}, r.userFilter()})
+	return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()})
 }
 
 func (r *playlistRepository) GetWithTracks(id string) (*model.Playlist, error) {
-	pls, err := r.findBy(And{Eq{"id": id}, r.userFilter()})
+	pls, err := r.Get(id)
 	if err != nil {
 		return nil, err
 	}
@@ -140,7 +140,7 @@ func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) {
 }
 
 func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) {
-	sel := r.newSelect().Columns("*").Where(sql)
+	sel := r.selectPlaylist().Where(sql)
 	var pls []dbPlaylist
 	err := r.queryAll(sel, &pls)
 	if err != nil {
@@ -169,7 +169,7 @@ func (r *playlistRepository) toModel(pls dbPlaylist) (*model.Playlist, error) {
 }
 
 func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) {
-	sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
+	sel := r.selectPlaylist(options...).Where(r.userFilter())
 	var res []dbPlaylist
 	err := r.queryAll(sel, &res)
 	if err != nil {
@@ -186,6 +186,11 @@ func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playli
 	return playlists, err
 }
 
+func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) SelectBuilder {
+	return r.newSelect(options...).Join("user on user.id = owner_id").
+		Columns(r.tableName+".*", "user.user_name as owner_name")
+}
+
 func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
 	// Only refresh if it is a smart playlist and was not refreshed in the last 5 seconds
 	if !pls.IsSmartPlaylist() || time.Since(pls.EvaluatedAt) < 5*time.Second {
@@ -194,7 +199,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
 
 	// Never refresh other users' playlists
 	usr := loggedUser(r.ctx)
-	if pls.Owner != usr.UserName {
+	if pls.OwnerID != usr.ID {
 		return false
 	}
 
@@ -362,7 +367,8 @@ func (r *playlistRepository) NewInstance() interface{} {
 
 func (r *playlistRepository) Save(entity interface{}) (string, error) {
 	pls := entity.(*model.Playlist)
-	pls.Owner = loggedUser(r.ctx).UserName
+	pls.OwnerID = loggedUser(r.ctx).ID
+	pls.ID = "" // Make sure we don't override an existing playlist
 	err := r.Put(pls)
 	if err != nil {
 		return "", err
@@ -373,7 +379,7 @@ func (r *playlistRepository) Save(entity interface{}) (string, error) {
 func (r *playlistRepository) Update(entity interface{}, cols ...string) error {
 	pls := entity.(*model.Playlist)
 	usr := loggedUser(r.ctx)
-	if !usr.IsAdmin && pls.Owner != usr.UserName {
+	if !usr.IsAdmin && pls.OwnerID != usr.ID {
 		return rest.ErrPermissionDenied
 	}
 	err := r.Put(pls)
@@ -432,7 +438,7 @@ func (r *playlistRepository) isWritable(playlistId string) bool {
 		return true
 	}
 	pls, err := r.Get(playlistId)
-	return err == nil && pls.Owner == usr.UserName
+	return err == nil && pls.OwnerID == usr.ID
 }
 
 var _ model.PlaylistRepository = (*playlistRepository)(nil)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 833aa0230..37a8242c9 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -76,7 +76,7 @@ var _ = Describe("PlaylistRepository", func() {
 
 	It("Put/Exists/Delete", func() {
 		By("saves the playlist to the DB")
-		newPls := model.Playlist{Name: "Great!", Owner: "userid"}
+		newPls := model.Playlist{Name: "Great!", OwnerID: "userid"}
 		newPls.AddTracks([]string{"1004", "1003"})
 
 		By("saves the playlist to the DB")
diff --git a/resources/i18n/cs.json b/resources/i18n/cs.json
index 23c6fd389..0054a1ec1 100644
--- a/resources/i18n/cs.json
+++ b/resources/i18n/cs.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Název",
                 "duration": "Délka",
-                "owner": "Vlastník",
+                "ownerName": "Vlastník",
                 "public": "Veřejný",
                 "updatedAt": "Nahrán",
                 "createdAt": "Vytvořen",
@@ -386,4 +386,4 @@
             "toggle_love": "Přidat tuto skladbu do oblíbených"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/da.json b/resources/i18n/da.json
index 315c5b3c3..80ec8b4ec 100644
--- a/resources/i18n/da.json
+++ b/resources/i18n/da.json
@@ -110,7 +110,7 @@
             "fields": {
                 "name": "Navn",
                 "duration": "Varighed",
-                "owner": "Ejer",
+                "ownerName": "Ejer",
                 "public": "Offentlig",
                 "updatedAt": "Opdateret den",
                 "createdAt": "Oprettet den",
@@ -324,4 +324,4 @@
         "serverUptime": "Server uptime",
         "serverDown": "OFFLINE"
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/de.json b/resources/i18n/de.json
index 37bbebdc0..be7e0cf64 100644
--- a/resources/i18n/de.json
+++ b/resources/i18n/de.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Name",
                 "duration": "Dauer",
-                "owner": "Inhaber",
+                "ownerName": "Inhaber",
                 "public": "Öffentlich",
                 "updatedAt": "Aktualisiert um",
                 "createdAt": "Erstellt um",
@@ -386,4 +386,4 @@
             "toggle_love": "Song zu Favoriten hinzufügen"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json
index 74fa5958b..545e2b42b 100644
--- a/resources/i18n/eo.json
+++ b/resources/i18n/eo.json
@@ -115,7 +115,7 @@
             "fields": {
                 "name": "Nomo",
                 "duration": "Daŭro",
-                "owner": "Posedanto",
+                "ownerName": "Posedanto",
                 "public": "Publika",
                 "updatedAt": "Ĝisdatigita je",
                 "createdAt": "Kreita je",
@@ -348,4 +348,4 @@
             "toggle_love": "Baskuli la stelon de nuna kanto"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/es.json b/resources/i18n/es.json
index abaf65131..bbdf4d56f 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Nombre",
                 "duration": "Duración",
-                "owner": "Dueño",
+                "ownerName": "Dueño",
                 "public": "Público",
                 "updatedAt": "Actualizado el",
                 "createdAt": "Creado el",
@@ -386,4 +386,4 @@
             "toggle_love": "Marca esta canción como favorita"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/fa.json b/resources/i18n/fa.json
index 22dbe02f6..453e19b20 100644
--- a/resources/i18n/fa.json
+++ b/resources/i18n/fa.json
@@ -133,7 +133,7 @@
       "fields": {
         "name": "نام",
         "duration": "مدّت زمان",
-        "owner": "مالک",
+        "ownerName": "مالک",
         "public": "عمومی",
         "updatedAt": "بروزشده در",
         "createdAt": "ایجادشده در",
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index 4d6be595f..0652c05f9 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Nimi",
                 "duration": "Kesto",
-                "owner": "Omistaja",
+                "ownerName": "Omistaja",
                 "public": "Julkinen",
                 "updatedAt": "Päivitetty",
                 "createdAt": "Luotu",
@@ -386,4 +386,4 @@
             "toggle_love": "Lisää kappale suosikkeihin"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json
index 1c62da262..048298f0e 100644
--- a/resources/i18n/fr.json
+++ b/resources/i18n/fr.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Nom",
                 "duration": "Durée",
-                "owner": "Propriétaire",
+                "ownerName": "Propriétaire",
                 "public": "Public",
                 "updatedAt": "Mise à jour le",
                 "createdAt": "Créé le",
@@ -386,4 +386,4 @@
             "toggle_love": "Ajouter/Enlever le morceau des favoris"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/it.json b/resources/i18n/it.json
index cc07f4b99..fc819190d 100644
--- a/resources/i18n/it.json
+++ b/resources/i18n/it.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Nome",
                 "duration": "Durata",
-                "owner": "Creatore",
+                "ownerName": "Creatore",
                 "public": "Pubblica",
                 "updatedAt": "Ultimo aggiornamento",
                 "createdAt": "Data creazione",
@@ -386,4 +386,4 @@
             "toggle_love": "Aggiungi questa traccia ai preferiti"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json
index f4404914c..f9d3eb6e4 100644
--- a/resources/i18n/ja.json
+++ b/resources/i18n/ja.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "名前",
                 "duration": "時間",
-                "owner": "所有者",
+                "ownerName": "所有者",
                 "public": "公開",
                 "updatedAt": "更新日",
                 "createdAt": "作成日",
@@ -386,4 +386,4 @@
             "toggle_love": "星の付け外し"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index 4424af9f9..226b514e9 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -130,7 +130,7 @@
             "fields": {
                 "name": "Titel",
                 "duration": "Lengte",
-                "owner": "Eigenaar",
+                "ownerName": "Eigenaar",
                 "public": "Publiek",
                 "updatedAt": "Laatst gewijzigd op",
                 "createdAt": "Aangemaakt op",
@@ -380,4 +380,4 @@
             "toggle_love": "Voeg toe aan favorieten"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json
index da1fad648..6cb72b906 100644
--- a/resources/i18n/pl.json
+++ b/resources/i18n/pl.json
@@ -130,7 +130,7 @@
             "fields": {
                 "name": "Nazwa",
                 "duration": "Czas trwania",
-                "owner": "Właściciel",
+                "ownerName": "Właściciel",
                 "public": "Publiczna",
                 "updatedAt": "Zaktualizowana",
                 "createdAt": "Stworzona",
@@ -380,4 +380,4 @@
             "toggle_love": "Dodaj ten utwór do ulubionych"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json
index 15f272802..a6468031f 100644
--- a/resources/i18n/pt.json
+++ b/resources/i18n/pt.json
@@ -138,7 +138,7 @@
             "fields": {
                 "name": "Nome",
                 "duration": "Duração",
-                "owner": "Dono",
+                "ownerName": "Dono",
                 "public": "Pública",
                 "updatedAt": "Últ. Atualização",
                 "createdAt": "Data de Criação ",
diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json
index a24c81fc1..18893b912 100644
--- a/resources/i18n/ru.json
+++ b/resources/i18n/ru.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Название",
                 "duration": "Длительность",
-                "owner": "Владелец",
+                "ownerName": "Владелец",
                 "public": "Публичный",
                 "updatedAt": "Обновлен",
                 "createdAt": "Создан",
@@ -386,4 +386,4 @@
             "toggle_love": "Добавить / удалить песню из избранного"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/sl.json b/resources/i18n/sl.json
index a822280cc..15dcc07b9 100644
--- a/resources/i18n/sl.json
+++ b/resources/i18n/sl.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "Ime",
                 "duration": "Dolžina",
-                "owner": "Lastnik",
+                "ownerName": "Lastnik",
                 "public": "Javno",
                 "updatedAt": "Posodobljen",
                 "createdAt": "Ustvarjen",
@@ -386,4 +386,4 @@
             "toggle_love": "Dodaj med priljubljene"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json
index d1a3f099c..0540ecb1a 100644
--- a/resources/i18n/sv.json
+++ b/resources/i18n/sv.json
@@ -127,7 +127,7 @@
             "fields": {
                 "name": "Namn",
                 "duration": "Längd",
-                "owner": "Ägare",
+                "ownerName": "Ägare",
                 "public": "Offentlig",
                 "updatedAt": "Uppdaterad",
                 "createdAt": "Skapad",
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 63a3d329f..f161fae34 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "ชื่อ",
                 "duration": "เวลา",
-                "owner": "เจ้าของ",
+                "ownerName": "เจ้าของ",
                 "public": "สาธารณะ",
                 "updatedAt": "อัปเดตเมื่อ",
                 "createdAt": "สร้างขึ้นเมื่อ",
@@ -386,4 +386,4 @@
             "toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json
index ad1342ec9..b6d498918 100644
--- a/resources/i18n/tr.json
+++ b/resources/i18n/tr.json
@@ -110,7 +110,7 @@
             "fields": {
                 "name": "Isim",
                 "duration": "Süre",
-                "owner": "Sahibi",
+                "ownerName": "Sahibi",
                 "public": "Görülebilir",
                 "updatedAt": "Güncelleme tarihi:",
                 "createdAt": "Oluşturma tarihi:",
@@ -324,4 +324,4 @@
         "serverUptime": "",
         "serverDown": ""
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json
index 14d6b1c51..86982f77f 100644
--- a/resources/i18n/uk.json
+++ b/resources/i18n/uk.json
@@ -130,7 +130,7 @@
             "fields": {
                 "name": "Назва",
                 "duration": "Тривалість",
-                "owner": "Власник",
+                "ownerName": "Власник",
                 "public": "Публічний",
                 "updatedAt": "Оновлено",
                 "createdAt": "Створено",
@@ -380,4 +380,4 @@
             "toggle_love": "Відмітити поточні пісні"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json
index 1174a2719..61ad10d96 100644
--- a/resources/i18n/zh-Hans.json
+++ b/resources/i18n/zh-Hans.json
@@ -130,7 +130,7 @@
             "fields": {
                 "name": "名称",
                 "duration": "时长",
-                "owner": "所有者",
+                "ownerName": "所有者",
                 "public": "公开",
                 "updatedAt": "更新于",
                 "createdAt": "创建于",
@@ -380,4 +380,4 @@
             "toggle_love": "添加/移除星标"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json
index 2b05b9cf0..dc0e55c86 100644
--- a/resources/i18n/zh-Hant.json
+++ b/resources/i18n/zh-Hant.json
@@ -133,7 +133,7 @@
             "fields": {
                 "name": "名稱",
                 "duration": "長度",
-                "owner": "擁有者",
+                "ownerName": "擁有者",
                 "public": "公開",
                 "updatedAt": "更新於",
                 "createdAt": "創建於",
@@ -386,4 +386,4 @@
             "toggle_love": "添加或移除星標"
         }
     }
-}
\ No newline at end of file
+}
diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go
index 6b2d1a07f..a79442e7a 100644
--- a/server/subsonic/helpers.go
+++ b/server/subsonic/helpers.go
@@ -64,12 +64,12 @@ func (e subError) Error() string {
 	return msg
 }
 
-func getUser(ctx context.Context) string {
+func getUser(ctx context.Context) model.User {
 	user, ok := request.UserFrom(ctx)
 	if ok {
-		return user.UserName
+		return user
 	}
-	return ""
+	return model.User{}
 }
 
 func toArtists(ctx context.Context, artists model.Artists) []responses.Artist {
diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go
index aa6563d39..2d1850256 100644
--- a/server/subsonic/playlists.go
+++ b/server/subsonic/playlists.go
@@ -74,14 +74,12 @@ func (c *PlaylistsController) create(ctx context.Context, playlistId, name strin
 			if err != nil {
 				return err
 			}
-			if owner != pls.Owner {
+			if owner.ID != pls.OwnerID {
 				return model.ErrNotAuthorized
 			}
 		} else {
-			pls = &model.Playlist{
-				Name:  name,
-				Owner: owner,
-			}
+			pls = &model.Playlist{Name: name}
+			pls.OwnerID = owner.ID
 		}
 		pls.Tracks = nil
 		pls.AddTracks(ids)
@@ -178,7 +176,7 @@ func (c *PlaylistsController) buildPlaylist(p model.Playlist) *responses.Playlis
 	pls.Name = p.Name
 	pls.Comment = p.Comment
 	pls.SongCount = p.SongCount
-	pls.Owner = p.Owner
+	pls.Owner = p.OwnerName
 	pls.Duration = int(p.Duration)
 	pls.Public = p.Public
 	pls.Created = p.CreatedAt
diff --git a/ui/src/common/Writable.js b/ui/src/common/Writable.js
index 577bc1fc2..27a46f9e0 100644
--- a/ui/src/common/Writable.js
+++ b/ui/src/common/Writable.js
@@ -1,19 +1,19 @@
 import { cloneElement, Children, isValidElement } from 'react'
 
-export const isWritable = (owner) => {
+export const isWritable = (ownerId) => {
   return (
-    localStorage.getItem('username') === owner ||
+    localStorage.getItem('userId') === ownerId ||
     localStorage.getItem('role') === 'admin'
   )
 }
 
-export const isReadOnly = (owner) => {
-  return !isWritable(owner)
+export const isReadOnly = (ownerId) => {
+  return !isWritable(ownerId)
 }
 
 export const Writable = (props) => {
   const { record = {}, children } = props
-  if (isWritable(record.owner)) {
+  if (isWritable(record.ownerId)) {
     return Children.map(children, (child) =>
       isValidElement(child) ? cloneElement(child, props) : child
     )
@@ -24,4 +24,4 @@ export const Writable = (props) => {
 export const isSmartPlaylist = (pls) => !!pls.rules
 
 export const canChangeTracks = (pls) =>
-  isWritable(pls.owner) && !isSmartPlaylist(pls)
+  isWritable(pls.ownerId) && !isSmartPlaylist(pls)
diff --git a/ui/src/common/useSelectedFields.js b/ui/src/common/useSelectedFields.js
index 7ebdcf60e..9784cf32b 100644
--- a/ui/src/common/useSelectedFields.js
+++ b/ui/src/common/useSelectedFields.js
@@ -22,7 +22,8 @@ export const useSelectedFields = ({
   useEffect(() => {
     if (
       !resourceFields ||
-      Object.keys(resourceFields).length !== Object.keys(columns).length
+      Object.keys(resourceFields).length !== Object.keys(columns).length ||
+      !Object.keys(columns).every((c) => c in resourceFields)
     ) {
       const obj = {}
       for (const key of Object.keys(columns)) {
diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.js b/ui/src/dialogs/AddToPlaylistDialog.test.js
index e2bf9b057..dbe2e1f56 100644
--- a/ui/src/dialogs/AddToPlaylistDialog.test.js
+++ b/ui/src/dialogs/AddToPlaylistDialog.test.js
@@ -5,22 +5,23 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { AddToPlaylistDialog } from './AddToPlaylistDialog'
 
 describe('AddToPlaylistDialog', () => {
+  beforeAll(() => localStorage.setItem('userId', 'admin'))
   afterEach(cleanup)
 
   const mockData = [
-    { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
-    { id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
+    { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
+    { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
   ]
   const mockIndexedData = {
     'sample-id1': {
       id: 'sample-id1',
       name: 'sample playlist 1',
-      owner: 'admin',
+      ownerId: 'admin',
     },
     'sample-id2': {
       id: 'sample-id2',
       name: 'sample playlist 2',
-      owner: 'admin',
+      ownerId: 'admin',
     },
   }
   const selectedIds = ['song-1', 'song-2']
diff --git a/ui/src/dialogs/SelectPlaylistInput.js b/ui/src/dialogs/SelectPlaylistInput.js
index fd076ed66..5e2df7b57 100644
--- a/ui/src/dialogs/SelectPlaylistInput.js
+++ b/ui/src/dialogs/SelectPlaylistInput.js
@@ -30,7 +30,7 @@ export const SelectPlaylistInput = ({ onChange }) => {
 
   const options =
     ids &&
-    ids.map((id) => data[id]).filter((option) => isWritable(option.owner))
+    ids.map((id) => data[id]).filter((option) => isWritable(option.ownerId))
 
   const handleOnChange = (event, newValue) => {
     let newState = []
diff --git a/ui/src/dialogs/SelectPlaylistInput.test.js b/ui/src/dialogs/SelectPlaylistInput.test.js
index c667ad3e0..f3485594e 100644
--- a/ui/src/dialogs/SelectPlaylistInput.test.js
+++ b/ui/src/dialogs/SelectPlaylistInput.test.js
@@ -5,24 +5,25 @@ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { SelectPlaylistInput } from './SelectPlaylistInput'
 
 describe('SelectPlaylistInput', () => {
+  beforeAll(() => localStorage.setItem('userId', 'admin'))
   afterEach(cleanup)
   const onChangeHandler = jest.fn()
 
   it('should call the handler with the selections', async () => {
     const mockData = [
-      { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
-      { id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
+      { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
+      { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
     ]
     const mockIndexedData = {
       'sample-id1': {
         id: 'sample-id1',
         name: 'sample playlist 1',
-        owner: 'admin',
+        ownerId: 'admin',
       },
       'sample-id2': {
         id: 'sample-id2',
         name: 'sample playlist 2',
-        owner: 'admin',
+        ownerId: 'admin',
       },
     }
 
@@ -74,7 +75,7 @@ describe('SelectPlaylistInput', () => {
     fireEvent.keyDown(document.activeElement, { key: 'Enter' })
     await waitFor(() => {
       expect(onChangeHandler).toHaveBeenCalledWith([
-        { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
+        { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
       ])
     })
 
@@ -82,8 +83,8 @@ describe('SelectPlaylistInput', () => {
     fireEvent.keyDown(document.activeElement, { key: 'Enter' })
     await waitFor(() => {
       expect(onChangeHandler).toHaveBeenCalledWith([
-        { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
-        { id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
+        { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
+        { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
       ])
     })
 
@@ -94,8 +95,8 @@ describe('SelectPlaylistInput', () => {
     fireEvent.keyDown(document.activeElement, { key: 'Enter' })
     await waitFor(() => {
       expect(onChangeHandler).toHaveBeenCalledWith([
-        { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
-        { id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
+        { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
+        { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
         { name: 'new playlist' },
       ])
     })
@@ -106,8 +107,8 @@ describe('SelectPlaylistInput', () => {
     fireEvent.keyDown(document.activeElement, { key: 'Enter' })
     await waitFor(() => {
       expect(onChangeHandler).toHaveBeenCalledWith([
-        { id: 'sample-id1', name: 'sample playlist 1', owner: 'admin' },
-        { id: 'sample-id2', name: 'sample playlist 2', owner: 'admin' },
+        { id: 'sample-id1', name: 'sample playlist 1', ownerId: 'admin' },
+        { id: 'sample-id2', name: 'sample playlist 2', ownerId: 'admin' },
         { name: 'new playlist' },
         { name: 'another new playlist' },
       ])
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index d4f143b75..46742a88c 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -138,7 +138,7 @@
             "fields": {
                 "name": "Name",
                 "duration": "Duration",
-                "owner": "Owner",
+                "ownerName": "Owner",
                 "public": "Public",
                 "updatedAt": "Updated at",
                 "createdAt": "Created at",
diff --git a/ui/src/layout/PlaylistsSubMenu.js b/ui/src/layout/PlaylistsSubMenu.js
index ad4e243db..84f7cb400 100644
--- a/ui/src/layout/PlaylistsSubMenu.js
+++ b/ui/src/layout/PlaylistsSubMenu.js
@@ -74,7 +74,7 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
     />
   )
 
-  const user = localStorage.getItem('username')
+  const userId = localStorage.getItem('userId')
   const myPlaylists = []
   const sharedPlaylists = []
 
@@ -82,7 +82,7 @@ const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => {
     const allPlaylists = Object.keys(data).map((id) => data[id])
 
     allPlaylists.forEach((pls) => {
-      if (user === pls.owner) {
+      if (userId === pls.ownerId) {
         myPlaylists.push(pls)
       } else {
         sharedPlaylists.push(pls)
diff --git a/ui/src/playlist/PlaylistEdit.js b/ui/src/playlist/PlaylistEdit.js
index 8f0b6029b..212783d04 100644
--- a/ui/src/playlist/PlaylistEdit.js
+++ b/ui/src/playlist/PlaylistEdit.js
@@ -35,7 +35,7 @@ const PlaylistEditForm = (props) => {
       <TextInput multiline source="comment" />
       <BooleanInput
         source="public"
-        disabled={!isWritable(record.owner) || isSmartPlaylist(record)}
+        disabled={!isWritable(record.ownerId) || isSmartPlaylist(record)}
       />
       <FormDataConsumer>
         {(formDataProps) => <SyncFragment {...formDataProps} />}
diff --git a/ui/src/playlist/PlaylistList.js b/ui/src/playlist/PlaylistList.js
index 33446a212..68785c65a 100644
--- a/ui/src/playlist/PlaylistList.js
+++ b/ui/src/playlist/PlaylistList.js
@@ -58,7 +58,7 @@ const TogglePublicInput = ({ resource, source }) => {
     <Switch
       checked={record[source]}
       onClick={handleClick}
-      disabled={!isWritable(record.owner) || isSmartPlaylist(record)}
+      disabled={!isWritable(record.ownerId) || isSmartPlaylist(record)}
     />
   )
 }
@@ -70,7 +70,7 @@ const PlaylistList = (props) => {
 
   const toggleableFields = useMemo(() => {
     return {
-      owner: <TextField source="owner" />,
+      ownerName: <TextField source="ownerName" />,
       songCount: isDesktop && <NumberField source="songCount" />,
       duration: isDesktop && <DurationField source="duration" />,
       updatedAt: isDesktop && (
@@ -94,10 +94,7 @@ const PlaylistList = (props) => {
       filters={<PlaylistFilter />}
       actions={<PlaylistListActions />}
     >
-      <Datagrid
-        rowClick="show"
-        isRowSelectable={(r) => isWritable(r && r.owner)}
-      >
+      <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
         <TextField source="name" />
         {columns}
         <Writable>