Repository: cgeo/cgeo (Java)

Issue:
Spoiler pic not shown
### Describe your problem!

The cache [GCB29Z6](https://coord.info/GCB29Z6) has a [spoiler pic](https://img.geocaching.com/cache/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg) - but it is not shown in c:geo.

### How to reproduce?

1. in c:geo open [GCB29Z6](https://coord.info/GCB29Z6) 
2. go to the images tab

### Actual result after these steps?

There's just the [listing pic](https://img.geocaching.com/dfb1340a-4ca3-4618-afdb-a13109b1a3dc.jpg), not the [spoiler](https://img.geocaching.com/cache/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg).

### Expected result after these steps?

[Spoiler](https://img.geocaching.com/cache/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg) should be shown.

### Reproducible

Yes

### c:geo Version

2025.02.13-NB-0339443

### System information

```text

```

### Additional Information

_No response_


Discussion: I just opened the listing in the browser and there is no spoiler picture. It seems to be included in the gallery, but not in the spoiler section.
We had such questions rather often in the last couple of months also in support. I am starting to suspect that GC.com changed something here like not including images uploaded by the owner into the spoiler list?

Maybe we have to rethink our position of (not) loading images from the gallery too. Even though that would be another server request and we have to clarify the dreadful question how many of the (potentially hundreds of) gallery images we download...
> I just opened the listing in the browser and there is no spoiler picture.

![Screenshot_20250213_112004_io_github_forkmaintainers_iceraven_HomeActivity.jpg](https://github.com/user-attachments/assets/3b8cc832-dd02-41c3-a37e-adcc23bff076)

I would consider this as the spoiler.
> Maybe we have to rethink our position of (not) loading images from the gallery too.

But there are pictures downloaded - what is the current criteria for downloading these? Just the gallery pics included in the downloaded last 15 logs, I guess?

> Even though that would be another server request and we have to clarify the dreadful question how many of the (potentially hundreds of) gallery images we download

Why not let the user decide: Leave it as it is right now (just the pics of the downloaded logs, I guess), but if he updates the images page, he clearly wants to see (more) images, so he should get them.

Would additionally be great to tell, how many pictures are on the server and then give the opportunity to download them bunch by bunch, perhaps like with the lists containing thousands of entries with the ``Load next xx pictures (of nnn)``

Or simply provide a link to the gallery on the server.

But we're getting OffTopic ... ;)
> But there are pictures downloaded - what is the current criteria for downloading these? Just the gallery pics included in the downloaded last 15 logs, I guess?

c:geo loads pictures from the listing (including spoilers) and logs.

I still don't see the spoiler picture.

![Image](https://github.com/user-attachments/assets/0af2f3d9-4e7b-4674-b1c8-a49c6b9faa9d)
> > I just opened the listing in the browser and there is no spoiler picture.
> 
> ![Screenshot_20250213_112004_io_github_forkmaintainers_iceraven_HomeActivity.jpg](https://github.com/user-attachments/assets/3b8cc832-dd02-41c3-a37e-adcc23bff076)
> 
> I would consider this as the spoiler.

I don't see this pic. Do you use any grease monkey or similar scripts to enhance your browser view?
Yes, you're right - I'm for real using GC little helper (can't imagine using gc.com without it  ;) ).

However - if I disable it, the "pseudo spoiler" is no longer in the listing, but in the gallery only.

But as it's date is before publish date, it obviously is an owner picture, and he most probably intended to add it as a spoiler, but did it wrong. GC little helper seems to recognize this and "corrects" the CO's fault ... i. e. does not show what is, but what most probably was intended by the owner ... interesting to see that GClh does not only correct the mistakes and glitches of gc.com, but the CO's ones, too. ;)

Nevertheless - is there any way to get such owner pictures onto the images page, at best in the listing section?
> Yes, you're right - I'm for real using GC little helper (can't imagine using gc.com without it ;) ).
> 
> However - if I disable it, the "pseudo spoiler" is no longer in the listing, but in the gallery only.
> 
> But as it's date is before publish date, it obviously is an owner picture, and he most probably intended to add it as a spoiler, but did it wrong. GC little helper seems to recognize this and "corrects" the CO's fault ... i. e. does not show what is, but what most probably was intended by the owner ... interesting to see that GClh does not only correct the mistakes and glitches of gc.com, but the CO's ones, too. ;)

Does anyone know the algorithm used by GClh to "detect" the "correct" gallery images to display under the cache listing?


Anyone for sure - at least over there:  [https://github.com/2Abendsegler/GClh](https://github.com/2Abendsegler/GClh).  ;)
GC little helper simply shows hidden images which are linked in the `.CachePageImages` element https://github.com/2Abendsegler/GClh/blob/3e9914e3ca3c8fadfcef9a58ee7c96af759cebfd/gc_little_helper_II.user.js#L10508-L10512
See PR #16699 for a fix
> GC little helper simply shows hidden images which are linked in the `.CachePageImages` element https://github.com/2Abendsegler/GClh/blob/3e9914e3ca3c8fadfcef9a58ee7c96af759cebfd/gc_little_helper_II.user.js#L10508-L10512

Oh. Why does Groundspeak put such links into the listing and not showing them?

Would it be enppugh if c:geo shows such hidden links? Would that make it obsolete to integrate the Gallery?
> Would it be enppugh if c:geo shows such hidden links? Would that make it obsolete to integrate the Gallery?

I would say, these are two different things:
a) would be great, if c:geo would show such links, best would be within the listing itself or as listing spoiler pics.
b) would be great to be able to get the gallery, too.
While b) is not a fix for a) anyway, cause at some point in the future, the "hidden spoiler" will not be loaded anymore anyway, cause these will always be the oldest pics in the gallery - and with galleries holding more than 20 pics, you will again not see the hidden spoiler and you would be forced to download the entire gallery - just to see that there's no hidden spoiler ... ;)



Fix (patch):
diff --git a/main/src/androidTest/java/cgeo/geocaching/connector/gc/GCParserTest.java b/main/src/androidTest/java/cgeo/geocaching/connector/gc/GCParserTest.java
index a8611c0ad9f..9c44516b56b 100644
--- a/main/src/androidTest/java/cgeo/geocaching/connector/gc/GCParserTest.java
+++ b/main/src/androidTest/java/cgeo/geocaching/connector/gc/GCParserTest.java
@@ -48,7 +48,7 @@ public void testUnpublishedCacheNotOwner() {
 
     private void assertUnpublished(final int cache) {
         final String page = CgeoTestUtils.getFileContent(cache);
-        final SearchResult result = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
+        final SearchResult result = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
         assertThat(result).isNotNull();
         assertThat(result.isEmpty()).isTrue();
         assertThat(result.getError()).isEqualTo(StatusCode.UNPUBLISHED_CACHE);
@@ -68,7 +68,7 @@ public void testPublishedCacheWithUnpublishedInDescription2() {
 
     private void assertPublishedCache(final int cachePage, final String cacheName) {
         final String page = CgeoTestUtils.getFileContent(cachePage);
-        final SearchResult result = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
+        final SearchResult result = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
         assertThat(result).isNotNull();
         assertThat(result.getCount()).isEqualTo(1);
         final Geocache cache = result.getFirstCacheFromResult(LoadFlags.LOAD_CACHE_OR_DB);
@@ -85,7 +85,7 @@ public void testOwnCache() {
         final Image spoiler = cache.getSpoilers().get(1);
         assertThat(spoiler.getUrl()).as("First spoiler image url wrong").isEqualTo("https://img.geocaching.com/6ddbbe82-8762-46ad-8f4c-57d03f4b0564.jpeg");
         assertThat(spoiler.getTitle()).as("First spoiler image text wrong").isEqualTo("SPOILER");
-        assertThat(spoiler.getDescription()).as("First spoiler image description").isNull();
+        assertThat(spoiler.getDescription()).as("First spoiler image description").isEqualTo("Spoiler");
     }
 
     private static Geocache createCache(final int index) {
@@ -96,7 +96,7 @@ private static Geocache createCache(final int index) {
         final SearchResult searchResult;
         try {
             Settings.setGcCustomDate(MockedCache.getDateFormat());
-            searchResult = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), mockedCache.getData(), null);
+            searchResult = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), mockedCache.getData(), null);
         } finally {
             Settings.setGcCustomDate(oldCustomDate);
         }
@@ -110,7 +110,7 @@ private static Geocache createCache(final int index) {
     }
 
     /**
-     * Test {@link GCParser#parseAndSaveCacheFromText(IConnector, String, DisposableHandler)} with "mocked" data
+     * Test {@link GCParser#testParseAndSaveCacheFromText(IConnector, String, DisposableHandler)} with "mocked" data
      */
     @MediumTest
     @Test
@@ -120,7 +120,7 @@ public void testParseCacheFromTextWithMockedData() {
             for (final MockedCache mockedCache : MockedCache.MOCKED_CACHES) {
                 // to get the same results we have to use the date format used when the mocked data was created
                 Settings.setGcCustomDate(MockedCache.getDateFormat());
-                final SearchResult searchResult = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), mockedCache.getData(), null);
+                final SearchResult searchResult = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), mockedCache.getData(), null);
                 final Geocache parsedCache = searchResult.getFirstCacheFromResult(LoadFlags.LOAD_CACHE_OR_DB);
                 assertThat(parsedCache).isNotNull();
                 assertThat(StringUtils.isNotBlank(mockedCache.getMockedDataUser())).isTrue();
@@ -193,7 +193,7 @@ public void testEditModifiedCoordinates() {
         editModifiedCoordinates(cache, new Geopoint("N51 21.544", "E07 02.566")).blockingSubscribe();
         cache.dropSynchronous();
         final String page = requestHtmlPage(cache.getGeocode(), null, "n");
-        final Geocache cache2 = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), page, null).getFirstCacheFromResult(LOAD_CACHE_ONLY);
+        final Geocache cache2 = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), page, null).getFirstCacheFromResult(LOAD_CACHE_ONLY);
         assertThat(cache2).isNotNull();
         assertThat(cache2.hasUserModifiedCoords()).isTrue();
         assertThat(cache2.getCoords()).isEqualTo(new Geopoint("N51 21.544", "E07 02.566"));
@@ -201,7 +201,7 @@ public void testEditModifiedCoordinates() {
         deleteModifiedCoordinates(cache2).blockingSubscribe();
         cache2.dropSynchronous();
         final String page2 = requestHtmlPage(cache.getGeocode(), null, "n");
-        final Geocache cache3 = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), page2, null).getFirstCacheFromResult(LOAD_CACHE_ONLY);
+        final Geocache cache3 = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), page2, null).getFirstCacheFromResult(LOAD_CACHE_ONLY);
         assertThat(cache3).isNotNull();
         assertThat(cache3.hasUserModifiedCoords()).isFalse();
     }
@@ -263,7 +263,7 @@ public void testNoteParsingWaypointTypes() {
     @Nullable
     private Geocache parseCache(@RawRes final int resourceId) {
         final String page = CgeoTestUtils.getFileContent(resourceId);
-        final SearchResult result = GCParser.parseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
+        final SearchResult result = GCParser.testParseAndSaveCacheFromText(GCConnector.getInstance(), page, null);
         assertThat(result).isNotNull();
         assertThat(result.isEmpty()).isFalse();
         return result.getFirstCacheFromResult(LoadFlags.LOAD_CACHE_OR_DB);
@@ -283,7 +283,7 @@ public void testTrackableNotActivated() {
     @MediumTest
     @Test
     public void testOnlineCacheUrl() {
-        assertThat(StringUtils.right(Objects.requireNonNull(new CgeoApplicationTest().searchByGeocode("GC5EF16")).getDescription(), 150)).as("related web page appended to description").contains("http://eventimgruenen.de/");
+        assertThat(StringUtils.right(Objects.requireNonNull(new CgeoApplicationTest().searchByGeocode("GC5EF16")).getDescription(), 300)).as("related web page appended to description").contains("http://eventimgruenen.de/");
     }
 
     @MediumTest
@@ -312,7 +312,7 @@ public void testSpoilerDescriptionForOwner() {
 
         spoiler = spoilers.get(1);
         assertThat(spoiler.getTitle()).isEqualTo("SPOILER");
-        assertThat(spoiler.getDescription()).isEqualTo("Suche diese Schraube");
+        assertThat(spoiler.getDescription()).isEqualTo("Spoiler: Suche diese Schraube");
     }
 
     @MediumTest
@@ -326,7 +326,7 @@ public void testSpoilerWithoutTitleAndLinkToLargerImage() {
 
         final Image spoiler = spoilers.get(0);
         assertThat(spoiler.getTitle()).isEqualTo("");
-        assertThat(spoiler.getDescription()).isEqualTo("FOTO SPOILER");
+        assertThat(spoiler.getDescription()).isEqualTo("Spoiler: FOTO SPOILER");
         assertThat(spoiler.getUrl()).isEqualTo("https://img.geocaching.com/124a14b5-87dd-42c6-8c83-52c184e07389.jpg");
     }
 
@@ -347,11 +347,54 @@ public void testSpoilerImageUrlWithStrong() {
         final String html = "<ul class=\"CachePageImages NoPrint\">\n" +
                 "            <li><a href=\"https://img.geocaching.com/cache/large/baf98e07-b431-4fbf-920d-0df4346c2847.JPG\" class=\"owner-image\" rel=\"owner_image_group\" data-title=\"<strong>Blick vom Weg</strong>\">Blick vom Weg</a></li><li><a href=\"https://img.geocaching.com/cache/large/a6772043-021c-4b6f-91c9-da4a92829f95.JPG\" class=\"owner-image\" rel=\"owner_image_group\" data-title=\"<strong>Da zeige ich auf den Cache</strong>\">Da zeige ich auf den Cache</a></li>\n" +
                 "        </ul>";
+
         final List<Image> images = GCParser.parseSpoiler(html);
         assertThat(images).hasSize(2);
-
+        assertThat(images.get(0).getUrl()).isEqualTo("https://img.geocaching.com/baf98e07-b431-4fbf-920d-0df4346c2847.JPG");
+        assertThat(images.get(0).getTitle()).isEqualTo("Blick vom Weg");
+        assertThat(images.get(0).getDescription()).isEqualTo("Spoiler");
     }
 
+    @Test
+    public void testGalleryImages() {
+        //from GCB29Z6
+        final String html = "    <h2>\n" +
+                "        <span id=\"ctl00_ContentBody_lbHeading\">Gallery Images</span>\n" +
+                "    </h2>\n" +
+                "    \n" +
+                "    \n" +
+                "\n" +
+                "    <p>\n" +
+                "        For&nbsp;<a id=\"ctl00_ContentBody_GalleryItems_LinkVisit\" href=\"https://www.geocaching.com/geocache/GCB29Z6\">11 randonnée avec vue</a></p>\n" +
+                "\n" +
+                "\n" +
+                "\n" +
+                "<table id=\"ctl00_ContentBody_GalleryItems_DataListGallery\" class=\"Table&#32;GalleryTable\" cellspacing=\"0\" style=\"border-collapse:collapse;\">\n" +
+                "\t\t<tr>\n" +
+                "\t\t\t<td>\n" +
+                "        <span class=\"date-stamp\">09.02.2025</span>\n" +
+                "        <a href='https://img.geocaching.com/cache/log/large/93cc7be4-0515-4364-9d9a-be9336f279c7.jpg' data-title='&lt;span class=&quot;LogImgTitle&quot;&gt;Bild&nbsp;&lt;/span&gt;&lt;span class=&quot;LogImgLink&quot;&gt;&lt;a href=&quot;https://www.geocaching.com/seek/log.aspx?LUID=73f7e6b6-11c3-4812-a1d5-fd0efd9e3c1a&IID=93cc7be4-0515-4364-9d9a-be9336f279c7&quot;>View Log&lt;/a&gt; &lt;a href=&quot;https://img.geocaching.com/cache/large/93cc7be4-0515-4364-9d9a-be9336f279c7.jpg&quot;>Print Picture&lt;/a&gt;&lt;/span&gt;' class=\"imageLink\" rel=\"gallery\">\n" +
+                "            <img src='https://img.geocaching.com/cache/log/thumb/93cc7be4-0515-4364-9d9a-be9336f279c7.jpg' alt='View Image' /></a>        \n" +
+                "            <span>Bild </span>\n" +
+                "    </td><td>\n" +
+                "        <span class=\"date-stamp\">15.01.2025</span>\n" +
+                "        <a href='https://img.geocaching.com/cache/log/large/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg' data-title='&lt;span class=&quot;LogImgTitle&quot;&gt;Image 2&nbsp;&lt;/span&gt;&lt;span class=&quot;LogImgLink&quot;&gt;&lt;a href=&quot;https://www.geocaching.com/seek/log.aspx?LUID=7f9ba457-022d-4269-a6f9-e15721fdf0c2&IID=3b8ffe04-f32e-4ec8-833e-f8ffd647187c&quot;>View Log&lt;/a&gt; &lt;a href=&quot;https://img.geocaching.com/cache/large/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg&quot;>Print Picture&lt;/a&gt;&lt;/span&gt;' class=\"imageLink\" rel=\"gallery\">\n" +
+                "            <img src='https://img.geocaching.com/cache/log/thumb/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg' alt='View Image' /></a>        \n" +
+                "            <span>Image 2 </span>\n" +
+                "    </td><td>\n" +
+                "        <span class=\"date-stamp\">13.01.2025</span>\n" +
+                "        <a href='https://img.geocaching.com/cache/large/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg' data-title='&lt;span class=&quot;LogImgTitle&quot;&gt;&nbsp;&lt;/span&gt;&lt;span class=&quot;LogImgLink&quot;&gt; &lt;a href=&quot;https://img.geocaching.com/cache/large/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg&quot;>Print Picture&lt;/a&gt;&lt;/span&gt;' class=\"imageLink\" rel=\"gallery\">\n" +
+                "            <img src='https://img.geocaching.com/cache/thumb/8879d1bc-8b10-4c18-9dc4-7b0ec9045fca.jpg' alt='View Image' /></a>        \n" +
+                "            <span> </span>\n" +
+                "    </td><td></td>\n" +
+                "\t\t</tr>\n" +
+                "\t</table>\n";
+        final List<Image> images = GCParser.parseGalleryImages(html, url -> true);
+        assertThat(images).hasSize(3);
+        assertThat(images.get(0).getUrl()).isEqualTo("https://img.geocaching.com/93cc7be4-0515-4364-9d9a-be9336f279c7.jpg");
+        assertThat(images.get(0).getTitle()).isEqualTo("Bild ");
+        assertThat(images.get(0).getDescription()).isEqualTo("Gallery: 09.02.2025");    }
+
     @MediumTest
     @Test
     public void testFullScaleImageUrl() {
diff --git a/main/src/main/java/cgeo/geocaching/connector/gc/GCConstants.java b/main/src/main/java/cgeo/geocaching/connector/gc/GCConstants.java
index 8b3ddda0b82..aa0b6e9c0b1 100644
--- a/main/src/main/java/cgeo/geocaching/connector/gc/GCConstants.java
+++ b/main/src/main/java/cgeo/geocaching/connector/gc/GCConstants.java
@@ -56,6 +56,10 @@ public final class GCConstants {
     static final Pattern PATTERN_FAVORITECOUNT = Pattern.compile("<span class=\"favorite-value\">\\D*([0-9]+?)\\D*</span>");
     static final Pattern PATTERN_COUNTLOGS = Pattern.compile("<span id=\"ctl00_ContentBody_lblFindCounts\"><ul(.+?)</ul></span>");
     static final Pattern PATTERN_WATCHLIST_COUNT = Pattern.compile("data-watchcount=\"(\\d+)\"");
+
+    //example gallery count:
+    //<li><a href="/seek/gallery.aspx?guid=3d6f0c14-7d72-43a2-a704-dc4a2b879c73">View gallery (1885)</a></li>
+    static final Pattern PATTERN_GALLERY_COUNT = Pattern.compile("<a href=\"/seek/gallery.aspx\\?guid=[^(]+?\\((\\d+)\\)</a>");
     // matches: the inner JSON Code (w/o {}) of "currentGeocache":{"id":123,"referenceCode":"GCxyz","name":"somename"}
     static final Pattern PATTERN_TB_CURRENT_GEOCACHE_JSON = Pattern.compile("\"currentGeocache\":\\{([^}]+)\\}");
 
@@ -70,8 +74,18 @@ public final class GCConstants {
      */
     static final Pattern PATTERN_ATTRIBUTESINSIDE = Pattern.compile("<img src=\"([^\"]+)\" alt=\"([^\"]+?)\"");
     private static final String IMAGE_FORMATS = "jpg|jpeg|png|gif|bmp|JPG|JPEG|PNG|GIF|BMP";
+    private static final String IMAGE_URL = "<a href=[\"'](https?://img(?:cdn)?\\.geocaching\\.com[^.]+\\.(?:" + IMAGE_FORMATS + "))[\"'][^>]+>(?:[^>]*</strong>[^>]+>)?";
     //Example HTML to match: <a href="https://img.geocaching.com/cache/large/1711f8a1-796a-405b-82ba-8685f2e9f024.jpg" class="owner-image" rel="owner_image_group" data-title="<strong>indy mit text netz Kopie</strong>">indy mit text netz Kopie</a></li>
-    static final Pattern PATTERN_SPOILER_IMAGE = Pattern.compile("<a href=\"(https?://img(?:cdn)?\\.geocaching\\.com[^.]+\\.(?:" + IMAGE_FORMATS + "))\"[^>]+>(?:[^>]+</strong>[^>]+>)?" + "([^<]*)</a>" + ".*?(?:description\"[^>]*>([^<]+)</span>)?</li>", Pattern.DOTALL);
+    static final Pattern PATTERN_SPOILER_IMAGE = Pattern.compile(IMAGE_URL + "([^<]*)</a>" + ".*?(?:description\"[^>]*>([^<]+)</span>)?</li>", Pattern.DOTALL);
+
+    //gallery image example:
+    //<td>
+    //        <span class="date-stamp">15.01.2025</span>
+    //        <a href='https://img.geocaching.com/cache/log/large/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg' data-title='&lt;span class=&quot;LogImgTitle&quot;&gt;Image 2&nbsp;&lt;/span&gt;&lt;span class=&quot;LogImgLink&quot;&gt;&lt;a href=&quot;https://www.geocaching.com/seek/log.aspx?LUID=7f9ba457-022d-4269-a6f9-e15721fdf0c2&IID=3b8ffe04-f32e-4ec8-833e-f8ffd647187c&quot;>View Log&lt;/a&gt; &lt;a href=&quot;https://img.geocaching.com/cache/large/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg&quot;>Print Picture&lt;/a&gt;&lt;/span&gt;' class="imageLink" rel="gallery">
+    //            <img src='https://img.geocaching.com/cache/log/thumb/3b8ffe04-f32e-4ec8-833e-f8ffd647187c.jpg' alt='View Image' /></a>
+    //            <span>Image 2 </span>
+    //    </td>
+    static final Pattern PATTERN_GALLERY_IMAGE = Pattern.compile("<span(?: [^>+]+)>([^>]+)</span>\\s*?" + IMAGE_URL + ".*?<span>([^<]*)</span>", Pattern.DOTALL);
     static final Pattern PATTERN_INVENTORY = Pattern.compile("ctl00_ContentBody_uxTravelBugList_uxInventoryLabel\">.*?WidgetBody(.*?)<div");
     static final Pattern PATTERN_INVENTORYINSIDE = Pattern.compile("[^<]*<li>[^<]*<a href=\"[a-z0-9\\-\\_\\.\\?\\/\\:\\@]*\\/(?:track|hide)\\/details\\.aspx\\?(guid|TB)=([0-9a-zA-Z\\-]+)[^\"]*\"[^>]*>[^<]*<img src=\"[^\"]+\"[^>]*>[^<]*<span>([^<]+)<\\/span>[^<]*<\\/a>[^<]*<\\/li>");
     static final Pattern PATTERN_WATCHLIST = Pattern.compile("data-cacheonwatchlist=\"True\"");
diff --git a/main/src/main/java/cgeo/geocaching/connector/gc/GCParser.java b/main/src/main/java/cgeo/geocaching/connector/gc/GCParser.java
index bf775d4b2eb..74202124ae3 100644
--- a/main/src/main/java/cgeo/geocaching/connector/gc/GCParser.java
+++ b/main/src/main/java/cgeo/geocaching/connector/gc/GCParser.java
@@ -32,6 +32,7 @@
 import cgeo.geocaching.utils.DisposableHandler;
 import cgeo.geocaching.utils.Formatter;
 import cgeo.geocaching.utils.JsonUtils;
+import cgeo.geocaching.utils.LocalizationUtils;
 import cgeo.geocaching.utils.Log;
 import cgeo.geocaching.utils.MatcherWrapper;
 import cgeo.geocaching.utils.SynchronizedDateFormat;
@@ -58,7 +59,11 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Set;
 import java.util.TimeZone;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.annotation.JsonProperty;
@@ -176,8 +181,9 @@ static SearchResult parseCache(final IConnector con, final String page, final Di
         return new SearchResult(cache);
     }
 
+    //method is only used in AndroidTests
     @NonNull
-    static SearchResult parseAndSaveCacheFromText(final IConnector con, @Nullable final String page, @Nullable final DisposableHandler handler) {
+    static SearchResult testParseAndSaveCacheFromText(final IConnector con, @Nullable final String page, @Nullable final DisposableHandler handler) {
         final ImmutablePair<StatusCode, Geocache> parsed = parseCacheFromText(page, handler);
         final SearchResult result = new SearchResult(con, parsed.left);
         if (parsed.left == StatusCode.NO_ERROR) {
@@ -404,6 +410,14 @@ private static ImmutablePair<StatusCode, Geocache> parseCacheFromText(@Nullable
         if (StringUtils.isNotEmpty(relatedWebPage)) {
             relatedWebPage = String.format("<br/><br/><a href=\"%s\"><b>%s</b></a>", relatedWebPage, relatedWebPage);
         }
+        String galleryImageLink = StringUtils.EMPTY;
+        final int galleryImages = getGalleryCount(page);
+        if (galleryImages > 0) {
+            galleryImageLink = String.format("<br/><br/><a href=\"%s\"><b>%s</b></a>",
+                "https://www.geocaching.com/seek/gallery.aspx?guid=" + cache.getGuid(),
+                CgeoApplication.getInstance().getString(R.string.link_gallery, galleryImages));
+        }
+        Log.d("Gallery image link: " + galleryImageLink);
         String gcChecker = StringUtils.EMPTY;
         if (page.contains(GCConstants.PATTERN_GC_CHECKER)) {
             gcChecker = "<!--" + CgeoApplication.getInstance().getString(R.string.link_gc_checker) + "-->";
@@ -461,14 +475,16 @@ private static ImmutablePair<StatusCode, Geocache> parseCacheFromText(@Nullable
         if (matcherBackgroundImage.find()) {
             final String url = matcherBackgroundImage.group(1);
             boolean present = false;
-            for (final Image image : cache.getSpoilers()) {
+            for (final Image image : cacheSpoilers) {
                 if (StringUtils.equals(image.getUrl(), url)) {
                     present = true;
                     break;
                 }
             }
             if (!present) {
-                cacheSpoilers.add(new Image.Builder().setUrl(url).setTitle(CgeoApplication.getInstance().getString(R.string.cache_image_background)).build());
+                cacheSpoilers.add(new Image.Builder().setUrl(url)
+                    .setTitle(LocalizationUtils.getString(R.string.image_listing_background))
+                    .setDescription(LocalizationUtils.getString(R.string.cache_image_background)).build());
             }
         }
         cache.setSpoilers(cacheSpoilers);
@@ -609,21 +625,20 @@ private static ImmutablePair<StatusCode, Geocache> parseCacheFromText(@Nullable
     public static List<Image> parseSpoiler(final String html) {
         final List<Image> cacheSpoilers = new ArrayList<>();
         final MatcherWrapper matcherSpoilersInside = new MatcherWrapper(GCConstants.PATTERN_SPOILER_IMAGE, html);
-
         while (matcherSpoilersInside.find()) {
             final String url = matcherSpoilersInside.group(1);
 
             String title = null;
             if (matcherSpoilersInside.group(2) != null) {
                 title = matcherSpoilersInside.group(2);
+            } else {
+                title = LocalizationUtils.getString(R.string.image_listing_spoiler);
             }
-            String description = null;
+            String description = LocalizationUtils.getString(R.string.image_listing_spoiler);
             if (matcherSpoilersInside.group(3) != null) {
-                description = matcherSpoilersInside.group(3);
-            }
-            if (title != null) {
-                cacheSpoilers.add(new Image.Builder().setUrl(url).setTitle(title).setDescription(description).build());
+                description += ": " + matcherSpoilersInside.group(3);
             }
+            cacheSpoilers.add(new Image.Builder().setUrl(url).setTitle(title).setDescription(description).build());
         }
         return cacheSpoilers;
     }
@@ -1011,7 +1026,16 @@ private static Single<Boolean> addToOrRemoveFromWatchlist(@NonNull final Geocach
      * @return Number of people watching geocache, -1 when error
      */
     static int getWatchListCount(final String page) {
-        final String sCount = TextUtils.getMatch(page, GCConstants.PATTERN_WATCHLIST_COUNT, true, 1, "notFound", false);
+        return getCount(page, GCConstants.PATTERN_WATCHLIST_COUNT, 1);
+    }
+
+    static int getGalleryCount(final String page) {
+        return getCount(page, GCConstants.PATTERN_GALLERY_COUNT, 1);
+    }
+
+    private static int getCount(final String page, final Pattern pattern, final int group) {
+
+        final String sCount = TextUtils.getMatch(page, pattern, true, group, "notFound", false);
         if ("notFound".equals(sCount)) {
             return -1;
         }
@@ -1023,6 +1047,8 @@ static int getWatchListCount(final String page) {
         }
     }
 
+
+
     /**
      * Removes the cache from the watch list
      *
@@ -1587,6 +1613,9 @@ private static void getExtraOnlineInfo(@NonNull final Geocache cache, final Stri
             });
         }
 
+        //add gallery images if wanted
+        addImagesFromGallery(cache, handler);
+
         if (Settings.isRatingWanted() && !DisposableHandler.isDisposed(handler)) {
             DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_gcvote);
             final GCVoteRating rating = GCVote.getRating(cache.getGuid(), cache.getGeocode());
@@ -1601,6 +1630,53 @@ private static void getExtraOnlineInfo(@NonNull final Geocache cache, final Stri
         mergedLogs.ignoreElement().blockingAwait();
     }
 
+    private static void addImagesFromGallery(@NonNull final Geocache cache, final DisposableHandler handler) {
+        if (StringUtils.isBlank(cache.getGuid())) {
+            return;
+        }
+        DisposableHandler.sendLoadProgressDetail(handler, R.string.cache_dialog_loading_details_status_spoilers);
+        //load page
+        //https://www.geocaching.com/seek/gallery.aspx?guid=0e670e6a-4b38-45c7-97b7-fa5da0f367a2
+        final String galleryFirstPage = GCLogin.getInstance().getRequestLogged("https://www.geocaching.com/seek/gallery.aspx", new Parameters("guid", cache.getGuid()));
+        if (galleryFirstPage == null) {
+            return;
+        }
+
+        //get existing Image URls
+        final Set<String> existingUrls = cache.getSpoilers().stream().map(Image::getUrl).collect(Collectors.toSet());
+        //collect new Images
+        final List<Image> newImages = parseGalleryImages(galleryFirstPage, url -> !existingUrls.contains(url));
+        newImages.addAll(0, cache.getSpoilers());
+        cache.setSpoilers(newImages);
+    }
+
+    public static List<Image> parseGalleryImages(final String html, final Predicate<String> take) {
+        final List<Image> images = new ArrayList<>();
+        final MatcherWrapper matcherImage = new MatcherWrapper(GCConstants.PATTERN_GALLERY_IMAGE, html);
+
+        while (matcherImage.find()) {
+            final String date = matcherImage.group(1);
+            final String url = matcherImage.group(2);
+            final String title = matcherImage.group(3);
+
+            String description = LocalizationUtils.getString(R.string.image_listing_gallery);
+            if (!StringUtils.isBlank(date)) {
+                description += ": " + date;
+            }
+
+            if (!take.test(url)) {
+                continue;
+            }
+
+            images.add(new Image.Builder().setCategory(Image.ImageCategory.LISTING)
+                .setUrl(url)
+                .setTitle(title)
+                .setDescription(description).build());
+
+        }
+        return images;
+    }
+
     @WorkerThread
     public static List<LogEntry> loadLogs(final String geocode, final Logs logs, final int take) {
         final String userToken = getUserToken(geocode);
diff --git a/main/src/main/java/cgeo/geocaching/models/Geocache.java b/main/src/main/java/cgeo/geocaching/models/Geocache.java
index cfb85c422ef..cf48e59db5e 100644
--- a/main/src/main/java/cgeo/geocaching/models/Geocache.java
+++ b/main/src/main/java/cgeo/geocaching/models/Geocache.java
@@ -2261,24 +2261,24 @@ private void guessEventTimeMinutes() {
 
     @NonNull
     public Collection<Image> getImages() {
-        final List<Image> result = new LinkedList<>(getSpoilers());
-        ImageUtils.addImagesFromText(result, getPersonalNote());
-        ImageUtils.addImagesFromHtml(result, geocode, getShortDescription(), getDescription());
+        final List<Image> images = new LinkedList<>();
+        //the order of adding imgaes will determine which duplicates will be removed. prio is to images further up
         for (final LogEntry log : getLogs()) {
-            result.addAll(log.logImages);
+            images.addAll(log.logImages);
         }
-        addLocalSpoilersTo(result);
+        images.addAll(ImageUtils.getImagesFromText(
+                (url, imgBuilder) -> imgBuilder.setCategory(Image.ImageCategory.NOTE),
+                getPersonalNote()));
+        images.addAll(ImageUtils.getImagesFromHtml(
+                (url, imgBuilder) -> imgBuilder.setCategory(Image.ImageCategory.LISTING),
+                getShortDescription(), getDescription()));
+        images.addAll(getSpoilers()); //for gc.com this includes gallery images, spoilers and background
+        addLocalSpoilersTo(images);
 
         // Deduplicate images and return them in requested size
-        final List<Image> uniqueImages = new LinkedList<>();
-        final List<String> uniqueUrls = new ArrayList<>();
-        for (final Image img : result) {
-            if (!uniqueUrls.contains(img.getUrl())) {
-                uniqueUrls.add(img.getUrl());
-                uniqueImages.add(img.buildUpon().setUrl(img.getUrl()).build());
-            }
-        }
-        return uniqueImages;
+        ImageUtils.deduplicateImageList(images);
+
+        return images;
     }
 
     @NonNull
diff --git a/main/src/main/java/cgeo/geocaching/models/Image.java b/main/src/main/java/cgeo/geocaching/models/Image.java
index 2b268830e7c..666ccc45ee2 100644
--- a/main/src/main/java/cgeo/geocaching/models/Image.java
+++ b/main/src/main/java/cgeo/geocaching/models/Image.java
@@ -30,9 +30,9 @@ public class Image implements Parcelable {
 
     public enum ImageCategory {
         UNCATEGORIZED(R.string.image_category_uncategorized),
+        OWN(R.string.image_category_own),
         LISTING(R.string.image_category_listing),
         LOG(R.string.image_category_log),
-        OWN(R.string.image_category_own),
         NOTE(R.string.cache_personal_note);
 
         @StringRes
diff --git a/main/src/main/java/cgeo/geocaching/models/Trackable.java b/main/src/main/java/cgeo/geocaching/models/Trackable.java
index 6b1d18515a8..c9dd8a3d873 100644
--- a/main/src/main/java/cgeo/geocaching/models/Trackable.java
+++ b/main/src/main/java/cgeo/geocaching/models/Trackable.java
@@ -419,10 +419,13 @@ public Collection<Image> getImages() {
         if (StringUtils.isNotBlank(image)) {
             images.add(new Image.Builder().setUrl(image).setTitle(StringUtils.defaultIfBlank(name, geocode)).setCategory(Image.ImageCategory.LISTING).build());
         }
-        ImageUtils.addImagesFromHtml(images, geocode, getDetails());
+        images.addAll(ImageUtils.getImagesFromHtml((url, builder) -> builder.setTitle(geocode).setCategory(Image.ImageCategory.LISTING), getDetails()));
         for (final LogEntry log : getLogs()) {
             images.addAll(log.logImages);
         }
+        // Deduplicate images and return them in requested size
+        ImageUtils.deduplicateImageList(images);
+
         return images;
     }
 
diff --git a/main/src/main/java/cgeo/geocaching/ui/ImageGalleryView.java b/main/src/main/java/cgeo/geocaching/ui/ImageGalleryView.java
index ff3ac0a1da0..63241884fad 100644
--- a/main/src/main/java/cgeo/geocaching/ui/ImageGalleryView.java
+++ b/main/src/main/java/cgeo/geocaching/ui/ImageGalleryView.java
@@ -331,7 +331,7 @@ private void changeImageCount(final int by) {
         }
     }
 
-    private void createCategory(final String category, final boolean atStart) {
+    public void createCategory(final String category, final boolean atStart) {
 
         if (!categorizedImageListHelper.containsCategory(category)) {
             categorizedImageListHelper.addOrMoveCategory(category, atStart);
diff --git a/main/src/main/java/cgeo/geocaching/utils/ImageUtils.java b/main/src/main/java/cgeo/geocaching/utils/ImageUtils.java
index fba85e23c96..60136617cda 100644
--- a/main/src/main/java/cgeo/geocaching/utils/ImageUtils.java
+++ b/main/src/main/java/cgeo/geocaching/utils/ImageUtils.java
@@ -47,6 +47,7 @@
 import androidx.annotation.Nullable;
 import androidx.core.content.FileProvider;
 import androidx.core.graphics.BitmapCompat;
+import androidx.core.util.Predicate;
 import androidx.core.util.Supplier;
 import androidx.exifinterface.media.ExifInterface;
 
@@ -61,14 +62,17 @@
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashSet;
-import java.util.LinkedHashSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import com.caverock.androidsvg.SVG;
 import com.igreenwood.loupe.Loupe;
@@ -415,19 +419,21 @@ public static String imageUrlForSpoilerCompare(@Nullable final String url) {
         return StringUtils.defaultString(Uri.parse(url).getLastPathSegment());
     }
 
+    public static Predicate<String> getImageContainsPredicate(final Collection<Image> images) {
+        final Set<String> urls = images == null ? Collections.emptySet() :
+            images.stream().map(img -> img == null ? "" : imageUrlForSpoilerCompare(img.getUrl())).collect(Collectors.toSet());
+        return url -> urls.contains(imageUrlForSpoilerCompare(url));
+    }
+
     /**
      * Add images present in plain text to the existing collection.
      *
-     * @param images   a collection of images
      * @param texts    plain texts to be searched for image URLs
      */
-    public static void addImagesFromText(final Collection<Image> images, final String... texts) {
-        final Set<String> urls = new LinkedHashSet<>();
-        for (final Image image : images) {
-            urls.add(imageUrlForSpoilerCompare(image.getUrl()));
-        }
+    public static List<Image> getImagesFromText(final BiConsumer<String, Image.Builder> modifier, final String ... texts) {
+        final List<Image> result = new ArrayList<>();
         if (null == texts) {
-            return;
+            return result;
         }
         for (final String text : texts) {
             //skip null or empty texts
@@ -439,40 +445,52 @@ public static void addImagesFromText(final Collection<Image> images, final Strin
             while (m.find()) {
                 if (m.groupCount() >= 1) {
                     final String imgUrl = m.group(1);
-                    if (!urls.contains(imgUrl)) {
-                        urls.add(imgUrl);
-                        images.add(new Image.Builder()
-                                .setUrl(imgUrl, "https")
-                                .setCategory(Image.ImageCategory.NOTE)
-                                .build());
+                    if (StringUtils.isBlank(imgUrl)) {
+                        continue;
+                    }
+                    final Image.Builder builder = new Image.Builder().setUrl(imgUrl, "https");
+                    if (modifier != null) {
+                        modifier.accept(imgUrl, builder);
                     }
+                    result.add(builder.build());
                 }
             }
+
         }
+        return result;
     }
 
     /**
      * Add images present in the HTML description to the existing collection.
      *
-     * @param images   a collection of images
-     * @param geocode  the common title for images in the description
      * @param htmlText the HTML description to be parsed, can be repeated
      */
-    public static void addImagesFromHtml(final Collection<Image> images, final String geocode, final String... htmlText) {
-        final Set<String> urls = new LinkedHashSet<>();
-        for (final Image image : images) {
-            urls.add(imageUrlForSpoilerCompare(image.getUrl()));
-        }
+    public static List<Image> getImagesFromHtml(final BiConsumer<String, Image.Builder> modifier, final String... htmlText) {
+        final List<Image> result = new ArrayList<>();
         forEachImageUrlInHtml(source -> {
-                if (!urls.contains(imageUrlForSpoilerCompare(source)) && canBeOpenedExternally(source)) {
-                    images.add(new Image.Builder()
-                            .setUrl(source, "https")
-                            .setTitle(StringUtils.defaultString(geocode))
-                            .setCategory(Image.ImageCategory.LISTING)
-                            .build());
-                    urls.add(source);
+                if (canBeOpenedExternally(source)) {
+                    final Image.Builder builder = new Image.Builder()
+                            .setUrl(source, "https");
+                    if (modifier != null) {
+                        modifier.accept(source, builder);
+                    }
+                    result.add(builder.build());
                 }
             }, htmlText);
+        return result;
+    }
+
+    public static void deduplicateImageList(final Collection<Image> list) {
+        final Set<String> urls = new HashSet<>();
+        final Iterator<Image> it = list.iterator();
+        while (it.hasNext()) {
+            final Image img = it.next();
+            final String url = ImageUtils.imageUrlForSpoilerCompare(img.getUrl());
+            if (urls.contains(url)) {
+                it.remove();
+            }
+            urls.add(url);
+        }
     }
 
     public static void forEachImageUrlInHtml(@Nullable final androidx.core.util.Consumer<String> callback, @Nullable final String ... htmlText) {
@@ -850,6 +868,11 @@ public static void initializeImageGallery(final ImageGalleryView imageGallery, f
             imageGallery.setEditableCategory(Image.ImageCategory.OWN.getI18n(), new ImageFolderCategoryHandler(geocode));
         }
         if (images != null) {
+            //pre-create all contained categories to be in control of their sort order
+            images.stream().map(img -> img.category)
+                    .distinct().sorted().forEach(cat -> imageGallery.createCategory(
+                            cat == Image.ImageCategory.UNCATEGORIZED ? null : cat.getI18n(), false));
+            //add the images
             imageGallery.addImages(images);
         }
     }
diff --git a/main/src/main/res/values/strings.xml b/main/src/main/res/values/strings.xml
index 8b8714e587c..e385d85ab6a 100644
--- a/main/src/main/res/values/strings.xml
+++ b/main/src/main/res/values/strings.xml
@@ -1759,6 +1759,10 @@
     <string name="image_category_listing">Listing</string>
     <string name="image_category_log">Log</string>
 
+    <string name="image_listing_spoiler">Spoiler</string>
+    <string name="image_listing_background">Background</string>
+    <string name="image_listing_gallery">Gallery</string>
+
     <string name="context_map_add_to_route">Append to route</string>
     <string name="context_map_add_to_route_start">Prepend to route</string>
     <string name="context_map_remove_from_route_start">Remove from start</string>
@@ -2622,6 +2626,7 @@
         <item quantity="other">%d waypoints extracted</item>
     </plurals>
     <string name="link_gc_checker">geocaching.com coordinate checker available via browser</string>
+    <string name="link_gallery">Images available in gallery (%d)</string>
     <string name="geochecker_available">Geochecker available via browser</string>
 
     <!-- calendar adder -->