From d2057d969d1f0344a5cbd308ef5f00b175948de2 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Thu, 13 Jun 2024 21:51:53 +0200 Subject: [PATCH] feat: api support for playlist bookmarks --- config.properties | 2 +- docker-compose.yml | 11 +- .../me/kavin/piped/server/ServerLauncher.java | 33 ++++- .../auth/PlaylistBookmarkHandlers.java | 122 ++++++++++++++++ .../me/kavin/piped/utils/DatabaseHelper.java | 45 ++++++ .../piped/utils/DatabaseSessionFactory.java | 3 +- .../piped/utils/obj/db/PlaylistBookmark.java | 135 ++++++++++++++++++ .../utils/resp/BookmarkedStatusResponse.java | 9 ++ .../changelog/db.changelog-master.xml | 1 + .../version/2-playlist-bookmarks.xml | 36 +++++ testing/api-test.sh | 16 +++ 11 files changed, 398 insertions(+), 15 deletions(-) create mode 100644 src/main/java/me/kavin/piped/server/handlers/auth/PlaylistBookmarkHandlers.java create mode 100644 src/main/java/me/kavin/piped/utils/obj/db/PlaylistBookmark.java create mode 100644 src/main/java/me/kavin/piped/utils/resp/BookmarkedStatusResponse.java create mode 100644 src/main/resources/changelog/version/2-playlist-bookmarks.xml diff --git a/config.properties b/config.properties index ff8592f7..c862c8cd 100644 --- a/config.properties +++ b/config.properties @@ -80,7 +80,7 @@ MATRIX_SERVER:https://matrix-client.matrix.org #S3_BUCKET:INSERT_HERE # Hibernate properties -hibernate.connection.url:jdbc:postgresql://postgres:5432/piped +hibernate.connection.url:jdbc:postgresql://localhost:5432/piped hibernate.connection.driver_class:org.postgresql.Driver hibernate.dialect:org.hibernate.dialect.PostgreSQLDialect hibernate.connection.username:piped diff --git a/docker-compose.yml b/docker-compose.yml index 01a83589..8452fe7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,11 @@ services: - piped: - image: 1337kavin/piped:latest - restart: unless-stopped - ports: - - "127.0.0.1:8080:8080" - volumes: - - ./config.properties:/app/config.properties - depends_on: - - postgres postgres: image: postgres:16-alpine restart: unless-stopped volumes: - ./data/db:/var/lib/postgresql/data + ports: + - 5432:5432 environment: - POSTGRES_DB=piped - POSTGRES_USER=piped diff --git a/src/main/java/me/kavin/piped/server/ServerLauncher.java b/src/main/java/me/kavin/piped/server/ServerLauncher.java index 8c7fe63b..09cd53f5 100644 --- a/src/main/java/me/kavin/piped/server/ServerLauncher.java +++ b/src/main/java/me/kavin/piped/server/ServerLauncher.java @@ -11,10 +11,7 @@ import it.unimi.dsi.fastutil.objects.ObjectArrayList; import me.kavin.piped.consts.Constants; import me.kavin.piped.server.handlers.*; -import me.kavin.piped.server.handlers.auth.AuthPlaylistHandlers; -import me.kavin.piped.server.handlers.auth.FeedHandlers; -import me.kavin.piped.server.handlers.auth.StorageHandlers; -import me.kavin.piped.server.handlers.auth.UserHandlers; +import me.kavin.piped.server.handlers.auth.*; import me.kavin.piped.utils.*; import me.kavin.piped.utils.resp.*; import org.apache.commons.lang3.StringUtils; @@ -453,6 +450,34 @@ AsyncServlet mainServlet(Executor executor) { } catch (Exception e) { return getErrorResponse(e, request.getPath()); } + })).map(POST, "/user/bookmarks/create", AsyncServlet.ofBlocking(executor, request -> { + try { + var playlistId = mapper.readTree(request.loadBody().getResult().asArray()).get("playlistId").textValue(); + return getJsonResponse(PlaylistBookmarkHandlers.createPlaylistBookmarkResponse(request.getHeader(AUTHORIZATION), playlistId), "private"); + } catch (Exception e) { + return getErrorResponse(e, request.getPath()); + } + })).map(GET, "/user/bookmarks", AsyncServlet.ofBlocking(executor, request -> { + try { + return getJsonResponse(PlaylistBookmarkHandlers.playlistBookmarksResponse(request.getHeader(AUTHORIZATION)), "private"); + } catch (Exception e) { + return getErrorResponse(e, request.getPath()); + } + })).map(GET, "/user/bookmarks/bookmarked", AsyncServlet.ofBlocking(executor, request -> { + try { + return getJsonResponse(PlaylistBookmarkHandlers.isBookmarkedResponse(request.getHeader(AUTHORIZATION), + request.getQueryParameter("playlistId")), "private"); + } catch (Exception e) { + return getErrorResponse(e, request.getPath()); + } + })).map(POST, "/user/bookmarks/delete", AsyncServlet.ofBlocking(executor, request -> { + try { + var json = mapper.readTree(request.loadBody().getResult().asArray()); + var playlistId = json.get("playlistId").textValue(); + return getJsonResponse(PlaylistBookmarkHandlers.deletePlaylistBookmarkResponse(request.getHeader(AUTHORIZATION), playlistId), "private"); + } catch (Exception e) { + return getErrorResponse(e, request.getPath()); + } })).map(GET, "/registered/badge", AsyncServlet.ofBlocking(executor, request -> { try { return HttpResponse.ofCode(302).withHeader(LOCATION, GenericHandlers.registeredBadgeRedirect()) diff --git a/src/main/java/me/kavin/piped/server/handlers/auth/PlaylistBookmarkHandlers.java b/src/main/java/me/kavin/piped/server/handlers/auth/PlaylistBookmarkHandlers.java new file mode 100644 index 00000000..16c1da89 --- /dev/null +++ b/src/main/java/me/kavin/piped/server/handlers/auth/PlaylistBookmarkHandlers.java @@ -0,0 +1,122 @@ +package me.kavin.piped.server.handlers.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import me.kavin.piped.utils.DatabaseHelper; +import me.kavin.piped.utils.DatabaseSessionFactory; +import me.kavin.piped.utils.ExceptionHandler; +import me.kavin.piped.utils.obj.db.PlaylistBookmark; +import me.kavin.piped.utils.obj.db.User; +import me.kavin.piped.utils.resp.AcceptedResponse; +import me.kavin.piped.utils.resp.AuthenticationFailureResponse; +import me.kavin.piped.utils.resp.BookmarkedStatusResponse; +import me.kavin.piped.utils.resp.InvalidRequestResponse; +import org.apache.commons.lang3.StringUtils; +import org.hibernate.Session; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; + +import java.io.IOException; + +import static me.kavin.piped.consts.Constants.mapper; +import static me.kavin.piped.utils.URLUtils.*; + +public class PlaylistBookmarkHandlers { + public static byte[] createPlaylistBookmarkResponse(String session, String playlistId) throws IOException, ExtractionException { + + if (StringUtils.isBlank(session) || StringUtils.isBlank(playlistId)) + ExceptionHandler.throwErrorResponse(new InvalidRequestResponse("session and name are required parameters")); + + User user = DatabaseHelper.getUserFromSession(session); + + if (user == null) ExceptionHandler.throwErrorResponse(new AuthenticationFailureResponse()); + + try (Session s = DatabaseSessionFactory.createSession()) { + if (DatabaseHelper.isBookmarked(s, user, playlistId)) { + var bookmark = DatabaseHelper.getPlaylistBookmarkFromPlaylistId(s, user, playlistId); + return mapper.writeValueAsBytes(createPlaylistBookmarkResponseItem(bookmark)); + } + + final PlaylistInfo info = PlaylistInfo.getInfo("https://www.youtube.com/playlist?list=" + playlistId); + + var playlistBookmark = new PlaylistBookmark(playlistId, info.getName(), info.getDescription().getContent(), getLastThumbnail(info.getThumbnails()), info.getUploaderName(), substringYouTube(info.getUploaderUrl()), getLastThumbnail(info.getUploaderAvatars()), info.getStreamCount(), user); + + var tr = s.beginTransaction(); + s.persist(playlistBookmark); + tr.commit(); + + ObjectNode response = createPlaylistBookmarkResponseItem(playlistBookmark); + + return mapper.writeValueAsBytes(response); + } + } + + public static byte[] deletePlaylistBookmarkResponse(String session, String playlistId) throws IOException { + + if (StringUtils.isBlank(session) || StringUtils.isBlank(playlistId)) + ExceptionHandler.throwErrorResponse(new InvalidRequestResponse("session and playlistId are required parameters")); + + User user = DatabaseHelper.getUserFromSession(session); + + if (user == null) ExceptionHandler.throwErrorResponse(new AuthenticationFailureResponse()); + + try (Session s = DatabaseSessionFactory.createSession()) { + + DatabaseHelper.deletePlaylistBookmark(s, user, playlistId); + + return mapper.writeValueAsBytes(new AcceptedResponse()); + } + } + + public static byte[] playlistBookmarksResponse(String session) throws IOException { + + if (StringUtils.isBlank(session)) + ExceptionHandler.throwErrorResponse(new InvalidRequestResponse("session is a required parameter")); + + User user = DatabaseHelper.getUserFromSession(session); + + if (user == null) ExceptionHandler.throwErrorResponse(new AuthenticationFailureResponse()); + + try (Session s = DatabaseSessionFactory.createSession()) { + + var responseArray = new ObjectArrayList<>(); + var playlistBookmarks = DatabaseHelper.getPlaylistBookmarks(s, user); + + for (PlaylistBookmark bookmark : playlistBookmarks) { + responseArray.add(createPlaylistBookmarkResponseItem(bookmark)); + } + + return mapper.writeValueAsBytes(responseArray); + } + } + + public static byte[] isBookmarkedResponse(String session, String playlistId) throws IOException { + + if (StringUtils.isBlank(session) || StringUtils.isBlank(playlistId)) + ExceptionHandler.throwErrorResponse(new InvalidRequestResponse("session and playlistId are required parameters")); + + User user = DatabaseHelper.getUserFromSession(session); + + if (user == null) ExceptionHandler.throwErrorResponse(new AuthenticationFailureResponse()); + + try (Session s = DatabaseSessionFactory.createSession()) { + boolean isBookmarked = DatabaseHelper.isBookmarked(s, user, playlistId); + + return mapper.writeValueAsBytes(new BookmarkedStatusResponse(isBookmarked)); + } + } + + private static ObjectNode createPlaylistBookmarkResponseItem(PlaylistBookmark bookmark) { + ObjectNode node = mapper.createObjectNode(); + node.put("playlistId", String.valueOf(bookmark.getPlaylistId())); + node.put("name", bookmark.getName()); + node.put("shortDescription", bookmark.getShortDescription()); + node.put("thumbnailUrl", rewriteURL(bookmark.getThumbnailUrl())); + node.put("uploader", bookmark.getUploader()); + node.put("uploaderUrl", bookmark.getUploaderUrl()); + node.put("uploaderAvatar", rewriteURL(bookmark.getUploaderAvatar())); + node.put("videos", bookmark.getVideoCount()); + return node; + } +} diff --git a/src/main/java/me/kavin/piped/utils/DatabaseHelper.java b/src/main/java/me/kavin/piped/utils/DatabaseHelper.java index a4af6272..8265ac28 100644 --- a/src/main/java/me/kavin/piped/utils/DatabaseHelper.java +++ b/src/main/java/me/kavin/piped/utils/DatabaseHelper.java @@ -6,8 +6,10 @@ import jakarta.persistence.criteria.Root; import me.kavin.piped.consts.Constants; import me.kavin.piped.utils.obj.db.*; +import me.kavin.piped.utils.resp.InvalidRequestResponse; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.exception.ExceptionUtils; +import org.hibernate.Session; import org.hibernate.SharedSessionContract; import org.hibernate.StatelessSession; import org.schabi.newpipe.extractor.channel.ChannelInfo; @@ -236,4 +238,47 @@ public static Channel saveChannel(String channelId) { return channel; } + + public static List getPlaylistBookmarks(SharedSessionContract s, User user) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(PlaylistBookmark.class); + Root root = cr.from(PlaylistBookmark.class); + cr.select(root).where(cb.equal(root.get("owner"), user)); + + return s.createQuery(cr).getResultList(); + } + + public static boolean isBookmarked(SharedSessionContract s, User user, String playlistId) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + + CriteriaQuery cr = cb.createQuery(Long.class); + Root root = cr.from(PlaylistBookmark.class); + cr.select(cb.count(root)).where(cb.and( + cb.equal(root.get("owner"), user)), + cb.equal(root.get("playlist_id"), playlistId) + ); + + return s.createQuery(cr).getSingleResult() > 0; + } + + public static PlaylistBookmark getPlaylistBookmarkFromPlaylistId(SharedSessionContract s, User user, String playlistId) { + CriteriaBuilder cb = s.getCriteriaBuilder(); + + CriteriaQuery cr = cb.createQuery(PlaylistBookmark.class); + Root root = cr.from(PlaylistBookmark.class); + cr.select(root).where(cb.and( + cb.equal(root.get("owner"), user)), + cb.equal(root.get("playlist_id"), playlistId) + ); + + return s.createQuery(cr).uniqueResult(); + } + + public static void deletePlaylistBookmark(Session s, User user, String playlistId) { + var playlistBookmark = DatabaseHelper.getPlaylistBookmarkFromPlaylistId(s, user, playlistId); + + var tr = s.beginTransaction(); + s.remove(playlistBookmark); + tr.commit(); + } } diff --git a/src/main/java/me/kavin/piped/utils/DatabaseSessionFactory.java b/src/main/java/me/kavin/piped/utils/DatabaseSessionFactory.java index edc60892..43a56dbe 100644 --- a/src/main/java/me/kavin/piped/utils/DatabaseSessionFactory.java +++ b/src/main/java/me/kavin/piped/utils/DatabaseSessionFactory.java @@ -20,7 +20,8 @@ public class DatabaseSessionFactory { sessionFactory = configuration.addAnnotatedClass(User.class).addAnnotatedClass(Channel.class) .addAnnotatedClass(Video.class).addAnnotatedClass(PubSub.class).addAnnotatedClass(Playlist.class) - .addAnnotatedClass(PlaylistVideo.class).addAnnotatedClass(UnauthenticatedSubscription.class).buildSessionFactory(); + .addAnnotatedClass(PlaylistVideo.class).addAnnotatedClass(UnauthenticatedSubscription.class) + .addAnnotatedClass(PlaylistBookmark.class).buildSessionFactory(); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/src/main/java/me/kavin/piped/utils/obj/db/PlaylistBookmark.java b/src/main/java/me/kavin/piped/utils/obj/db/PlaylistBookmark.java new file mode 100644 index 00000000..bcc8dc18 --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/obj/db/PlaylistBookmark.java @@ -0,0 +1,135 @@ +package me.kavin.piped.utils.obj.db; + +import jakarta.persistence.*; + +@Entity +@Table(name = "playlist_bookmarks", indexes = {@Index(columnList = "playlist_id", name = "playlist_bookmarks_playlist_id_idx"), @Index(columnList = "owner", name = "playlist_bookmarks_owner_idx")}) +public class PlaylistBookmark { + + public PlaylistBookmark() { + } + + public PlaylistBookmark(String playlist_id, String name, String short_description, String thumbnail_url, String uploader, String uploader_url, String uploader_avatar, long video_count, User owner) { + this.playlist_id = playlist_id; + this.name = name; + this.short_description = short_description; + this.thumbnail_url = thumbnail_url; + this.uploader = uploader; + this.uploader_url = uploader_url; + this.uploader_avatar = uploader_avatar; + this.video_count = video_count; + this.owner = owner; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(name = "playlist_id", nullable = false) + private String playlist_id; + + @Column(name = "name", length = 200) + private String name; + + @Column(name = "short_description", length = 300) + private String short_description; + + @Column(name = "thumbnail_url", length = 300) + private String thumbnail_url; + + @Column(name = "uploader", length = 100) + private String uploader; + + @Column(name = "uploader_url", length = 100) + private String uploader_url; + + @Column(name = "uploader_avatar", length = 150) + private String uploader_avatar; + + @Column(name = "video_count") + private long video_count; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner", nullable = false) + private User owner; + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getPlaylistId() { + return playlist_id; + } + + public void setPlaylistId(String playlist_id) { + this.playlist_id = playlist_id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getShortDescription() { + return short_description; + } + + public void setShortDescription(String short_description) { + this.short_description = short_description; + } + + public String getThumbnailUrl() { + return thumbnail_url; + } + + public void setThumbnailUrl(String thumbnailUrl) { + this.thumbnail_url = thumbnailUrl; + } + + public String getUploader() { + return uploader; + } + + public void setUploader(String uploader) { + this.uploader = uploader; + } + + public String getUploaderUrl() { + return uploader_url; + } + + public void setUploaderUrl(String uploaderUrl) { + this.uploader_url = uploaderUrl; + } + + public String getUploaderAvatar() { + return uploader_avatar; + } + + public void setUploaderAvatar(String uploaderAvatar) { + this.uploader_avatar = uploaderAvatar; + } + + public long getVideoCount() { + return video_count; + } + + public void setVideoCount(long videoCount) { + this.video_count = videoCount; + } + + public User getOwner() { + return owner; + } + + public void setOwner(User owner) { + this.owner = owner; + } +} diff --git a/src/main/java/me/kavin/piped/utils/resp/BookmarkedStatusResponse.java b/src/main/java/me/kavin/piped/utils/resp/BookmarkedStatusResponse.java new file mode 100644 index 00000000..b4d52ea0 --- /dev/null +++ b/src/main/java/me/kavin/piped/utils/resp/BookmarkedStatusResponse.java @@ -0,0 +1,9 @@ +package me.kavin.piped.utils.resp; + +public class BookmarkedStatusResponse { + public boolean bookmarked; + + public BookmarkedStatusResponse(boolean bookmarked) { + this.bookmarked = bookmarked; + } +} diff --git a/src/main/resources/changelog/db.changelog-master.xml b/src/main/resources/changelog/db.changelog-master.xml index 4d3a056a..24c93447 100644 --- a/src/main/resources/changelog/db.changelog-master.xml +++ b/src/main/resources/changelog/db.changelog-master.xml @@ -6,4 +6,5 @@ + diff --git a/src/main/resources/changelog/version/2-playlist-bookmarks.xml b/src/main/resources/changelog/version/2-playlist-bookmarks.xml new file mode 100644 index 00000000..9de7e0aa --- /dev/null +++ b/src/main/resources/changelog/version/2-playlist-bookmarks.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/testing/api-test.sh b/testing/api-test.sh index 1f045253..a5c791b1 100755 --- a/testing/api-test.sh +++ b/testing/api-test.sh @@ -147,6 +147,22 @@ curl "${CURLOPTS[@]}" $HOST/user/playlists/delete -X POST -H "Content-Type: appl # Import Playlist Test curl "${CURLOPTS[@]}" $HOST/import/playlist -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d "$(jq -n --compact-output --arg playlistId "PLQSoWXSpjA3-egtFq45DcUydZ885W7MTT" '{"playlistId": $playlistId}')" || exit 1 +PLAYLIST_ID="PLQSoWXSpjA3-egtFq45DcUydZ885W7MTT" +# Create bookmark test +curl "${CURLOPTS[@]}" $HOST/user/bookmarks/create -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d "$(jq -n --compact-output --arg playlistId "$PLAYLIST_ID" '{"playlistId": $playlistId}')" || exit 1 + +# See created bookmarks +curl "${CURLOPTS[@]}" $HOST/user/bookmarks -H "Authorization: $AUTH_TOKEN" || exit 1 + +# Check bookmarked status of playlist +curl "${CURLOPTS[@]}" $HOST/user/bookmarks/bookmarked -G --data-urlencode "playlistId=$PLAYLIST_ID" -H "Authorization: $AUTH_TOKEN" || exit 1 + +# Delete bookmark +curl "${CURLOPTS[@]}" $HOST/user/bookmarks/delete -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d "$(jq -n --compact-output --arg playlistId "$PLAYLIST_ID" '{"playlistId": $playlistId}')" || exit 1 + +# See created bookmarks +curl "${CURLOPTS[@]}" $HOST/user/bookmarks -H "Authorization: $AUTH_TOKEN" || exit 1 + # Delete User Test curl "${CURLOPTS[@]}" $HOST/user/delete -X POST -H "Content-Type: application/json" -H "Authorization: $AUTH_TOKEN" -d "$(jq -n --compact-output --arg password "$PASS" '{"password": $password}')" || exit 1