From c845f4db505533b31cc0a11377d2ba54e339b808 Mon Sep 17 00:00:00 2001 From: whatdoineed2do/Ray Date: Thu, 9 Nov 2023 20:52:42 +0000 Subject: [PATCH] [library,scan] library api to automatically write metadata upon to media update changes (at this time 'rating') Implemented for filescanner via ffmpeg --- src/library.c | 17 ++- src/library.h | 5 + src/library/filescanner.c | 96 ++++++++++++- src/library/filescanner_ffmpeg.c | 240 +++++++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 7 deletions(-) diff --git a/src/library.c b/src/library.c index 4463d9a6b3..1c34c23077 100644 --- a/src/library.c +++ b/src/library.c @@ -119,6 +119,9 @@ static struct library_callback_register library_cb_register[LIBRARY_MAX_CALLBACK int library_media_save(struct media_file_info *mfi) { + int ret, i; + struct library_source **ls; + if (!mfi->path || !mfi->fname || !mfi->scan_kind) { DPRINTF(E_LOG, L_LIB, "Ignoring media file with missing values (path='%s', fname='%s', scan_kind='%d', data_kind='%d')\n", @@ -134,9 +137,19 @@ library_media_save(struct media_file_info *mfi) } if (mfi->id == 0) - return db_file_add(mfi); + ret = db_file_add(mfi); else - return db_file_update(mfi); + { + ret = db_file_update(mfi); + + ls = sources; + for (i=0; ls[i]; ++i) + { + if (!ls[i]->disabled && ls[i]->write_metadata) + ls[i]->write_metadata(mfi->virtual_path, NULL, mfi->rating); + } + } + return ret; } int diff --git a/src/library.h b/src/library.h index 1019a2fc7c..a9265155fa 100644 --- a/src/library.h +++ b/src/library.h @@ -88,6 +88,11 @@ struct library_source */ int (*fullrescan)(void); + /* + * write meta to media, via virtual_path OR id + */ + int (*write_metadata)(const char *virtual_path, const uint32_t *id, uint32_t rating); + /* * Add an item to the library */ diff --git a/src/library/filescanner.c b/src/library/filescanner.c index 2cf0a03298..6c72edb228 100644 --- a/src/library/filescanner.c +++ b/src/library/filescanner.c @@ -76,6 +76,13 @@ #define F_SCAN_TYPE_AUDIOBOOK (1 << 2) #define F_SCAN_TYPE_COMPILATION (1 << 3) +#ifdef __linux__ +#define INOTIFY_FLAGS (IN_ATTRIB | IN_CREATE | IN_DELETE | IN_CLOSE_WRITE | IN_MOVE | IN_DELETE | IN_MOVE_SELF) +#else +#define INOTIFY_FLAGS (IN_CREATE | IN_DELETE | IN_MOVE) +#endif + + enum file_type { FILE_UNKNOWN = 0, @@ -156,6 +163,8 @@ filescanner_rescan(); static int filescanner_fullrescan(); +int +filescanner_ffmpeg_write_rating(const struct media_file_info *mfi); /* ----------------------- Internal utility functions --------------------- */ @@ -906,11 +915,7 @@ process_directory(char *path, int parent_id, int flags) // Add inotify watch (for FreeBSD we limit the flags so only dirs will be // opened, otherwise we will be opening way too many files) -#ifdef __linux__ - wi.wd = inotify_add_watch(inofd, path, IN_ATTRIB | IN_CREATE | IN_DELETE | IN_CLOSE_WRITE | IN_MOVE | IN_DELETE | IN_MOVE_SELF); -#else - wi.wd = inotify_add_watch(inofd, path, IN_CREATE | IN_DELETE | IN_MOVE); -#endif + wi.wd = inotify_add_watch(inofd, path, INOTIFY_FLAGS); if (wi.wd < 0) { DPRINTF(E_WARN, L_SCAN, "Could not create inotify watch for %s: %s\n", path, strerror(errno)); @@ -1719,6 +1724,86 @@ filescanner_fullrescan() return 0; } +static int +filescanner_write_metadata(const char *virtual_path, const uint32_t *id, uint32_t rating) +{ + int ret; + char inotify_path[PATH_MAX] = { 0 }; + struct watch_info wi = { 0 }; + struct media_file_info* mfi = NULL; + + if (virtual_path) + { + mfi = db_file_fetch_byvirtualpath(virtual_path); + if (!mfi) + { + DPRINTF(E_INFO, L_SCAN, "No known path for local media, (%s) requested for rating write\n", virtual_path); + return -1; + } + } + else + { + // Determine if this is local media + mfi = db_file_fetch_byid(*id); + if (!mfi) + { + DPRINTF(E_LOG, L_SCAN, "No known path for local media, (%d) requested for rating write\n", *id); + return -1; + } + } + + if (mfi->data_kind != DATA_KIND_FILE) + { + DPRINTF(E_INFO, L_SCAN, "Ignoring non local media (%d/%s is %s) requested for rating write\n", mfi->id, mfi->path, db_data_kind_label(mfi->data_kind)); + ret = -1; + goto cleanup; + } + + // Inotify watches dir paths + strcpy(inotify_path, mfi->path); + dirname(inotify_path); + + if (access(mfi->path, W_OK) < 0 || access(inotify_path, W_OK) < 0) + { + DPRINTF(E_INFO, L_SCAN, "No permissions to update metadata, skipping %s\n", mfi->path); + ret = 0; + goto cleanup; + } + + // Temporarily disable inotify + ret = db_watch_get_bypath(&wi, inotify_path); + if (ret == 0) + { + inotify_rm_watch(inofd, wi.wd); + db_watch_delete_bywd(wi.wd); + free_wi(&wi, 1); + } + + + filescanner_ffmpeg_write_rating(mfi); + + + // and re-enable + wi.wd = inotify_add_watch(inofd, inotify_path, INOTIFY_FLAGS); + if (wi.wd < 0) + { + DPRINTF(E_WARN, L_SCAN, "Could not create inotify watch for %s: %s\n", inotify_path, strerror(errno)); + ret = -1; + goto cleanup; + } + + wi.cookie = 0; + wi.path = inotify_path; + + db_watch_add(&wi); + +cleanup: + free_mfi(mfi, 0); + + return ret; +} + + static int queue_item_stream_add(const char *path, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id) { @@ -2166,6 +2251,7 @@ struct library_source filescanner = .rescan = filescanner_rescan, .metarescan = filescanner_metarescan, .fullrescan = filescanner_fullrescan, + .write_metadata = filescanner_write_metadata, .playlist_item_add = playlist_item_add, .playlist_remove = playlist_remove, .queue_save = queue_save, diff --git a/src/library/filescanner_ffmpeg.c b/src/library/filescanner_ffmpeg.c index a2f1a28e2d..f7ba9b24b6 100644 --- a/src/library/filescanner_ffmpeg.c +++ b/src/library/filescanner_ffmpeg.c @@ -24,6 +24,7 @@ #include #include #include +#include #include @@ -801,3 +802,242 @@ scan_metadata_ffmpeg(struct media_file_info *mfi, const char *file) return 0; } + +// based on FFmpeg's doc/examples and in particular mux.c +static int +file_write_rating(AVFormatContext* in_fmt_ctx, const char* new_rating_file) +{ + int ret; + + AVFormatContext *out_fmt_ctx = NULL; + AVPacket pkt; + const AVDictionaryEntry *tag; + AVDictionary *opts; + AVStream *out_stream; + AVStream *in_stream; + AVCodecParameters *in_codecpar; +#if (LIBAVCODEC_VERSION_MAJOR > 59) || ((LIBAVCODEC_VERSION_MAJOR == 59) && (LIBAVCODEC_VERSION_MINOR >= 0) && (LIBAVCODEC_VERSION_MICRO >= 100)) + const +#endif + struct AVOutputFormat *out_fmt = NULL; + + int i; + int stream_idx; + int *stream_mapping = NULL; + + + if ((ret = avformat_find_stream_info (in_fmt_ctx, NULL)) < 0) + { + DPRINTF(E_LOG, L_SCAN, "Failed to retrieve input stream information '%s' - %s\n", in_fmt_ctx->url, av_err2str(ret)); + goto end; + } + + // The output file has an extension that is to be ignored by the server so its not going to get scanned + // by the library (and added to db) only to be removed shortly after - this is why we look at the input + // file name to guess output fmt + out_fmt = av_guess_format(in_fmt_ctx->iformat->name, in_fmt_ctx->url, in_fmt_ctx->iformat->mime_type); + if (out_fmt == NULL) + { + DPRINTF(E_LOG, L_SCAN, "Could not determine output format from '%s'\n", in_fmt_ctx->url); + ret = AVERROR_UNKNOWN; + goto end; + } + + ret = avformat_alloc_output_context2 (&out_fmt_ctx, out_fmt, NULL, NULL); + if (!out_fmt_ctx) + { + DPRINTF(E_LOG, L_SCAN, "Could not create output context '%s' - %s\n", in_fmt_ctx->url, av_err2str(ret)); + ret = AVERROR_UNKNOWN; + goto end; + } + + stream_mapping = av_calloc(in_fmt_ctx->nb_streams, sizeof (*stream_mapping)); + + if (!stream_mapping) + { + ret = AVERROR (ENOMEM); + goto end; + } + + // copy the basic/generic meta + tag = NULL; + while ((tag = av_dict_iterate(in_fmt_ctx->metadata, tag))) + { + av_dict_set(&(out_fmt_ctx->metadata), tag->key, tag->value, 0); + } + + stream_idx = 0; + for (i = 0; i < in_fmt_ctx->nb_streams; i++) + { + in_stream = in_fmt_ctx->streams[i]; + in_codecpar = in_stream->codecpar; + + stream_mapping[i] = stream_idx++; + out_stream = avformat_new_stream (out_fmt_ctx, NULL); + if (!out_stream) + { + DPRINTF(E_LOG, L_SCAN, "Failed allocating output stream '%s'\n", in_fmt_ctx->url); + ret = AVERROR_UNKNOWN; + goto end; + } + ret = avcodec_parameters_copy (out_stream->codecpar, in_codecpar); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Failed to copy codec parameters '%s' - %s\n", in_fmt_ctx->url, av_err2str(ret)); + goto end; + } + + if (in_stream->metadata) + { + tag = NULL; + while ((tag = av_dict_iterate(in_stream->metadata, tag))) + { + av_dict_set(&(out_stream->metadata), tag->key, tag->value, 0); + } + } + } + + ret = avio_open (&out_fmt_ctx->pb, new_rating_file, AVIO_FLAG_WRITE); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Could not open output rating file '%s' - %s\n", new_rating_file, av_err2str(ret)); + goto end; + } + + opts = NULL; + ret = avformat_write_header (out_fmt_ctx, &opts); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error occurred when writing output header: '%s' - %s\n", new_rating_file, av_err2str(ret)); + goto end; + } + + while (1) + { + ret = av_read_frame (in_fmt_ctx, &pkt); + if (ret < 0) + break; + + in_stream = in_fmt_ctx->streams[pkt.stream_index]; + if (pkt.stream_index >= in_fmt_ctx->nb_streams || stream_mapping[pkt.stream_index] < 0) + { + av_packet_unref (&pkt); + continue; + } + + pkt.stream_index = stream_mapping[pkt.stream_index]; + out_stream = out_fmt_ctx->streams[pkt.stream_index]; + + /* copy packet */ + pkt.pts = av_rescale_q_rnd (pkt.pts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + pkt.dts = av_rescale_q_rnd (pkt.dts, in_stream->time_base, out_stream->time_base, AV_ROUND_NEAR_INF | AV_ROUND_PASS_MINMAX); + pkt.duration = av_rescale_q (pkt.duration, in_stream->time_base, out_stream->time_base); + pkt.pos = -1; + + ret = av_interleaved_write_frame (out_fmt_ctx, &pkt); + av_packet_unref (&pkt); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error muxing pkt for rating '%s' - %s\n", in_fmt_ctx->url, av_err2str(ret)); + break; + } + } + av_write_trailer (out_fmt_ctx); + +end: + if (out_fmt_ctx && !(out_fmt_ctx->oformat->flags & AVFMT_NOFILE)) + { + avio_closep (&out_fmt_ctx->pb); + } + avformat_free_context (out_fmt_ctx); + av_freep (&stream_mapping); + if (ret < 0 && ret != AVERROR_EOF) + { + unlink(new_rating_file); + return -1; + } + return 0; +} + +int +filescanner_ffmpeg_write_rating(const struct media_file_info *mfi) +{ + int ret; + char rating[5] = { '\0' }; + char new_rating_file[PATH_MAX]; + int i; + bool supported = false; + AVDictionaryEntry *entry; + int fd = -1; + + AVFormatContext *ctx = NULL; + if ( (ret = avformat_open_input(&ctx, mfi->path, NULL, NULL)) != 0) + { + DPRINTF(E_LOG, L_SCAN, "Failed to open library file for rating metadata update '%s' - %s\n", mfi->path, av_err2str(ret)); + return -1; + } + + ret = -1; + for (i=0; inb_streams && !supported; ++i) + { + if (ctx->streams[i]->codecpar->codec_type != AVMEDIA_TYPE_AUDIO) + continue; + + switch (ctx->streams[i]->codecpar->codec_id) + { + // ffmpeg's metadata update are limited - some formats do not support + // rating update even though the write completes; keep this in sync with + // supported formats + case AV_CODEC_ID_FLAC: + case AV_CODEC_ID_MP3: + supported = true; + break; + + default: + DPRINTF(E_WARN, L_SCAN, "Unsupported metadata update for 'rating' on '%s' (%s) - skipping\n", mfi->path, avcodec_get_name(ctx->streams[i]->codecpar->codec_id)); + } + } + + if (!supported) + goto end; + + safe_snprintf_cat(rating, sizeof(rating)-1, "%d", mfi->rating); + + // Save a potential write if metadata on the underlying file matches requested rating + entry = av_dict_get(ctx->metadata, "rating", NULL, 0); + if ( !(entry == NULL || (entry && entry->value == NULL) || (entry && strcmp(entry->value, rating) != 0) )) + { + ret = 0; + goto end; + } + + av_dict_set(&ctx->metadata, "rating", rating, 0); + DPRINTF(E_LOG, L_SCAN, "Updating rating to %s on '%s'\n", rating, mfi->path); + + // We ignore the fd - we use mkstemps to generate a unique filename that is + // stored in 'new_rating_file' string; a string filename is required by ffmpeg + // to create the output + sprintf(new_rating_file, "%s.XXXXXX.metadata", ctx->url); + fd = mkstemps(new_rating_file, 9); + if (fd < 0) + { + DPRINTF(E_WARN, L_SCAN, "Failed to create temp rating file '%s - %s'\n", new_rating_file, strerror(errno)); + ret = -1; + goto end; + } + + file_write_rating(ctx, new_rating_file); + ret = rename(new_rating_file, ctx->url); + + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Failed to replace library rating file '%s' with temp '%s' - %s\n", ctx->url, new_rating_file, strerror(errno)); + unlink(new_rating_file); + } + +end: + avformat_close_input(&ctx); + if (fd) + close(fd); + return ret; +}