1 /*
2  * Copyright 2003-2021 The Music Player Daemon Project
3  * http://www.musicpd.org
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License along
16  * with this program; if not, write to the Free Software Foundation, Inc.,
17  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
18  */
19 
20 #include "SoundCloudPlaylistPlugin.hxx"
21 #include "../PlaylistPlugin.hxx"
22 #include "../MemorySongEnumerator.hxx"
23 #include "lib/yajl/Handle.hxx"
24 #include "lib/yajl/Callbacks.hxx"
25 #include "lib/yajl/ParseInputStream.hxx"
26 #include "config/Block.hxx"
27 #include "input/InputStream.hxx"
28 #include "tag/Builder.hxx"
29 #include "util/AllocatedString.hxx"
30 #include "util/ASCII.hxx"
31 #include "util/StringCompare.hxx"
32 #include "util/Domain.hxx"
33 #include "Log.hxx"
34 
35 #include <string>
36 
37 #include <string.h>
38 #include <stdlib.h>
39 
40 using std::string_view_literals::operator""sv;
41 
42 static struct {
43 	std::string apikey;
44 } soundcloud_config;
45 
46 static constexpr Domain soundcloud_domain("soundcloud");
47 
48 static bool
soundcloud_init(const ConfigBlock & block)49 soundcloud_init(const ConfigBlock &block)
50 {
51 	// APIKEY for MPD application, registered under DarkFox' account.
52 	soundcloud_config.apikey = block.GetBlockValue("apikey", "a25e51780f7f86af0afa91f241d091f8");
53 	if (soundcloud_config.apikey.empty()) {
54 		LogDebug(soundcloud_domain,
55 			 "disabling the soundcloud playlist plugin "
56 			 "because API key is not set");
57 		return false;
58 	}
59 
60 	return true;
61 }
62 
63 /**
64  * Construct a full soundcloud resolver URL from the given fragment.
65  * @param uri uri of a soundcloud page (or just the path)
66  * @return Constructed URL. Must be freed with free().
67  */
68 static AllocatedString
soundcloud_resolve(StringView uri)69 soundcloud_resolve(StringView uri) noexcept
70 {
71 	if (uri.StartsWithIgnoreCase("https://")) {
72 		return AllocatedString{uri};
73 	} else if (uri.StartsWith("soundcloud.com")) {
74 		return AllocatedString{"https://"sv, uri};
75 	}
76 
77 
78 	/* assume it's just a path on soundcloud.com */
79 	AllocatedString u{"https://soundcloud.com/"sv, uri};
80 
81 	return AllocatedString{
82 		"https://api.soundcloud.com/resolve.json?url="sv,
83 		u, "&client_id="sv,
84 		soundcloud_config.apikey,
85 	};
86 }
87 
88 static AllocatedString
TranslateSoundCloudUri(StringView uri)89 TranslateSoundCloudUri(StringView uri) noexcept
90 {
91 	if (uri.SkipPrefix("track/"sv)) {
92 		return AllocatedString{
93 			"https://api.soundcloud.com/tracks/"sv,
94 			uri, ".json?client_id="sv,
95 			soundcloud_config.apikey,
96 		};
97 	} else if (uri.SkipPrefix("playlist/"sv)) {
98 		return AllocatedString{
99 			"https://api.soundcloud.com/playlists/"sv,
100 			uri, ".json?client_id="sv,
101 			soundcloud_config.apikey,
102 		};
103 	} else if (uri.SkipPrefix("user/"sv)) {
104 		return AllocatedString{
105 			"https://api.soundcloud.com/users/"sv,
106 			uri, "/tracks.json?client_id="sv,
107 			soundcloud_config.apikey,
108 		};
109 	} else if (uri.SkipPrefix("search/"sv)) {
110 		return AllocatedString{
111 			"https://api.soundcloud.com/tracks.json?q="sv,
112 			uri, "&client_id="sv,
113 			soundcloud_config.apikey,
114 		};
115 	} else if (uri.SkipPrefix("url/"sv)) {
116 		/* Translate to soundcloud resolver call. libcurl will automatically
117 		   follow the redirect to the right resource. */
118 		return soundcloud_resolve(uri);
119 	} else
120 		return nullptr;
121 }
122 
123 /* YAJL parser for track data from both /tracks/ and /playlists/ JSON */
124 
125 static const char *const key_str[] = {
126 	"duration",
127 	"title",
128 	"stream_url",
129 	nullptr,
130 };
131 
132 struct SoundCloudJsonData {
133 	enum class Key {
134 		DURATION,
135 		TITLE,
136 		STREAM_URL,
137 		OTHER,
138 	};
139 
140 	Key key;
141 	std::string stream_url;
142 	long duration;
143 	std::string title;
144 	int got_url = 0; /* nesting level of last stream_url */
145 
146 	std::forward_list<DetachedSong> songs;
147 
148 	bool Integer(long long value) noexcept;
149 	bool String(StringView value) noexcept;
150 	bool StartMap() noexcept;
151 	bool MapKey(StringView value) noexcept;
152 	bool EndMap() noexcept;
153 };
154 
155 inline bool
Integer(long long intval)156 SoundCloudJsonData::Integer(long long intval) noexcept
157 {
158 	switch (key) {
159 	case SoundCloudJsonData::Key::DURATION:
160 		duration = intval;
161 		break;
162 	default:
163 		break;
164 	}
165 
166 	return true;
167 }
168 
169 inline bool
String(StringView value)170 SoundCloudJsonData::String(StringView value) noexcept
171 {
172 	switch (key) {
173 	case SoundCloudJsonData::Key::TITLE:
174 		title.assign(value.data, value.size);
175 		break;
176 
177 	case SoundCloudJsonData::Key::STREAM_URL:
178 		stream_url.assign(value.data, value.size);
179 		got_url = 1;
180 		break;
181 
182 	default:
183 		break;
184 	}
185 
186 	return true;
187 }
188 
189 inline bool
MapKey(StringView value)190 SoundCloudJsonData::MapKey(StringView value) noexcept
191 {
192 	const auto *i = key_str;
193 	while (*i != nullptr && !StringStartsWith(*i, value))
194 		++i;
195 
196 	key = SoundCloudJsonData::Key(i - key_str);
197 	return true;
198 }
199 
200 inline bool
StartMap()201 SoundCloudJsonData::StartMap() noexcept
202 {
203 	if (got_url > 0)
204 		got_url++;
205 
206 	return true;
207 }
208 
209 inline bool
EndMap()210 SoundCloudJsonData::EndMap() noexcept
211 {
212 	if (got_url > 1) {
213 		got_url--;
214 		return true;
215 	}
216 
217 	if (got_url == 0)
218 		return true;
219 
220 	/* got_url == 1, track finished, make it into a song */
221 	got_url = 0;
222 
223 	const std::string u = stream_url + "?client_id=" +
224 		soundcloud_config.apikey;
225 
226 	TagBuilder tag;
227 	tag.SetDuration(SignedSongTime::FromMS(duration));
228 	if (!title.empty())
229 		tag.AddItem(TAG_NAME, title.c_str());
230 
231 	songs.emplace_front(u.c_str(), tag.Commit());
232 
233 	return true;
234 }
235 
236 using Wrapper = Yajl::CallbacksWrapper<SoundCloudJsonData>;
237 static constexpr yajl_callbacks parse_callbacks = {
238 	nullptr,
239 	nullptr,
240 	Wrapper::Integer,
241 	nullptr,
242 	nullptr,
243 	Wrapper::String,
244 	Wrapper::StartMap,
245 	Wrapper::MapKey,
246 	Wrapper::EndMap,
247 	nullptr,
248 	nullptr,
249 };
250 
251 /**
252  * Read JSON data and parse it using the given YAJL parser.
253  * @param url URL of the JSON data.
254  * @param handle YAJL parser handle.
255  */
256 static void
soundcloud_parse_json(const char * url,Yajl::Handle & handle,Mutex & mutex)257 soundcloud_parse_json(const char *url, Yajl::Handle &handle,
258 		      Mutex &mutex)
259 {
260 	auto input_stream = InputStream::OpenReady(url, mutex);
261 	Yajl::ParseInputStream(handle, *input_stream);
262 }
263 
264 /**
265  * Parse a soundcloud:// URL and create a playlist.
266  * @param uri A soundcloud URL. Accepted forms:
267  *	soundcloud://track/<track-id>
268  *	soundcloud://playlist/<playlist-id>
269  *	soundcloud://url/<url or path of soundcloud page>
270  */
271 static std::unique_ptr<SongEnumerator>
soundcloud_open_uri(const char * uri,Mutex & mutex)272 soundcloud_open_uri(const char *uri, Mutex &mutex)
273 {
274 	assert(StringEqualsCaseASCII(uri, "soundcloud://", 13));
275 	uri += 13;
276 
277 	auto u = TranslateSoundCloudUri(uri);
278 	if (u == nullptr) {
279 		LogWarning(soundcloud_domain, "unknown soundcloud URI");
280 		return nullptr;
281 	}
282 
283 	SoundCloudJsonData data;
284 	Yajl::Handle handle(&parse_callbacks, nullptr, &data);
285 	soundcloud_parse_json(u.c_str(), handle, mutex);
286 
287 	data.songs.reverse();
288 	return std::make_unique<MemorySongEnumerator>(std::move(data.songs));
289 }
290 
291 static const char *const soundcloud_schemes[] = {
292 	"soundcloud",
293 	nullptr
294 };
295 
296 const PlaylistPlugin soundcloud_playlist_plugin =
297 	PlaylistPlugin("soundcloud", soundcloud_open_uri)
298 	.WithInit(soundcloud_init)
299 	.WithSchemes(soundcloud_schemes);
300