From 9c6856b584430291040ba3c560d8e7e3b6d46e10 Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 10 Mar 2026 15:26:19 +0700 Subject: [PATCH] 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 --- .github/workflows/release.yml | 106 +++++++++------------- cliff.toml | 105 +++++++++++++++++++++ lib/providers/local_library_provider.dart | 78 ++-------------- lib/screens/queue_tab.dart | 35 ++++++- lib/utils/path_match_keys.dart | 104 +++++++++++++++++++++ 5 files changed, 292 insertions(+), 136 deletions(-) create mode 100644 cliff.toml create mode 100644 lib/utils/path_match_keys.dart diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04ade01e..1129349f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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** → text - # - `code` → code - # - ### Header → Header - # - 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/&/\&/g' | \ sed 's//\>/g' | \ - sed 's/`\([^`]*\)`/\1<\/code>/g' | \ sed 's/\*\*\([^*]*\)\*\*/\1<\/b>/g' | \ sed 's/^### \(.*\)$/\1<\/b>/g' | \ sed 's/^## \(.*\)$/\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 diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 00000000..0509818b --- /dev/null +++ b/cliff.toml @@ -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 = "New Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^doc", group = "Documentation" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(deps\\)", group = "Dependencies" }, + { message = "^chore\\(l10n\\)", skip = true }, + { message = "^chore|^ci", group = "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" diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index fe9f21ad..1dd10dd7 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -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 { await _loadFromDatabase(); } - Set _buildPathMatchKeys(String? filePath) { - final raw = filePath?.trim() ?? ''; - if (raw.isEmpty) return const {}; - - final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw; - final keys = {}; - - 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 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 { 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 { }; final downloadedPathKeys = {}; 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 { Future 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; } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 1aa3bad3..e05429c9 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { if (count > 1) albumCount++; } + final downloadedPathKeys = {}; + 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 = {}; final localAlbumMap = >{}; - 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 { .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) .toList(growable: false); - final merged = [...unifiedDownloaded, ...unifiedLocal] - ..sort((a, b) => b.addedAt.compareTo(a.addedAt)); + final downloadedPathKeys = {}; + for (final item in unifiedDownloaded) { + downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); + } + + final dedupedUnifiedLocal = []; + for (final item in unifiedLocal) { + final localPathKeys = buildPathMatchKeys(item.filePath); + final overlapsDownloaded = localPathKeys.any(downloadedPathKeys.contains); + if (!overlapsDownloaded) { + dedupedUnifiedLocal.add(item); + } + } + + final merged = [ + ...unifiedDownloaded, + ...dedupedUnifiedLocal, + ]..sort((a, b) => b.addedAt.compareTo(a.addedAt)); _unifiedItemsCache[filterMode] = _UnifiedCacheEntry( historyItems: historyItems, diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart new file mode 100644 index 00000000..ace140dd --- /dev/null +++ b/lib/utils/path_match_keys.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +const _androidStoragePathAliases = [ + '/storage/emulated/0', + '/storage/emulated/legacy', + '/storage/self/primary', + '/sdcard', + '/mnt/sdcard', +]; + +Set 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 = {}; + final visited = {}; + + 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 _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'); +}