1
0
Fork 0
mirror of https://github.com/anyproto/anytype-heart.git synced 2025-06-10 01:51:07 +09:00
anytype-heart/util/unsplash/unsplash.go
Roman Khafizianov b13c1e37cc
unsplash fixes
2022-02-14 18:26:57 +01:00

285 lines
6.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package unsplash
import (
"context"
"fmt"
"github.com/anytypeio/go-anytype-middleware/app"
"github.com/anytypeio/go-anytype-middleware/core/configfetcher"
"github.com/anytypeio/go-anytype-middleware/util/ocache"
"github.com/anytypeio/go-anytype-middleware/util/pbtypes"
"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"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const (
CName = "unsplash"
DefaultToken = "TLKq5P192MptAcTHnGM8WQPZV8kKNn1eT9FEi5Srem0"
cacheTTL = time.Minute * 10
cacheGCPeriod = time.Minute * 5
)
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
}
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() Unsplash {
return &unsplashService{}
}
// exifArtistWithUrl matches and extracts additional information we store in the Artist field the URL of the author page.
// We use it within the Unsplash integration
var exifArtistWithUrl = regexp.MustCompile(`(.*?); (http.*)`)
type Result struct {
ID string
Description string
PictureThumbUrl string
PictureSmallUrl string
PictureFullUrl string
Artist string
ArtistURL string
}
type results struct {
results []Result
}
func (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.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.GetCafeConfig()
token := DefaultToken
if configToken := pbtypes.GetString(cfg.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
}
}
// we must call download endpoint according to the API guidelines
picUrl, _, err := l.client.Photos.DownloadLink(id)
if err != nil {
return "", err
}
req, err := http.NewRequest("GET", picUrl.String(), 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(os.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())
}
return p, nil
}
func PackArtistNameAndURL(name, url string) string {
return fmt.Sprintf("%s; %s", name, url)
}
func UnpackArtist(packed string) (name, url string) {
artistParts := exifArtistWithUrl.FindStringSubmatch(packed)
if len(artistParts) == 3 {
return artistParts[1], artistParts[2]
}
return packed, ""
}
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
}