1
0
Fork 0
mirror of https://github.com/anyproto/anytype-heart.git synced 2025-06-09 17:44:59 +09:00
anytype-heart/util/unsplash/unsplash.go
2023-04-25 18:17:59 +02:00

316 lines
7.7 KiB
Go

package unsplash
import (
"context"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/anytypeio/any-sync/app"
"github.com/anytypeio/any-sync/app/ocache"
"github.com/dsoprea/go-exif/v3"
jpegstructure "github.com/dsoprea/go-jpeg-image-structure/v2"
"github.com/hbagdi/go-unsplash/unsplash"
"golang.org/x/oauth2"
"github.com/anytypeio/go-anytype-middleware/core/anytype/config/loadenv"
"github.com/anytypeio/go-anytype-middleware/core/configfetcher"
"github.com/anytypeio/go-anytype-middleware/pkg/lib/core"
"github.com/anytypeio/go-anytype-middleware/pkg/lib/logging"
"github.com/anytypeio/go-anytype-middleware/util/pbtypes"
"github.com/anytypeio/go-anytype-middleware/util/uri"
)
var log = logging.Logger("unsplash")
var DefaultToken = ""
const (
CName = "unsplash"
cacheTTL = time.Minute * 10
cacheGCPeriod = time.Minute * 5
anytypeURL = "https://unsplash.anytype.io/"
)
type Unsplash interface {
Search(ctx context.Context, query string, max int) ([]Result, error)
Download(ctx context.Context, id string) (imgPath string, err error)
app.Component
}
type unsplashService struct {
mu sync.Mutex
cache ocache.OCache
client *unsplash.Unsplash
limit int
config configfetcher.ConfigFetcher
tempDirProvider core.TempDirProvider
}
func (l *unsplashService) Init(app *app.App) (err error) {
l.cache = ocache.New(l.search, ocache.WithTTL(cacheTTL), ocache.WithGCPeriod(cacheGCPeriod))
l.config = app.MustComponent(configfetcher.CName).(configfetcher.ConfigFetcher)
return
}
func (l *unsplashService) Name() (name string) {
return CName
}
func New(tempDirProvider core.TempDirProvider) Unsplash {
return &unsplashService{tempDirProvider: tempDirProvider}
}
type Result struct {
ID string
Description string
PictureThumbUrl string
PictureSmallUrl string
PictureFullUrl string
PictureHDUrl string
Artist string
ArtistURL string
}
type results struct {
results []Result
}
func (r results) TryClose(objectTTL time.Duration) (bool, error) {
return true, r.Close()
}
func (r results) Close() error {
return nil
}
func newFromPhoto(v unsplash.Photo) (Result, error) {
if v.ID == nil || v.Urls == nil {
return Result{}, fmt.Errorf("nil input from unsplash")
}
res := Result{ID: *v.ID}
if v.Urls.Thumb != nil {
res.PictureThumbUrl = v.Urls.Thumb.String()
}
if v.Description != nil && *v.Description != "" {
res.Description = *v.Description
} else if v.AltDescription != nil {
res.Description = *v.AltDescription
}
if v.Urls.Small != nil {
res.PictureSmallUrl = v.Urls.Small.String()
}
if v.Urls.Regular != nil {
fUrl := v.Urls.Regular.String()
// hack to have full hd instead of 1080w,
// in case unsplash will change the URL format it will not break things
u, err := uri.ParseURI(fUrl)
if err == nil {
if q := u.Query(); q.Get("w") != "" {
q.Set("w", "1920")
u.RawQuery = q.Encode()
}
}
res.PictureHDUrl = u.String()
}
if v.Urls.Full != nil {
res.PictureFullUrl = v.Urls.Full.String()
}
if v.Photographer == nil {
return res, nil
}
if v.Photographer.Name != nil {
res.Artist = *v.Photographer.Name
}
if v.Photographer.Links != nil && v.Photographer.Links.HTML != nil {
res.ArtistURL = v.Photographer.Links.HTML.String()
}
return res, nil
}
func (l *unsplashService) lazyInitClient() {
l.mu.Lock()
defer l.mu.Unlock()
if l.client != nil {
return
}
cfg := l.config.GetAccountState()
token := DefaultToken
if configToken := pbtypes.GetString(cfg.Config.Extra, "unsplash"); configToken != "" {
token = configToken
}
ts := oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
)
l.client = unsplash.New(oauth2.NewClient(context.Background(), ts))
}
func (l *unsplashService) Search(ctx context.Context, query string, limit int) ([]Result, error) {
query = strings.ToLower(strings.TrimSpace(query))
l.limit = limit
v, err := l.cache.Get(ctx, query)
if err != nil {
return nil, err
}
if r, ok := v.(results); ok {
return r.results, nil
} else {
panic("invalid cache value")
}
}
func (l *unsplashService) search(ctx context.Context, query string) (ocache.Object, error) {
l.lazyInitClient()
query = strings.ToLower(strings.TrimSpace(query))
var opt unsplash.RandomPhotoOpt
opt.Count = l.limit
opt.SearchQuery = query
res, _, err := l.client.Photos.Random(&opt)
if err != nil {
if strings.Contains("404", err.Error()) {
return nil, nil
}
return nil, err
}
if res == nil {
return nil, nil
}
var photos = make([]Result, 0, len(*res))
for _, v := range *res {
res, err := newFromPhoto(v)
if err != nil {
continue
}
photos = append(photos, res)
}
return results{results: photos}, nil
}
func (l *unsplashService) Download(ctx context.Context, id string) (imgPath string, err error) {
l.lazyInitClient()
var picture Result
l.cache.ForEach(func(v ocache.Object) (isContinue bool) {
// todo: it will be better to save the last result, but we need another lock for this
if r, ok := v.(results); ok {
for _, res := range r.results {
if res.ID == id {
picture = res
break
}
}
}
return picture.ID == ""
})
if picture.ID == "" {
res, _, err := l.client.Photos.Photo(id, nil)
if err != nil {
return "", err
}
picture, err = newFromPhoto(*res)
if err != nil {
return "", err
}
}
req, err := http.NewRequest("GET", picture.PictureHDUrl, nil)
if err != nil {
return "", err
}
req = req.WithContext(ctx)
client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download file from unsplash: %s", err.Error())
}
defer resp.Body.Close()
tmpfile, err := ioutil.TempFile(l.tempDirProvider.TempDir(), picture.ID)
if err != nil {
return "", fmt.Errorf("failed to create temp file: %s", err.Error())
}
_, _ = io.Copy(tmpfile, resp.Body)
tmpfile.Close()
err = injectIntoExif(tmpfile.Name(), picture.Artist, picture.ArtistURL, picture.Description)
if err != nil {
return "", fmt.Errorf("failed to inject exif: %s", err.Error())
}
p, err := filepath.Abs(tmpfile.Name())
if err != nil {
return "", fmt.Errorf("failed to inject exif: %s", err.Error())
}
go func(cl *unsplash.Unsplash) {
// we must call download endpoint according to the API guidelines
// but we can do it in a separate goroutine to make sure we will download the picture as fast as possible
_, _, err = cl.Photos.DownloadLink(id)
if err != nil {
log.Errorf("failed to call unsplash download endpoint: %s", err.Error())
}
}(l.client)
return p, nil
}
func PackArtistNameAndURL(name, url string) string {
return fmt.Sprintf("%s; %s", name, url)
}
func injectIntoExif(filePath, artistName, artistUrl, description string) error {
jmp := jpegstructure.NewJpegMediaParser()
intfc, err := jmp.ParseFile(filePath)
if err != nil {
return fmt.Errorf("failed to open file to read exif: %s", err.Error())
}
sl := intfc.(*jpegstructure.SegmentList)
rootIb, err := sl.ConstructExifBuilder()
if err != nil {
return err
}
ifdPath := "IFD0"
ifdIb, err := exif.GetOrCreateIbFromRootIb(rootIb, ifdPath)
if err != nil {
return err
}
// Artist key in decimal is 315 https://www.exiv2.org/tags.html
err = ifdIb.SetStandard(315, PackArtistNameAndURL(artistName, artistUrl))
err = ifdIb.SetStandard(270, description)
err = sl.SetExif(rootIb)
f, err := os.OpenFile(filePath, os.O_RDWR|os.O_TRUNC, 0755)
defer f.Close()
if err != nil {
return fmt.Errorf("failed to open file to write exif: %s", err.Error())
}
err = sl.Write(f)
if err != nil {
return fmt.Errorf("failed to write exif: %s", err.Error())
}
return nil
}
func init() {
if DefaultToken == "" {
DefaultToken = loadenv.Get("UNSPLASH_KEY")
}
setAnytypeURL()
}
func setAnytypeURL() {
unsplash.SetupBaseUrl(anytypeURL)
}