mirror of
https://codeberg.org/VnPower/PixivFE
synced 2024-12-06 19:16:23 +01:00
529 lines
13 KiB
Go
529 lines
13 KiB
Go
package core
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"codeberg.org/vnpower/pixivfe/v2/i18n"
|
|
"codeberg.org/vnpower/pixivfe/v2/server/session"
|
|
"github.com/goccy/go-json"
|
|
)
|
|
|
|
// pixivfe internal data type. not used by pixiv.
|
|
type UserWorkCategory string
|
|
|
|
const (
|
|
CategoryAny UserWorkCategory = ""
|
|
CategoryAnyAlt UserWorkCategory = "artworks"
|
|
CategoryIllustration UserWorkCategory = "illustrations"
|
|
CategoryManga UserWorkCategory = "manga"
|
|
CategoryBookmarks UserWorkCategory = "bookmarks" // what this user has bookmarked; not art by this user
|
|
CategoryNovels UserWorkCategory = "novels"
|
|
)
|
|
|
|
func (s UserWorkCategory) Validate() error {
|
|
if s != CategoryAny &&
|
|
s != CategoryAnyAlt &&
|
|
s != CategoryIllustration &&
|
|
s != CategoryManga &&
|
|
s != CategoryBookmarks &&
|
|
s != CategoryNovels {
|
|
return i18n.Errorf(`Invalid work category: %#v. Only "%s", "%s", "%s", "%s", "%s" and "%s" are available`, s, CategoryAny, CategoryAnyAlt, CategoryIllustration, CategoryManga, CategoryBookmarks, CategoryNovels)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
type FrequentTag struct {
|
|
Name string `json:"tag"`
|
|
TranslatedName string `json:"tag_translation"`
|
|
}
|
|
|
|
type CountInfo struct {
|
|
All int
|
|
Illustrations int
|
|
Manga int
|
|
Novels int
|
|
Bookmarks int
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"userId"`
|
|
Name string `json:"name"`
|
|
Avatar string `json:"imageBig"`
|
|
Following int `json:"following"`
|
|
MyPixiv int `json:"mypixivCount"`
|
|
Comment HTML `json:"commentHtml"`
|
|
Webpage string `json:"webpage"`
|
|
SocialRaw json.RawMessage `json:"social"`
|
|
Artworks []ArtworkBrief `json:"artworks"` // this slice includes both illustrations and manga, but not novels
|
|
Illustrations []ArtworkBrief
|
|
Manga []ArtworkBrief
|
|
Novels []NovelBrief `json:"novels"`
|
|
Background map[string]any `json:"background"`
|
|
CategoryItemCount int
|
|
FrequentTags []FrequentTag
|
|
Social map[string]map[string]string
|
|
BackgroundImage string
|
|
NovelSeries []NovelSeries
|
|
MangaSeries []MangaSeries
|
|
|
|
IsFollowed bool `json:"isFollowed"` // Denotes whether the logged in user currently following the given user
|
|
|
|
// The following fields are internal to PixivFE, used to display the number of works for a given category
|
|
CountInfo CountInfo
|
|
}
|
|
|
|
// IntStringMap is a custom type that can handle both JSON objects and empty arrays.
|
|
//
|
|
// Required as the Pixiv API returns empty arrays for work types that
|
|
// don't exist for a user.
|
|
type IntStringMap map[int]string
|
|
|
|
// UnmarshalJSON implements custom unmarshalling for IntStringMap.
|
|
func (m *IntStringMap) UnmarshalJSON(data []byte) error {
|
|
// Check if the data is an empty array.
|
|
if string(data) == "[]" {
|
|
*m = make(map[int]string)
|
|
return nil
|
|
}
|
|
|
|
// Otherwise, expect an object.
|
|
var temp map[string]*string
|
|
if err := json.Unmarshal(data, &temp); err != nil {
|
|
return err
|
|
}
|
|
|
|
result := make(map[int]string)
|
|
for keyStr, value := range temp {
|
|
key, err := strconv.Atoi(keyStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid key '%s': %v", keyStr, err)
|
|
}
|
|
if value != nil {
|
|
result[key] = *value
|
|
} else {
|
|
result[key] = ""
|
|
}
|
|
}
|
|
|
|
*m = result
|
|
return nil
|
|
}
|
|
|
|
// GetUserArtworksIDAndSeries retrieves artwork IDs and series information for a user.
|
|
func GetUserArtworksIDAndSeries(r *http.Request, id string, category UserWorkCategory, page int) (idsString string, count int, illustCount int, mangaCount int, novelCount int, series json.RawMessage, err error) {
|
|
URL := GetUserArtworksURL(id)
|
|
|
|
resp, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header)
|
|
if err != nil {
|
|
return "", -1, -1, -1, -1, nil, err
|
|
}
|
|
|
|
resp = session.ProxyImageUrl(r, resp)
|
|
|
|
var body struct {
|
|
// *json.RawMessage allows us to easily check whether
|
|
// a field exists by verifying if pointer != nil
|
|
Illusts *json.RawMessage `json:"illusts"`
|
|
Manga *json.RawMessage `json:"manga"`
|
|
MangaSeries *json.RawMessage `json:"mangaSeries"`
|
|
Novels *json.RawMessage `json:"novels"`
|
|
NovelSeries *json.RawMessage `json:"novelSeries"`
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(resp), &body)
|
|
if err != nil {
|
|
return "", -1, -1, -1, -1, nil, fmt.Errorf("failed to unmarshal response body: %w", err)
|
|
}
|
|
|
|
var ids []int
|
|
|
|
var illusts IntStringMap
|
|
var manga IntStringMap
|
|
var novels IntStringMap
|
|
|
|
if category == CategoryIllustration || category == CategoryAny || category == CategoryAnyAlt {
|
|
if body.Illusts != nil && len(*body.Illusts) > 0 {
|
|
if err = json.Unmarshal(*body.Illusts, &illusts); err != nil {
|
|
fmt.Printf("Error unmarshalling illusts: %v\n", err)
|
|
illusts = make(IntStringMap)
|
|
}
|
|
for k := range illusts {
|
|
ids = append(ids, k)
|
|
count++
|
|
}
|
|
illustCount = len(illusts)
|
|
}
|
|
}
|
|
|
|
if category == CategoryManga || category == CategoryAny {
|
|
if body.Manga != nil && len(*body.Manga) > 0 {
|
|
if err = json.Unmarshal(*body.Manga, &manga); err != nil {
|
|
fmt.Printf("Error unmarshalling manga: %v\n", err)
|
|
manga = make(IntStringMap)
|
|
}
|
|
for k := range manga {
|
|
ids = append(ids, k)
|
|
count++
|
|
}
|
|
mangaCount = len(manga)
|
|
if body.MangaSeries != nil {
|
|
series = *body.MangaSeries
|
|
}
|
|
}
|
|
}
|
|
|
|
if category == CategoryNovels {
|
|
if body.Novels != nil && len(*body.Novels) > 0 {
|
|
if err = json.Unmarshal(*body.Novels, &novels); err != nil {
|
|
fmt.Printf("Error unmarshalling novels: %v\n", err)
|
|
novels = make(IntStringMap)
|
|
}
|
|
for k := range novels {
|
|
ids = append(ids, k)
|
|
count++
|
|
}
|
|
novelCount = len(novels)
|
|
if body.NovelSeries != nil {
|
|
series = *body.NovelSeries
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.Sort(sort.Reverse(sort.IntSlice(ids)))
|
|
|
|
worksPerPage := 30.0
|
|
|
|
start, end, err := computeSliceBounds(page, worksPerPage, len(ids))
|
|
if err != nil {
|
|
return "", -1, -1, -1, -1, nil, err
|
|
}
|
|
|
|
// Build the IDs string for the API request
|
|
for _, k := range ids[start:end] {
|
|
idsString += fmt.Sprintf("&ids[]=%d", k)
|
|
}
|
|
|
|
return idsString, count, illustCount, mangaCount, novelCount, series, nil
|
|
}
|
|
|
|
func fetchAndProcessArtworks(r *http.Request, id, ids string, category UserWorkCategory) ([]ArtworkBrief, []NovelBrief, error) {
|
|
var artworks []ArtworkBrief
|
|
var novels []NovelBrief
|
|
var err error
|
|
|
|
if category == CategoryNovels {
|
|
novels, err = GetUserNovelIDs(r, id, ids)
|
|
} else {
|
|
artworks, err = GetUserArtworkIDs(r, id, ids)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Sort the works
|
|
if category == CategoryNovels {
|
|
sort.Slice(novels[:], func(i, j int) bool {
|
|
return numberGreaterThan(novels[i].ID, novels[j].ID)
|
|
})
|
|
} else {
|
|
sort.Slice(artworks[:], func(i, j int) bool {
|
|
return numberGreaterThan(artworks[i].ID, artworks[j].ID)
|
|
})
|
|
}
|
|
|
|
return artworks, novels, nil
|
|
}
|
|
|
|
func handleSeriesData(series json.RawMessage, category UserWorkCategory) ([]NovelSeries, []MangaSeries) {
|
|
var novelSeries []NovelSeries
|
|
var mangaSeries []MangaSeries
|
|
|
|
if series != nil {
|
|
if category == CategoryNovels {
|
|
_ = json.Unmarshal(series, &novelSeries)
|
|
} else {
|
|
_ = json.Unmarshal(series, &mangaSeries)
|
|
}
|
|
}
|
|
|
|
return novelSeries, mangaSeries
|
|
}
|
|
|
|
func GetUserProfile(r *http.Request, id string, category UserWorkCategory, page int, getTags bool) (User, error) {
|
|
var user User
|
|
|
|
token := session.GetUserToken(r)
|
|
|
|
URL := GetUserInformationURL(id)
|
|
|
|
resp, err := API_GET_UnwrapJson(r.Context(), URL, token, r.Header)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
|
|
resp = session.ProxyImageUrl(r, resp)
|
|
|
|
err = json.Unmarshal([]byte(resp), &user)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
|
|
// Get counts for illustrations and manga
|
|
_, allCount, illustCount, mangaCount, _, _, err := GetUserArtworksIDAndSeries(r, id, CategoryAny, 1)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
user.CountInfo.All = allCount
|
|
user.CountInfo.Illustrations = illustCount
|
|
user.CountInfo.Manga = mangaCount
|
|
|
|
// Get count for novels separately
|
|
_, _, _, _, novelCount, _, err := GetUserArtworksIDAndSeries(r, id, CategoryNovels, 1)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
user.CountInfo.Novels = novelCount
|
|
|
|
// Get bookmarks count
|
|
_, bookmarksCount, err := GetUserBookmarks(r, id, "show", 1)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
user.CountInfo.Bookmarks = bookmarksCount
|
|
|
|
if category == CategoryBookmarks {
|
|
// Bookmarks
|
|
works, _, err := GetUserBookmarks(r, id, "show", page)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
|
|
user.Artworks = works
|
|
user.CategoryItemCount = bookmarksCount
|
|
} else {
|
|
ids, count, _, _, _, series, err := GetUserArtworksIDAndSeries(r, id, category, page)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
|
|
if count > 0 {
|
|
artworks, novels, err := fetchAndProcessArtworks(r, id, ids, category)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
|
|
if category == CategoryNovels {
|
|
user.Novels = novels
|
|
} else {
|
|
user.Artworks = artworks
|
|
user.Illustrations = make([]ArtworkBrief, 0)
|
|
user.Manga = make([]ArtworkBrief, 0)
|
|
for _, artwork := range artworks {
|
|
if artwork.IllustType == 0 {
|
|
user.Illustrations = append(user.Illustrations, artwork)
|
|
} else if artwork.IllustType == 1 {
|
|
user.Manga = append(user.Manga, artwork)
|
|
}
|
|
}
|
|
}
|
|
|
|
if getTags {
|
|
user.FrequentTags, err = GetUserFrequentTags(r, ids, category)
|
|
if err != nil {
|
|
return user, err
|
|
}
|
|
}
|
|
}
|
|
|
|
user.NovelSeries, user.MangaSeries = handleSeriesData(series, category)
|
|
user.CategoryItemCount = count
|
|
}
|
|
|
|
err = user.ParseSocial()
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
if user.Background != nil {
|
|
user.BackgroundImage = user.Background["url"].(string)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// Work is a generic type constraint.
|
|
type Work interface{}
|
|
|
|
// GetUserWorks is a generic helper function to fetch user works.
|
|
func GetUserWorks[T Work](r *http.Request, url string) ([]T, error) {
|
|
resp, err := API_GET_UnwrapJson(r.Context(), url, "", r.Header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp = session.ProxyImageUrl(r, resp)
|
|
|
|
// Define a generic body structure.
|
|
var body struct {
|
|
Works map[int]json.RawMessage `json:"works"`
|
|
}
|
|
|
|
// Unmarshal the response into the body.
|
|
if err := json.Unmarshal([]byte(resp), &body); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Initialize the slice to hold the works.
|
|
var works []T
|
|
|
|
// Iterate over each work and unmarshal into the specific type.
|
|
for _, v := range body.Works {
|
|
var work T
|
|
if err := json.Unmarshal(v, &work); err != nil {
|
|
return nil, err
|
|
}
|
|
works = append(works, work)
|
|
}
|
|
|
|
return works, nil
|
|
}
|
|
|
|
// GetUserFrequentTags retrieves frequent tags for a user based on category.
|
|
func GetUserFrequentTags(r *http.Request, ids string, category UserWorkCategory) ([]FrequentTag, error) {
|
|
var tags []FrequentTag
|
|
var URL string
|
|
|
|
if category != "novels" {
|
|
URL = GetUserFrequentArtworkTagsURL(ids)
|
|
} else {
|
|
URL = GetUserFrequentNovelTagsURL(ids)
|
|
}
|
|
|
|
response, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(response), &tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tags, nil
|
|
}
|
|
|
|
// GetUserArtworkIDs fetches the list of artwork IDs for a user (without metadata).
|
|
func GetUserArtworkIDs(r *http.Request, id, ids string) ([]ArtworkBrief, error) {
|
|
URL := GetUserFullArtworkURL(id, ids)
|
|
|
|
works, err := GetUserWorks[ArtworkBrief](r, URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return works, nil
|
|
}
|
|
|
|
// GetUserNovelIDs fetches the list of novel IDs for a user (without metadata).
|
|
func GetUserNovelIDs(r *http.Request, id, ids string) ([]NovelBrief, error) {
|
|
URL := GetUserFullNovelURL(id, ids)
|
|
|
|
works, err := GetUserWorks[NovelBrief](r, URL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return works, nil
|
|
}
|
|
|
|
// GetUserBookmarks fetches the list of bookmarks for a user (with metadata).
|
|
//
|
|
// This function cannot be neatly refactored to use GetUserWorks due
|
|
// to having a different API response structure
|
|
func GetUserBookmarks(r *http.Request, id, mode string, page int) ([]ArtworkBrief, int, error) {
|
|
page--
|
|
|
|
URL := GetUserBookmarksURL(id, mode, page)
|
|
|
|
resp, err := API_GET_UnwrapJson(r.Context(), URL, "", r.Header)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
resp = session.ProxyImageUrl(r, resp)
|
|
|
|
var body struct {
|
|
Artworks []json.RawMessage `json:"works"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
err = json.Unmarshal([]byte(resp), &body)
|
|
if err != nil {
|
|
return nil, -1, err
|
|
}
|
|
|
|
artworks := make([]ArtworkBrief, len(body.Artworks))
|
|
|
|
for index, value := range body.Artworks {
|
|
var artwork ArtworkBrief
|
|
|
|
err = json.Unmarshal([]byte(value), &artwork)
|
|
if err != nil {
|
|
artworks[index] = ArtworkBrief{
|
|
ID: "#",
|
|
Title: "Deleted or Private",
|
|
Thumbnail: "https://s.pximg.net/common/images/limit_unknown_360.png",
|
|
}
|
|
continue
|
|
}
|
|
artworks[index] = artwork
|
|
}
|
|
|
|
return artworks, body.Total, nil
|
|
}
|
|
|
|
func (s *User) ParseSocial() error {
|
|
if string(s.SocialRaw[:]) == "[]" {
|
|
// Fuck Pixiv
|
|
return nil
|
|
}
|
|
|
|
err := json.Unmarshal(s.SocialRaw, &s.Social)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// computeSliceBounds is a utility function to compute slice bounds safely
|
|
func computeSliceBounds(page int, worksPerPage float64, totalItems int) (start, end int, err error) {
|
|
if totalItems == 0 {
|
|
return 0, 0, nil
|
|
}
|
|
|
|
maxPages := int(math.Ceil(float64(totalItems) / worksPerPage))
|
|
if page < 1 || page > maxPages {
|
|
return 0, 0, i18n.Error("Invalid page number.")
|
|
}
|
|
|
|
start = (page - 1) * int(worksPerPage)
|
|
end = min(start+int(worksPerPage), totalItems)
|
|
|
|
return start, end, nil
|
|
}
|
|
|
|
func numberGreaterThan(l, r string) bool {
|
|
if len(l) > len(r) {
|
|
return true
|
|
}
|
|
if len(l) < len(r) {
|
|
return false
|
|
}
|
|
return l > r
|
|
}
|