refactor: migrate to git-cliff for changelog and deduplicate library items

- Replace manual CHANGELOG.md parsing with git-cliff action in release workflow
- Add cliff.toml config for conventional commit grouping and GitHub integration
- Extract buildPathMatchKeys into shared utility with Android storage alias support
- Deduplicate local library items overlapping with downloaded items in queue tab
This commit is contained in:
zarzet 2026-03-10 15:26:19 +07:00
parent a4899144c5
commit 9c6856b584
5 changed files with 292 additions and 136 deletions

View file

@ -309,32 +309,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Full history needed for git-cliff
- name: Extract changelog for version
- name: Generate changelog with git-cliff
id: changelog
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip header
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/changelog.txt
- name: Show generated changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v} # Remove 'v' prefix
echo "Looking for version: $VERSION_NUM"
# Extract changelog section for this version using sed
# Find the line with version, then print until next version header or end
CHANGELOG=$(sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" CHANGELOG.md)
# If no changelog found, use default message
if [ -z "$CHANGELOG" ]; then
echo "No changelog found for version $VERSION_NUM"
CHANGELOG="See CHANGELOG.md for details."
else
echo "Found changelog content"
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
fi
# Save to file for multiline support
echo "$CHANGELOG" > /tmp/changelog.txt
echo "Extracted changelog:"
echo "Generated changelog:"
cat /tmp/changelog.txt
- name: Download Android APK
@ -352,15 +342,13 @@ jobs:
- name: Prepare release body
run: |
VERSION=${{ needs.get-version.outputs.version }}
cat > /tmp/release_body.txt << 'HEADER'
### What's New
HEADER
cat /tmp/changelog.txt >> /tmp/release_body.txt
REPO_OWNER="${{ github.repository_owner }}"
REPO_NAME="${{ github.event.repository.name }}"
# Start with git-cliff changelog
cp /tmp/changelog.txt /tmp/release_body.txt
# Append download section
cat >> /tmp/release_body.txt << FOOTER
---
@ -404,6 +392,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download Android APK
uses: actions/download-artifact@v7
@ -417,52 +407,40 @@ jobs:
name: ios-ipa
path: ./release
- name: Extract changelog for version
- name: Generate changelog with git-cliff for Telegram
uses: orhun/git-cliff-action@v4
with:
config: cliff.toml
args: --latest --strip all
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OUTPUT: /tmp/cliff_tg.txt
- name: Convert changelog for Telegram
id: changelog
run: |
VERSION=${{ needs.get-version.outputs.version }}
VERSION_NUM=${VERSION#v}
# Extract changelog, limit to ~2500 chars for Telegram (4096 limit minus message overhead)
# Use tr -d '\r' to handle CRLF line endings from Windows
FULL_CHANGELOG=$(cat CHANGELOG.md | tr -d '\r' | sed -n "/^## \[$VERSION_NUM\]/,/^## \[/{ /^## \[$VERSION_NUM\]/d; /^## \[/d; p; }" | sed '/^---$/d')
echo "DEBUG: Extracted changelog length: ${#FULL_CHANGELOG}"
echo "DEBUG: First 200 chars: ${FULL_CHANGELOG:0:200}"
if [ -z "$FULL_CHANGELOG" ]; then
CHANGELOG="See release notes on GitHub for details."
if [ ! -s /tmp/cliff_tg.txt ]; then
echo "See release notes on GitHub for details." > /tmp/changelog.txt
else
# Convert GitHub Markdown to Telegram HTML:
# - **text** → <b>text</b>
# - `code` → <code>code</code>
# - ### Header → <b>Header</b>
# - Escape HTML special chars first
# - Remove > blockquote prefix
CHANGELOG=$(echo "$FULL_CHANGELOG" | \
sed 's/^> //' | \
# Convert Markdown to Telegram HTML
CHANGELOG=$(cat /tmp/cliff_tg.txt | \
sed '/^\*\*Full Changelog\*\*/d' | \
sed 's/\[#\([0-9]*\)\]([^)]*)/#\1/g' | \
sed 's/\[@\([^]]*\)\]([^)]*)/@\1/g' | \
sed 's/&/\&amp;/g' | \
sed 's/</\&lt;/g' | \
sed 's/>/\&gt;/g' | \
sed 's/`\([^`]*\)`/<code>\1<\/code>/g' | \
sed 's/\*\*\([^*]*\)\*\*/<b>\1<\/b>/g' | \
sed 's/^### \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^## \(.*\)$/<b>\1<\/b>/g' | \
sed 's/^- /• /g' | \
sed 's/^ - / ◦ /g')
# Take first 2500 characters, then cut at last complete line
sed 's/^- /• /g')
# Truncate for Telegram 4096 char limit
CHANGELOG=$(echo "$CHANGELOG" | head -c 2500 | sed '$d')
# Check if truncated
FULL_LEN=${#FULL_CHANGELOG}
if [ $FULL_LEN -gt 2500 ]; then
CHANGELOG="${CHANGELOG}"$'\n\n... (see full changelog on GitHub)'
fi
echo "$CHANGELOG" > /tmp/changelog.txt
fi
echo "$CHANGELOG" > /tmp/changelog.txt
echo "DEBUG: Final changelog:"
echo "Telegram changelog:"
cat /tmp/changelog.txt
- name: Send to Telegram Channel

105
cliff.toml Normal file
View file

@ -0,0 +1,105 @@
# git-cliff configuration for SpotiFLAC Mobile
# https://git-cliff.org/docs/configuration
[changelog]
# Template for the changelog body
body = """
{%- macro remote_url() -%}
https://github.com/zarzet/SpotiFLAC-Mobile
{%- endmacro -%}
{% if version %}\
## {{ version | trim_start_matches(pat="v") }}
{% else %}\
## Unreleased
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | upper_first }}\
{% if commit.github.pr_number %} \
([#{{ commit.github.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.github.pr_number }}))\
{% endif %}\
{%- if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif %}
{%- endfor %}
{% endfor %}
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
### New Contributors
{%- for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
* @{{ contributor.username }} made their first contribution
{%- if contributor.pr_number %} in \
[#{{ contributor.pr_number }}]({{ self::remote_url() }}/pull/{{ contributor.pr_number }}) \
{%- endif %}
{%- endfor %}
{%- endif -%}
{% if version %}
{% if previous.version %}
**Full Changelog**: [{{ previous.version }}...{{ version }}]({{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }})
{% endif %}
{% else -%}
{% raw %}\n{% endraw %}
{% endif %}
"""
# Remove leading and trailing whitespace
trim = true
[git]
# Parse conventional commits
conventional_commits = true
filter_unconventional = true
# Process each line of a commit as an individual commit
split_commits = false
# Regex for preprocessing the commit messages
commit_preprocessors = [
# Remove PR number from message (we add it back via GitHub integration)
{ pattern = '\(#(\d+)\)', replace = '' },
# Strip conventional commit prefix for cleaner messages
# (group header already shows the type)
]
# Regex for parsing and grouping commits
commit_parsers = [
# Skip noise: translation commits from Crowdin
{ message = "^New translations", skip = true },
{ message = "^Update source file", skip = true },
# Skip merge commits
{ message = "^Merge", skip = true },
# Skip version bump commits
{ message = "^v\\d+", skip = true },
{ message = "^chore: update VirusTotal", skip = true },
# Group by conventional commit type
{ message = "^feat", group = "<!-- 0 -->New Features" },
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
{ message = "^perf", group = "<!-- 2 -->Performance" },
{ message = "^refactor", group = "<!-- 3 -->Refactoring" },
{ message = "^doc", group = "<!-- 4 -->Documentation" },
{ message = "^style", group = "<!-- 5 -->Styling" },
{ message = "^test", group = "<!-- 6 -->Testing" },
{ message = "^chore\\(deps\\)", group = "<!-- 7 -->Dependencies" },
{ message = "^chore\\(l10n\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 8 -->Chores" },
]
# Protect breaking changes from being skipped
protect_breaking_commits = true
# Filter out commits by matching patterns
filter_commits = false
# Tag pattern for version detection
tag_pattern = "v[0-9].*"
# Sort commits by newest first
sort_commits = "newest"
[remote.github]
owner = "zarzet"
repo = "SpotiFLAC-Mobile"

View file

@ -9,6 +9,7 @@ import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('LocalLibrary');
@ -193,74 +194,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final noQueryOrFragment = parsed.replace(query: null, fragment: null);
keys.add(noQueryOrFragment.toString());
keys.add(noQueryOrFragment.toString().toLowerCase());
if (parsed.scheme == 'file') {
try {
final fileOnly = parsed.toFilePath();
if (fileOnly.isNotEmpty) {
keys.add(fileOnly);
keys.add(fileOnly.toLowerCase());
if (fileOnly.contains('\\')) {
final slash = fileOnly.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
}
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
final candidateKeys = buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
@ -322,8 +260,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
resolvedPath =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
resolvedPath = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolvedPath != null) {
didStartSecurityAccess = true;
_log.i('Started iOS security-scoped access: $resolvedPath');
@ -354,7 +293,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
downloadedPathKeys.addAll(buildPathMatchKeys(path));
}
_log.i(
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
@ -834,8 +773,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<int> cleanupMissingFiles({String? iosBookmark}) async {
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
final resolved =
await PlatformBridge.startAccessingIosBookmark(iosBookmark);
final resolved = await PlatformBridge.startAccessingIosBookmark(
iosBookmark,
);
if (resolved != null) {
didStartSecurityAccess = true;
}

View file

@ -29,6 +29,7 @@ import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
enum LibraryItemSource { downloaded, local }
@ -2112,10 +2113,22 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (count > 1) albumCount++;
}
final downloadedPathKeys = <String>{};
for (final item in items) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedLocalItems = localItems
.where((item) {
final localPathKeys = buildPathMatchKeys(item.filePath);
return !localPathKeys.any(downloadedPathKeys.contains);
})
.toList(growable: false);
// Calculate local library stats
final localAlbumCounts = <String, int>{};
final localAlbumMap = <String, List<LocalLibraryItem>>{};
for (final item in localItems) {
for (final item in dedupedLocalItems) {
final key =
'${item.albumName.toLowerCase()}|${(item.albumArtist ?? item.artistName).toLowerCase()}';
localAlbumCounts[key] = (localAlbumCounts[key] ?? 0) + 1;
@ -2841,8 +2854,24 @@ class _QueueTabState extends ConsumerState<QueueTab> {
.map((item) => UnifiedLibraryItem.fromLocalLibrary(item))
.toList(growable: false);
final merged = <UnifiedLibraryItem>[...unifiedDownloaded, ...unifiedLocal]
..sort((a, b) => b.addedAt.compareTo(a.addedAt));
final downloadedPathKeys = <String>{};
for (final item in unifiedDownloaded) {
downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath));
}
final dedupedUnifiedLocal = <UnifiedLibraryItem>[];
for (final item in unifiedLocal) {
final localPathKeys = buildPathMatchKeys(item.filePath);
final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains);
if (!overlapsDownloaded) {
dedupedUnifiedLocal.add(item);
}
}
final merged = <UnifiedLibraryItem>[
...unifiedDownloaded,
...dedupedUnifiedLocal,
]..sort((a, b) => b.addedAt.compareTo(a.addedAt));
_unifiedItemsCache[filterMode] = _UnifiedCacheEntry(
historyItems: historyItems,

View file

@ -0,0 +1,104 @@
import 'dart:io';
const _androidStoragePathAliases = <String>[
'/storage/emulated/0',
'/storage/emulated/legacy',
'/storage/self/primary',
'/sdcard',
'/mnt/sdcard',
];
Set<String> buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
if (cleaned.isEmpty) return const {};
final keys = <String>{};
final visited = <String>{};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
if (!visited.add(trimmed)) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
if (slash != trimmed) {
addNormalized(slash);
}
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
if (decoded != trimmed) {
addNormalized(decoded);
}
} catch (_) {}
}
Uri? parsed;
try {
parsed = Uri.parse(trimmed);
} catch (_) {}
if (parsed != null && parsed.hasScheme) {
final withoutQueryOrFragment = parsed.replace(
query: null,
fragment: null,
);
final uriString = withoutQueryOrFragment.toString();
keys.add(uriString);
keys.add(uriString.toLowerCase());
if (parsed.scheme == 'file') {
try {
addNormalized(parsed.toFilePath());
} catch (_) {}
}
} else if (trimmed.startsWith('/')) {
try {
final asFileUri = Uri.file(trimmed).toString();
keys.add(asFileUri);
keys.add(asFileUri.toLowerCase());
} catch (_) {}
}
if (Platform.isAndroid) {
for (final alias in _androidEquivalentPaths(trimmed)) {
if (alias != trimmed) {
addNormalized(alias);
}
}
}
}
addNormalized(cleaned);
return keys;
}
Iterable<String> _androidEquivalentPaths(String path) {
final normalized = path.replaceAll('\\', '/');
final lower = normalized.toLowerCase();
String? suffix;
for (final prefix in _androidStoragePathAliases) {
if (lower == prefix) {
suffix = '';
break;
}
final withSlash = '$prefix/';
if (lower.startsWith(withSlash)) {
suffix = normalized.substring(prefix.length);
break;
}
}
if (suffix == null) return const [];
return _androidStoragePathAliases.map((prefix) => '$prefix$suffix');
}