diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 8b350d8..07704b0 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -16,7 +16,7 @@ target_include_directories(glossarystoretest PRIVATE ../src/translate) ecm_add_test(playlistmodeltest.cpp ../src/playlist/playlistmodel.cpp TEST_NAME playlistmodeltest - LINK_LIBRARIES Qt6::Qml Qt6::Test + LINK_LIBRARIES qtpotato_core Qt6::Qml Qt6::Concurrent Qt6::Test ) target_include_directories(playlistmodeltest PRIVATE ../src/playlist) @@ -24,3 +24,8 @@ ecm_add_test(trackselectiontest.cpp TEST_NAME trackselectiontest LINK_LIBRARIES qtpotato_core Qt6::Test ) + +ecm_add_test(mediainfotest.cpp + TEST_NAME mediainfotest + LINK_LIBRARIES qtpotato_core Qt6::Test +) diff --git a/autotests/mediainfotest.cpp b/autotests/mediainfotest.cpp new file mode 100644 index 0000000..d104170 --- /dev/null +++ b/autotests/mediainfotest.cpp @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "mediainfo.h" + +#include +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +class MediaInfoTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void cleanTitle_data() + { + QTest::addColumn("fileName"); + QTest::addColumn("title"); + + QTest::newRow("scene episode") << u"VisionQuest.S01E01.No.Strings.DSNP.DV.HDR.WEB-DL.DDP5.1.Atmos.H.265-GRP.mkv"_s + << u"S01E01 · No Strings"_s; + QTest::newRow("episode without title") << u"The.Show.S02E10.1080p.WEB.h264-GRP.mkv"_s << u"The Show · S02E10"_s; + QTest::newRow("lowercase double episode") << u"show.s01e01e02.pilot.720p.hdtv.mkv"_s << u"S01E01E02 · pilot"_s; + QTest::newRow("spaced episode") << u"Mr. Robot - S01E01 - eps1.0_hellofriend.mov.mkv"_s << u"S01E01 · eps1.0 hellofriend.mov"_s; + QTest::newRow("movie with year") << u"Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv"_s << u"Dune Part Two (2024)"_s; + QTest::newRow("anime release") << u"[SubsPlease] Frieren - 05 (1080p) [A1B2C3D4].mkv"_s << u"Frieren - 05"_s; + QTest::newRow("plain name") << u"Holiday 2019 at the beach.mp4"_s << u"Holiday 2019 at the beach"_s; + QTest::newRow("music") << u"01 - Intro.flac"_s << u"01 - Intro"_s; + QTest::newRow("only tags") << u"1080p.mkv"_s << u"1080p"_s; + } + + void cleanTitle() + { + QFETCH(QString, fileName); + QFETCH(QString, title); + QCOMPARE(MediaInfo::cleanTitle(fileName), title); + } + + void probeDurationReadsTheLength() + { + const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s); + if (ffmpeg.isEmpty()) { + QSKIP("ffmpeg is needed to make a sample file"); + } + QTemporaryDir dir; + const QString path = dir.filePath(u"tone.mka"_s); + QProcess process; + process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=3"_s, path}); + QVERIFY(process.waitForFinished()); + QCOMPARE(process.exitCode(), 0); + + const std::optional seconds = MediaInfo::probeDuration(path); + QVERIFY(seconds); + QVERIFY2(qAbs(*seconds - 3) < 0.1, qPrintable(QString::number(*seconds))); + QVERIFY(!MediaInfo::probeDuration(dir.filePath(u"missing.mkv"_s))); + } +}; + +QTEST_GUILESS_MAIN(MediaInfoTest) + +#include "mediainfotest.moc" diff --git a/autotests/playlistmodeltest.cpp b/autotests/playlistmodeltest.cpp index bd6172b..44fe884 100644 --- a/autotests/playlistmodeltest.cpp +++ b/autotests/playlistmodeltest.cpp @@ -4,7 +4,9 @@ #include "playlistmodel.h" #include +#include #include +#include #include #include @@ -98,6 +100,31 @@ private Q_SLOTS: QCOMPARE(model.playingIndex(), -1); QCOMPARE(model.count(), 1); } + + void titleAndDurationRoles() + { + const QString ffmpeg = QStandardPaths::findExecutable(u"ffmpeg"_s); + if (ffmpeg.isEmpty()) { + QSKIP("ffmpeg is needed to make a sample file"); + } + QTemporaryDir dir; + const QString path = dir.filePath(u"Show.S01E02.The.Tone.1080p.WEB.mka"_s); + QProcess process; + process.start(ffmpeg, {u"-v"_s, u"error"_s, u"-f"_s, u"lavfi"_s, u"-i"_s, u"sine=duration=2"_s, path}); + QVERIFY(process.waitForFinished()); + QCOMPARE(process.exitCode(), 0); + + PlaylistModel model; + QSignalSpy changed(&model, &QAbstractItemModel::dataChanged); + model.append({QUrl::fromLocalFile(path), QUrl(u"https://example.com/a"_s)}); + QCOMPARE(model.data(model.index(0), PlaylistModel::TitleRole).toString(), u"S01E02 · The Tone"_s); + QCOMPARE(model.data(model.index(1), PlaylistModel::TitleRole).toString(), u"https://example.com/a"_s); + QCOMPARE(model.data(model.index(1), PlaylistModel::DurationRole).toDouble(), -1.0); + + QTRY_COMPARE(changed.count(), 1); + QCOMPARE(changed.first().at(2).value>(), QList{PlaylistModel::DurationRole}); + QVERIFY(qAbs(model.data(model.index(0), PlaylistModel::DurationRole).toDouble() - 2) < 0.1); + } }; QTEST_GUILESS_MAIN(PlaylistModelTest) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index da3aad5..e740bb7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,6 +1,8 @@ # Code without UI (subtitles, mpv.conf handling), kept apart from the app so autotests # can link it. add_library(qtpotato_core STATIC + media/mediainfo.cpp + media/mediainfo.h mpv/mpvuserconfig.cpp mpv/mpvuserconfig.h mpv/trackselection.cpp @@ -10,7 +12,7 @@ add_library(qtpotato_core STATIC subtitles/subtitleextractor.cpp subtitles/subtitleextractor.h ) -target_include_directories(qtpotato_core PUBLIC mpv subtitles) +target_include_directories(qtpotato_core PUBLIC media mpv subtitles) target_link_libraries(qtpotato_core PUBLIC Qt6::Core KF6::I18n diff --git a/src/media/mediainfo.cpp b/src/media/mediainfo.cpp new file mode 100644 index 0000000..6b116b7 --- /dev/null +++ b/src/media/mediainfo.cpp @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "mediainfo.h" + +#include +#include +#include + +#include + +extern "C" { +#include +} + +using namespace Qt::Literals::StringLiterals; + +namespace +{ +// Words that start the release details in scene-style names; everything after them is dropped. +bool isReleaseTag(const QString &word) +{ + static const QSet tags{ + u"web"_s, u"web-dl"_s, u"webdl"_s, u"webrip"_s, u"bluray"_s, u"blu-ray"_s, u"bdrip"_s, u"brrip"_s, u"hdtv"_s, + u"dvdrip"_s, u"hdrip"_s, u"remux"_s, u"uhd"_s, u"dsnp"_s, u"amzn"_s, u"nf"_s, u"hmax"_s, u"atvp"_s, + u"hulu"_s, u"pcok"_s, u"dv"_s, u"hdr"_s, u"hdr10"_s, u"hdr10+"_s, u"sdr"_s, u"x264"_s, u"x265"_s, + u"h264"_s, u"h265"_s, u"hevc"_s, u"avc"_s, u"aac"_s, u"ac3"_s, u"dts"_s, u"ddp"_s, u"10bit"_s, + u"8bit"_s, u"repack"_s, u"proper"_s, u"internal"_s, u"imax"_s, u"multi"_s, + }; + static const QRegularExpression pattern(uR"(^(\d{3,4}[pi]|[248]k|ddp?\d.*|h\d{3}|aac\d.*|dts-.*)$)"_s, QRegularExpression::CaseInsensitiveOption); + const QString lower = word.toLower(); + return tags.contains(lower) || pattern.match(lower).hasMatch(); +} + +bool isYear(const QString &word) +{ + static const QRegularExpression pattern(uR"(^\(?(19|20)\d\d\)?$)"_s); + return pattern.match(word).hasMatch(); +} + +// The words before the first release tag, joined by spaces. +QString untilReleaseTags(const QString &text) +{ + QStringList kept; + const QStringList words = text.split(u' ', Qt::SkipEmptyParts); + for (const QString &word : words) { + if (isReleaseTag(word)) { + break; + } + kept.append(word); + } + // "Movie 2024" reads better as "Movie (2024)". + if (kept.size() > 1 && isYear(kept.last())) { + QString year = kept.takeLast(); + year.remove(u'(').remove(u')'); + kept.append(u"(%1)"_s.arg(year)); + } + QString result = kept.join(u' '); + // Leftover separators like "Show -" when the tags followed a dash. + static const QRegularExpression trailing(uR"([\s\-–—:·]+$)"_s); + static const QRegularExpression leading(uR"(^[\s\-–—:·]+)"_s); + return result.remove(trailing).remove(leading); +} +} + +namespace MediaInfo +{ +QString cleanTitle(const QString &fileName) +{ + QString base = fileName; + static const QRegularExpression extension(uR"(\.[A-Za-z0-9]{2,4}$)"_s); + base.remove(extension); + const QString fallback = base; + + // "[Group] Name [1080p] (ABCD1234)": bracketed parts are release details. + static const QRegularExpression brackets(uR"(\[[^\]]*\]|\{[^}]*\})"_s); + base.remove(brackets); + // Parentheses too, when they hold release tags or a checksum, but not "(2024)". + static const QRegularExpression parens(uR"(\(([^)]*)\))"_s); + static const QRegularExpression wordSeparator(uR"([\s,]+)"_s); + static const QRegularExpression checksum(uR"(^[0-9A-Fa-f]{8}$)"_s); + for (auto it = parens.globalMatch(QString(base)); it.hasNext();) { + const auto match = it.next(); + const QStringList words = match.captured(1).split(wordSeparator, Qt::SkipEmptyParts); + if (std::any_of(words.begin(), words.end(), isReleaseTag) || checksum.match(match.captured(1)).hasMatch()) { + base.replace(match.captured(0), u" "_s); + } + } + + // Scene names use dots or underscores between words. + base.replace(u'_', u' '); + if (!base.trimmed().contains(u' ')) { + base.replace(u'.', u' '); + } + base = base.simplified(); + + static const QRegularExpression episode(uR"(\b[Ss](\d{1,2})[ ._-]?[Ee](\d{1,3})(?:-?[Ee]\d{1,3})*\b)"_s); + if (const auto match = episode.match(base); match.hasMatch()) { + const QString code = match.captured(0).toUpper().remove(u' ').remove(u'.').remove(u'_'); + const QString episodeTitle = untilReleaseTags(base.mid(match.capturedEnd())); + if (!episodeTitle.isEmpty()) { + return u"%1 · %2"_s.arg(code, episodeTitle); + } + const QString show = untilReleaseTags(base.left(match.capturedStart())); + return show.isEmpty() ? code : u"%1 · %2"_s.arg(show, code); + } + + const QString title = untilReleaseTags(base); + return title.isEmpty() ? fallback : title; +} + +std::optional probeDuration(const QString &path) +{ + AVFormatContext *context = nullptr; + if (avformat_open_input(&context, path.toUtf8().constData(), nullptr, nullptr) < 0) { + return std::nullopt; + } + // Most containers store the length in their header; others need a look at the streams. + if (context->duration == AV_NOPTS_VALUE || context->duration <= 0) { + avformat_find_stream_info(context, nullptr); + } + std::optional seconds; + if (context->duration != AV_NOPTS_VALUE && context->duration > 0) { + seconds = double(context->duration) / AV_TIME_BASE; + } + avformat_close_input(&context); + return seconds; +} +} diff --git a/src/media/mediainfo.h b/src/media/mediainfo.h new file mode 100644 index 0000000..2a15946 --- /dev/null +++ b/src/media/mediainfo.h @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Tri Vo Khac +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include + +#include + +namespace MediaInfo +{ +// A short title for a media file name, without the release tags: "Show.S01E01.Pilot.1080p.WEB.mkv" +// becomes "S01E01 · Pilot" and "Movie.2024.2160p.BluRay.mkv" becomes "Movie (2024)". Returns the +// name without its extension when nothing better is found. +QString cleanTitle(const QString &fileName); + +// Length of the media file at \a path in seconds, or nothing when it can't be read. +std::optional probeDuration(const QString &path); +} diff --git a/src/playlist/playlistmodel.cpp b/src/playlist/playlistmodel.cpp index c784b87..a971134 100644 --- a/src/playlist/playlistmodel.cpp +++ b/src/playlist/playlistmodel.cpp @@ -3,10 +3,13 @@ #include "playlistmodel.h" +#include "mediainfo.h" + #include #include #include #include +#include #include @@ -15,6 +18,13 @@ using namespace Qt::Literals::StringLiterals; PlaylistModel::PlaylistModel(QObject *parent) : QAbstractListModel(parent) { + // Reading many files at once from one disk is slower than a few at a time. + m_probePool.setMaxThreadCount(2); +} + +PlaylistModel::~PlaylistModel() +{ + m_probePool.clear(); } int PlaylistModel::rowCount(const QModelIndex &parent) const @@ -36,6 +46,10 @@ QVariant PlaylistModel::data(const QModelIndex &index, int role) const return url.isLocalFile() ? url.fileName() : url.toDisplayString(); case IsPlayingRole: return index.row() == m_playingIndex; + case TitleRole: + return url.isLocalFile() ? MediaInfo::cleanTitle(url.fileName()) : url.toDisplayString(); + case DurationRole: + return m_durations.value(url, -1); } return {}; } @@ -46,6 +60,8 @@ QHash PlaylistModel::roleNames() const {UrlRole, "url"}, {NameRole, "name"}, {IsPlayingRole, "isPlaying"}, + {TitleRole, "title"}, + {DurationRole, "duration"}, }; } @@ -127,6 +143,7 @@ void PlaylistModel::openFile(const QUrl &url, bool loadSiblings) m_playingIndex = -1; endResetModel(); Q_EMIT countChanged(); + probeDurations(m_urls); play(std::max(0, int(m_urls.indexOf(url)))); } @@ -140,6 +157,7 @@ void PlaylistModel::append(const QList &urls) m_urls.append(added); endInsertRows(); Q_EMIT countChanged(); + probeDurations(added); } void PlaylistModel::replace(const QList &urls) @@ -153,6 +171,7 @@ void PlaylistModel::replace(const QList &urls) m_playingIndex = -1; endResetModel(); Q_EMIT countChanged(); + probeDurations(m_urls); play(0); } @@ -274,3 +293,25 @@ void PlaylistModel::setPlayingIndex(int index) } Q_EMIT playingIndexChanged(); } + +void PlaylistModel::probeDurations(const QList &urls) +{ + for (const QUrl &url : urls) { + // Streams are skipped: opening them can take long and costs bandwidth. + if (!url.isLocalFile() || m_durations.contains(url)) { + continue; + } + m_durations.insert(url, -1); + QtConcurrent::run(&m_probePool, MediaInfo::probeDuration, url.toLocalFile()).then(this, [this, url](std::optional seconds) { + if (!seconds) { + return; + } + m_durations.insert(url, *seconds); + for (int row = 0; row < m_urls.size(); ++row) { + if (m_urls.at(row) == url) { + Q_EMIT dataChanged(index(row), index(row), {DurationRole}); + } + } + }); + } +} diff --git a/src/playlist/playlistmodel.h b/src/playlist/playlistmodel.h index c080b78..5d81656 100644 --- a/src/playlist/playlistmodel.h +++ b/src/playlist/playlistmodel.h @@ -4,7 +4,9 @@ #pragma once #include +#include #include +#include #include #include @@ -24,10 +26,15 @@ public: UrlRole = Qt::UserRole + 1, NameRole, IsPlayingRole, + // Short name without release tags; see MediaInfo::cleanTitle(). + TitleRole, + // Length in seconds, or -1 while unknown. Local files are read in the background. + DurationRole, }; Q_ENUM(Roles) explicit PlaylistModel(QObject *parent = nullptr); + ~PlaylistModel() override; int rowCount(const QModelIndex &parent = {}) const override; QVariant data(const QModelIndex &index, int role) const override; @@ -66,7 +73,12 @@ Q_SIGNALS: private: QList expand(const QList &urls) const; void setPlayingIndex(int index); + void probeDurations(const QList &urls); QList m_urls; int m_playingIndex = -1; + // Known lengths in seconds; -1 for files that couldn't be read or are still being read. + QHash m_durations; + // Declared last so it is destroyed first and waits for running probes. + QThreadPool m_probePool; }; diff --git a/src/qml/PlaylistItem.qml b/src/qml/PlaylistItem.qml index 0c545e3..70a07bd 100644 --- a/src/qml/PlaylistItem.qml +++ b/src/qml/PlaylistItem.qml @@ -8,20 +8,23 @@ import QtQuick.Layouts import org.kde.ki18n import org.kde.kirigami as Kirigami -// One row of the playlist: row number, playing icon and file name. +// One compact row of the playlist: row number (or playing icon), short title, length and, +// on hover or focus, a remove button. Item { id: root required property int index required property url url required property string name + required property string title + required property real duration required property bool isPlaying required property PlaylistModel playlist required property MpvItem mpv property real alpha: QtPotatoSettings.playlistOverlayVideo ? 0.6 : 1 readonly property bool hovered: hoverHandler.hovered - readonly property real fontSize: Kirigami.Theme.defaultFont.pointSize + readonly property bool highlighted: root.isPlaying && !root.hovered activeFocusOnTab: true function activate() { @@ -32,6 +35,18 @@ Item { contextMenu.popup() } + // "1:02:03" or "4:05". + function formatDuration(seconds: real): string { + if (seconds < 0) { + return "" + } + const total = Math.round(seconds) + const hours = Math.floor(total / 3600) + const minutes = Math.floor(total / 60) % 60 + const secs = String(total % 60).padStart(2, "0") + return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${secs}` : `${minutes}:${secs}` + } + Keys.onReturnPressed: root.activate() Keys.onEnterPressed: root.activate() Keys.onMenuPressed: root.showContextMenu() @@ -42,15 +57,8 @@ Item { } } - function labelColor(): color { - if (root.isPlaying && !root.hovered) { - return Kirigami.Theme.highlightedTextColor - } - return Kirigami.Theme.textColor - } - implicitWidth: ListView.view.width - implicitHeight: fontSize * 3 + implicitHeight: titleLabel.implicitHeight + Kirigami.Units.smallSpacing * 2 HoverHandler { id: hoverHandler @@ -71,7 +79,7 @@ Item { anchors.fill: parent Kirigami.Theme.colorSet: Kirigami.Theme.View color: { - if (root.isPlaying && !root.hovered) { + if (root.highlighted) { return Qt.alpha(Kirigami.Theme.highlightColor, root.alpha) } if (root.hovered) { @@ -85,67 +93,75 @@ Item { RowLayout { anchors.fill: parent + anchors.leftMargin: Kirigami.Units.largeSpacing + anchors.rightMargin: removeButton.visible ? Kirigami.Units.smallSpacing : Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing - Label { - text: root.index + 1 - color: root.labelColor() - visible: QtPotatoSettings.playlistShowRowNumber - font.pointSize: root.fontSize - font.features: { "tnum": 1 } - horizontalAlignment: Qt.AlignHCenter - Layout.leftMargin: Kirigami.Units.largeSpacing - Layout.preferredWidth: rowNumberMetrics.width - } - - TextMetrics { - id: rowNumberMetrics - font.pointSize: root.fontSize - text: String(root.playlist.count).replace(/./g, "0") - } - - Rectangle { - color: Qt.alpha(Kirigami.Theme.textColor, 0.2) - visible: QtPotatoSettings.playlistShowRowNumber - Layout.preferredWidth: 1 + // Same width on every row, so the titles line up. + Item { Layout.fillHeight: true - Layout.topMargin: Kirigami.Units.smallSpacing - Layout.bottomMargin: Kirigami.Units.smallSpacing - } + Layout.preferredWidth: Math.max(QtPotatoSettings.playlistShowRowNumber ? rowNumberMetrics.width : 0, + Kirigami.Units.iconSizes.small) - Kirigami.Icon { - source: "media-playback-start" - color: root.labelColor() - visible: root.isPlaying - Layout.leftMargin: QtPotatoSettings.playlistShowRowNumber ? 0 : Kirigami.Units.largeSpacing - Layout.preferredHeight: Kirigami.Units.iconSizes.smallMedium - Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Label { + anchors.centerIn: parent + text: root.index + 1 + visible: QtPotatoSettings.playlistShowRowNumber && !root.isPlaying + color: Kirigami.Theme.disabledTextColor + font: Kirigami.Theme.smallFont + } + + Kirigami.Icon { + anchors.centerIn: parent + width: Kirigami.Units.iconSizes.small + height: Kirigami.Units.iconSizes.small + source: "media-playback-start" + color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor + visible: root.isPlaying + } + + TextMetrics { + id: rowNumberMetrics + font: Kirigami.Theme.smallFont + text: String(root.playlist.count).replace(/./g, "0") + } } LabelWithTooltip { - text: root.name - toolTipText: root.url.toString().startsWith("file://") ? decodeURIComponent(root.url.toString().substring(7)) : root.url - color: root.labelColor() + id: titleLabel + text: root.title + toolTipText: root.name + alwaysShowToolTip: root.title !== root.name + color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.textColor elide: Text.ElideRight - font.pointSize: root.fontSize - font.bold: root.isPlaying + maximumLineCount: 1 verticalAlignment: Qt.AlignVCenter Layout.fillWidth: true - Layout.leftMargin: !QtPotatoSettings.playlistShowRowNumber && !root.isPlaying ? Kirigami.Units.largeSpacing : 0 - Layout.rightMargin: Kirigami.Units.largeSpacing } - Button { + Label { + text: root.formatDuration(root.duration) + visible: text !== "" + color: root.highlighted ? Kirigami.Theme.highlightedTextColor : Kirigami.Theme.disabledTextColor + font: Kirigami.Theme.smallFont + } + + ToolButton { + id: removeButton text: KI18n.i18nc("@action:button remove playlist item", "Remove") icon.name: "list-remove" + icon.width: Kirigami.Units.iconSizes.small + icon.height: Kirigami.Units.iconSizes.small display: AbstractButton.IconOnly - flat: true + focusPolicy: Qt.TabFocus + visible: root.hovered || root.activeFocus || activeFocus + padding: 0 Accessible.name: text ToolTip.text: text ToolTip.visible: hovered ToolTip.delay: Kirigami.Units.toolTipDelay - Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium + Kirigami.Units.smallSpacing * 2 - Layout.rightMargin: Kirigami.Units.smallSpacing + Layout.fillHeight: true + Layout.preferredWidth: height onClicked: root.playlist.remove(root.index) } }