PR #24416 opened by michaelni URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24416 Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24416.patch
This pull request adds Spotify support to libavformat using librespot-c. Streams the Ogg Vorbis audio of Spotify tracks with a Premium account. librespot-c handles the Spotify session and the streaming of the encrypted tracks, the Spotify Web API provides the track lists and the metadata. I love Spotify and would like FFmpeg to support it so I can listen through ffplay and other libavformat-based players. I will contact Spotify to ask for feedback on this implementation and to discuss any concerns they may have. My goal is an integration that meets the FFmpeg community's needs and expectations while also working well with Spotify. Reviews, testing, and suggestions are welcome! >From 417d1e804369aee8c91d2718e8dd18643c50444c Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 3 Sep 2026 15:38:00 +0200 Subject: [PATCH 1/4] avcodec/vorbisdec: factor extradata parsing out of vorbis_decode_init Move the parsing of the identification and setup headers held in the extradata into vorbis_parse_extradata(), so it can be reused for extradata that changes during decoding. Assisted-by: Claude --- libavcodec/vorbisdec.c | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/libavcodec/vorbisdec.c b/libavcodec/vorbisdec.c index aeea1b4908..92a1cc39e4 100644 --- a/libavcodec/vorbisdec.c +++ b/libavcodec/vorbisdec.c @@ -1052,21 +1052,15 @@ static int vorbis_parse_id_hdr(vorbis_context *vc) // Process the extradata using the functions above (identification header, setup header) -static av_cold int vorbis_decode_init(AVCodecContext *avctx) +static int vorbis_parse_extradata(AVCodecContext *avctx, + const uint8_t *headers, int headers_len) { vorbis_context *vc = avctx->priv_data; - uint8_t *headers = avctx->extradata; - int headers_len = avctx->extradata_size; const uint8_t *header_start[3]; int header_len[3]; GetBitContext *gb = &vc->gb; int hdr_type, ret; - vc->avctx = avctx; - ff_vorbisdsp_init(&vc->dsp); - - avctx->sample_fmt = AV_SAMPLE_FMT_FLTP; - if (!headers_len) { av_log(avctx, AV_LOG_ERROR, "Extradata missing.\n"); return AVERROR_INVALIDDATA; @@ -1115,6 +1109,18 @@ static av_cold int vorbis_decode_init(AVCodecContext *avctx) return 0; } +static av_cold int vorbis_decode_init(AVCodecContext *avctx) +{ + vorbis_context *vc = avctx->priv_data; + + vc->avctx = avctx; + ff_vorbisdsp_init(&vc->dsp); + + avctx->sample_fmt = AV_SAMPLE_FMT_FLTP; + + return vorbis_parse_extradata(avctx, avctx->extradata, avctx->extradata_size); +} + // Decode audiopackets ------------------------------------------------- // Read and decode floor -- 2.52.0 >From 48d25875a96c7f56d4ad1dfce2ff6f96ad9237dd Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Thu, 3 Sep 2026 15:38:00 +0200 Subject: [PATCH 2/4] avcodec/vorbisdec: reinitialize from AV_PKT_DATA_NEW_EXTRADATA side data The ogg demuxer attaches the headers of a new link in a chained stream to the first following data packet as new extradata. The decoder ignored it and kept decoding with the codebooks and setup of the previous link, which produces garbage when they differ. Parse the new extradata like the initial one when it arrives. Assisted-by: Claude --- libavcodec/vorbisdec.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/libavcodec/vorbisdec.c b/libavcodec/vorbisdec.c index 92a1cc39e4..fbb149483b 100644 --- a/libavcodec/vorbisdec.c +++ b/libavcodec/vorbisdec.c @@ -1792,10 +1792,20 @@ static int vorbis_decode_frame(AVCodecContext *avctx, AVFrame *frame, vorbis_context *vc = avctx->priv_data; GetBitContext *gb = &vc->gb; float *channel_ptrs[255]; + const uint8_t *new_extradata; + size_t new_extradata_size; int i, len, ret; ff_dlog(NULL, "packet length %d \n", buf_size); + new_extradata = av_packet_get_side_data(avpkt, AV_PKT_DATA_NEW_EXTRADATA, + &new_extradata_size); + if (new_extradata) { + vorbis_free(vc); + if ((ret = vorbis_parse_extradata(avctx, new_extradata, new_extradata_size)) < 0) + return ret; + } + if (*buf == 1 && buf_size > 7) { if ((ret = init_get_bits8(gb, buf + 1, buf_size - 1)) < 0) return ret; -- 2.52.0 >From f018263b47119f4aae73fffb719babff95d7b094 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Fri, 4 Sep 2026 03:16:06 +0200 Subject: [PATCH 3/4] tools: add spotify_token.py Logs in to Spotify with the OAuth PKCE flow through the browser and stores the credentials for the spotify demuxer in $XDG_CONFIG_HOME/ffmpeg/spotify_token.json. Playback and the Web API may be granted to different client IDs, so it optionally logs in with a second client ID for playback. Assisted-by: Claude --- tools/spotify_token.py | 111 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100755 tools/spotify_token.py diff --git a/tools/spotify_token.py b/tools/spotify_token.py new file mode 100755 index 0000000000..11d6eb5160 --- /dev/null +++ b/tools/spotify_token.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +# +# Logs in to Spotify for the spotify demuxer. +# +# Opens the Spotify login page in a browser and stores the OAuth credentials +# in $XDG_CONFIG_HOME/ffmpeg/spotify_token.json, where the demuxer reads and +# refreshes them. Needs the client ID of an app registered at +# https://developer.spotify.com/dashboard with the redirect URI +# http://127.0.0.1:8898/login. When playback is granted to another client ID +# than the Web API, it logs in once with each. +# +# This file is part of FFmpeg. +# +# FFmpeg is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# FFmpeg is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with FFmpeg; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +import argparse +import base64 +import hashlib +import http.server +import json +import os +import secrets +import sys +import time +import urllib.parse +import urllib.request +import webbrowser + +REDIRECT_URI = "http://127.0.0.1:8898/login" +API_SCOPE = "user-read-private user-library-read playlist-read-private playlist-read-collaborative" +PLAYBACK_SCOPE = "streaming" +TOKEN_FILE = os.path.join(os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")), + "ffmpeg", "spotify_token.json") + + +def authorize(client_id, scope): + verifier = secrets.token_urlsafe(64) + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=") + url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({ + "client_id": client_id, + "response_type": "code", + "redirect_uri": REDIRECT_URI, + "scope": scope, + "code_challenge_method": "S256", + "code_challenge": challenge.decode(), + }) + codes = [] + + class LoginHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + codes.extend(urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("code", [])) + self.send_response(200) + self.end_headers() + self.wfile.write(b"Logged in to Spotify, this window can be closed.") + + def log_message(self, *args): + pass + + with http.server.HTTPServer(("127.0.0.1", 8898), LoginHandler) as server: + print("Log in at", url, file=sys.stderr) + webbrowser.open(url) + while not codes: + server.handle_request() + + form = urllib.parse.urlencode({ + "client_id": client_id, + "grant_type": "authorization_code", + "code": codes[0], + "redirect_uri": REDIRECT_URI, + "code_verifier": verifier, + }).encode() + with urllib.request.urlopen("https://accounts.spotify.com/api/token", form) as reply: + token = json.load(reply) + token["client_id"] = client_id + token["expires_at"] = int(time.time()) + token["expires_in"] + return token + + +def main(): + parser = argparse.ArgumentParser(description="Logs in to Spotify for the spotify demuxer") + parser.add_argument("--client-id", required=True, help="client ID of your app registered at " + "https://developer.spotify.com/dashboard with the redirect URI " + REDIRECT_URI) + parser.add_argument("--playback-client-id", + help="client ID granted the streaming scope, if it is another one") + args = parser.parse_args() + + if args.playback_client_id: + credentials = {"api": authorize(args.client_id, API_SCOPE), + "playback": authorize(args.playback_client_id, PLAYBACK_SCOPE)} + else: + credentials = {"api": authorize(args.client_id, API_SCOPE + " " + PLAYBACK_SCOPE)} + os.makedirs(os.path.dirname(TOKEN_FILE), exist_ok=True) + with open(TOKEN_FILE, "w", opener=lambda path, flags: os.open(path, flags, 0o600)) as token_file: + json.dump(credentials, token_file) + print("Credentials stored in", TOKEN_FILE, file=sys.stderr) + + +if __name__ == "__main__": + main() -- 2.52.0 >From 162893e59cab431f2f6eebdf9b79c1cd69885364 Mon Sep 17 00:00:00 2001 From: Michael Niedermayer <[email protected]> Date: Fri, 4 Sep 2026 03:16:14 +0200 Subject: [PATCH 4/4] avformat: add Spotify demuxer using librespot-c Streams the Ogg Vorbis audio of Spotify tracks with a Premium account. librespot-c handles the Spotify session and the streaming of the encrypted tracks, the Spotify Web API provides the track lists and the metadata. Accepted URLs are Spotify URIs and open.spotify.com links of a track, an album or a playlist, and spotify:collection for the liked songs of the account, optionally in random order. Every track is a chapter carrying its metadata, which is also attached to the first packet of the track and set on the stream while the track plays. The OAuth credentials come from the file tools/spotify_token.py writes, holding a token for the Web API and optionally a separate one for the playback login. Access tokens expire after an hour, so expired ones are refreshed with the refresh token and the file is updated, as Spotify rotates refresh tokens. Web API replies are cached for an hour below $XDG_CACHE_HOME, so a library is not listed again on every run, and rate limited requests are retried after the delay Spotify asks for. librespot-c cannot seek a track once streaming started, so seeking reopens the track at a byte offset estimated from the target time and replays the cached Ogg header pages in front of the data. Packets before the target are dropped, an overshoot backs the offset off and retries. Assisted-by: Claude --- Changelog | 1 + configure | 6 + doc/demuxers.texi | 97 ++++ doc/general_contents.texi | 6 + libavformat/Makefile | 1 + libavformat/allformats.c | 1 + libavformat/spotify.c | 1078 +++++++++++++++++++++++++++++++++++++ 7 files changed, 1190 insertions(+) create mode 100644 libavformat/spotify.c diff --git a/Changelog b/Changelog index c05d1c840a..041acbbae3 100644 --- a/Changelog +++ b/Changelog @@ -13,6 +13,7 @@ version <next>: - NVIDIA optical flow accelerated interpolation filter (vf_fruc_vulkan) - H.264 data partitioning support - DSD (dsd_msbf) encoder +- Spotify demuxer via librespot-c version 9.0: diff --git a/configure b/configure index c025dbc95d..715fd48055 100755 --- a/configure +++ b/configure @@ -269,6 +269,7 @@ External library support: --enable-libquirc enable QR decoding via libquirc [no] --enable-librabbitmq enable RabbitMQ library [no] --enable-librav1e enable AV1 encoding via rav1e [no] + --enable-librespotc enable Spotify streaming via librespot-c [no] --enable-librist enable RIST via librist [no] --enable-librsvg enable SVG rasterization via librsvg [no] --enable-librubberband enable rubberband needed for rubberband filter [no] @@ -2118,6 +2119,7 @@ EXTERNAL_LIBRARY_LIST=" libquirc librabbitmq librav1e + librespotc librist librsvg librtmp @@ -4022,6 +4024,8 @@ sdp_demuxer_select="rtpdec" smoothstreaming_muxer_select="ismv_muxer" spdif_demuxer_select="adts_header" spdif_muxer_select="adts_header" +spotify_demuxer_deps="librespotc" +spotify_demuxer_select="https_protocol ogg_demuxer" spx_muxer_select="ogg_muxer" swf_demuxer_suggest="zlib" tak_demuxer_select="tak_parser" @@ -7442,6 +7446,8 @@ enabled libqrencode && require_pkg_config libqrencode libqrencode qrencode enabled libquirc && require libquirc quirc.h quirc_decode -lquirc enabled librabbitmq && require_pkg_config librabbitmq "librabbitmq >= 0.7.1" amqp.h amqp_new_connection enabled librav1e && require_pkg_config librav1e "rav1e >= 0.5.0" rav1e.h rav1e_context_new +enabled librespotc && require_pkg_config librespotc_json_c json-c json.h json_tokener_parse && + require librespotc librespot-c.h librespotc_init -lrespot-c -levent -lgcrypt -lgpg-error -lcurl -lprotobuf-c $librespotc_json_c_extralibs -lpthread enabled librist && require_pkg_config librist "librist >= 0.2.7" librist/librist.h rist_receiver_create enabled librsvg && require_pkg_config librsvg librsvg-2.0 librsvg-2.0/librsvg/rsvg.h rsvg_handle_new_from_data enabled librtmp && require_pkg_config librtmp librtmp librtmp/rtmp.h RTMP_Socket diff --git a/doc/demuxers.texi b/doc/demuxers.texi index 8603069949..4f5d6b0adc 100644 --- a/doc/demuxers.texi +++ b/doc/demuxers.texi @@ -1127,6 +1127,103 @@ the script is directly played, the actual times will match the absolute timestamps up to the sound controller's clock accuracy, but if the user somehow pauses the playback or seeks, all times will be shifted accordingly. +@section spotify + +Spotify demuxer, streaming the Ogg Vorbis audio of Spotify tracks through +librespot-c. It requires a Spotify Premium account and the +@code{--enable-librespotc} configure option. + +Accepted URLs are Spotify URIs and @url{https://open.spotify.com} links of a +track, an album or a playlist, and @code{spotify:collection} for the liked +songs of the account. Every track becomes a chapter carrying the metadata +Spotify provides (title, artist, album, album_artist, date, track, disc, isrc, +explicit, popularity, url, artwork_url and added_at). The same metadata is +attached to the first packet of each track and set on the audio stream while +the track plays. + +Logging in is done once with @file{tools/spotify_token.py}, which stores the +OAuth credentials in @file{$XDG_CONFIG_HOME/ffmpeg/spotify_token.json}. The +demuxer reads them from there, refreshes the access tokens when they expired +and updates the file. The script needs playback and API client IDs, these IDs +are not provided by FFmpeg currently and need to be supplied by the library user. + +This demuxer is an independent implementation intended to support +lawful interoperability. It is not affiliated with, endorsed by, or +sponsored by Spotify. + +Client identifiers must be supplied by the user; FFmpeg does not +select or supply a default client identifier. Users are responsible +for determining whether they are entitled to use the identifiers, +credentials, services and content involved, under applicable law and +any contractual obligations binding on them, and for obtaining any +permissions required for their use. Successful authentication does +not, by itself, establish that a particular use is permitted. + +The FFmpeg software license grants rights in the software, not rights +to access Spotify's services or use third-party content. The FFmpeg +project and the authors and maintainers of this demuxer do not +encourage or endorse copyright infringement, unlawful circumvention +of technological protection measures, or unauthorized access. + +Spotify may change or withdraw access to its services. No assurance +is given that this demuxer will continue to work or that its use will +not result in account restrictions. The software is provided subject +to the warranty disclaimers and limitations of liability in the +applicable FFmpeg license, to the extent permitted by law. + +This notice does not impose additional restrictions on the rights +granted by the applicable software license, incorporate third-party +terms into that license, or limit any rights, exceptions or defenses +available under applicable law. + + +This demuxer accepts the following options: +@table @option + +@item credentials +JSON file with the OAuth credentials written by @file{tools/spotify_token.py}. +Default is @file{$XDG_CONFIG_HOME/ffmpeg/spotify_token.json}. + +@item cache_dir +Directory caching the Web API replies, so that track lists are only fetched +again when they may have changed. Default is +@file{$XDG_CACHE_HOME/ffmpeg/spotify}. + +@item cache_ttl +Seconds a cached Web API reply is used, 0 disables the cache. Default is 3600. + +@item username +Spotify username. It is looked up from the token if unset. + +@item bitrate +Preferred bitrate in kbit/s, one of 96, 160 or 320. Default is 320. + +@item shuffle +Play the tracks in random order. Default is false. + +@item seed +Seed of the shuffle order, -1 selects a random one. Default is -1. + +@end table + +@subsection Examples + +@itemize +@item +Play the liked songs of the account in random order: +@example +tools/spotify_token.py --client-id @var{client_id} --playback-client-id @var{playback_client_id} +ffplay -shuffle 1 spotify:collection +@end example + +@item +listen to a track +@example +tools/spotify_token.py --client-id @var{client_id} --playback-client-id @var{playback_client_id} +ffplay spotify:track:@var{id} +@end example +@end itemize + @section tedcaptions JSON captions used for @url{http://www.ted.com/, TED Talks}. diff --git a/doc/general_contents.texi b/doc/general_contents.texi index ad73894d99..acf46e7abd 100644 --- a/doc/general_contents.texi +++ b/doc/general_contents.texi @@ -191,6 +191,12 @@ period of life. See @url{https://jpegxl.info/} for more information, and see @url{https://github.com/libjxl/libjxl} for the library source. You can pass @code{--enable-libjxl} to configure in order enable the libjxl wrapper. +@section librespot-c + +FFmpeg can use librespot-c to stream audio from Spotify with a Premium account. +Pass @code{--enable-librespotc} to configure to enable it. +See @url{https://github.com/ejurgensen/librespot-c}. + @section libvpx FFmpeg can make use of the libvpx library for VP8/VP9 decoding and encoding. diff --git a/libavformat/Makefile b/libavformat/Makefile index 038e0afd41..75ce0bfe9a 100644 --- a/libavformat/Makefile +++ b/libavformat/Makefile @@ -587,6 +587,7 @@ OBJS-$(CONFIG_SPDIF_DEMUXER) += spdif.o spdifdec.o OBJS-$(CONFIG_SPDIF_MUXER) += spdif.o spdifenc.o OBJS-$(CONFIG_SPEEX_MUXER) += oggenc.o \ vorbiscomment.o +OBJS-$(CONFIG_SPOTIFY_DEMUXER) += spotify.o OBJS-$(CONFIG_SRT_DEMUXER) += srtdec.o subtitles.o OBJS-$(CONFIG_SRT_MUXER) += srtenc.o OBJS-$(CONFIG_STL_DEMUXER) += stldec.o subtitles.o diff --git a/libavformat/allformats.c b/libavformat/allformats.c index e121c7441c..cb50d88de6 100644 --- a/libavformat/allformats.c +++ b/libavformat/allformats.c @@ -455,6 +455,7 @@ extern const FFOutputFormat ff_sox_muxer; extern const FFOutputFormat ff_spx_muxer; extern const FFInputFormat ff_spdif_demuxer; extern const FFOutputFormat ff_spdif_muxer; +extern const FFInputFormat ff_spotify_demuxer; extern const FFInputFormat ff_srt_demuxer; extern const FFOutputFormat ff_srt_muxer; extern const FFInputFormat ff_str_demuxer; diff --git a/libavformat/spotify.c b/libavformat/spotify.c new file mode 100644 index 0000000000..735284ff3a --- /dev/null +++ b/libavformat/spotify.c @@ -0,0 +1,1078 @@ +/* + * Spotify demuxer + * Copyright (c) 2026 Michael Niedermayer <[email protected]> + * + * This file is part of FFmpeg. + * + * FFmpeg is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * FFmpeg is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with FFmpeg; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + */ + +#include <dirent.h> +#include <errno.h> +#include <poll.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +#include <json.h> +#include <librespot-c.h> + +#include "libavutil/avstring.h" +#include "libavutil/bprint.h" +#include "libavutil/getenv_utf8.h" +#include "libavutil/lfg.h" +#include "libavutil/mem.h" +#include "libavutil/opt.h" +#include "libavutil/random_seed.h" +#include "libavutil/sha.h" +#include "libavutil/thread.h" + +#include "avformat.h" +#include "demux.h" +#include "internal.h" +#include "network.h" +#include "os_support.h" +#include "url.h" + +#define API_URL "https://api.spotify.com/v1/" +#define TOKEN_URL "https://accounts.spotify.com/api/token" +#define ID_LEN 22 +#define IO_BUFFER_SIZE 32768 +#define MAX_SEEK_RETRIES 3 +#define MAX_RESUMES 3 +#define MAX_OPEN_RETRIES 24 +#define OPEN_RETRY_DELAY 5 + +// Error codes of librespot-c, which its public header does not declare +#define SP_ERR_NOCONNECTION (-5) +#define SP_ERR_TIMEOUT (-9) + +static const AVRational ms_tb = { 1, 1000 }; + +typedef char SpotifyID[ID_LEN + 1]; + +typedef struct SpotifyCredentials { + char *token; + char client_id[33]; +} SpotifyCredentials; + +typedef struct SpotifyContext { + const AVClass *class; + char *credentials; + char *cache_dir; + int cache_ttl; + char *username; + SpotifyCredentials api; + SpotifyCredentials playback; + int bitrate; + int shuffle; + int64_t seed; + + int initialized; + struct sp_session *session; + SpotifyID *ids; // one per chapter, in playback order + int cur; // chapter being streamed + + int fd; // librespot-c handle of the current track + int read_fd; // duplicate of the audio pipe, librespot-c closes fd on session errors + size_t file_len; + int64_t received; + AVIOContext *pb; + AVFormatContext *ogg; + + // Ogg header pages of track hdr_track, replayed before data fetched from a byte offset + uint8_t *hdr; + int hdr_len, hdr_size; + int hdr_track; + int caching_hdr; + int replayed; + + int64_t skip_until; // in-track pts to reach after a seek, AV_NOPTS_VALUE otherwise + int64_t seek_ms; + int seek_retries; + int64_t pos; // in-track pts of the next packet + int resumes; // since the last packet was read + int open_retries; // since the last successful open + int new_track; +} SpotifyContext; + +static AVMutex librespot_mutex = AV_MUTEX_INITIALIZER; +static int librespot_users; + +static int tcp_connect(const char *host, unsigned short port) +{ + struct addrinfo hints = { .ai_socktype = SOCK_STREAM }, *ai, *cur; + char service[8]; + int fd = -1; + + snprintf(service, sizeof(service), "%hu", port); + if (getaddrinfo(host, service, &hints, &ai)) + return -1; + for (cur = ai; cur; cur = cur->ai_next) { + fd = socket(cur->ai_family, SOCK_STREAM, cur->ai_protocol); + if (fd < 0) + continue; + if (!connect(fd, cur->ai_addr, cur->ai_addrlen)) + break; + close(fd); + fd = -1; + } + freeaddrinfo(ai); + return fd; +} + +static void tcp_disconnect(int fd) +{ + close(fd); +} + +static void log_message(const char *fmt, ...) +{ + va_list ap; + + va_start(ap, fmt); + av_vlog(NULL, AV_LOG_DEBUG, fmt, ap); + va_end(ap); +} + +static void log_hexdump(const char *msg, uint8_t *data, size_t len) +{ +} + +static int librespot_ref(AVFormatContext *s, const char *client_id) +{ + struct sp_callbacks callbacks = { + .tcp_connect = tcp_connect, + .tcp_disconnect = tcp_disconnect, + .hexdump = log_hexdump, + .logmsg = log_message, + }; + struct sp_sysinfo sysinfo = { 0 }; + int ret = 0; + + ff_mutex_lock(&librespot_mutex); + if (!librespot_users) { + snprintf(sysinfo.device_id, sizeof(sysinfo.device_id), "%08x%08x%08x%08x%08x", + av_get_random_seed(), av_get_random_seed(), av_get_random_seed(), + av_get_random_seed(), av_get_random_seed()); + av_strlcpy(sysinfo.client_id, client_id, sizeof(sysinfo.client_id)); + if (librespotc_init(&sysinfo, &callbacks) < 0) { + av_log(s, AV_LOG_ERROR, "Initializing librespot-c failed: %s\n", + librespotc_last_errmsg()); + ret = AVERROR_EXTERNAL; + } + } + librespot_users += !ret; + ff_mutex_unlock(&librespot_mutex); + return ret; +} + +static void librespot_unref(void) +{ + ff_mutex_lock(&librespot_mutex); + if (!--librespot_users) + librespotc_deinit(); + ff_mutex_unlock(&librespot_mutex); +} + +// Looks up a value by a path of object keys and array indices separated by '/' +static json_object *json_lookup(json_object *obj, const char *path) +{ + while (obj && *path) { + size_t len = strcspn(path, "/"); + json_object *child = NULL; + char key[64]; + + av_strlcpy(key, path, FFMIN(len + 1, sizeof(key))); + if (json_object_is_type(obj, json_type_array)) + child = json_object_array_get_idx(obj, strtol(key, NULL, 10)); + else + json_object_object_get_ex(obj, key, &child); + obj = child; + path += len + (path[len] == '/'); + } + return obj && !json_object_is_type(obj, json_type_null) ? obj : NULL; +} + +static const char *json_lookup_str(json_object *obj, const char *path) +{ + obj = json_lookup(obj, path); + return obj ? json_object_get_string(obj) : NULL; +} + +// Reads the whole content of url, body must be finalized by the caller even on failure +static int read_url(AVFormatContext *s, const char *url, AVDictionary **opts, AVBPrint *body) +{ + AVIOContext *pb = NULL; + int ret; + + av_bprint_init(body, 0, AV_BPRINT_SIZE_UNLIMITED); + ret = s->io_open(s, &pb, url, AVIO_FLAG_READ, opts); + if (ret < 0) + return ret; + ret = avio_read_to_bprint(pb, body, SIZE_MAX); + ff_format_io_close(s, &pb); + return ret; +} + +static int read_json(AVFormatContext *s, const char *url, AVDictionary **opts, json_object **reply) +{ + AVBPrint body; + int ret; + + ret = read_url(s, url, opts, &body); + if (ret >= 0) { + *reply = json_tokener_parse(body.str); + if (!*reply) + ret = AVERROR_INVALIDDATA; + } + av_bprint_finalize(&body, NULL); + return ret; +} + +static int write_json(AVFormatContext *s, const char *path, json_object *obj) +{ + const char *text = json_object_to_json_string(obj); + AVIOContext *pb = NULL; + int ret; + + ret = s->io_open(s, &pb, path, AVIO_FLAG_WRITE, NULL); + if (ret < 0) + return ret; + avio_write(pb, text, strlen(text)); + return ff_format_io_close(s, &pb); +} + +// Path of a file in the user's directory for var, or in fallback below HOME +static char *user_dir_path(const char *var, const char *fallback, const char *file) +{ + char *base = getenv_utf8(var); + char *home = getenv_utf8("HOME"); + char *path = NULL; + + if (base) + path = av_asprintf("%s/ffmpeg/%s", base, file); + else if (home) + path = av_asprintf("%s/%s/ffmpeg/%s", home, fallback, file); + freeenv_utf8(base); + freeenv_utf8(home); + return path; +} + +static char *cache_path(SpotifyContext *c, const char *url) +{ + struct AVSHA *sha = av_sha_alloc(); + uint8_t digest[20]; + char hex[2 * sizeof(digest) + 1]; + + if (!sha) + return NULL; + av_sha_init(sha, 8 * sizeof(digest)); + av_sha_update(sha, url, strlen(url)); + av_sha_final(sha, digest); + av_free(sha); + ff_data_to_hex(hex, digest, sizeof(digest), 1); + return av_asprintf("%s/%s.json", c->cache_dir, hex); +} + +static int cache_open(AVFormatContext *s) +{ + SpotifyContext *c = s->priv_data; + struct dirent *entry; + DIR *dir; + + if (!c->cache_dir) + c->cache_dir = user_dir_path("XDG_CACHE_HOME", ".cache", "spotify"); + if (!c->cache_dir) + return AVERROR(ENOMEM); + ff_mkdir_p(c->cache_dir); + chmod(c->cache_dir, 0700); + + dir = opendir(c->cache_dir); + if (!dir) + return 0; + while ((entry = readdir(dir))) { + char *path = av_asprintf("%s/%s", c->cache_dir, entry->d_name); + struct stat st; + + if (path && !stat(path, &st) && S_ISREG(st.st_mode) && + st.st_mtime + c->cache_ttl <= time(NULL)) + unlink(path); + av_free(path); + } + closedir(dir); + return 0; +} + +static int cache_read(AVFormatContext *s, const char *path, json_object **reply) +{ + SpotifyContext *c = s->priv_data; + struct stat st; + + if (stat(path, &st) || st.st_mtime + c->cache_ttl <= time(NULL)) + return AVERROR(ENOENT); + return read_json(s, path, NULL, reply); +} + +static int api_get(AVFormatContext *s, const char *url, json_object **reply) +{ + SpotifyContext *c = s->priv_data; + AVDictionary *opts = NULL; + char *path = NULL; + int ret; + + if (c->cache_ttl) { + path = cache_path(c, url); + if (!path) + return AVERROR(ENOMEM); + if (cache_read(s, path, reply) >= 0) { + av_free(path); + return 0; + } + } + + av_dict_set(&opts, "headers", + av_asprintf("Authorization: Bearer %s\r\n", c->api.token), + AV_DICT_DONT_STRDUP_VAL); + av_dict_set(&opts, "reconnect_on_http_error", "429", 0); + ret = read_json(s, url, &opts, reply); + av_dict_free(&opts); + if (ret < 0) + av_log(s, AV_LOG_ERROR, "Spotify Web API request %s failed%s\n", url, + ret == AVERROR_HTTP_UNAUTHORIZED ? ", the token is invalid or expired" : ""); + else if (path && write_json(s, path, *reply) < 0) + av_log(s, AV_LOG_WARNING, "Cannot write %s\n", path); + av_free(path); + return ret; +} + +static void write_credentials(AVFormatContext *s, json_object *credentials) +{ + SpotifyContext *c = s->priv_data; + + if (write_json(s, c->credentials, credentials) < 0) + av_log(s, AV_LOG_WARNING, "Cannot update %s, the refreshed token is not saved\n", + c->credentials); +} + +static int refresh_token(AVFormatContext *s, json_object *credentials, const char *client_id) +{ + SpotifyContext *c = s->priv_data; + const char *refresh_token = json_lookup_str(credentials, "refresh_token"); + AVDictionary *opts = NULL; + json_object *reply, *value; + char *form, *hex; + int ret; + + if (!refresh_token) { + av_log(s, AV_LOG_ERROR, "%s lacks the refresh token, log in with tools/spotify_token.py\n", + c->credentials); + return AVERROR(EINVAL); + } + form = av_asprintf("grant_type=refresh_token&refresh_token=%s&client_id=%s", + refresh_token, client_id); + if (!form) + return AVERROR(ENOMEM); + hex = av_malloc(2 * strlen(form) + 1); + if (!hex) { + av_free(form); + return AVERROR(ENOMEM); + } + ff_data_to_hex(hex, form, strlen(form), 0); + av_free(form); + av_dict_set(&opts, "post_data", hex, AV_DICT_DONT_STRDUP_VAL); + av_dict_set(&opts, "headers", "Content-Type: application/x-www-form-urlencoded\r\n", 0); + ret = read_json(s, TOKEN_URL, &opts, &reply); + av_dict_free(&opts); + if (ret < 0) { + av_log(s, AV_LOG_ERROR, "Refreshing the Spotify token failed, log in again with tools/spotify_token.py\n"); + return ret; + } + + if ((value = json_lookup(reply, "access_token"))) + json_object_object_add(credentials, "access_token", json_object_get(value)); + if ((value = json_lookup(reply, "refresh_token"))) + json_object_object_add(credentials, "refresh_token", json_object_get(value)); + json_object_object_add(credentials, "expires_at", + json_object_new_int64(time(NULL) + json_object_get_int64(json_lookup(reply, "expires_in")))); + json_object_put(reply); + return 0; +} + +// The credentials file holds a set for the Web API and optionally a separate +// one for the playback login, as Spotify grants them to different client IDs +static int load_credentials(AVFormatContext *s) +{ + static const char *const purposes[] = { "api", "playback" }; + SpotifyContext *c = s->priv_data; + SpotifyCredentials *sets[] = { &c->api, &c->playback }; + json_object *root; + int refreshed = 0; + int ret; + + if (!c->credentials) + c->credentials = user_dir_path("XDG_CONFIG_HOME", ".config", "spotify_token.json"); + if (!c->credentials) { + av_log(s, AV_LOG_ERROR, "The token or credentials option is required\n"); + return AVERROR(EINVAL); + } + ret = read_json(s, c->credentials, NULL, &root); + if (ret < 0) { + av_log(s, AV_LOG_ERROR, "Cannot read %s, log in with tools/spotify_token.py\n", c->credentials); + return ret; + } + for (int i = 0; i < FF_ARRAY_ELEMS(purposes) && ret >= 0; i++) { + json_object *credentials = json_lookup(root, purposes[i]); + const char *client_id; + + if (!credentials) + continue; + client_id = json_lookup_str(credentials, "client_id"); + if (!client_id) { + av_log(s, AV_LOG_ERROR, "%s lacks the %s client ID, log in with tools/spotify_token.py\n", + c->credentials, purposes[i]); + ret = AVERROR_INVALIDDATA; + break; + } + if (json_object_get_int64(json_lookup(credentials, "expires_at")) < time(NULL) + 60) { + ret = refresh_token(s, credentials, client_id); + refreshed |= ret >= 0; + } + if (ret >= 0) { + av_strlcpy(sets[i]->client_id, client_id, sizeof(sets[i]->client_id)); + sets[i]->token = av_strdup(json_lookup_str(credentials, "access_token")); + if (!sets[i]->token) + ret = AVERROR_INVALIDDATA; + } + } + if (refreshed) + write_credentials(s, root); + json_object_put(root); + if (ret >= 0 && !c->api.token) { + av_log(s, AV_LOG_ERROR, "%s holds no Web API credentials, log in with tools/spotify_token.py\n", + c->credentials); + ret = AVERROR_INVALIDDATA; + } + return ret; +} + +static void set_names(AVDictionary **metadata, const char *key, json_object *artists) +{ + AVBPrint names; + + av_bprint_init(&names, 0, AV_BPRINT_SIZE_UNLIMITED); + for (int i = 0; artists && i < json_object_array_length(artists); i++) { + const char *name = json_lookup_str(json_object_array_get_idx(artists, i), "name"); + if (name) + av_bprintf(&names, "%s%s", names.len ? ", " : "", name); + } + if (names.len) + av_dict_set(metadata, key, names.str, 0); + av_bprint_finalize(&names, NULL); +} + +static void set_field(AVDictionary **metadata, const char *key, json_object *obj, const char *path) +{ + const char *value = json_lookup_str(obj, path); + + if (value) + av_dict_set(metadata, key, value, 0); +} + +static void set_fields(AVDictionary **metadata, json_object *obj, + const char *const fields[][2], int nb_fields) +{ + for (int i = 0; i < nb_fields; i++) + set_field(metadata, fields[i][0], obj, fields[i][1]); +} + +// item is a track or an object wrapping one, album supplies album data for +// simplified track objects +static int track_add(AVFormatContext *s, json_object *item, json_object *album) +{ + static const char *const track_fields[][2] = { + { "title", "name" }, + { "track", "track_number" }, + { "disc", "disc_number" }, + { "isrc", "external_ids/isrc" }, + { "explicit", "explicit" }, + { "popularity", "popularity" }, + { "url", "external_urls/spotify" }, + }; + static const char *const album_fields[][2] = { + { "album", "name" }, + { "date", "release_date" }, + { "artwork_url", "images/0/url" }, + }; + SpotifyContext *c = s->priv_data; + json_object *track = json_lookup(item, "track"); + const char *id; + AVChapter *chapter; + SpotifyID *ids; + + if (!track) + track = item; + id = json_lookup_str(track, "id"); + if (!id || strlen(id) != ID_LEN || !json_object_get_boolean(json_lookup(track, "is_playable"))) { + av_log(s, AV_LOG_WARNING, "Skipping unplayable track %s\n", + json_lookup_str(track, "name")); + return 0; + } + if (json_lookup(track, "album")) + album = json_lookup(track, "album"); + + ids = av_realloc_array(c->ids, s->nb_chapters + 1, sizeof(*c->ids)); + if (!ids) + return AVERROR(ENOMEM); + c->ids = ids; + chapter = avpriv_new_chapter(s, s->nb_chapters, ms_tb, 0, + json_object_get_int64(json_lookup(track, "duration_ms")), + NULL); + if (!chapter) + return AVERROR(ENOMEM); + av_strlcpy(c->ids[s->nb_chapters - 1], id, sizeof(*c->ids)); + + set_fields(&chapter->metadata, track, track_fields, FF_ARRAY_ELEMS(track_fields)); + set_field(&chapter->metadata, "added_at", item, "added_at"); + set_fields(&chapter->metadata, album, album_fields, FF_ARRAY_ELEMS(album_fields)); + set_names(&chapter->metadata, "artist", json_lookup(track, "artists")); + set_names(&chapter->metadata, "album_artist", json_lookup(album, "artists")); + if (json_lookup(track, "track_number") && json_lookup(album, "total_tracks")) + av_dict_set(&chapter->metadata, "track", + av_asprintf("%s/%s", json_lookup_str(track, "track_number"), + json_lookup_str(album, "total_tracks")), + AV_DICT_DONT_STRDUP_VAL); + return 0; +} + +static int add_tracks(AVFormatContext *s, json_object *paging, json_object *album) +{ + json_object *page = NULL; + int ret = 0; + + for (;;) { + json_object *items = json_lookup(paging, "items"); + char *next; + + for (int i = 0; items && i < json_object_array_length(items); i++) { + ret = track_add(s, json_object_array_get_idx(items, i), album); + if (ret < 0) + break; + } + next = ret >= 0 ? av_strdup(json_lookup_str(paging, "next")) : NULL; + json_object_put(page); + if (!next) + return ret; + ret = api_get(s, next, &page); + av_free(next); + if (ret < 0) + return ret; + paging = page; + } +} + +static void shuffle_tracks(AVFormatContext *s) +{ + SpotifyContext *c = s->priv_data; + AVLFG lfg; + + av_lfg_init(&lfg, c->seed == -1 ? av_get_random_seed() : c->seed); + for (int i = s->nb_chapters - 1; i > 0; i--) { + int j = av_lfg_get(&lfg) % (i + 1); + SpotifyID id; + + FFSWAP(AVChapter *, s->chapters[i], s->chapters[j]); + memcpy(id, c->ids[i], sizeof(id)); + memcpy(c->ids[i], c->ids[j], sizeof(id)); + memcpy(c->ids[j], id, sizeof(id)); + } +} + +static void track_close(SpotifyContext *c) +{ + avformat_close_input(&c->ogg); + if (c->pb) + av_freep(&c->pb->buffer); + avio_context_free(&c->pb); + if (c->read_fd >= 0) + close(c->read_fd); + if (c->fd >= 0) + librespotc_close(c->fd); + c->fd = c->read_fd = -1; +} + +static int track_read(void *opaque, uint8_t *buf, int size) +{ + AVFormatContext *s = opaque; + SpotifyContext *c = s->priv_data; + struct pollfd pfd = { .fd = c->read_fd, .events = POLLIN }; + + if (c->replayed < c->hdr_len) { + size = FFMIN(size, c->hdr_len - c->replayed); + memcpy(buf, c->hdr + c->replayed, size); + c->replayed += size; + return size; + } + + for (;;) { + int n = read(c->read_fd, buf, size); + + if (n > 0) { + c->received += n; + if (c->caching_hdr) { + void *hdr = av_fast_realloc(c->hdr, &c->hdr_size, c->hdr_len + n); + if (!hdr) + return AVERROR(ENOMEM); + c->hdr = hdr; + memcpy(c->hdr + c->hdr_len, buf, n); + c->hdr_len += n; + } + return n; + } + if (!n) + return AVERROR_EOF; + if (errno != EAGAIN && errno != EINTR) + return AVERROR(errno); + if (ff_check_interrupt(&s->interrupt_callback)) + return AVERROR_EXIT; + poll(&pfd, 1, 100); + } +} + +// Starts streaming chapter cur, from a byte offset estimated for seek_ms when +// that is nonzero +static int track_open(AVFormatContext *s, int64_t seek_ms) +{ + SpotifyContext *c = s->priv_data; + AVChapter *chapter = s->chapters[c->cur]; + struct sp_metadata metadata; + char path[64]; + uint8_t *buf; + size_t pos = 0; + int ret; + + if (seek_ms && c->hdr_track != c->cur) { + if ((ret = track_open(s, 0)) < 0) + return ret; + track_close(c); + } + if (!seek_ms && !c->seek_retries) { + AVDictionaryEntry *artist = av_dict_get(chapter->metadata, "artist", NULL, 0); + AVDictionaryEntry *title = av_dict_get(chapter->metadata, "title", NULL, 0); + + av_log(s, AV_LOG_INFO, "Track %d/%d: %s - %s\n", c->cur + 1, s->nb_chapters, + artist ? artist->value : "", title ? title->value : ""); + } + + snprintf(path, sizeof(path), "spotify:track:%s", c->ids[c->cur]); + ret = librespotc_open(path, c->session); + if (ret < 0) { + av_log(s, AV_LOG_ERROR, "Opening %s failed: %s\n", path, librespotc_last_errmsg()); + return ret == SP_ERR_NOCONNECTION || ret == SP_ERR_TIMEOUT ? AVERROR(ETIMEDOUT) : AVERROR_EXTERNAL; + } + c->fd = ret; + c->read_fd = dup(c->fd); + if (c->read_fd < 0) { + ret = AVERROR(errno); + goto fail; + } + if (librespotc_metadata_get(&metadata, c->fd) < 0) { + av_log(s, AV_LOG_ERROR, "Getting the size of %s failed: %s\n", path, + librespotc_last_errmsg()); + ret = AVERROR_EXTERNAL; + goto fail; + } + c->file_len = metadata.file_len; + + if (seek_ms && chapter->end > chapter->start) { + int64_t margin = ((int64_t)c->file_len / 32 + 65536) << c->seek_retries; + int64_t estimate = (int64_t)c->file_len * seek_ms / (chapter->end - chapter->start); + + pos = FFMAX(estimate - margin, 0); + if (pos && librespotc_seek(c->fd, pos) < 0) { + av_log(s, AV_LOG_ERROR, "Seeking in %s failed: %s\n", path, librespotc_last_errmsg()); + ret = AVERROR_EXTERNAL; + goto fail; + } + } + c->received = pos; + c->replayed = pos ? 0 : c->hdr_len; + c->caching_hdr = !pos && c->hdr_track != c->cur; + if (c->caching_hdr) { + c->hdr_len = 0; + c->hdr_track = c->cur; + } + librespotc_write(c->fd, NULL, NULL); + + buf = av_malloc(IO_BUFFER_SIZE); + if (!buf) { + ret = AVERROR(ENOMEM); + goto fail; + } + c->pb = avio_alloc_context(buf, IO_BUFFER_SIZE, 0, s, track_read, NULL, NULL); + c->ogg = avformat_alloc_context(); + if (!c->pb || !c->ogg) { + if (!c->pb) + av_free(buf); + ret = AVERROR(ENOMEM); + goto fail; + } + c->ogg->pb = c->pb; + c->ogg->flags |= AVFMT_FLAG_CUSTOM_IO; + c->ogg->interrupt_callback = s->interrupt_callback; + ret = ff_copy_whiteblacklists(c->ogg, s); + if (ret < 0) + goto fail; + ret = avformat_open_input(&c->ogg, path, av_find_input_format("ogg"), NULL); + if (ret < 0) + goto fail; + if (c->caching_hdr) { + c->hdr_len = avio_tell(c->ogg->pb); + c->replayed = c->hdr_len; + c->caching_hdr = 0; + } + c->new_track = 1; + return 0; + +fail: + track_close(c); + return ret; +} + +// Continues chapter cur at the in-track position pos, in the stream time base +static void track_seek(AVFormatContext *s, int64_t pos) +{ + SpotifyContext *c = s->priv_data; + + track_close(c); + c->pos = pos; + c->seek_ms = av_rescale_q(pos, s->streams[0]->time_base, ms_tb); + c->seek_retries = 0; + c->skip_until = pos ? pos : AV_NOPTS_VALUE; +} + +static void track_next(AVFormatContext *s) +{ + SpotifyContext *c = s->priv_data; + + c->cur++; + track_seek(s, 0); +} + +static int spotify_probe(const AVProbeData *p) +{ + return av_strstart(p->filename, "spotify:", NULL) || + av_strstart(p->filename, "https://open.spotify.com/", NULL) ? AVPROBE_SCORE_MAX : 0; +} + +static int spotify_read_header(AVFormatContext *s) +{ + SpotifyContext *c = s->priv_data; + const char *url = s->url; + char kind[16] = "", id[ID_LEN + 1] = "", api[128]; + json_object *root = NULL; + SpotifyCredentials *login; + int is_collection, is_track; + AVStream *st; + int64_t end = 0; + int ret; + + c->fd = -1; + c->read_fd = -1; + c->hdr_track = -1; + c->skip_until = AV_NOPTS_VALUE; + + ret = load_credentials(s); + if (ret < 0) + return ret; + login = c->playback.token ? &c->playback : &c->api; + if (c->cache_ttl) { + ret = cache_open(s); + if (ret < 0) + return ret; + } + if (!av_strstart(url, "spotify:", &url)) + av_strstart(url, "https://open.spotify.com/", &url); + sscanf(url, "%15[a-z]%*[:/]%22[A-Za-z0-9]", kind, id); + is_collection = !strcmp(kind, "collection"); + is_track = !strcmp(kind, "track"); + if (is_collection) + snprintf(api, sizeof(api), API_URL "me/tracks?limit=50&market=from_token"); + else if (strlen(id) == ID_LEN && (is_track || !strcmp(kind, "album") || !strcmp(kind, "playlist"))) + snprintf(api, sizeof(api), API_URL "%ss/%s?market=from_token", kind, id); + else { + av_log(s, AV_LOG_ERROR, "Unsupported Spotify URL %s\n", s->url); + return AVERROR(EINVAL); + } + + ret = librespot_ref(s, login->client_id); + if (ret < 0) + return ret; + c->initialized = 1; + + if (!c->username) { + const char *product; + + ret = api_get(s, API_URL "me", &root); + if (ret < 0) + return ret; + product = json_lookup_str(root, "product"); + if (product && strcmp(product, "premium")) { + av_log(s, AV_LOG_ERROR, "Spotify Premium is required, this account is \"%s\"\n", product); + ret = AVERROR(EPERM); + } + c->username = av_strdup(json_lookup_str(root, "id")); + json_object_put(root); + if (ret < 0) + return ret; + if (!c->username) + return AVERROR_INVALIDDATA; + } + + c->session = librespotc_login_token(c->username, login->token); + if (!c->session) { + av_log(s, AV_LOG_ERROR, "Spotify login failed: %s\n", librespotc_last_errmsg()); + return AVERROR_EXTERNAL; + } + librespotc_bitrate_set(c->session, c->bitrate < 160 ? SP_BITRATE_96 : + c->bitrate < 320 ? SP_BITRATE_160 : SP_BITRATE_320); + + ret = api_get(s, api, &root); + if (ret < 0) + return ret; + if (is_track) { + ret = track_add(s, root, NULL); + if (s->nb_chapters) + av_dict_copy(&s->metadata, s->chapters[0]->metadata, 0); + } else { + json_object *album = strcmp(kind, "album") ? NULL : root; + + av_dict_set(&s->metadata, "title", + is_collection ? "Liked Songs" : json_lookup_str(root, "name"), 0); + set_names(&s->metadata, "artist", json_lookup(album, "artists")); + ret = add_tracks(s, is_collection ? root : json_lookup(root, "tracks"), album); + } + json_object_put(root); + if (ret < 0) + return ret; + if (!s->nb_chapters) { + av_log(s, AV_LOG_ERROR, "No playable tracks\n"); + return AVERROR_INVALIDDATA; + } + + if (c->shuffle) + shuffle_tracks(s); + for (int i = 0; i < s->nb_chapters; i++) { + AVChapter *chapter = s->chapters[i]; + + chapter->id = i; + chapter->start = end; + end += chapter->end; + chapter->end = end; + } + + while ((ret = track_open(s, 0)) == AVERROR_EXTERNAL && ++c->cur < s->nb_chapters) + ; + if (ret < 0) + return ret; + c->new_track = 0; + + st = avformat_new_stream(s, NULL); + if (!st) + return AVERROR(ENOMEM); + ret = avcodec_parameters_copy(st->codecpar, c->ogg->streams[0]->codecpar); + if (ret < 0) + return ret; + avpriv_set_pts_info(st, 64, c->ogg->streams[0]->time_base.num, c->ogg->streams[0]->time_base.den); + st->start_time = 0; + st->duration = av_rescale_q(end, ms_tb, st->time_base); + return 0; +} + +static int track_changed(AVFormatContext *s, AVPacket *pkt) +{ + SpotifyContext *c = s->priv_data; + AVStream *st = s->streams[0]; + AVCodecParameters *par = c->ogg->streams[0]->codecpar; + AVDictionary *metadata = s->chapters[c->cur]->metadata; + uint8_t *extradata = av_packet_new_side_data(pkt, AV_PKT_DATA_NEW_EXTRADATA, par->extradata_size); + size_t size; + uint8_t *packed; + int ret; + + if (!extradata) + return AVERROR(ENOMEM); + memcpy(extradata, par->extradata, par->extradata_size); + + packed = av_packet_pack_dictionary(metadata, &size); + if (!packed) + return AVERROR(ENOMEM); + ret = av_packet_add_side_data(pkt, AV_PKT_DATA_STRINGS_METADATA, packed, size); + if (ret < 0) { + av_free(packed); + return ret; + } + + av_dict_free(&st->metadata); + st->event_flags |= AVSTREAM_EVENT_FLAG_METADATA_UPDATED; + return av_dict_copy(&st->metadata, metadata, 0); +} + +static int spotify_read_packet(AVFormatContext *s, AVPacket *pkt) +{ + SpotifyContext *c = s->priv_data; + AVStream *st = s->streams[0]; + int ret; + + for (;;) { + AVChapter *chapter; + int64_t start; + + if (!c->ogg) { + if (c->cur >= s->nb_chapters) + return AVERROR_EOF; + ret = track_open(s, c->seek_ms); + if (ret >= 0) { + c->open_retries = 0; + } else if (ret == AVERROR(ETIMEDOUT) && c->open_retries++ < MAX_OPEN_RETRIES) { + av_log(s, AV_LOG_WARNING, "Retrying in %d seconds\n", OPEN_RETRY_DELAY); + if (ff_network_sleep_interruptible(OPEN_RETRY_DELAY * 1000000LL, &s->interrupt_callback) == AVERROR_EXIT) + return AVERROR_EXIT; + } else if (ret == AVERROR_EXTERNAL) { + track_next(s); + } else { + return ret; + } + continue; + } + + ret = av_read_frame(c->ogg, pkt); + if (ret < 0 && ret != AVERROR_EXIT && c->received < c->file_len && c->resumes < MAX_RESUMES) { + av_log(s, AV_LOG_WARNING, "Track ended after %"PRId64" of %zu bytes, resuming\n", + c->received, c->file_len); + c->resumes++; + track_seek(s, c->pos); + continue; + } + if (ret == AVERROR_EOF) { + track_next(s); + continue; + } + if (ret < 0) + return ret; + av_packet_rescale_ts(pkt, c->ogg->streams[pkt->stream_index]->time_base, st->time_base); + + if (c->skip_until != AV_NOPTS_VALUE) { + if (pkt->pts == AV_NOPTS_VALUE || pkt->pts < c->skip_until) { + av_packet_unref(pkt); + continue; + } + if (pkt->pts > c->skip_until + av_rescale_q(2000, ms_tb, st->time_base) && + c->seek_retries < MAX_SEEK_RETRIES) { + av_packet_unref(pkt); + track_close(c); + c->seek_retries++; + continue; + } + c->skip_until = AV_NOPTS_VALUE; + } + if (pkt->pts != AV_NOPTS_VALUE) + c->pos = pkt->pts; + c->pos += pkt->duration; + c->resumes = 0; + + chapter = s->chapters[c->cur]; + start = av_rescale_q(chapter->start, ms_tb, st->time_base); + if (pkt->pts != AV_NOPTS_VALUE) + pkt->pts += start; + if (pkt->dts != AV_NOPTS_VALUE) + pkt->dts += start; + pkt->stream_index = 0; + + if (c->new_track) { + c->new_track = 0; + ret = track_changed(s, pkt); + if (ret < 0) { + av_packet_unref(pkt); + return ret; + } + } + return 0; + } +} + +static int spotify_read_seek(AVFormatContext *s, int stream_index, int64_t timestamp, int flags) +{ + SpotifyContext *c = s->priv_data; + AVStream *st = s->streams[0]; + int64_t ms = av_rescale_q(timestamp, st->time_base, ms_tb); + int i; + + for (i = 0; i < s->nb_chapters - 1 && ms >= s->chapters[i]->end; i++) + ; + c->cur = i; + track_seek(s, FFMAX(timestamp - av_rescale_q(s->chapters[i]->start, ms_tb, st->time_base), 0)); + return 0; +} + +static int spotify_read_close(AVFormatContext *s) +{ + SpotifyContext *c = s->priv_data; + + track_close(c); + if (c->session) + librespotc_logout(c->session); + if (c->initialized) + librespot_unref(); + av_freep(&c->api.token); + av_freep(&c->playback.token); + av_freep(&c->hdr); + av_freep(&c->ids); + return 0; +} + +#define OFFSET(x) offsetof(SpotifyContext, x) +#define D AV_OPT_FLAG_DECODING_PARAM +static const AVOption options[] = { + { "credentials", "JSON file with the OAuth credentials written by tools/spotify_token.py", OFFSET(credentials), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D }, + { "cache_dir", "directory caching the Web API replies", OFFSET(cache_dir), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D }, + { "cache_ttl", "seconds a cached Web API reply is used, 0 disables the cache", OFFSET(cache_ttl), AV_OPT_TYPE_INT, { .i64 = 3600 }, 0, INT_MAX, D }, + { "username", "Spotify username, looked up from the token if unset", OFFSET(username), AV_OPT_TYPE_STRING, { .str = NULL }, 0, 0, D }, + { "bitrate", "preferred bitrate in kbit/s (96, 160 or 320)", OFFSET(bitrate), AV_OPT_TYPE_INT, { .i64 = 320 }, 96, 320, D }, + { "shuffle", "play the tracks in random order", OFFSET(shuffle), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1, D }, + { "seed", "seed of the shuffle order, -1 for a random one", OFFSET(seed), AV_OPT_TYPE_INT64, { .i64 = -1 }, -1, UINT32_MAX, D }, + { NULL } +}; + +static const AVClass spotify_class = { + .class_name = "spotify", + .item_name = av_default_item_name, + .option = options, + .version = LIBAVUTIL_VERSION_INT, +}; + +const FFInputFormat ff_spotify_demuxer = { + .p.name = "spotify", + .p.long_name = NULL_IF_CONFIG_SMALL("Spotify (via librespot-c)"), + .p.flags = AVFMT_NOFILE, + .p.priv_class = &spotify_class, + .priv_data_size = sizeof(SpotifyContext), + .flags_internal = FF_INFMT_FLAG_INIT_CLEANUP, + .read_probe = spotify_probe, + .read_header = spotify_read_header, + .read_packet = spotify_read_packet, + .read_seek = spotify_read_seek, + .read_close = spotify_read_close, +}; -- 2.52.0 _______________________________________________ ffmpeg-devel mailing list -- [email protected] To unsubscribe send an email to [email protected]
