Tools for the Atmosphere tools.slices.network
quickslice atproto html

feat(teal-scrobble): improve track search with smart album matching

- Show all album versions for each track in dropdown
- Sort results by quality: official albums first, compilations last
- Prefer original releases (earliest date) over reissues
- Dedupe to show one entry per album (keeping best version)
- Remove separate album search field (track dropdown now includes album)
- Make selected artist/track tags full width
- Increase search limit to 50 recordings

+76 -134
+76 -134
teal-scrobble.html
··· 228 229 /* Selected Tag */ 230 .selected-tag { 231 - display: inline-flex; 232 align-items: center; 233 gap: 0.5rem; 234 background: var(--bg-hover); 235 border: 1px solid var(--accent); 236 border-radius: 0.375rem; 237 - padding: 0.5rem 0.75rem; 238 color: var(--text-primary); 239 } 240 241 .selected-tag button { ··· 579 if (query.length < 2) return []; 580 581 const fullQuery = `${query} AND arid:${artistMbid}`; 582 - const url = `${MB_API}/recording?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=20`; 583 const res = await fetch(url, { headers: MB_HEADERS }); 584 585 if (!res.ok) throw new Error("MusicBrainz search failed"); 586 587 const data = await res.json(); 588 return data.recordings || []; 589 - } 590 - 591 - async function searchReleases(query, artistMbid) { 592 - if (query.length < 2) return []; 593 - 594 - const fullQuery = `${query} AND arid:${artistMbid}`; 595 - const url = `${MB_API}/release?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=10`; 596 - const res = await fetch(url, { headers: MB_HEADERS }); 597 - 598 - if (!res.ok) throw new Error("MusicBrainz search failed"); 599 - 600 - const data = await res.json(); 601 - return data.releases || []; 602 } 603 604 function debounce(fn, ms) { ··· 804 <div id="track-field"></div> 805 </div> 806 807 - <div class="form-row"> 808 - <div class="form-group"> 809 - <label>Album <span style="font-weight: normal; color: var(--text-secondary);">(optional)</span></label> 810 - <div id="album-field"></div> 811 - </div> 812 - <div class="form-group" style="flex: 0 0 80px;"> 813 - <label>Duration</label> 814 - <input type="text" id="duration-display" class="read-only" readonly placeholder="--:--" /> 815 - </div> 816 </div> 817 818 <div class="form-group"> ··· 841 842 renderArtistField(); 843 renderTrackField(); 844 - renderAlbumField(); 845 } 846 847 function renderArtistField() { ··· 874 const container = document.getElementById("track-field"); 875 876 if (state.selectedRecording) { 877 container.innerHTML = ` 878 <div class="selected-tag"> 879 - <span>${esc(state.selectedRecording.title)}</span> 880 <button onclick="clearRecording()">&times;</button> 881 </div> 882 `; ··· 898 } 899 } 900 901 - function renderAlbumField() { 902 - const container = document.getElementById("album-field"); 903 - const disabled = !state.selectedArtist; 904 - const albumName = state.selectedRecording?.releaseName || ""; 905 - 906 - container.innerHTML = ` 907 - <div class="autocomplete-wrapper"> 908 - <input 909 - type="text" 910 - id="album-input" 911 - placeholder="${disabled ? "Select an artist first" : "Search or leave blank..."}" 912 - value="${esc(albumName)}" 913 - ${disabled ? "disabled" : ""} 914 - oninput="handleAlbumInput(this.value)" 915 - onfocus="handleAlbumInput(this.value)" 916 - /> 917 - <div id="album-dropdown" class="autocomplete-dropdown hidden"></div> 918 - </div> 919 - `; 920 - } 921 - 922 // ============================================================================= 923 // ARTIST SEARCH HANDLERS 924 // ============================================================================= ··· 977 978 renderArtistField(); 979 renderTrackField(); 980 - renderAlbumField(); 981 updateSubmitButton(); 982 } 983 ··· 988 989 renderArtistField(); 990 renderTrackField(); 991 - renderAlbumField(); 992 updateSubmitButton(); 993 } 994 ··· 1020 return; 1021 } 1022 1023 - dropdown.innerHTML = recordings 1024 - .map((r, i) => { 1025 - const release = r.releases?.[0]; 1026 const duration = r.length ? formatDuration(Math.floor(r.length / 1000)) : ""; 1027 const album = release?.title || ""; 1028 const artUrl = release?.id ··· 1030 : ""; 1031 1032 return ` 1033 - <div class="autocomplete-item" onclick="selectRecording(${i})" data-index="${i}"> 1034 <div class="autocomplete-item-art"> 1035 ${artUrl ? `<img src="${artUrl}" alt="" onerror="this.style.display='none'">` : ""} 1036 </div> 1037 <div class="autocomplete-item-info"> 1038 - <div class="autocomplete-item-title">${esc(r.title)} ${duration ? `<span style="color: var(--text-secondary); font-weight: normal;">${duration}</span>` : ""}</div> 1039 - ${album ? `<div class="autocomplete-item-subtitle">${esc(album)}</div>` : ""} 1040 </div> 1041 </div> 1042 `; 1043 }) 1044 .join(""); 1045 1046 - dropdown.dataset.recordings = JSON.stringify(recordings); 1047 } catch (error) { 1048 dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`; 1049 } 1050 }, 300); 1051 1052 - function selectRecording(index) { 1053 const dropdown = document.getElementById("track-dropdown"); 1054 - const recordings = JSON.parse(dropdown.dataset.recordings || "[]"); 1055 - const recording = recordings[index]; 1056 1057 - if (!recording) return; 1058 1059 - const release = recording.releases?.[0]; 1060 const durationSecs = recording.length ? Math.floor(recording.length / 1000) : null; 1061 1062 state.selectedRecording = { ··· 1076 : ""; 1077 1078 renderTrackField(); 1079 - renderAlbumField(); 1080 updateSubmitButton(); 1081 } 1082 ··· 1085 document.getElementById("duration-display").value = ""; 1086 1087 renderTrackField(); 1088 - renderAlbumField(); 1089 updateSubmitButton(); 1090 } 1091 ··· 1094 const m = Math.floor(secs / 60); 1095 const s = secs % 60; 1096 return `${m}:${s.toString().padStart(2, "0")}`; 1097 - } 1098 - 1099 - // ============================================================================= 1100 - // ALBUM SEARCH HANDLERS 1101 - // ============================================================================= 1102 - 1103 - const handleAlbumInput = debounce(async (query) => { 1104 - const dropdown = document.getElementById("album-dropdown"); 1105 - 1106 - // Update the release name in state as user types 1107 - if (state.selectedRecording) { 1108 - state.selectedRecording.releaseName = query || null; 1109 - state.selectedRecording.releaseMbid = null; 1110 - } 1111 - 1112 - if (!state.selectedArtist || query.length < 2) { 1113 - dropdown.classList.add("hidden"); 1114 - return; 1115 - } 1116 - 1117 - dropdown.innerHTML = `<div class="autocomplete-status">Searching...</div>`; 1118 - dropdown.classList.remove("hidden"); 1119 - 1120 - try { 1121 - const releases = await searchReleases(query, state.selectedArtist.mbid); 1122 - 1123 - if (releases.length === 0) { 1124 - dropdown.innerHTML = `<div class="autocomplete-status">No albums found</div>`; 1125 - return; 1126 - } 1127 - 1128 - dropdown.innerHTML = releases 1129 - .map((r, i) => { 1130 - const date = r.date ? r.date.substring(0, 4) : ""; 1131 - const artUrl = r.id ? `https://coverartarchive.org/release/${r.id}/front-250` : ""; 1132 - 1133 - return ` 1134 - <div class="autocomplete-item" onclick="selectRelease(${i})" data-index="${i}"> 1135 - <div class="autocomplete-item-art"> 1136 - ${artUrl ? `<img src="${artUrl}" alt="" onerror="this.style.display='none'">` : ""} 1137 - </div> 1138 - <div class="autocomplete-item-info"> 1139 - <div class="autocomplete-item-title">${esc(r.title)}</div> 1140 - ${date ? `<div class="autocomplete-item-subtitle">${date}</div>` : ""} 1141 - </div> 1142 - </div> 1143 - `; 1144 - }) 1145 - .join(""); 1146 - 1147 - dropdown.dataset.releases = JSON.stringify(releases); 1148 - } catch (error) { 1149 - dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`; 1150 - } 1151 - }, 300); 1152 - 1153 - function selectRelease(index) { 1154 - const dropdown = document.getElementById("album-dropdown"); 1155 - const releases = JSON.parse(dropdown.dataset.releases || "[]"); 1156 - const release = releases[index]; 1157 - 1158 - if (!release) return; 1159 - 1160 - if (state.selectedRecording) { 1161 - state.selectedRecording.releaseName = release.title; 1162 - state.selectedRecording.releaseMbid = release.id; 1163 - } 1164 - 1165 - dropdown.classList.add("hidden"); 1166 - document.getElementById("album-input").value = release.title; 1167 } 1168 1169 // =============================================================================
··· 228 229 /* Selected Tag */ 230 .selected-tag { 231 + display: flex; 232 align-items: center; 233 + justify-content: space-between; 234 gap: 0.5rem; 235 background: var(--bg-hover); 236 border: 1px solid var(--accent); 237 border-radius: 0.375rem; 238 + padding: 0.75rem; 239 color: var(--text-primary); 240 + width: 100%; 241 } 242 243 .selected-tag button { ··· 581 if (query.length < 2) return []; 582 583 const fullQuery = `${query} AND arid:${artistMbid}`; 584 + const url = `${MB_API}/recording?query=${encodeURIComponent(fullQuery)}&fmt=json&limit=50`; 585 const res = await fetch(url, { headers: MB_HEADERS }); 586 587 if (!res.ok) throw new Error("MusicBrainz search failed"); 588 589 const data = await res.json(); 590 return data.recordings || []; 591 } 592 593 function debounce(fn, ms) { ··· 793 <div id="track-field"></div> 794 </div> 795 796 + <div class="form-group" style="max-width: 100px;"> 797 + <label>Duration</label> 798 + <input type="text" id="duration-display" class="read-only" readonly placeholder="--:--" /> 799 </div> 800 801 <div class="form-group"> ··· 824 825 renderArtistField(); 826 renderTrackField(); 827 } 828 829 function renderArtistField() { ··· 856 const container = document.getElementById("track-field"); 857 858 if (state.selectedRecording) { 859 + const albumText = state.selectedRecording.releaseName 860 + ? ` · ${esc(state.selectedRecording.releaseName)}` 861 + : ""; 862 container.innerHTML = ` 863 <div class="selected-tag"> 864 + <span>${esc(state.selectedRecording.title)}${albumText}</span> 865 <button onclick="clearRecording()">&times;</button> 866 </div> 867 `; ··· 883 } 884 } 885 886 // ============================================================================= 887 // ARTIST SEARCH HANDLERS 888 // ============================================================================= ··· 941 942 renderArtistField(); 943 renderTrackField(); 944 updateSubmitButton(); 945 } 946 ··· 951 952 renderArtistField(); 953 renderTrackField(); 954 updateSubmitButton(); 955 } 956 ··· 982 return; 983 } 984 985 + // Flatten recordings into recording+release pairs 986 + let items = []; 987 + recordings.forEach((r) => { 988 + const releases = r.releases || []; 989 + if (releases.length === 0) { 990 + items.push({ recording: r, release: null }); 991 + } else { 992 + releases.forEach((release) => { 993 + items.push({ recording: r, release }); 994 + }); 995 + } 996 + }); 997 + 998 + // Sort: prefer official albums without secondary types, then by date 999 + items.sort((a, b) => { 1000 + const aRelease = a.release || {}; 1001 + const bRelease = b.release || {}; 1002 + const aGroup = aRelease["release-group"] || {}; 1003 + const bGroup = bRelease["release-group"] || {}; 1004 + 1005 + // Prefer official status 1006 + const aOfficial = aRelease.status === "Official" ? 0 : 1; 1007 + const bOfficial = bRelease.status === "Official" ? 0 : 1; 1008 + if (aOfficial !== bOfficial) return aOfficial - bOfficial; 1009 + 1010 + // Prefer albums without secondary types (not compilations) 1011 + const aIsCompilation = (aGroup["secondary-types"] || []).length > 0 ? 1 : 0; 1012 + const bIsCompilation = (bGroup["secondary-types"] || []).length > 0 ? 1 : 0; 1013 + if (aIsCompilation !== bIsCompilation) return aIsCompilation - bIsCompilation; 1014 + 1015 + // Prefer primary type "Album" 1016 + const aIsAlbum = aGroup["primary-type"] === "Album" ? 0 : 1; 1017 + const bIsAlbum = bGroup["primary-type"] === "Album" ? 0 : 1; 1018 + if (aIsAlbum !== bIsAlbum) return aIsAlbum - bIsAlbum; 1019 + 1020 + // Prefer earlier release date (original release) 1021 + const aDate = aRelease.date || "9999"; 1022 + const bDate = bRelease.date || "9999"; 1023 + return aDate.localeCompare(bDate); 1024 + }); 1025 + 1026 + // Dedupe after sorting - keep first (best) occurrence of each track+album 1027 + const seen = new Set(); 1028 + items = items.filter((item) => { 1029 + const key = `${item.recording.title}|${item.release?.title || ""}`; 1030 + if (seen.has(key)) return false; 1031 + seen.add(key); 1032 + return true; 1033 + }); 1034 + 1035 + dropdown.innerHTML = items 1036 + .map((item, i) => { 1037 + const r = item.recording; 1038 + const release = item.release; 1039 const duration = r.length ? formatDuration(Math.floor(r.length / 1000)) : ""; 1040 const album = release?.title || ""; 1041 const artUrl = release?.id ··· 1043 : ""; 1044 1045 return ` 1046 + <div class="autocomplete-item" onclick="selectRecordingItem(${i})" data-index="${i}"> 1047 <div class="autocomplete-item-art"> 1048 ${artUrl ? `<img src="${artUrl}" alt="" onerror="this.style.display='none'">` : ""} 1049 </div> 1050 <div class="autocomplete-item-info"> 1051 + <div class="autocomplete-item-title">${esc(r.title)}${album ? ` <span style="color: var(--text-secondary); font-weight: normal;">· ${esc(album)}</span>` : ""}</div> 1052 + ${duration ? `<div class="autocomplete-item-subtitle">${duration}</div>` : ""} 1053 </div> 1054 </div> 1055 `; 1056 }) 1057 .join(""); 1058 1059 + dropdown.dataset.items = JSON.stringify(items); 1060 } catch (error) { 1061 dropdown.innerHTML = `<div class="autocomplete-status" style="color: var(--error-text)">Search failed</div>`; 1062 } 1063 }, 300); 1064 1065 + function selectRecordingItem(index) { 1066 const dropdown = document.getElementById("track-dropdown"); 1067 + const items = JSON.parse(dropdown.dataset.items || "[]"); 1068 + const item = items[index]; 1069 1070 + if (!item) return; 1071 1072 + const recording = item.recording; 1073 + const release = item.release; 1074 const durationSecs = recording.length ? Math.floor(recording.length / 1000) : null; 1075 1076 state.selectedRecording = { ··· 1090 : ""; 1091 1092 renderTrackField(); 1093 updateSubmitButton(); 1094 } 1095 ··· 1098 document.getElementById("duration-display").value = ""; 1099 1100 renderTrackField(); 1101 updateSubmitButton(); 1102 } 1103 ··· 1106 const m = Math.floor(secs / 60); 1107 const s = secs % 60; 1108 return `${m}:${s.toString().padStart(2, "0")}`; 1109 } 1110 1111 // =============================================================================