Home Download Docs Code Community
     1	/*
     2	Copyright 2017 The Perkeep Authors
     3	
     4	Licensed under the Apache License, Version 2.0 (the "License");
     5	you may not use this file except in compliance with the License.
     6	You may obtain a copy of the License at
     7	
     8	     http://www.apache.org/licenses/LICENSE-2.0
     9	
    10	Unless required by applicable law or agreed to in writing, software
    11	distributed under the License is distributed on an "AS IS" BASIS,
    12	WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13	See the License for the specific language governing permissions and
    14	limitations under the License.
    15	*/
    16	
    17	// Package gphotos implements a Google Photos importer, using the Google Drive
    18	// API to access the Google Photos folder.
    19	package gphotos // import "perkeep.org/pkg/importer/gphotos"
    20	
    21	import (
    22		"context"
    23		"errors"
    24		"fmt"
    25		"io"
    26		"log"
    27		"net/http"
    28		"net/url"
    29		"os"
    30		"strconv"
    31		"strings"
    32		"time"
    33	
    34		"perkeep.org/internal/httputil"
    35		"perkeep.org/pkg/blob"
    36		"perkeep.org/pkg/importer"
    37		"perkeep.org/pkg/importer/picasa"
    38		"perkeep.org/pkg/schema"
    39		"perkeep.org/pkg/schema/nodeattr"
    40		"perkeep.org/pkg/search"
    41	
    42		"go4.org/ctxutil"
    43		"go4.org/syncutil"
    44		"golang.org/x/oauth2"
    45		"golang.org/x/oauth2/google"
    46		"golang.org/x/sync/errgroup"
    47	)
    48	
    49	const (
    50		// runCompleteVersion is a cache-busting version number of the
    51		// importer code. It should be incremented whenever the
    52		// behavior of this importer is updated enough to warrant a
    53		// complete run.  Otherwise, if the importer runs to
    54		// completion, this version number is recorded on the account
    55		// permanode and subsequent importers can stop early.
    56		runCompleteVersion = "0"
    57	
    58		// attrDriveId is the Google Drive object ID of the photo.
    59		attrDriveId = "driveId"
    60	
    61		// acctAttrOAuthToken stores access + " " + refresh + " " + expiry
    62		// See encodeToken and decodeToken.
    63		acctAttrOAuthToken = "oauthToken"
    64	
    65		// acctSinceToken store the GPhotos-returned nextToken
    66		acctSinceToken = "sinceToken"
    67	)
    68	
    69	var (
    70		logger = log.New(os.Stderr, "gphotos: ", log.LstdFlags)
    71		logf   = logger.Printf
    72	)
    73	
    74	var (
    75		_ importer.Importer            = imp{}
    76		_ importer.ImporterSetupHTMLer = imp{}
    77	)
    78	
    79	func init() {
    80		importer.Register("gphotos", imp{})
    81	}
    82	
    83	// imp is the implementation of the gphotos importer.
    84	type imp struct {
    85		importer.OAuth2
    86	}
    87	
    88	func (imp) Properties() importer.Properties {
    89		return importer.Properties{
    90			Title:               "Google Photos (via Drive API)",
    91			Description:         "import all your photos from Google Photos, via Google Drive. (requires settings changes in Drive)",
    92			SupportsIncremental: true, // TODO: but not well
    93			NeedsAPIKey:         true,
    94		}
    95	}
    96	
    97	type userInfo struct {
    98		ID    string // numeric gphotos user ID ("11583474931002155675")
    99		Name  string // "Jane Smith"
   100		Email string // jane.smith@example.com
   101	}
   102	
   103	func (imp) getUserInfo(ctx context.Context) (*userInfo, error) {
   104		u, err := getUser(ctx, ctxutil.Client(ctx))
   105		if err != nil {
   106			return nil, err
   107		}
   108		return &userInfo{ID: u.PermissionId, Email: u.EmailAddress, Name: u.DisplayName}, nil
   109	}
   110	
   111	func (imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
   112		if acctNode.Attr(importer.AcctAttrUserID) != "" && acctNode.Attr(acctAttrOAuthToken) != "" {
   113			return true, nil
   114		}
   115		return false, nil
   116	}
   117	
   118	func (im imp) SummarizeAccount(acct *importer.Object) string {
   119		ok, err := im.IsAccountReady(acct)
   120		if err != nil || !ok {
   121			return ""
   122		}
   123		if acct.Attr(importer.AcctAttrUserName) == "" || acct.Attr(importer.AcctAttrName) == "" {
   124			return fmt.Sprintf("userid %s", acct.Attr(importer.AcctAttrUserID))
   125		}
   126		return fmt.Sprintf("%s <%s>, userid %s",
   127			acct.Attr(importer.AcctAttrName),
   128			acct.Attr(importer.AcctAttrUserName),
   129			acct.Attr(importer.AcctAttrUserID),
   130		)
   131	}
   132	
   133	func (im imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
   134		oauthConfig, err := im.auth(ctx)
   135		if err == nil {
   136			// we will get back this with the token, so use it for preserving account info
   137			state := "acct:" + ctx.AccountNode.PermanodeRef().String()
   138			// AccessType needs to be "offline", as the user is not here all the time;
   139			// ApprovalPrompt needs to be "force" to be able to get a RefreshToken
   140			// everytime, even for Re-logins, too.
   141			//
   142			// Source: https://developers.google.com/youtube/v3/guides/authentication#server-side-apps
   143			http.Redirect(w, r, oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce), http.StatusFound)
   144		}
   145		return err
   146	}
   147	
   148	// CallbackURLParameters returns the needed callback parameters - empty for Google gphotos.
   149	func (im imp) CallbackURLParameters(acctRef blob.Ref) url.Values {
   150		return url.Values{}
   151	}
   152	
   153	func (im imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
   154		oauthConfig, err := im.auth(ctx)
   155		if err != nil {
   156			httputil.ServeError(w, r, fmt.Errorf("Error getting oauth config: %v", err))
   157			return
   158		}
   159	
   160		if r.Method != "GET" {
   161			http.Error(w, "Expected a GET", http.StatusBadRequest)
   162			return
   163		}
   164		code := r.FormValue("code")
   165		if code == "" {
   166			http.Error(w, "Expected a code", http.StatusBadRequest)
   167			return
   168		}
   169	
   170		token, err := oauthConfig.Exchange(ctx, code)
   171		if err != nil {
   172			logf("token exchange error: %v", err)
   173			httputil.ServeError(w, r, fmt.Errorf("token exchange error: %v", err))
   174			return
   175		}
   176	
   177		gphotosCtx := context.WithValue(ctx, ctxutil.HTTPClient, oauthConfig.Client(ctx, token))
   178	
   179		userInfo, err := im.getUserInfo(gphotosCtx)
   180		if err != nil {
   181			logf("couldn't get username: %v", err)
   182			httputil.ServeError(w, r, fmt.Errorf("can't get username: %v", err))
   183			return
   184		}
   185	
   186		if err := ctx.AccountNode.SetAttrs(
   187			importer.AcctAttrUserID, userInfo.ID,
   188			importer.AcctAttrName, userInfo.Name,
   189			importer.AcctAttrUserName, userInfo.Email,
   190			acctAttrOAuthToken, encodeToken(token),
   191		); err != nil {
   192			httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
   193			return
   194		}
   195		http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
   196	}
   197	
   198	// encodeToken encodes the oauth2.Token as
   199	// AccessToken + " " + RefreshToken + " " + Expiry.Unix()
   200	func encodeToken(token *oauth2.Token) string {
   201		if token == nil {
   202			return ""
   203		}
   204		var seconds int64
   205		if !token.Expiry.IsZero() {
   206			seconds = token.Expiry.Unix()
   207		}
   208		return token.AccessToken + " " + token.RefreshToken + " " + strconv.FormatInt(seconds, 10)
   209	}
   210	
   211	// decodeToken parses an access token, refresh token, and optional
   212	// expiry unix timestamp separated by spaces into an oauth2.Token.
   213	// It returns as much as it can.
   214	func decodeToken(encoded string) *oauth2.Token {
   215		t := new(oauth2.Token)
   216		f := strings.Fields(encoded)
   217		if len(f) > 0 {
   218			t.AccessToken = f[0]
   219		}
   220		if len(f) > 1 {
   221			t.RefreshToken = f[1]
   222		}
   223		if len(f) > 2 && f[2] != "0" {
   224			sec, err := strconv.ParseInt(f[2], 10, 64)
   225			if err == nil {
   226				t.Expiry = time.Unix(sec, 0)
   227			}
   228		}
   229		return t
   230	}
   231	
   232	func (im imp) auth(ctx *importer.SetupContext) (*oauth2.Config, error) {
   233		clientID, secret, err := ctx.Credentials()
   234		if err != nil {
   235			return nil, err
   236		}
   237		conf := &oauth2.Config{
   238			Endpoint:     google.Endpoint,
   239			RedirectURL:  ctx.CallbackURL(),
   240			ClientID:     clientID,
   241			ClientSecret: secret,
   242			Scopes:       scopeURLs,
   243		}
   244		return conf, nil
   245	}
   246	
   247	func (imp) AccountSetupHTML(host *importer.Host) string {
   248		// Google Cloud credentials require a URI of the kind scheme://domain for
   249		// javascript origins, so we strip the path.
   250		origin := host.ImporterBaseURL()
   251		if u, err := url.Parse(origin); err == nil {
   252			u.Path = ""
   253			origin = u.String()
   254		}
   255	
   256		callback := host.ImporterBaseURL() + "gphotos/callback"
   257		return fmt.Sprintf(`
   258	<h1>Configuring Google Photos</h1>
   259	<p>Please note that because of limitations of the Google Photos folder, this importer can only retrieve photos as they were originally uploaded, and not as they currently are in Google Photos, if modified.</p>
   260	<p>First, you need to enable the Google Photos folder in the <a href='https://drive.google.com/'>Google Drive</a> settings.</p>
   261	<p>Then visit <a href='https://console.developers.google.com/'>https://console.developers.google.com/</a>
   262	and create a new project.</p>
   263	<p>Next, go to the <a href='https://console.cloud.google.com/apis/library'>API Library</a> of your project, and enable the <em>Google Drive API</em>. You may have to wait a few minutes after this step, before the API is enabled on Google's side.</p>
   264	<p>Finally, go to the <a href='https://console.cloud.google.com/apis/credentials'>API Credentials</a> of your project. Click the button <b>"Create credentials"</b>, and pick <b>"OAuth client ID"</b>.</p>
   265	<p>Use the following settings:</p>
   266	<ul>
   267	  <li>Web application</li>
   268	  <li>Authorized JavaScript origins: <b>%s</b></li>
   269	  <li>Authorized Redirect URI: <b>%s</b></li>
   270	</ul>
   271	<p>Click "Create Client ID".  Copy the "Client ID" and "Client Secret" into the boxes above.</p>
   272	`, origin, callback)
   273	}
   274	
   275	// A run is our state for a given run of the importer.
   276	type run struct {
   277		*importer.RunContext
   278		photoGate    *syncutil.Gate
   279		setNextToken func(string) error
   280		dl           *downloader
   281	}
   282	
   283	func (imp) Run(rctx *importer.RunContext) error {
   284		clientID, secret, err := rctx.Credentials()
   285		if err != nil {
   286			return err
   287		}
   288		acctNode := rctx.AccountNode()
   289	
   290		ocfg := &oauth2.Config{
   291			Endpoint:     google.Endpoint,
   292			ClientID:     clientID,
   293			ClientSecret: secret,
   294			Scopes:       scopeURLs,
   295		}
   296	
   297		token := decodeToken(acctNode.Attr(acctAttrOAuthToken))
   298		sinceToken := acctNode.Attr(acctSinceToken)
   299		baseCtx := rctx.Context()
   300		ctx := context.WithValue(baseCtx, ctxutil.HTTPClient, ocfg.Client(baseCtx, token))
   301	
   302		root := rctx.RootNode()
   303		if root.Attr(nodeattr.Title) == "" {
   304			if err := root.SetAttr(
   305				nodeattr.Title,
   306				fmt.Sprintf("%s's Google Photos Data", acctNode.Attr(importer.AcctAttrName)),
   307			); err != nil {
   308				return err
   309			}
   310		}
   311	
   312		dl, err := newDownloader(ctxutil.Client(ctx))
   313		if err != nil {
   314			return err
   315		}
   316		r := &run{
   317			RunContext:   rctx,
   318			photoGate:    syncutil.NewGate(3),
   319			setNextToken: func(nextToken string) error { return acctNode.SetAttr(acctSinceToken, nextToken) },
   320			dl:           dl,
   321		}
   322		if err := r.importPhotos(ctx, sinceToken); err != nil {
   323			return err
   324		}
   325	
   326		if err := acctNode.SetAttrs(importer.AcctAttrCompletedVersion, runCompleteVersion); err != nil {
   327			return err
   328		}
   329	
   330		return nil
   331	}
   332	
   333	func (r *run) importPhotos(ctx context.Context, sinceToken string) error {
   334		photosNode, err := r.getTopLevelNode("photos")
   335		if err != nil {
   336			return fmt.Errorf("gphotos importer: get top level node: %v", err)
   337		}
   338	
   339		grp, grpCtx := errgroup.WithContext(ctx)
   340	
   341		nextToken, err := r.dl.foreachPhoto(grpCtx, sinceToken, func(ctx context.Context, ph *photo) error {
   342			select {
   343			case <-ctx.Done():
   344				return ctx.Err()
   345			default:
   346			}
   347	
   348			r.photoGate.Start()
   349			grp.Go(func() error {
   350				defer r.photoGate.Done()
   351				return r.updatePhoto(ctx, photosNode, ph)
   352			})
   353			return nil
   354	
   355		})
   356		if gerr := grp.Wait(); gerr != nil {
   357			if err == nil || err == context.Canceled || err == context.DeadlineExceeded {
   358				err = gerr
   359			}
   360		}
   361		if err != nil {
   362			return fmt.Errorf("gphotos importer: %v", err)
   363		}
   364		if r.setNextToken != nil {
   365			r.setNextToken(nextToken)
   366		}
   367		return nil
   368	}
   369	
   370	func (ph photo) filename() string {
   371		filename := ph.Name
   372		if filename == "" {
   373			filename = ph.OriginalFilename
   374		}
   375		if filename == "" {
   376			filename = ph.ID
   377		}
   378		return strings.Replace(filename, "/", "-", -1)
   379	}
   380	
   381	func orAltAttr(attr, alt string) string {
   382		if attr != "" {
   383			return attr
   384		}
   385		return alt
   386	}
   387	
   388	func (ph photo) title(altTitle string) string {
   389		title := strings.TrimSpace(ph.Description)
   390		if title == "" {
   391			title = orAltAttr(title, altTitle)
   392		}
   393		filename := ph.filename()
   394		if title == "" && schema.IsInterestingTitle(filename) {
   395			title = filename
   396		}
   397		if strings.Contains(title, "\n") {
   398			title = title[:strings.Index(title, "\n")]
   399		}
   400		return title
   401	}
   402	
   403	// updatePhoto creates a new permanode with the attributes of photo, or updates
   404	// an existing one if appropriate. It also downloads the photo contents when
   405	// needed. In particular, it reuses a permanode created by the picasa importer if
   406	// that permanode seems to be about the same photo contents, to avoid what would
   407	// look like duplicates. For now, it can handle the following cases:
   408	// 1) No permanode for the photo object exists, and no permanode for the
   409	// contents of the photo exists. So it creates a new one.
   410	// 2) No permanode for the photo object exists, but a picasa permanode for the
   411	// same contents exists. So we reuse the picasa node.
   412	// 3) No permanode for the photo object exists, but a permanode for the same
   413	// contents, and with no conflicting attributes, exists. So we reuse that
   414	// permanode.
   415	// 4) A permanode for the photo object already exists, so we reuse it.
   416	func (r *run) updatePhoto(ctx context.Context, parent *importer.Object, ph *photo) error {
   417		if ph.ID == "" {
   418			return errors.New("photo has no ID")
   419		}
   420		select {
   421		case <-ctx.Done():
   422			return ctx.Err()
   423		default:
   424		}
   425	
   426		// fileRefStr, in addition to being used as the camliConent value, is used
   427		// as a sentinel: if it is still blank after the call to
   428		// ChildPathObjectOrFunc, it means that a permanode for the photo object
   429		// already exists.
   430		var fileRefStr string
   431		// picasAttrs holds the attributes of the picasa node for the photo, if any is found.
   432		var picasAttrs url.Values
   433	
   434		filename := ph.filename()
   435	
   436		photoNode, err := parent.ChildPathObjectOrFunc(ph.ID, func() (*importer.Object, error) {
   437			h := blob.NewHash()
   438			rc, err := r.dl.openPhoto(ctx, *ph)
   439			if err != nil {
   440				return nil, err
   441			}
   442			fileRef, err := schema.WriteFileFromReader(r.Context(), r.Host.Target(), filename, io.TeeReader(rc, h))
   443			rc.Close()
   444			if err != nil {
   445				return nil, err
   446			}
   447			fileRefStr = fileRef.String()
   448			wholeRef := blob.RefFromHash(h)
   449			pn, attrs, err := findExistingPermanode(r.Context(), r.Host.Searcher(), wholeRef)
   450			if err != nil {
   451				if err != os.ErrNotExist {
   452					return nil, fmt.Errorf("could not look for permanode with %v as camliContent : %v", fileRefStr, err)
   453				}
   454				return r.Host.NewObject()
   455			}
   456			if attrs != nil {
   457				picasAttrs = attrs
   458			}
   459			return r.Host.ObjectFromRef(pn)
   460		})
   461		if err != nil {
   462			if fileRefStr != "" {
   463				return fmt.Errorf("error getting permanode for photo %q, with content %v: %v", ph.ID, fileRefStr, err)
   464			}
   465			return fmt.Errorf("error getting permanode for photo %q: %v", ph.ID, err)
   466		}
   467	
   468		if fileRefStr == "" {
   469			// photoNode was created in a previous run, but it is not
   470			// guaranteed its attributes were set. e.g. the importer might have
   471			// been interrupted. So we check for an existing camliContent.
   472			if camliContent := photoNode.Attr(nodeattr.CamliContent); camliContent == "" {
   473				// looks like an incomplete node, so we need to re-download.
   474				rc, err := r.dl.openPhoto(ctx, *ph)
   475				if err != nil {
   476					return err
   477				}
   478				fileRef, err := schema.WriteFileFromReader(r.Context(), r.Host.Target(), filename, rc)
   479				rc.Close()
   480				if err != nil {
   481					return err
   482				}
   483				fileRefStr = fileRef.String()
   484			}
   485		} else {
   486			if picasAttrs.Get(nodeattr.CamliContent) != "" {
   487				// We've just created a new file schema, but we're also recycling a
   488				// picasa node, and we prefer keeping the existing file schema from the
   489				// picasa node, because the file from Drive never gets updates
   490				// (https://productforums.google.com/forum/#!msg/drive/HbNOd1o40CQ/VfIJCncyAAAJ).
   491				// Thanks to blob deduplication, these two file schemas are most likely
   492				// the same anyway. If not, the newly created one will/should get GCed
   493				// eventually.
   494				fileRefStr = picasAttrs.Get(nodeattr.CamliContent)
   495			}
   496		}
   497	
   498		attrs := []string{
   499			attrDriveId, ph.ID,
   500			nodeattr.Version, strconv.FormatInt(ph.Version, 10),
   501			nodeattr.Title, ph.title(picasAttrs.Get(nodeattr.Title)),
   502			nodeattr.Description, orAltAttr(ph.Description, picasAttrs.Get(nodeattr.Description)),
   503			nodeattr.DateCreated, schema.RFC3339FromTime(ph.CreatedTime),
   504			nodeattr.DateModified, orAltAttr(schema.RFC3339FromTime(ph.ModifiedTime), picasAttrs.Get(nodeattr.DateModified)),
   505			// Even if the node already had some nodeattr.URL picasa attribute, it's
   506			// ok to overwrite it, because from what I've tested it's useless nowadays
   507			// (gives a 404 in a browser). Plus, we don't overwrite the actually useful
   508			// "picasaMediaURL" attribute.
   509			nodeattr.URL, ph.WebContentLink,
   510		}
   511	
   512		if ph.Location != nil {
   513			if ph.Location.Altitude != 0 {
   514				attrs = append(attrs, nodeattr.Altitude, floatToString(ph.Location.Altitude))
   515			}
   516			if ph.Location.Latitude != 0 || ph.Location.Longitude != 0 {
   517				attrs = append(attrs,
   518					nodeattr.Latitude, floatToString(ph.Location.Latitude),
   519					nodeattr.Longitude, floatToString(ph.Location.Longitude),
   520				)
   521			}
   522		}
   523		if err := photoNode.SetAttrs(attrs...); err != nil {
   524			return err
   525		}
   526	
   527		if fileRefStr != "" {
   528			// camliContent is set last, as its presence defines whether we consider a
   529			// photo successfully updated.
   530			if err := photoNode.SetAttr(nodeattr.CamliContent, fileRefStr); err != nil {
   531				return err
   532			}
   533		}
   534	
   535		return nil
   536	}
   537	
   538	func (r *run) displayName() string {
   539		acctNode := r.AccountNode()
   540	
   541		// e.g. "Jane Smith"
   542		if name := acctNode.Attr(importer.AcctAttrName); name != "" {
   543			// Keep only the given name, for a shorter title.
   544			// e.g. "Jane"
   545			return strings.Fields(name)[0]
   546		}
   547	
   548		// e.g. "jane.smith@gmail.com"
   549		if name := acctNode.Attr(importer.AcctAttrUserName); name != "" {
   550			// Keep only the first part of the e-mail address, for a shorter title.
   551			// e.g. "jane"
   552			return strings.SplitN(name, ".", 2)[0]
   553		}
   554	
   555		// e.g. 08054589012345261101
   556		return acctNode.Attr(importer.AcctAttrUserID)
   557	}
   558	
   559	func (r *run) getTopLevelNode(path string) (*importer.Object, error) {
   560		root := r.RootNode()
   561		name := r.displayName()
   562		rootTitle := fmt.Sprintf("%s's Google Photos Data", name)
   563		logf("root title = %q; want %q", root.Attr(nodeattr.Title), rootTitle)
   564		if err := root.SetAttr(nodeattr.Title, rootTitle); err != nil {
   565			return nil, err
   566		}
   567	
   568		obj, err := root.ChildPathObject(path)
   569		if err != nil {
   570			return nil, err
   571		}
   572		var title string
   573		switch path {
   574		case "photos":
   575			title = fmt.Sprintf("%s's Google Photos", name)
   576		}
   577		return obj, obj.SetAttr(nodeattr.Title, title)
   578	}
   579	
   580	var sensitiveAttrs = []string{
   581		nodeattr.Type,
   582		attrDriveId,
   583		nodeattr.Title,
   584		nodeattr.DateModified,
   585		nodeattr.DatePublished,
   586		nodeattr.Latitude,
   587		nodeattr.Longitude,
   588		nodeattr.Description,
   589	}
   590	
   591	// findExistingPermanode finds an existing permanode that has a
   592	// camliContent pointing to a file with the provided wholeRef and
   593	// doesn't have any conflicting attributes that would prevent the
   594	// gphotos importer from re-using that permanode for its own use.
   595	// If it finds a picasa permanode, it is returned immediately,
   596	// as well as the existing attributes on the node, so the caller
   597	// can merge them with whatever new attributes it wants to add to
   598	// the node.
   599	func findExistingPermanode(ctx context.Context, qs search.QueryDescriber, wholeRef blob.Ref) (pn blob.Ref, picasaAttrs url.Values, err error) {
   600		res, err := qs.Query(ctx, &search.SearchQuery{
   601			Constraint: &search.Constraint{
   602				Permanode: &search.PermanodeConstraint{
   603					Attr: "camliContent",
   604					ValueInSet: &search.Constraint{
   605						File: &search.FileConstraint{
   606							WholeRef: wholeRef,
   607						},
   608					},
   609				},
   610			},
   611			Describe: &search.DescribeRequest{
   612				Depth: 1,
   613			},
   614		})
   615		if err != nil {
   616			return
   617		}
   618		if res.Describe == nil {
   619			return pn, nil, os.ErrNotExist
   620		}
   621	Res:
   622		for _, resBlob := range res.Blobs {
   623			br := resBlob.Blob
   624			desBlob, ok := res.Describe.Meta[br.String()]
   625			if !ok || desBlob.Permanode == nil {
   626				continue
   627			}
   628			attrs := desBlob.Permanode.Attr
   629			if attrs.Get(picasa.AttrMediaURL) != "" {
   630				// If we found a picasa permanode, we're going to reuse it, in order to avoid
   631				// creating what would look like duplicates. We let the caller deal with merging
   632				// properly on the node the existing (Picasa) attributes, with the new (Google
   633				// Photos) attributes.
   634				return br, attrs, nil
   635			}
   636			// otherwise, only keep it if attributes are not conflicting.
   637			for _, attr := range sensitiveAttrs {
   638				if attrs.Get(attr) != "" {
   639					continue Res
   640				}
   641			}
   642			return br, nil, nil
   643		}
   644		return pn, nil, os.ErrNotExist
   645	}
   646	
   647	func floatToString(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }
Website layout inspired by memcached.
Content by the authors.