Skip to content

Commit 4f20773

Browse files
committed
subsonic: use paged search3 api as fullsync source
1 parent 37f7699 commit 4f20773

6 files changed

Lines changed: 513 additions & 21 deletions

File tree

src/main/java/org/subsound/integration/ServerClient.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ public interface ServerClient {
3939
boolean testConnection();
4040
ServerInfo getServerInfo();
4141
SearchResult search(String query);
42+
/**
43+
* search3 with explicit per-type paging. OpenSubsonic servers must support an empty
44+
* query and return the whole library, which is how {@code SyncService} walks the
45+
* entire collection in a few paged requests instead of per-artist/per-album calls.
46+
*/
47+
SearchResult search3(String query, SearchPage page);
4248
void scrobble(ScrobbleRequest req);
4349
void nowPlaying(ReportNowPlaying req);
4450
URI getStreamUri(String songId);
@@ -102,6 +108,24 @@ record SearchResult(
102108
List<ArtistAlbumInfo> albums,
103109
List<SongInfo> songs
104110
) {}
111+
record SearchPage(
112+
int artistCount,
113+
int artistOffset,
114+
int albumCount,
115+
int albumOffset,
116+
int songCount,
117+
int songOffset
118+
) {
119+
public static SearchPage artists(int count, int offset) {
120+
return new SearchPage(count, offset, 0, 0, 0, 0);
121+
}
122+
public static SearchPage albums(int count, int offset) {
123+
return new SearchPage(0, 0, count, offset, 0, 0);
124+
}
125+
public static SearchPage songs(int count, int offset) {
126+
return new SearchPage(0, 0, 0, 0, count, offset);
127+
}
128+
}
105129

106130
record ServerInfo(
107131
Optional<String> serverType,

src/main/java/org/subsound/integration/servers/subsonic/SubsonicClientV2.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ private SongInfo toSongInfo(ChildJson song) {
678678
ofNullable(song.bitRate()),
679679
song.size() != null ? song.size() : 0,
680680
ofNullable(song.year()),
681-
ofNullable(song.genre()).orElse(""),
681+
genres.stream().findFirst().orElse(null),
682682
song.playCount() != null ? song.playCount() : 0L,
683683
ofNullable(song.userRating()),
684684
new ArtistId(song.artistId(), song.artist()),
@@ -1028,7 +1028,24 @@ private List<ArtistAlbumInfo> loadAlbumListByYear() {
10281028

10291029
@Override
10301030
public SearchResult search(String query) {
1031-
var res = fetchAndCheck("/rest/search3", Map.of("query", query), SearchResult3ResponseJson.class);
1031+
return doSearch3(Map.of("query", query));
1032+
}
1033+
1034+
@Override
1035+
public SearchResult search3(String query, SearchPage page) {
1036+
return doSearch3(Map.of(
1037+
"query", query,
1038+
"artistCount", String.valueOf(page.artistCount()),
1039+
"artistOffset", String.valueOf(page.artistOffset()),
1040+
"albumCount", String.valueOf(page.albumCount()),
1041+
"albumOffset", String.valueOf(page.albumOffset()),
1042+
"songCount", String.valueOf(page.songCount()),
1043+
"songOffset", String.valueOf(page.songOffset())
1044+
));
1045+
}
1046+
1047+
private SearchResult doSearch3(Map<String, String> params) {
1048+
var res = fetchAndCheck("/rest/search3", params, SearchResult3ResponseJson.class);
10321049
var sr = res.subsonicResponse.searchResult3;
10331050
var artists = sr != null && sr.artist() != null
10341051
? sr.artist().stream().map(this::toArtistEntry).toList()

src/main/java/org/subsound/persistence/CachingClient.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,12 @@ public SearchResult search(String query) {
498498
}
499499
}
500500

501+
@Override
502+
public SearchResult search3(String query, SearchPage page) {
503+
// Only used by SyncService which requires the server to be reachable; no offline fallback.
504+
return delegate.search3(query, page);
505+
}
506+
501507
private SearchResult searchDatabase(String query) {
502508
var artists = dbService.searchArtists(query, SEARCH_ARTIST_LIMIT);
503509
var albums = dbService.searchAlbums(query, SEARCH_ALBUM_LIMIT);

src/main/java/org/subsound/persistence/database/SyncService.java

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import org.subsound.integration.ServerClient.ArtistEntry;
99
import org.subsound.integration.ServerClient.ArtistInfo;
1010
import org.subsound.integration.ServerClient.CoverArt;
11+
import org.subsound.integration.ServerClient.SearchPage;
1112
import org.subsound.integration.ServerClient.SongInfo;
1213
import org.subsound.persistence.SongCache;
1314
import org.subsound.persistence.SongCacheChecker;
@@ -16,7 +17,10 @@
1617

1718
import java.time.Duration;
1819
import java.util.ArrayList;
20+
import java.util.HashMap;
21+
import java.util.LinkedHashMap;
1922
import java.util.List;
23+
import java.util.Map;
2024
import java.util.Optional;
2125
import java.util.UUID;
2226
import java.util.concurrent.CompletableFuture;
@@ -27,6 +31,7 @@
2731

2832
public class SyncService {
2933
private static final Logger logger = LoggerFactory.getLogger(SyncService.class);
34+
private static final int SEARCH3_PAGE_SIZE = 500;
3035

3136
private final ServerClient serverClient;
3237
private final DatabaseServerService databaseServerService;
@@ -54,6 +59,9 @@ public SyncStats syncAll() {
5459
var artists = serverClient.getArtists().list();
5560
logger.info("Fetched {} artists from server", artists.size());
5661

62+
// Probe search3 empty-query support before truncating anything
63+
boolean useSearch3 = probeSearch3EmptyQuery(artists.size());
64+
5765
// Step 2: Truncate existing data (server confirmed online)
5866
logger.info("Truncating existing data for server: {}", serverId);
5967
databaseServerService.deleteAllPlaylistSongs();
@@ -62,23 +70,11 @@ public SyncStats syncAll() {
6270
databaseServerService.deleteAllAlbums();
6371
databaseServerService.deleteAllArtists();
6472

65-
// Step 3: Sync all data from server in parallel
73+
// Step 3: Sync all data from server
6674
collectedCoverArts.clear();
67-
List<Future<SyncStats>> futures = new ArrayList<>();
68-
for (ArtistEntry artistEntry : artists) {
69-
futures.add(executor.submit(() -> syncArtist(artistEntry.id())));
70-
}
71-
// Wait for all and aggregate stats
72-
var stats = new SyncStats(0, 0, 0, 0);
73-
for (Future<SyncStats> future : futures) {
74-
var s = future.get();
75-
stats = new SyncStats(
76-
stats.artists + s.artists,
77-
stats.albums + s.albums,
78-
stats.songs + s.songs,
79-
stats.playlists
80-
);
81-
}
75+
SyncStats stats = useSearch3
76+
? syncViaSearch3()
77+
: syncViaArtistWalk(artists);
8278
int playlistCount = syncPlaylists();
8379
stats = new SyncStats(stats.artists, stats.albums, stats.songs, playlistCount);
8480

@@ -146,6 +142,153 @@ public SyncStats syncAll() {
146142
}
147143
}
148144

145+
/**
146+
* OpenSubsonic servers must support search3 with an empty query returning the whole
147+
* library. Probe with a single-song page so we can fall back to the per-artist walk
148+
* on servers that reject the empty query (Subsonic error 10) or silently return nothing.
149+
*/
150+
private boolean probeSearch3EmptyQuery(int artistCount) {
151+
try {
152+
var probe = serverClient.search3("", SearchPage.songs(1, 0));
153+
if (probe.songs().isEmpty() && artistCount > 0) {
154+
logger.info("search3 empty-query probe returned no songs; using artist walk");
155+
return false;
156+
}
157+
logger.info("search3 empty-query supported; using paged search3 sync");
158+
return true;
159+
} catch (Exception e) {
160+
logger.info("search3 empty-query not supported ({}); using artist walk", e.getMessage());
161+
return false;
162+
}
163+
}
164+
165+
private SyncStats syncViaArtistWalk(List<ArtistEntry> artists) throws Exception {
166+
List<Future<SyncStats>> futures = new ArrayList<>();
167+
for (ArtistEntry artistEntry : artists) {
168+
futures.add(executor.submit(() -> syncArtist(artistEntry.id())));
169+
}
170+
// Wait for all and aggregate stats
171+
var stats = new SyncStats(0, 0, 0, 0);
172+
for (Future<SyncStats> future : futures) {
173+
var s = future.get();
174+
stats = new SyncStats(
175+
stats.artists + s.artists,
176+
stats.albums + s.albums,
177+
stats.songs + s.songs,
178+
stats.playlists
179+
);
180+
}
181+
return stats;
182+
}
183+
184+
private SyncStats syncViaSearch3() throws Exception {
185+
var artistsTask = executor.submit(this::syncArtistsViaSearch3);
186+
var albumsTask = executor.submit(this::fetchAlbumsViaSearch3);
187+
var songsByAlbum = fetchSongsViaSearch3();
188+
int artistCount = artistsTask.get();
189+
Map<String, Album> albums = albumsTask.get();
190+
191+
int songCount = 0;
192+
for (Album album : albums.values()) {
193+
List<DBSong> songs = songsByAlbum.remove(album.id());
194+
if (songs == null) {
195+
songs = List.of();
196+
}
197+
databaseServerService.syncAlbumBatch(album, songs);
198+
songCount += songs.size();
199+
}
200+
if (!songsByAlbum.isEmpty()) {
201+
int orphans = songsByAlbum.values().stream().mapToInt(List::size).sum();
202+
logger.warn("search3 sync: skipped {} songs across {} albumIds missing from album list", orphans, songsByAlbum.size());
203+
}
204+
return new SyncStats(artistCount, albums.size(), songCount, 0);
205+
}
206+
207+
private int syncArtistsViaSearch3() {
208+
int offset = 0;
209+
int count = 0;
210+
int skipped = 0;
211+
while (true) {
212+
var page = serverClient.search3("", SearchPage.artists(SEARCH3_PAGE_SIZE, offset)).artists();
213+
for (ArtistEntry entry : page) {
214+
// search3 returns every artist incl. participant-only ones (composers,
215+
// performers); getArtists only lists album artists. Skip album-less
216+
// artists so the offline artists list matches the online one.
217+
if (entry.albumCount() <= 0) {
218+
skipped++;
219+
continue;
220+
}
221+
Artist artist = new Artist(
222+
entry.id(),
223+
serverId,
224+
entry.name(),
225+
entry.albumCount(),
226+
entry.starredAt(),
227+
entry.coverArt().map(CoverArt::coverArtId),
228+
Optional.empty()
229+
);
230+
databaseServerService.insert(artist);
231+
entry.coverArt().ifPresent(ca -> collectedCoverArts.put(ca.coverArtId(), ca));
232+
count++;
233+
}
234+
if (page.size() < SEARCH3_PAGE_SIZE) {
235+
if (skipped > 0) {
236+
logger.info("search3 sync: skipped {} artists without albums", skipped);
237+
}
238+
return count;
239+
}
240+
offset += SEARCH3_PAGE_SIZE;
241+
}
242+
}
243+
244+
private Map<String, Album> fetchAlbumsViaSearch3() {
245+
var addedAt = java.time.Instant.now();
246+
var albums = new LinkedHashMap<String, Album>();
247+
int offset = 0;
248+
while (true) {
249+
var page = serverClient.search3("", SearchPage.albums(SEARCH3_PAGE_SIZE, offset)).albums();
250+
for (ArtistAlbumInfo info : page) {
251+
albums.put(info.id(), new Album(
252+
info.id(),
253+
serverId,
254+
info.artistId(),
255+
info.name(),
256+
info.songCount(),
257+
info.year(),
258+
info.artistName(),
259+
info.duration(),
260+
info.starredAt(),
261+
info.coverArt().map(CoverArt::coverArtId),
262+
addedAt,
263+
info.genre()
264+
));
265+
info.coverArt().ifPresent(ca -> collectedCoverArts.put(ca.coverArtId(), ca));
266+
}
267+
if (page.size() < SEARCH3_PAGE_SIZE) {
268+
return albums;
269+
}
270+
offset += SEARCH3_PAGE_SIZE;
271+
}
272+
}
273+
274+
private Map<String, List<DBSong>> fetchSongsViaSearch3() {
275+
var songsByAlbum = new HashMap<String, List<DBSong>>();
276+
int offset = 0;
277+
while (true) {
278+
var page = serverClient.search3("", SearchPage.songs(SEARCH3_PAGE_SIZE, offset)).songs();
279+
for (SongInfo songInfo : page) {
280+
songsByAlbum
281+
.computeIfAbsent(songInfo.albumId(), _ -> new ArrayList<>())
282+
.add(DBSong.from(songInfo, serverId));
283+
songInfo.coverArt().ifPresent(ca -> collectedCoverArts.put(ca.coverArtId(), ca));
284+
}
285+
if (page.size() < SEARCH3_PAGE_SIZE) {
286+
return songsByAlbum;
287+
}
288+
offset += SEARCH3_PAGE_SIZE;
289+
}
290+
}
291+
149292
private SyncStats syncArtist(String artistId) {
150293
ArtistInfo artistInfo = serverClient.getArtistWithAlbums(artistId);
151294
Artist artist = new Artist(
@@ -171,9 +314,6 @@ private SyncStats syncArtist(String artistId) {
171314

172315
private int syncAlbum(String albumId, java.util.Optional<String> genre) {
173316
AlbumInfo albumInfo = serverClient.getAlbumInfo(albumId);
174-
if (albumId.contains("al-5CcViuxlnidLI1TGKZcjjN")) {
175-
System.out.println("Album: %s %s %s".formatted(albumInfo.id(), albumInfo.name(), albumInfo.artistName()));
176-
}
177317
Album album = new Album(
178318
albumInfo.id(),
179319
serverId,

0 commit comments

Comments
 (0)