Ctrl K
ComunidadGuías…

podcast_title.h

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
#pragma once
#include "queue_lms_cli.h"
#include "queue_playlist.h"
#include "podcast_settings.h"
#include <chrono>
#include <cstdio>
#include <optional>
#include <sstream>
#include <vector>
namespace queue_audio {
struct PodcastMetadata { std::string title, podcast, publisher; };
inline void applyPodcastMetadata(PlaylistEntry &track, const PodcastMetadata &metadata,
                                 const bridge::PodcastSettings &settings) {
    if (track.url.compare(0,10,"podcast://") != 0) return;
    if (settings[0].enabled) track.title=metadata.title;
    if (track.artist.empty() && settings[1].enabled) track.artist=metadata.podcast;
    if (track.album.empty() && settings[2].enabled) track.album=metadata.publisher;
}
struct PodcastMenu {
    struct Row { std::string id, name, title; bool feed = false; };
    std::vector<Row> rows;
    unsigned count = 0;
    static PodcastMenu parse(const std::string &wire) {
        PodcastMenu menu;std::istringstream input(wire);std::string field;
        while(input>>field) {
            field=decodeLms(field);auto colon=field.find(':');
            if(colon==std::string::npos)continue;
            auto key=field.substr(0,colon),value=field.substr(colon+1);
            if(key=="id")menu.rows.push_back({value,{},{}});
            else if(key=="count")menu.count=unsigned(std::stoul(value));
            else if(!menu.rows.empty()) {
                if(key=="name")menu.rows.back().name=value;
                if(key=="title")menu.rows.back().title=value;
                if(key=="url")menu.rows.back().feed=true;
            }
        }
        return menu;
    }
    std::optional<std::string> plainTitle(const std::string &formatted) const {
        std::optional<std::string> candidate;
        for(const auto &row:rows) {
            const auto &title=row.title.empty()?row.name:row.title;
            if(title.empty())continue;
            if(title==formatted)return title; // a literal title containing by/from stays intact
            if(formatted.compare(0,title.size()+4,title+" by ")==0) {
                if(candidate && *candidate!=title)return std::nullopt;
                candidate=title;
            }
        }
        return candidate;
    }
};
// Compare menu decorations without changing the name extracted from LMS TITLE.
inline std::string podcastFeedBase(std::string name) {
    auto decoration=name.find(" | ");
    if(decoration!=std::string::npos)name.resize(decoration);
    return name;
}
inline bool podcastFeedMatches(const std::string &menu,const std::string &name) {
    return !name.empty() && podcastFeedBase(menu)==podcastFeedBase(name);
}
inline std::optional<PodcastMetadata> splitPodcastTitle(const std::string &formatted,
        const std::string &plain,const std::string &feed,std::string &reason) {
    if(formatted==plain)return PodcastMetadata{plain,podcastFeedBase(feed),{}};
    const auto prefix=plain+" by ";
    if(formatted.compare(0,prefix.size(),prefix)!=0) {
        reason="episode reason=no episode match";return std::nullopt;
    }
    std::vector<PodcastMetadata> candidates;
    for(auto pos=formatted.find(" from ",prefix.size());pos!=std::string::npos;
        pos=formatted.find(" from ",pos+1)) {
        auto publisher=formatted.substr(prefix.size(),pos-prefix.size());
        auto podcast=formatted.substr(pos+6);
        if(!publisher.empty() && podcastFeedMatches(feed,podcast))
            candidates.push_back({plain,podcast,publisher});
    }
    if(candidates.size()==1)return candidates.front();
    reason=candidates.empty()?"split reason=no feed match":"split reason=ambiguous split";
    // Names are the only diagnostic payload: never source URLs or tokens.
    for(const auto &candidate:candidates)
        reason+=" [publisher="+candidate.publisher+", podcast="+candidate.podcast+"]";
    return std::nullopt;
}
inline std::optional<PodcastMetadata> podcastMetadata(const std::string &host,const std::string &mac,
        const std::string &formatted,std::string *diagnostic=nullptr) {
    auto fail=[&](const std::string &reason)->std::optional<PodcastMetadata> {
        if(diagnostic)*diagnostic=reason;
        return std::nullopt;
    };
    const auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(2);
    std::string failure;
    auto query=[&](const std::string &command)->std::optional<PodcastMenu> {
        const auto left=std::chrono::duration_cast<std::chrono::milliseconds>(deadline-std::chrono::steady_clock::now()).count();
        if(left<=0){failure="lookup reason=lookup timeout";return std::nullopt;}
        std::string reply;
        if(!controlLms(host,mac,command,9090,&reply,int(left))) {
            failure=std::chrono::steady_clock::now()>=deadline ? "lookup reason=lookup timeout":"lookup reason=CLI query failed";
            return std::nullopt;
        }
        return PodcastMenu::parse(reply);
    };
    auto top=query("podcasts items 0 100 want_url:1");
    if(!top)return fail(failure);
    std::optional<PodcastMetadata> result;
    struct FeedPage { PodcastMenu::Row feed; unsigned count=100; };
    std::vector<FeedPage> feeds;
    const bool formattedShape=formatted.find(" by ")!=std::string::npos && formatted.find(" from ")!=std::string::npos;
    for(const auto &feed:top->rows) {
        const auto name=feed.name.empty()?feed.title:feed.name;
        if(name.empty() || feed.id.empty() || feed.id.find_first_not_of("0123456789.")!=std::string::npos)continue;
        bool plausible=!formattedShape && feed.feed;
        for(auto pos=formatted.find(" from ");pos!=std::string::npos;pos=formatted.find(" from ",pos+1))
            plausible=plausible || podcastFeedMatches(name,formatted.substr(pos+6));
        if(plausible)feeds.push_back({feed,100});
    }
    // Visit the first page of every plausible feed before older pages. A large
    // unrelated subscription must not exhaust the budget before a recent match.
    for(unsigned first=0;first<60000;first+=100) {
        bool pending=false;
        for(auto &candidate:feeds) {
            if(first>=candidate.count)continue;
            pending=true;
            const auto &feed=candidate.feed;
            const auto name=feed.name.empty()?feed.title:feed.name;
            auto page=query("podcasts items "+std::to_string(first)+" 100 want_url:1 item_id:"+feed.id);
            if(!page)return fail(failure);
            candidate.count=page->count;
            if(auto plain=page->plainTitle(formatted)) {
                std::string reason;
                auto metadata=splitPodcastTitle(formatted,*plain,name,reason);
                if(!metadata)return fail(reason);
                if(result)return fail("episode reason=ambiguous episode match across feeds");
                result=metadata;
            }
        }
        if(result || !pending)break;
    }
    if(result) {
        if(diagnostic && result->publisher.empty())*diagnostic="split reason=plain LMS title has no publisher";
        return result;
    }
    return fail(!feeds.empty()?"episode reason=no episode match":"feed reason=no feed match");
}
inline std::optional<std::string> podcastPlainTitle(const std::string &host,const std::string &mac,
                                                   const std::string &formatted) {
    auto metadata=podcastMetadata(host,mac,formatted);
    return metadata?std::optional<std::string>(metadata->title):std::nullopt;
}
} // namespace queue_audio