From 2dc448fa30dbeb3d016263c90bac3887f49b56ab Mon Sep 17 00:00:00 2001 From: whatdoineed2do Date: Wed, 24 Jan 2024 22:30:02 +0000 Subject: [PATCH] [scan/library] Media rating sync (#1681) Automatically read/write ratings to files in the library, if options read_rating/ write_rating are enabled. Also adds a max_rating so the user can set the rating scale. Doesn't sync automatic rating updates, because that could lead to whole-playlist file rewriting. Closes #1678 --------- Co-authored-by: whatdoineed2do/Ray Co-authored-by: ejurgensen --- owntone.conf.in | 10 + src/conffile.c | 3 + src/db.c | 123 +++++----- src/db.h | 17 +- src/httpd_dacp.c | 27 +-- src/httpd_jsonapi.c | 61 ++--- src/library.c | 124 +++++++++- src/library.h | 20 ++ src/library/filescanner.c | 20 +- src/library/filescanner.h | 6 + src/library/filescanner_ffmpeg.c | 386 ++++++++++++++++++++++++++++++- src/mpd.c | 20 +- 12 files changed, 648 insertions(+), 169 deletions(-) diff --git a/owntone.conf.in b/owntone.conf.in index 221e2f33..32c54de3 100644 --- a/owntone.conf.in +++ b/owntone.conf.in @@ -223,6 +223,16 @@ library { # new rating = 0.75 * stable rating + 0.25 * rolling rating) # rating_updates = false + # By default, ratings are only saved in the server's database. Enable + # the below to make the server also read ratings from file metadata and + # write on update (requires write access). To avoid excessive writing to + # the library, automatic rating updates are not written, even with the + # write_rating option enabled. +# read_rating = false +# write_rating = false + # The scale used when reading/writing ratings to files +# max_rating = 100 + # Allows creating, deleting and modifying m3u playlists in the library directories. # Only supported by the player web interface and some mpd clients # Defaults to being disabled. diff --git a/src/conffile.c b/src/conffile.c index bfbcfd72..b8b88634 100644 --- a/src/conffile.c +++ b/src/conffile.c @@ -116,6 +116,9 @@ static cfg_opt_t sec_library[] = CFG_INT("pipe_sample_rate", 44100, CFGF_NONE), CFG_INT("pipe_bits_per_sample", 16, CFGF_NONE), CFG_BOOL("rating_updates", cfg_false, CFGF_NONE), + CFG_BOOL("read_rating", cfg_false, CFGF_NONE), + CFG_BOOL("write_rating", cfg_false, CFGF_NONE), + CFG_INT("max_rating", 100, CFGF_NONE), CFG_BOOL("allow_modifying_stored_playlists", cfg_false, CFGF_NONE), CFG_STR("default_playlist_directory", NULL, CFGF_NONE), CFG_BOOL("clear_queue_on_stop_disable", cfg_false, CFGF_NONE), diff --git a/src/db.c b/src/db.c index 1f33a842..32a51624 100644 --- a/src/db.c +++ b/src/db.c @@ -2921,7 +2921,9 @@ db_file_inc_playcount_byfilter(const char *filter) return; } - ret = db_query_run(query, 1, 0); + // Perhaps this should in principle emit LISTENER_DATABASE, but that would + // cause a lot of useless cache updates + ret = db_query_run(query, 1, db_rating_updates ? LISTENER_RATING : 0); if (ret == 0) db_admin_setint64(DB_ADMIN_DB_MODIFIED, (int64_t) time(NULL)); #undef Q_TMPL @@ -2987,7 +2989,7 @@ db_file_inc_skipcount(int id) return; } - ret = db_query_run(query, 1, 0); + ret = db_query_run(query, 1, db_rating_updates ? LISTENER_RATING : 0); if (ret == 0) db_admin_setint64(DB_ADMIN_DB_MODIFIED, (int64_t) time(NULL)); #undef Q_TMPL @@ -3155,6 +3157,30 @@ db_file_id_byquery(const char *query) return ret; } +bool +db_file_id_exists(int id) +{ +#define Q_TMPL "SELECT f.id FROM files f WHERE f.id = %d;" + char *query; + int ret; + + query = sqlite3_mprintf(Q_TMPL, id); + if (!query) + { + DPRINTF(E_LOG, L_DB, "Out of memory for query string\n"); + + return 0; + } + + ret = db_file_id_byquery(query); + + sqlite3_free(query); + + return (id == ret); + +#undef Q_TMPL +} + int db_file_id_bypath(const char *path) { @@ -3228,13 +3254,37 @@ db_file_id_byurl(const char *url) } int -db_file_id_by_virtualpath_match(const char *path) +db_file_id_byvirtualpath(const char *virtual_path) +{ +#define Q_TMPL "SELECT f.id FROM files f WHERE f.virtual_path = %Q;" + char *query; + int ret; + + query = sqlite3_mprintf(Q_TMPL, virtual_path); + if (!query) + { + DPRINTF(E_LOG, L_DB, "Out of memory for query string\n"); + + return 0; + } + + ret = db_file_id_byquery(query); + + sqlite3_free(query); + + return ret; + +#undef Q_TMPL +} + +int +db_file_id_byvirtualpath_match(const char *virtual_path) { #define Q_TMPL "SELECT f.id FROM files f WHERE f.virtual_path LIKE '%%%q%%';" char *query; int ret; - query = sqlite3_mprintf(Q_TMPL, path); + query = sqlite3_mprintf(Q_TMPL, virtual_path); if (!query) { DPRINTF(E_LOG, L_DB, "Out of memory for query string\n"); @@ -3454,67 +3504,6 @@ db_file_seek_update(int id, uint32_t seek) #undef Q_TMPL } -static int -db_file_rating_update(char *query) -{ - int ret; - - ret = db_query_run(query, 1, 0); - - if (ret == 0) - { - db_admin_setint64(DB_ADMIN_DB_MODIFIED, (int64_t) time(NULL)); - listener_notify(LISTENER_RATING); - } - - return ((ret < 0) ? -1 : sqlite3_changes(hdl)); -} - -int -db_file_rating_update_byid(uint32_t id, uint32_t rating) -{ -#define Q_TMPL "UPDATE files SET rating = %d WHERE id = %d;" - char *query; - - query = sqlite3_mprintf(Q_TMPL, rating, id); - - return db_file_rating_update(query); -#undef Q_TMPL -} - -int -db_file_rating_update_byvirtualpath(const char *virtual_path, uint32_t rating) -{ -#define Q_TMPL "UPDATE files SET rating = %d WHERE virtual_path = %Q;" - char *query; - - query = sqlite3_mprintf(Q_TMPL, rating, virtual_path); - - return db_file_rating_update(query); -#undef Q_TMPL -} - -int -db_file_usermark_update_byid(uint32_t id, uint32_t usermark) -{ -#define Q_TMPL "UPDATE files SET usermark = %d WHERE id = %d;" - char *query; - int ret; - - query = sqlite3_mprintf(Q_TMPL, usermark, id); - - ret = db_query_run(query, 1, 0); - - if (ret == 0) - { - db_admin_setint64(DB_ADMIN_DB_MODIFIED, (int64_t) time(NULL)); - listener_notify(LISTENER_UPDATE); - } - - return ((ret < 0) ? -1 : sqlite3_changes(hdl)); -#undef Q_TMPL -} - void db_file_delete_bypath(const char *path) { @@ -6350,8 +6339,6 @@ db_watch_get_byquery(struct watch_info *wi, char *query) ret = db_blocking_step(stmt); if (ret != SQLITE_ROW) { - DPRINTF(E_WARN, L_DB, "Watch not found: '%s'\n", query); - sqlite3_finalize(stmt); sqlite3_free(query); return -1; @@ -6577,7 +6564,7 @@ db_watch_enum_fetchwd(struct watch_enum *we, uint32_t *wd) ret = db_blocking_step(we->stmt); if (ret == SQLITE_DONE) { - DPRINTF(E_INFO, L_DB, "End of watch enum results\n"); + DPRINTF(E_DBG, L_DB, "End of watch enum results\n"); return 0; } else if (ret != SQLITE_ROW) diff --git a/src/db.h b/src/db.h index fc4d7e7a..1e452685 100644 --- a/src/db.h +++ b/src/db.h @@ -685,6 +685,9 @@ db_file_ping_bymatch(const char *path, int isdir); char * db_file_path_byid(int id); +bool +db_file_id_exists(int id); + int db_file_id_bypath(const char *path); @@ -695,7 +698,10 @@ int db_file_id_byurl(const char *url); int -db_file_id_by_virtualpath_match(const char *path); +db_file_id_byvirtualpath(const char *virtual_path); + +int +db_file_id_byvirtualpath_match(const char *virtual_path); struct media_file_info * db_file_fetch_byid(int id); @@ -712,15 +718,6 @@ db_file_update(struct media_file_info *mfi); void db_file_seek_update(int id, uint32_t seek); -int -db_file_rating_update_byid(uint32_t id, uint32_t rating); - -int -db_file_usermark_update_byid(uint32_t id, uint32_t usermark); - -int -db_file_rating_update_byvirtualpath(const char *virtual_path, uint32_t rating); - void db_file_delete_bypath(const char *path); diff --git a/src/httpd_dacp.c b/src/httpd_dacp.c index c4d23146..446c94fe 100644 --- a/src/httpd_dacp.c +++ b/src/httpd_dacp.c @@ -40,6 +40,7 @@ #include "conffile.h" #include "artwork.h" #include "dmap_common.h" +#include "library.h" #include "db.h" #include "player.h" #include "listener.h" @@ -1106,31 +1107,7 @@ dacp_propset_userrating(const char *value, struct httpd_request *hreq) return; } - ret = db_file_rating_update_byid(itemid, rating); - - /* If no mfi, it may be because we sent an invalid nowplaying itemid. In this - * case request the real one from the player and default to that. - */ - if (ret == 0) - { - DPRINTF(E_WARN, L_DACP, "Invalid id %d for rating, defaulting to player id\n", itemid); - - ret = player_playing_now(&itemid); - if (ret < 0) - { - DPRINTF(E_WARN, L_DACP, "Could not find an id for rating\n"); - - return; - } - - ret = db_file_rating_update_byid(itemid, rating); - if (ret <= 0) - { - DPRINTF(E_WARN, L_DACP, "Could not find an id for rating\n"); - - return; - } - } + library_item_attrib_save(itemid, LIBRARY_ATTRIB_RATING, rating); } diff --git a/src/httpd_jsonapi.c b/src/httpd_jsonapi.c index b857f0f4..d38562be 100644 --- a/src/httpd_jsonapi.c +++ b/src/httpd_jsonapi.c @@ -334,25 +334,6 @@ track_to_json(struct db_media_file_info *dbmfi) return item; } -// TODO Only partially implemented. A full implementation should use a mapping -// table, which should also be used above in track_to_json(). It should also -// return errors if there are incorrect/mispelled fields, but not sure how to -// walk a json object with json-c. -static int -json_to_track(struct media_file_info *mfi, json_object *json) -{ - if (jparse_contains_key(json, "id", json_type_int)) - mfi->id = jparse_int_from_obj(json, "id"); - if (jparse_contains_key(json, "usermark", json_type_int)) - mfi->usermark = jparse_int_from_obj(json, "usermark"); - if (jparse_contains_key(json, "rating", json_type_int)) - mfi->rating = jparse_int_from_obj(json, "rating"); - if (jparse_contains_key(json, "play_count", json_type_int)) - mfi->play_count = jparse_int_from_obj(json, "play_count"); - - return HTTP_OK; -} - static json_object * playlist_to_json(struct db_playlist_info *dbpli) { @@ -3217,7 +3198,6 @@ jsonapi_reply_library_tracks_put(struct httpd_request *hreq) json_object *request = NULL; json_object *tracks; json_object *track = NULL; - struct media_file_info *mfi = NULL; int ret; int err; int32_t track_id; @@ -3251,30 +3231,21 @@ jsonapi_reply_library_tracks_put(struct httpd_request *hreq) goto error; } - mfi = db_file_fetch_byid(track_id); - if (!mfi) + if (!db_file_id_exists(track_id)) { DPRINTF(E_LOG, L_WEB, "Unknown track_id %d in json tracks request\n", track_id); err = HTTP_NOTFOUND; goto error; } - ret = json_to_track(mfi, track); - if (ret != HTTP_OK) - { - err = ret; - goto error; - } + // These are async, so no error check + if (jparse_contains_key(track, "rating", json_type_int)) + library_item_attrib_save(track_id, LIBRARY_ATTRIB_RATING, jparse_int_from_obj(track, "rating")); + if (jparse_contains_key(track, "usermark", json_type_int)) + library_item_attrib_save(track_id, LIBRARY_ATTRIB_USERMARK, jparse_int_from_obj(track, "usermark")); + if (jparse_contains_key(track, "play_count", json_type_int)) + library_item_attrib_save(track_id, LIBRARY_ATTRIB_PLAY_COUNT, jparse_int_from_obj(track, "play_count")); - ret = db_file_update(mfi); - if (ret < 0) - { - err = HTTP_INTERNAL; - goto error; - } - - free_mfi(mfi, 0); - mfi = NULL; i++; } @@ -3286,7 +3257,6 @@ jsonapi_reply_library_tracks_put(struct httpd_request *hreq) jparse_free(request); if (track) db_transaction_rollback(); - free_mfi(mfi, 0); return err; } @@ -3299,8 +3269,11 @@ jsonapi_reply_library_tracks_put_byid(struct httpd_request *hreq) int ret; ret = safe_atoi32(hreq->path_parts[3], &track_id); - if (ret < 0) - return HTTP_INTERNAL; + if (ret < 0 || !db_file_id_exists(track_id)) + { + DPRINTF(E_WARN, L_WEB, "Invalid or unknown track id in request '%s'\n", hreq->path); + return HTTP_NOTFOUND; + } param = httpd_query_value_find(hreq->query, "play_count"); if (param) @@ -3330,9 +3303,7 @@ jsonapi_reply_library_tracks_put_byid(struct httpd_request *hreq) return HTTP_BADREQUEST; } - ret = db_file_rating_update_byid(track_id, val); - if (ret < 0) - return HTTP_INTERNAL; + library_item_attrib_save(track_id, LIBRARY_ATTRIB_RATING, val); } // Retreive marked tracks via "/api/search?type=tracks&expression=usermark+=+1" @@ -3346,9 +3317,7 @@ jsonapi_reply_library_tracks_put_byid(struct httpd_request *hreq) return HTTP_BADREQUEST; } - ret = db_file_usermark_update_byid(track_id, val); - if (ret < 0) - return HTTP_INTERNAL; + library_item_attrib_save(track_id, LIBRARY_ATTRIB_USERMARK, val); } return HTTP_OK; diff --git a/src/library.c b/src/library.c index 8ea5d681..4d4f8428 100644 --- a/src/library.c +++ b/src/library.c @@ -67,6 +67,14 @@ struct queue_item_add_param int *new_item_id; }; +struct item_param +{ + const char *path; + uint32_t id; + enum library_attrib attrib; + uint32_t value; +}; + static struct commands_base *cmdbase; static pthread_t tid_library; @@ -119,6 +127,8 @@ static struct library_callback_register library_cb_register[LIBRARY_MAX_CALLBACK int library_media_save(struct media_file_info *mfi) { + int ret; + 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 +144,11 @@ 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); + + return ret; } int @@ -579,11 +591,11 @@ queue_save(void *arg, int *retval) static enum command_state item_add(void *arg, int *retval) { - const char *path = arg; + struct item_param *param = arg; int i; int ret = LIBRARY_ERROR; - DPRINTF(E_DBG, L_LIB, "Adding item to library '%s'\n", path); + DPRINTF(E_DBG, L_LIB, "Adding item to library '%s'\n", param->path); for (i = 0; sources[i]; i++) { @@ -593,11 +605,11 @@ item_add(void *arg, int *retval) continue; } - ret = sources[i]->item_add(path); + ret = sources[i]->item_add(param->path); if (ret == LIBRARY_OK) { - DPRINTF(E_DBG, L_LIB, "Add item to path '%s' with library source '%s'\n", path, db_scan_kind_label(sources[i]->scan_kind)); + DPRINTF(E_DBG, L_LIB, "Add item to path '%s' with library source '%s'\n", param->path, db_scan_kind_label(sources[i]->scan_kind)); listener_notify(LISTENER_DATABASE); break; } @@ -617,6 +629,87 @@ item_add(void *arg, int *retval) return COMMAND_END; } +static int +write_metadata(struct media_file_info *mfi) +{ + int ret; + int i; + + for (i = 0; sources[i]; i++) + { + if (sources[i]->disabled || !sources[i]->write_metadata) + continue; + + ret = sources[i]->write_metadata(mfi); + if (ret == LIBRARY_OK) + return ret; + } + + return LIBRARY_PATH_INVALID; +} + +static enum command_state +item_attrib_save(void *arg, int *retval) +{ + struct item_param *param = arg; + struct media_file_info *mfi = NULL; + int ret; + + if (scanning) + goto error; + + mfi = db_file_fetch_byid(param->id); + if (!mfi) + goto error; + + *retval = LIBRARY_OK; + + switch (param->attrib) + { + case LIBRARY_ATTRIB_RATING: + if (param->value < 0 || param->value > DB_FILES_RATING_MAX) + goto error; + + mfi->rating = param->value; + + if (cfg_getbool(cfg_getsec(cfg, "library"), "write_rating")) + *retval = write_metadata(mfi); + + listener_notify(LISTENER_RATING); + break; + + case LIBRARY_ATTRIB_USERMARK: + if (param->value < 0) + goto error; + + mfi->usermark = param->value; + break; + + case LIBRARY_ATTRIB_PLAY_COUNT: + if (param->value < 0) + goto error; + + mfi->play_count = param->value; + break; + + default: + goto error; + } + + ret = db_file_update(mfi); + if (ret < 0) + goto error; + + free_mfi(mfi, 0); + return COMMAND_END; + + error: + DPRINTF(E_LOG, L_LIB, "Error updating attribute %d to %d for file with id %d\n", param->attrib, param->value, param->id); + *retval = LIBRARY_ERROR; + free_mfi(mfi, 0); + return COMMAND_END; +} + // Callback to notify listeners of database changes static void update_trigger_cb(int fd, short what, void *arg) @@ -861,6 +954,8 @@ library_queue_item_add(const char *path, int position, char reshuffle, uint32_t int library_item_add(const char *path) { + struct item_param param; + if (scanning) { DPRINTF(E_INFO, L_LIB, "Scan already running, ignoring request to add item '%s'\n", path); @@ -869,7 +964,22 @@ library_item_add(const char *path) scanning = true; - return commands_exec_sync(cmdbase, item_add, NULL, (char *)path); + param.path = path; + + return commands_exec_sync(cmdbase, item_add, NULL, ¶m); +} + +void +library_item_attrib_save(uint32_t id, enum library_attrib attrib, uint32_t value) +{ + struct item_param *param; + + param = malloc(sizeof(struct item_param)); + param->id = id; + param->attrib = attrib; + param->value = value; + + commands_exec_async(cmdbase, item_attrib_save, param); } struct library_source ** diff --git a/src/library.h b/src/library.h index 1019a2fc..5201d951 100644 --- a/src/library.h +++ b/src/library.h @@ -47,6 +47,13 @@ enum library_cb_action LIBRARY_CB_DELETE, }; +enum library_attrib +{ + LIBRARY_ATTRIB_RATING, + LIBRARY_ATTRIB_USERMARK, + LIBRARY_ATTRIB_PLAY_COUNT, +}; + /* * Definition of a library source * @@ -88,6 +95,11 @@ struct library_source */ int (*fullrescan)(void); + /* + * Write metadata to an item in the library + */ + int (*write_metadata)(struct media_file_info *mfi); + /* * Add an item to the library */ @@ -219,6 +231,14 @@ library_queue_item_add(const char *path, int position, char reshuffle, uint32_t int library_item_add(const char *path); +/* + * Async function to set selected attributes for an item in the library. In case + * of ratrings also writes the rating to the source if the "write_rating" config + * option is enabled. + */ +void +library_item_attrib_save(uint32_t id, enum library_attrib attrib, uint32_t value); + struct library_source ** library_sources(void); diff --git a/src/library/filescanner.c b/src/library/filescanner.c index 5b12bbbd..54b6d257 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, @@ -906,11 +913,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 +1722,12 @@ filescanner_fullrescan() return 0; } +static int +filescanner_write_metadata(struct media_file_info *mfi) +{ + return write_metadata_ffmpeg(mfi); +} + static int queue_item_file_add(const char *sub_uri, int position, char reshuffle, uint32_t item_id, int *count, int *new_item_id) { @@ -2217,6 +2226,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.h b/src/library/filescanner.h index 72200f12..7000faa1 100644 --- a/src/library/filescanner.h +++ b/src/library/filescanner.h @@ -77,4 +77,10 @@ playlist_fill(struct playlist_info *pli, const char *path); int playlist_add(const char *path); + +/* --------------------------------- Other -------------------------------- */ + +int +write_metadata_ffmpeg(const struct media_file_info *mfi); + #endif /* !__FILESCANNER_H__ */ diff --git a/src/library/filescanner_ffmpeg.c b/src/library/filescanner_ffmpeg.c index 4d92dc91..dadec411 100644 --- a/src/library/filescanner_ffmpeg.c +++ b/src/library/filescanner_ffmpeg.c @@ -22,10 +22,22 @@ #include #include +#include #include #include +#include -#include +// For fstat() +#include +#include + +// For file copy +#include +#if defined(__APPLE__) || defined(__FreeBSD__) +#include +#else +#include +#endif #include #include @@ -174,6 +186,28 @@ parse_albumid(struct media_file_info *mfi, const char *id_string) return 1; } +static int +parse_rating(struct media_file_info *mfi, const char *rating_string) +{ + cfg_t *library = cfg_getsec(cfg, "library"); + int max_rating; + + if (!cfg_getbool(library, "read_rating")) + return 0; + + if (safe_atou32(rating_string, &mfi->rating) < 0) + return 0; + + // Make sure mfi->rating is in proper range + max_rating = cfg_getint(library, "max_rating"); + if (max_rating < 5) // Invalid config + max_rating = DB_FILES_RATING_MAX; + + mfi->rating = MIN(DB_FILES_RATING_MAX * mfi->rating / max_rating, DB_FILES_RATING_MAX); + return 1; +} + + /* Lookup is case-insensitive, first occurrence takes precedence */ static const struct metadata_map md_map_generic[] = { @@ -198,6 +232,7 @@ static const struct metadata_map md_map_generic[] = { "album-sort", 0, mfi_offsetof(album_sort), NULL }, { "compilation", 1, mfi_offsetof(compilation), NULL }, { "lyrics", 0, mfi_offsetof(lyrics), NULL, AV_DICT_IGNORE_SUFFIX }, + { "rating", 1, mfi_offsetof(rating), parse_rating }, // ALAC sort tags { "sort_name", 0, mfi_offsetof(title_sort), NULL }, @@ -768,3 +803,352 @@ scan_metadata_ffmpeg(struct media_file_info *mfi, const char *file) return 0; } + + +/* ----------------------- Writing metadata to files ------------------------ */ + +// Adapted from https://stackoverflow.com/questions/2180079/how-can-i-copy-a-file-on-unix-using-c +static int +fast_copy(int fd_dst, int fd_src) +{ + // Here we use kernel-space copying for performance reasons +#if defined(__APPLE__) || defined(__FreeBSD__) + // fcopyfile works on FreeBSD and OS X 10.5+ + return fcopyfile(fd_src, fd_dst, 0, COPYFILE_ALL); +#else + // sendfile will work with non-socket output (i.e. regular file) on Linux 2.6.33+ + struct stat fileinfo = { 0 }; + fstat(fd_src, &fileinfo); + return sendfile(fd_dst, fd_src, NULL, fileinfo.st_size); +#endif +} + +static int +file_copy(const char *dst, const char *src) +{ + int fd_src = -1; + int fd_dst = -1; + int ret; + + fd_src = open(src, O_RDONLY); + if (fd_src < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error opening source '%s' for copy: %s\n", src, strerror(errno)); + goto error; + } + + fd_dst = open(dst, O_WRONLY); + if (fd_src < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error opening destination '%s' for copy: %s\n", dst, strerror(errno)); + goto error; + } + + ret = fast_copy(fd_dst, fd_src); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error copying '%s' to file '%s': %s\n", src, dst, strerror(errno)); + goto error; + } + + close(fd_src); + close(fd_dst); + return 0; + + error: + if (fd_src != -1) + close(fd_src); + if (fd_dst != -1) + close(fd_dst); + return -1; +} + +static int +file_copy_to_tmp(char *dst, size_t dst_size, const char *src) +{ + int fd_src = -1; + int fd_dst = -1; + const char *ext; + int ret; + + ext = strrchr(src, '.'); + if (!ext || strlen(ext) < 2) + return -1; + + // Obviously, copying only requires read access, but we will need write access + // later, so let's fail early if it isn't going to work. + fd_src = open(src, O_RDWR); + if (fd_src < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error opening '%s' for metadata update: %s\n", src, strerror(errno)); + goto error; + } + + ret = snprintf(dst, dst_size, "/tmp/owntone.tmpXXXXXX%s", ext); + if (ret < 0 || ret >= dst_size) + { + DPRINTF(E_LOG, L_SCAN, "Error creating tmp file name\n"); + goto error; + } + + fd_dst = mkstemps(dst, strlen(ext)); + if (fd_dst < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error creating tmp file '%s' for metadata update: %s\n", dst, strerror(errno)); + goto error; + } + + ret = fast_copy(fd_dst, fd_src); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error copying '%s' to tmp file '%s': %s\n", src, dst, strerror(errno)); + goto error; + } + + close(fd_src); + close(fd_dst); + return 0; + + error: + if (fd_src != -1) + close(fd_src); + if (fd_dst != -1) + close(fd_dst); + return -1; +} + +// based on FFmpeg's doc/examples and in particular mux.c +static int +file_write_rating(const char *dst, const char *src, const char *rating) +{ + AVFormatContext *in_fmt_ctx = NULL; + AVFormatContext *out_fmt_ctx = NULL; + AVPacket pkt; + const AVDictionaryEntry *tag; + AVStream *out_stream; + AVStream *in_stream; +#if (LIBAVCODEC_VERSION_MAJOR > 59) || ((LIBAVCODEC_VERSION_MAJOR == 59) && (LIBAVCODEC_VERSION_MINOR >= 0) && (LIBAVCODEC_VERSION_MICRO >= 100)) + const AVOutputFormat *out_fmt; +#else + AVOutputFormat *out_fmt; +#endif + bool restore_src = false; + int ret; + int i; + int stream_idx; + int *stream_mapping = NULL; + + ret = avformat_open_input(&in_fmt_ctx, src, NULL, NULL); + if (ret != 0) + { + DPRINTF(E_LOG, L_SCAN, "Error opening tmpfile '%s' for rating metadata update: %s\n", src, av_err2str(ret)); + goto error; + } + + av_dict_set(&in_fmt_ctx->metadata, "rating", rating, 0); + + ret = avformat_find_stream_info(in_fmt_ctx, NULL); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error reading input stream information from '%s': %s\n", in_fmt_ctx->url, av_err2str(ret)); + goto error; + } + + 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); + goto error; + } + + ret = avformat_alloc_output_context2(&out_fmt_ctx, out_fmt, NULL, NULL); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Could not create output context '%s' - %s\n", in_fmt_ctx->url, av_err2str(ret)); + goto error; + } + + CHECK_NULL(L_SCAN, stream_mapping = av_calloc(in_fmt_ctx->nb_streams, sizeof(*stream_mapping))); + + 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]; + stream_mapping[i] = stream_idx++; + + out_stream = avformat_new_stream(out_fmt_ctx, NULL); + if (!out_stream) + { + DPRINTF(E_LOG, L_SCAN, "Error allocating output stream for '%s'\n", in_fmt_ctx->url); + goto error; + } + + ret = avcodec_parameters_copy(out_stream->codecpar, in_stream->codecpar); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error copying codec parameters from '%s': %s\n", in_fmt_ctx->url, av_err2str(ret)); + goto error; + } + + 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, dst, AVIO_FLAG_WRITE); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Could not open output rating file '%s': %s\n", dst, av_err2str(ret)); + goto error; + } + + ret = avformat_write_header(out_fmt_ctx, NULL); + if (ret < 0) + { + DPRINTF(E_LOG, L_SCAN, "Error occurred when writing output header to '%s': %s\n", dst, av_err2str(ret)); + goto error; + } + + while (1) + { + ret = av_read_frame(in_fmt_ctx, &pkt); + if (ret < 0) + { + if (ret == AVERROR_EOF) + break; + + DPRINTF(E_LOG, L_SCAN, "Error reading '%s': %s\n", in_fmt_ctx->url, av_err2str(ret)); + restore_src = true; + goto error; + } + + 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)); + restore_src = true; + goto error; + } + } + + av_write_trailer(out_fmt_ctx); + + 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); + return 0; + + error: + 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 (restore_src) + file_copy(dst, src); + return -1; +} + +static bool +file_rating_matches(const char *path, const char *rating) +{ + AVFormatContext *in_fmt_ctx = NULL; + AVDictionaryEntry *entry; + bool has_rating; + int ret; + + ret = avformat_open_input(&in_fmt_ctx, path, NULL, NULL); + if (ret != 0) + { + DPRINTF(E_LOG, L_SCAN, "Failed to open library file for rating metadata update '%s' - %s\n", path, av_err2str(ret)); + return true; // Return true so called aborts + } + + entry = av_dict_get(in_fmt_ctx->metadata, "rating", NULL, 0); + has_rating = (entry && entry->value && strcmp(entry->value, rating) == 0); + + avformat_close_input(&in_fmt_ctx); + + return has_rating; +} + +// ffmpeg's metadata update is limited - some formats do not support rating +// update even though the write completes; keep this in sync with supported +// formats +static bool +format_is_supported(const char *format) +{ + if (strcmp(format, "mp3") == 0) + return true; + if (strcmp(format, "flac") == 0) + return true; + + return false; +} + +int +write_metadata_ffmpeg(struct media_file_info *mfi) +{ + char rating_str[32]; + char tmpfile[PATH_MAX]; + int max_rating; + int file_rating; + int ret; + + if (mfi->data_kind != DATA_KIND_FILE || !format_is_supported(mfi->type)) + { + DPRINTF(E_WARN, L_SCAN, "Update of rating metadata requires file in MP3 or FLAC format: '%s'\n", mfi->path); + return -1; + } + + max_rating = cfg_getint(cfg_getsec(cfg, "library"), "max_rating"); + if (max_rating < 5) // Invalid config + max_rating = DB_FILES_RATING_MAX; + file_rating = mfi->rating * max_rating / DB_FILES_RATING_MAX; + snprintf(rating_str, sizeof(rating_str), "%d", file_rating); + + // Save a write if metadata of the underlying file matches requested rating + if (file_rating_matches(mfi->path, rating_str)) + return 0; + + ret = file_copy_to_tmp(tmpfile, sizeof(tmpfile), mfi->path); + if (ret < 0) + return -1; + + ret = file_write_rating(mfi->path, tmpfile, rating_str); + unlink(tmpfile); + if (ret < 0) + return -1; + + DPRINTF(E_DBG, L_SCAN, "Wrote rating metadata to '%s'\n", mfi->path); + + return 0; +} diff --git a/src/mpd.c b/src/mpd.c index fe0bb968..6e8b4471 100644 --- a/src/mpd.c +++ b/src/mpd.c @@ -3269,7 +3269,8 @@ static int mpd_sticker_set(struct evbuffer *evbuf, int argc, char **argv, char **errmsg, const char *virtual_path) { uint32_t rating; - int ret = 0; + int id; + int ret; if (strcmp(argv[4], "rating") != 0) { @@ -3291,20 +3292,22 @@ mpd_sticker_set(struct evbuffer *evbuf, int argc, char **argv, char **errmsg, co return ACK_ERROR_ARG; } - ret = db_file_rating_update_byvirtualpath(virtual_path, rating); - if (ret <= 0) + id = db_file_id_byvirtualpath(virtual_path); + if (id <= 0) { *errmsg = safe_asprintf("Invalid path '%s'", virtual_path); return ACK_ERROR_ARG; } + library_item_attrib_save(id, LIBRARY_ATTRIB_RATING, rating); + return 0; } static int mpd_sticker_delete(struct evbuffer *evbuf, int argc, char **argv, char **errmsg, const char *virtual_path) { - int ret = 0; + int id; if (strcmp(argv[4], "rating") != 0) { @@ -3312,12 +3315,15 @@ mpd_sticker_delete(struct evbuffer *evbuf, int argc, char **argv, char **errmsg, return ACK_ERROR_NO_EXIST; } - ret = db_file_rating_update_byvirtualpath(virtual_path, 0); - if (ret <= 0) + id = db_file_id_byvirtualpath(virtual_path); + if (id <= 0) { *errmsg = safe_asprintf("Invalid path '%s'", virtual_path); return ACK_ERROR_ARG; } + + library_item_attrib_save(id, LIBRARY_ATTRIB_RATING, 0); + return 0; } @@ -4714,7 +4720,7 @@ artwork_cb(struct evhttp_request *req, void *arg) DPRINTF(E_DBG, L_MPD, "Artwork request for path: %s\n", decoded_path); - itemid = db_file_id_by_virtualpath_match(decoded_path); + itemid = db_file_id_byvirtualpath_match(decoded_path); if (!itemid) { DPRINTF(E_WARN, L_MPD, "No item found for path '%s' from request uri '%s'\n", decoded_path, uri);