88import org .subsound .integration .ServerClient .ArtistEntry ;
99import org .subsound .integration .ServerClient .ArtistInfo ;
1010import org .subsound .integration .ServerClient .CoverArt ;
11+ import org .subsound .integration .ServerClient .SearchPage ;
1112import org .subsound .integration .ServerClient .SongInfo ;
1213import org .subsound .persistence .SongCache ;
1314import org .subsound .persistence .SongCacheChecker ;
1617
1718import java .time .Duration ;
1819import java .util .ArrayList ;
20+ import java .util .HashMap ;
21+ import java .util .LinkedHashMap ;
1922import java .util .List ;
23+ import java .util .Map ;
2024import java .util .Optional ;
2125import java .util .UUID ;
2226import java .util .concurrent .CompletableFuture ;
2731
2832public 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